From 22b049d70becf9f5319b592ef96ea3c3ff1f1c38 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:10:56 -0400 Subject: [PATCH 01/25] Apply 416.1163(e)(2) to deemed SSI and identify couples by marriage SSI under spousal income deeming was capped at the individual Federal benefit rate. 20 CFR 416.1163(e)(2) makes it the lesser of the couple computation and the individual rate less the claimant's own countable income, so a claimant with income of their own could get more with deeming than without it, and lower income could lower SSI. Add uncapped_ssi_without_spousal_deeming and take the lesser in ssi_if_takes_up; deem nothing while the $30 rate applies (416.1163(f)(5)). Joint claims and the ineligible spouse whose income is deemed were tied to tax-unit head and spouse roles, which go to the two oldest adults of a tax unit. Use the co-resident spouse test that resource deeming already uses (416.1801(c), 416.1806, 416.1160(d)), require both members of a joint claim to meet the citizenship condition, and combine couple income and resources over the two spouses rather than the marital unit. Fixes #10138. Co-Authored-By: Claude Opus 5.5 --- .../ssi-couples-and-deeming-cap.fixed.md | 1 + .../test_ssi_spousal_deeming_invariants.py | 259 ++++++++++++++++++ ..._income_deemed_from_ineligible_spouse.yaml | 40 +++ ..._ssi_spousal_resource_deeming_applies.yaml | 20 +- .../gov/ssa/ssi/ssi_claim_is_joint.yaml | 166 +++++++++++ .../baseline/gov/ssa/ssi/ssi_if_takes_up.yaml | 130 +++++++++ .../ssa/ssi/_ssi_living_arrangement_amount.py | 29 ++ .../deemed/from_ineligible_spouse/README.md | 8 + ...ed_income_deemed_from_ineligible_spouse.py | 23 +- ...ed_income_deemed_from_ineligible_spouse.py | 17 +- .../marital/ssi_marital_earned_income.py | 16 +- .../marital/ssi_marital_unearned_income.py | 16 +- .../resources/deemed/_ssi_spouses.py | 17 ++ ...resources_deemed_from_ineligible_spouse.py | 7 +- .../resources/meets_ssi_resource_test.py | 8 +- .../status/is_ssi_eligible_spouse.py | 6 +- .../status/is_ssi_ineligible_spouse.py | 29 +- .../gov/ssa/ssi/ssi_amount_if_eligible.py | 29 +- .../gov/ssa/ssi/ssi_claim_is_joint.py | 25 +- .../ssa/ssi/ssi_couple_computation_applies.py | 6 +- .../variables/gov/ssa/ssi/ssi_if_takes_up.py | 47 ++-- .../uncapped_ssi_without_spousal_deeming.py | 35 +++ 22 files changed, 833 insertions(+), 101 deletions(-) create mode 100644 changelog.d/ssi-couples-and-deeming-cap.fixed.md create mode 100644 policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/_ssi_living_arrangement_amount.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.py diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md new file mode 100644 index 00000000000..9e9e95f0888 --- /dev/null +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -0,0 +1 @@ +SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate, and no spousal income is deemed while the $30 medical facility rate applies (416.1163(f)(5)). SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, and a spouse who fails the citizenship condition is an ineligible spouse: joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py new file mode 100644 index 00000000000..bff46b626b0 --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -0,0 +1,259 @@ +"""Simulation-API invariants for SSI couples and spousal deeming. + +Two vectorized simulations, each built once per module: + +- An income grid of 400 two-person households: an aged claimant with only + unearned income and a spouse under 65 with unearned and earned income. The + axes include the $20 and $65 exclusion edges, the $497 deeming threshold + ($1,491 couple less $994 individual Federal benefit rate in 2026) and its + neighbours, and a claimant income that leaves nothing of the individual + rate. SSI must equal an independent numpy reading of 20 CFR 416.1163, never + exceed the benefit without deeming (416.1163(e)(2)), and never rise when any + income rises. +- A role grid of 144 three-adult households: a married couple living with a + third adult, over all nine assignments of tax-unit head and spouse roles + among the three adults, aged or not for each adult, and citizen or + undocumented for one spouse. Couple status and SSI must not depend on tax roles + (416.1801(c), 416.1806, 416.1160(d)), and joint claims must be symmetric and + disjoint from ineligible-spouse status. + +YAML covers worked cases. Python is needed to check the for-all properties +over every ordered edge of the grids and to compare with an oracle that does +not use the model. Both grids share the read-only system singleton; neither +constructs a model per household. This file routes to the Rest/core group. +""" + +from itertools import product + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2026 +PERIOD = "2026-01" +INDIVIDUAL_FBR = 994 +COUPLE_FBR = 1_491 +GENERAL_EXCLUSION = 20 +EARNED_EXCLUSION = 65 + +CLAIMANT_UNEARNED = (0, 19, 20, 400, 743, 974, 1_014, 1_200) +SPOUSE_UNEARNED = (0, 100, 300, 497, 498) +SPOUSE_EARNED = (0, 64, 65, 400, 496, 497, 498, 600, 1_000, 3_100) + + +def _countable(unearned, earned): + """416.1112, 416.1124: $20 general, then $65 and half of the rest.""" + general = np.minimum(GENERAL_EXCLUSION, unearned) + leftover = GENERAL_EXCLUSION - general + earned_after = np.maximum(earned - EARNED_EXCLUSION - leftover, 0) / 2 + return unearned - general + earned_after + + +def _oracle_ssi(claimant_unearned, spouse_unearned, spouse_earned): + """Monthly SSI for an aged claimant with an ineligible spouse. + + 416.1163(d)(1): no deeming when the spouse's income is not more than the + couple rate less the individual rate. (d)(2): otherwise the couple's + combined countable income is subtracted from the couple rate. (e)(2): the + benefit is the lesser of that and the individual rate less the claimant's + own countable income. + """ + alone = np.maximum( + INDIVIDUAL_FBR + - _countable(claimant_unearned, np.zeros_like(claimant_unearned)), + 0, + ) + couple = np.maximum( + COUPLE_FBR - _countable(claimant_unearned + spouse_unearned, spouse_earned), + 0, + ) + deems = spouse_unearned + spouse_earned > COUPLE_FBR - INDIVIDUAL_FBR + return np.where(deems, np.minimum(couple, alone), alone) + + +def _income_situation(rows): + situation = { + plural: {} + for plural in ( + "people", + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ) + } + for index, (claimant_unearned, spouse_unearned, spouse_earned) in enumerate(rows): + claimant, spouse = f"c{index}", f"s{index}" + situation["people"][claimant] = { + "age": {YEAR: 70}, + "pension_income": {YEAR: 12 * claimant_unearned}, + } + situation["people"][spouse] = { + "age": {YEAR: 60}, + "pension_income": {YEAR: 12 * spouse_unearned}, + "employment_income": {YEAR: 12 * spouse_earned}, + } + members = [claimant, spouse] + for plural in ( + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ): + situation[plural][f"u{index}"] = {"members": members} + situation["households"][f"u{index}"]["state_code"] = {YEAR: "TX"} + return situation + + +def _income_ssi(rows): + simulation = Simulation(situation=_income_situation(rows)) + ssi = simulation.calculate("ssi", PERIOD) + without_deeming = simulation.calculate( + "uncapped_ssi_without_spousal_deeming", PERIOD + ) + return ssi[0::2], ssi[1::2], np.maximum(without_deeming[0::2], 0) + + +@pytest.fixture(scope="module") +def income_grid(): + rows = list(product(CLAIMANT_UNEARNED, SPOUSE_UNEARNED, SPOUSE_EARNED)) + claimant_ssi, spouse_ssi, without_deeming = _income_ssi(rows) + shape = (len(CLAIMANT_UNEARNED), len(SPOUSE_UNEARNED), len(SPOUSE_EARNED)) + columns = np.array(rows, dtype=float).T + return { + "rows": columns, + "ssi": claimant_ssi.reshape(shape), + "spouse_ssi": spouse_ssi, + "without_deeming": without_deeming.reshape(shape), + "oracle": _oracle_ssi(*columns).reshape(shape), + } + + +def test_income_grid_matches_independent_oracle(income_grid): + np.testing.assert_allclose(income_grid["ssi"], income_grid["oracle"], atol=0.01) + assert np.all(income_grid["spouse_ssi"] == 0) + + +def test_deemed_benefit_never_exceeds_benefit_without_deeming(income_grid): + assert np.all(income_grid["ssi"] <= income_grid["without_deeming"] + 0.01) + + +@pytest.mark.parametrize("axis", [0, 1, 2], ids=["claimant", "unearned", "earned"]) +def test_ssi_never_rises_with_income(income_grid, axis): + # Each axis is sorted ascending, so every ordered edge is a rise in one + # income with the others fixed. + assert np.all(np.diff(income_grid["ssi"], axis=axis) <= 0.01) + + +@settings(max_examples=3, deadline=None, derandomize=True) +@given( + st.lists( + st.tuples( + st.integers(0, 1_300), + st.integers(0, 900), + st.integers(0, 3_500), + ), + min_size=40, + max_size=40, + ) +) +def test_random_incomes_match_independent_oracle(rows): + claimant_ssi, _, _ = _income_ssi(rows) + expected = _oracle_ssi(*np.array(rows, dtype=float).T) + np.testing.assert_allclose(claimant_ssi, expected, atol=0.01) + + +# Role grid: adults "a" and "b" are married; "c" lives with them in its own +# marital unit. Every head/spouse assignment among the three is supplied. +ADULTS = ("a", "b", "c") +ROLE_ASSIGNMENTS = [ + (head, spouse) for head in ADULTS for spouse in (None, *ADULTS) if spouse != head +] +AGED = (False, True) +B_STATUS = ("CITIZEN", "UNDOCUMENTED") + + +def _role_situation(): + situation = { + plural: {} + for plural in ( + "people", + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ) + } + configurations = [] + income = {"a": 600, "b": 300, "c": 900} + for index, (aged, status, roles) in enumerate( + product(product(AGED, repeat=3), B_STATUS, ROLE_ASSIGNMENTS) + ): + head, spouse = roles + names = {adult: f"{adult}{index}" for adult in ADULTS} + for adult, is_aged in zip(ADULTS, aged): + person = { + "age": {YEAR: 70 if is_aged else 50}, + "pension_income": {YEAR: 12 * income[adult]}, + "is_tax_unit_head": {YEAR: adult == head}, + "is_tax_unit_spouse": {YEAR: adult == spouse}, + } + if adult == "b": + person["immigration_status"] = {YEAR: status} + situation["people"][names[adult]] = person + members = list(names.values()) + for plural in ("tax_units", "households", "families", "spm_units"): + situation[plural][f"u{index}"] = {"members": members} + situation["households"][f"u{index}"]["state_code"] = {YEAR: "TX"} + situation["marital_units"][f"couple{index}"] = { + "members": [names["a"], names["b"]] + } + situation["marital_units"][f"single{index}"] = {"members": [names["c"]]} + configurations.append((aged, status)) + return situation, configurations + + +@pytest.fixture(scope="module") +def role_grid(): + situation, configurations = _role_situation() + simulation = Simulation(situation=situation) + shape = (len(configurations) // len(ROLE_ASSIGNMENTS), len(ROLE_ASSIGNMENTS), 3) + + def calculate(variable, period): + return simulation.calculate(variable, period).reshape(shape) + + return { + "configurations": configurations[:: len(ROLE_ASSIGNMENTS)], + "joint": calculate("ssi_claim_is_joint", YEAR), + "ineligible_spouse": calculate("is_ssi_ineligible_spouse", YEAR), + "ssi": calculate("ssi", PERIOD), + } + + +def test_couple_status_and_ssi_do_not_depend_on_tax_roles(role_grid): + for name in ("joint", "ineligible_spouse", "ssi"): + values = role_grid[name] + assert np.all(values == values[:, :1, :]), name + + +def test_joint_claims_follow_the_eligible_couple_definition(role_grid): + joint = role_grid["joint"] + ineligible_spouse = role_grid["ineligible_spouse"] + for index, (aged, status) in enumerate(role_grid["configurations"]): + a_eligible = aged[0] + b_eligible = aged[1] and status == "CITIZEN" + couple = a_eligible and b_eligible + assert np.all(joint[index, :, 0] == couple) + assert np.all(joint[index, :, 1] == couple) + # The third adult is not married to either spouse. + assert not np.any(joint[index, :, 2]) + assert not np.any(ineligible_spouse[index, :, 2]) + assert np.all(ineligible_spouse[index, :, 0] == (b_eligible and not a_eligible)) + assert np.all(ineligible_spouse[index, :, 1] == (a_eligible and not b_eligible)) + assert not np.any(joint & ineligible_spouse) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_income_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_income_deemed_from_ineligible_spouse.yaml index a8f66e81e0d..2ec599e9540 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_income_deemed_from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_income_deemed_from_ineligible_spouse.yaml @@ -154,3 +154,43 @@ members: [person1, person2] output: ssi_income_deemed_from_ineligible_spouse: [0, 0] + +# 416.1160(d): 'Ineligible spouse means someone who lives with you as your +# husband or wife and is not eligible for SSI benefits.' Tax-unit roles do +# not decide it: here the model makes the elder head and the claimant the +# tax-unit spouse, and the claimant's husband a dependent. +# 2026 rates: $994 individual, $1,491 couple. +- name: Income of an ineligible spouse who is a tax-unit dependent is deemed + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 90 + social_security_retirement: 900 * 12 + claimant: + age: 70 + social_security_retirement: 600 * 12 + husband: + age: 60 + employment_income: 2_000 * 12 + tax_units: + tax_unit: + members: [elder, claimant, husband] + marital_units: + elder_unit: + members: [elder] + couple: + members: [claimant, husband] + households: + household: + members: [elder, claimant, husband] + state_code: FL + output: + is_ssi_ineligible_spouse: [false, false, true] + # $2,000 > $497, so income is deemed (416.1163(d)(2)). Couple countable + # income: ($600 - $20) + ($2,000 - $65) / 2 = $1,547.50; alone $580; + # deemed $967.50. $1,547.50 exceeds the $1,491 couple rate, so the + # claimant is ineligible on income. + ssi_income_deemed_from_ineligible_spouse: [0, 967.50, 0] + ssi: [114, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml index 569eef9cce1..540cefa8061 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml @@ -355,14 +355,18 @@ home: members: [mother, claimant, spouse] output: - is_ssi_spousal_resource_deeming_applies: [false, true, true] - ssi_resources_deemed_from_ineligible_spouse: [0, 3001, 0] + # 416.1801(c): two aged, blind or disabled spouses living together are an + # eligible couple whatever their tax roles, so 416.1205(b) combines their + # resources through the joint claim rather than spousal deeming. + ssi_claim_is_joint: [false, true, true] + is_ssi_spousal_resource_deeming_applies: [false, false, false] + ssi_resources_deemed_from_ineligible_spouse: [0, 0, 0] meets_ssi_resource_test: [true, false, false] # Explicit marital units stand whatever tax roles the model infers: with # roles omitted the model may treat one spouse as the mother's tax spouse and -# the other as her dependent, but 416.1205(b) still combines the couple's -# resources: 0 + 3,001 > 3,000. +# the other as her dependent, but the couple is still a joint claim and +# 416.1205(b) combines their resources: 0 + 3,001 > 3,000. - name: Married adults in a parent's tax unit with roles omitted count each other's resources period: 2026-01 input: @@ -394,7 +398,8 @@ home: members: [mother, claimant, spouse] output: - ssi_resources_deemed_from_ineligible_spouse: [0, 3001, 0] + ssi_claim_is_joint: [false, true, true] + ssi_resources_deemed_from_ineligible_spouse: [0, 0, 0] meets_ssi_resource_test: [true, false, false] # 416.1202(a)(2) excludes retroactive military payments of an ineligible @@ -441,7 +446,10 @@ home: members: [mother, claimant, spouse] output: - ssi_resources_deemed_from_ineligible_spouse: [0, 2000, 2000] + # The couple is a joint claim (416.1801(c)), so each spouse's 2,000 + # counts in full in the combined test. + ssi_claim_is_joint: [false, true, true] + ssi_resources_deemed_from_ineligible_spouse: [0, 0, 0] meets_ssi_resource_test: [true, false, false] # A spouse who fails SSI's citizenship or qualified-noncitizen condition is diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml index 2a93f0279ed..6502a2b2efc 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml @@ -156,3 +156,169 @@ members: [person1, person2, person3] output: ssi_claim_is_joint: [true, true, false] + +# 20 CFR 416.1801(c): an eligible spouse is a person eligible for SSI whom +# SSA considers the spouse of another person eligible for SSI and who lives +# in the same household with that person. Tax-unit roles play no part, so an +# aged couple whose tax unit also holds an older adult is a joint claim. +# 2026 rates: $994 individual, $1,491 couple; resource limits $2,000 and +# $3,000 (416.1205). +- name: Case 6, aged couple sharing a tax unit with an older adult is a joint claim. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 90 + social_security_retirement: 900 * 12 + husband: + age: 70 + social_security_retirement: 700 * 12 + bank_account_assets: 2_500 + wife: + age: 68 + social_security_retirement: 600 * 12 + tax_units: + tax_unit: + members: [elder, husband, wife] + marital_units: + elder_unit: + members: [elder] + couple: + members: [husband, wife] + households: + household: + members: [elder, husband, wife] + state_code: FL + output: + # The model makes the elder head and the husband the tax-unit spouse; + # the wife is a dependent. The couple is still an eligible couple. + ssi_claim_is_joint: [false, true, true] + # 416.1205(b): combined resources $2,500 <= $3,000. + meets_ssi_resource_test: [true, true, true] + # 416.1802(b)(1), 416.412: combined countable income $700 + $600 - $20 = + # $1,280; $1,491 - $1,280 = $211, $105.50 each. The elder is an + # individual: $994 - ($900 - $20) = $114. + ssi: [114, 105.50, 105.50] + +- name: Case 7, the same couple fails the couple resource limit together. + period: 2026-01 + input: + people: + elder: + age: 90 + husband: + age: 70 + bank_account_assets: 2_000 + wife: + age: 68 + bank_account_assets: 1_500 + tax_units: + tax_unit: + members: [elder, husband, wife] + marital_units: + elder_unit: + members: [elder] + couple: + members: [husband, wife] + households: + household: + members: [elder, husband, wife] + state_code: FL + output: + ssi_claim_is_joint: [false, true, true] + # 416.1205(b): $2,000 + $1,500 = $3,500 > $3,000. Tested one by one + # against the $2,000 individual limit both spouses would pass. + meets_ssi_resource_test: [true, false, false] + +- name: Case 8, aged couple who are both tax dependents of their son is a joint claim. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + son: + age: 45 + is_tax_unit_head: true + father: + age: 75 + is_tax_unit_dependent: true + social_security_retirement: 500 * 12 + mother: + age: 73 + is_tax_unit_dependent: true + social_security_retirement: 300 * 12 + tax_units: + tax_unit: + members: [son, father, mother] + marital_units: + son_unit: + members: [son] + couple: + members: [father, mother] + households: + household: + members: [son, father, mother] + state_code: FL + output: + ssi_claim_is_joint: [false, true, true] + # $1,491 - ($500 + $300 - $20) = $711, $355.50 each. + ssi: [0, 355.50, 355.50] + +- name: Case 9, an aged spouse who fails the citizenship condition does not make a joint claim. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + immigration_status: CITIZEN + social_security_retirement: 500 * 12 + spouse: + age: 68 + immigration_status: UNDOCUMENTED + pension_income: 700 * 12 + tax_units: + tax_unit: + members: [claimant, spouse] + marital_units: + marital_unit: + members: [claimant, spouse] + households: + household: + members: [claimant, spouse] + state_code: FL + output: + # 416.1801(c) requires an eligible spouse to be 'eligible for SSI'; this + # spouse is not (416.1160(d) ineligible spouse), so the claimant is an + # eligible individual and the spouse's income is deemed (416.1163): + # $700 > $497. Couple countable income $500 + $700 - $20 = $1,180; + # $1,491 - $1,180 = $311. Without deeming, $994 - $480 = $514, so the + # lesser is $311 (416.1163(e)(2)). + ssi_claim_is_joint: [false, false] + is_ssi_ineligible_spouse: [false, true] + ssi_income_deemed_from_ineligible_spouse: [700, 0] + ssi: [311, 0] + +- name: Case 10, spouses who live in different households are not a joint claim. + period: 2026-01 + input: + people: + husband: + age: 70 + wife: + age: 68 + tax_units: + tax_unit: + members: [husband, wife] + marital_units: + couple: + members: [husband, wife] + households: + home: + members: [husband] + state_code: FL + facility: + members: [wife] + state_code: FL + output: + ssi_claim_is_joint: [false, false] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml index bb5ab0ecb2f..d4cdce145dd 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml @@ -38,3 +38,133 @@ # 2025 individual federal benefit rate, no countable income ssi_if_takes_up: 967 ssi: 0 + +# 20 CFR 416.1163(e)(2): 'Your SSI benefit under the deeming rules cannot be +# higher than it would be if deeming did not apply. Therefore, your benefit +# is the lesser of the amount computed under the rules in paragraph (d)(2) of +# this section or the amount remaining after we subtract only your own +# countable income from an individual's Federal benefit rate.' +# 2026 Federal benefit rates: $994 individual, $1,491 couple; the deeming +# threshold is their difference, $497. +- name: Case 5, deemed benefit is the lesser of the couple computation and the benefit without deeming. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + social_security_retirement: 743 * 12 + bank_account_assets: 1_500 + spouse: + age: 64 + social_security_retirement: 100 * 12 + employment_income: 400 * 12 + tax_units: + tax_unit: + members: [claimant, spouse] + marital_units: + marital_unit: + members: [claimant, spouse] + households: + household: + members: [claimant, spouse] + state_code: FL + output: + # Spouse income $100 + $400 = $500 > $497, so deeming applies + # (416.1163(d)(2)). Couple countable income: unearned $743 + $100 - $20 + # = $823; earned ($400 - $65) / 2 = $167.50; total $990.50. + # Couple computation: $1,491 - $990.50 = $500.50. + # Without deeming: $994 - ($743 - $20) = $271. + ssi_countable_income: [990.50, 0] + uncapped_ssi: [500.50, 0] + uncapped_ssi_without_spousal_deeming: [271, 0] + ssi: [271, 0] + +- name: Case 6, the same couple with lower income and no deeming gets more SSI than Case 5. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + social_security_retirement: 579 * 12 + bank_account_assets: 1_500 + spouse: + age: 64 + social_security_retirement: 78 * 12 + employment_income: 400 * 12 + tax_units: + tax_unit: + members: [claimant, spouse] + marital_units: + marital_unit: + members: [claimant, spouse] + households: + household: + members: [claimant, spouse] + state_code: FL + output: + # Spouse income $78 + $400 = $478 <= $497: no income is deemed + # (416.1163(d)(1)), so SSI is $994 - ($579 - $20) = $435, above the $271 + # of Case 5 although every income is lower. + ssi_income_deemed_from_ineligible_spouse: [0, 0] + ssi: [435, 0] + +- name: Case 7, the benefit without deeming takes the one-third reduction of another person's household. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + social_security_retirement: 400 * 12 + ssi_federal_living_arrangement: ANOTHER_PERSONS_HOUSEHOLD + spouse: + age: 60 + employment_income: 600 * 12 + tax_units: + tax_unit: + members: [claimant, spouse] + marital_units: + marital_unit: + members: [claimant, spouse] + households: + household: + members: [claimant, spouse] + state_code: FL + output: + # 416.1131: the individual rate is reduced by one-third, $994 x 2/3 = + # $662.67; without deeming $662.67 - ($400 - $20) = $282.67. Under + # deeming the couple countable income is $380 + ($600 - $65) / 2 = + # $647.50, which leaves more than $282.67 of the couple rate whether or + # not that rate is reduced, so the benefit is $282.67. + uncapped_ssi_without_spousal_deeming: [282.67, 0] + ssi: [282.67, 0] + +- name: Case 8, no spousal income is deemed while the 30 dollar medical facility rate applies. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + ssi_federal_living_arrangement: MEDICAL_TREATMENT_FACILITY + spouse: + age: 60 + employment_income: 2_000 * 12 + tax_units: + tax_unit: + members: [claimant, spouse] + marital_units: + marital_unit: + members: [claimant, spouse] + households: + household: + members: [claimant, spouse] + state_code: FL + output: + # 416.1163(f)(5): 'If you become a resident of a medical care facility + # and the $30 Federal benefit rate applies, we do not deem your + # ineligible spouse's income to you'. The claimant has no income of + # their own, so SSI is the full $30. + ssi: [30, 0] diff --git a/policyengine_us/variables/gov/ssa/ssi/_ssi_living_arrangement_amount.py b/policyengine_us/variables/gov/ssa/ssi/_ssi_living_arrangement_amount.py new file mode 100644 index 00000000000..f357c6d462c --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/_ssi_living_arrangement_amount.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( + SSIFederalLivingArrangement, +) + + +def ssi_living_arrangement_amount(person, period, parameters, base_amount): + """Adjust a federal benefit rate for the person's living arrangement.""" + p = parameters(period).gov.ssa.ssi.amount + arrangement = person("ssi_federal_living_arrangement", period) + + # 20 CFR § 416.1131: One-third reduction for another person's + # household. Applies to the applicable FBR (individual or couple + # based on deeming). + is_another_household = ( + arrangement == SSIFederalLivingArrangement.ANOTHER_PERSONS_HOUSEHOLD + ) + base_amount = where( + is_another_household, + base_amount * (1 - p.one_third_reduction_rate), + base_amount, + ) + + # 42 USC § 1382(e)(1)(A), 20 CFR § 416.414: Medical treatment + # facility, $30/month per person. + is_medical_facility = ( + arrangement == SSIFederalLivingArrangement.MEDICAL_TREATMENT_FACILITY + ) + return where(is_medical_facility, p.medical_facility, base_amount) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md index 2458846818d..b839a548227 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md @@ -34,6 +34,14 @@ The implementation follows these steps according to the regulations: 5. **Calculate deemed income amount** - The deemed amount is the difference between the combined countable income and the individual's countable income +6. **Limit the benefit to the amount without deeming** (§416.1163(e)(2)) + - The benefit is the lesser of the couple computation and the individual Federal benefit rate less only the individual's own countable income (`uncapped_ssi_without_spousal_deeming`), applied in `ssi_if_takes_up` + - While the $30 medical facility rate applies, no spousal income is deemed (§416.1163(f)(5)) + +## Who is an ineligible spouse + +An ineligible spouse is "someone who lives with you as your husband or wife and is not eligible for SSI benefits" (§416.1160(d)). The spouse is found by the co-resident spouse test in `eligibility/resources/deemed/_ssi_spouses.py`, which resource deeming and joint claims also use, not by tax-unit head and spouse roles. A spouse who is aged, blind or disabled but fails the citizenship or qualified-noncitizen condition is an ineligible spouse. + ## Exclusions Applied Income exclusions are applied in the following order: diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py index 2be85271634..3dc7de68f08 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class ssi_earned_income_deemed_from_ineligible_spouse(Variable): @@ -11,18 +14,20 @@ class ssi_earned_income_deemed_from_ineligible_spouse(Variable): def formula(person, period, parameters): # First, determine unearned income from the ineligible spouse. - # This is (a) in the law. - ineligible_spouse = person("is_ssi_ineligible_spouse", period) + # This is (a) in the law. The spouse is the co-resident husband or + # wife (416.1160(d)), whatever their tax-unit role. + spouse = _ssi_spouse_index(person, period) + safe_spouse = np.maximum(spouse, 0) + has_ineligible_spouse = (spouse >= 0) & person( + "is_ssi_ineligible_spouse", period + )[safe_spouse] unearned_income = person("ssi_unearned_income", period) - marital_unit = person.marital_unit - ineligible_spousal_unearned_income = ( - marital_unit.sum(ineligible_spouse * unearned_income) - - ineligible_spouse * unearned_income + ineligible_spousal_unearned_income = where( + has_ineligible_spouse, unearned_income[safe_spouse], 0 ) earned_income = person("ssi_earned_income", period) - ineligible_spousal_earned_income = ( - marital_unit.sum(ineligible_spouse * earned_income) - - ineligible_spouse * earned_income + ineligible_spousal_earned_income = where( + has_ineligible_spouse, earned_income[safe_spouse], 0 ) # Next, (b) determine and subtract allocations for ineligible children. diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py index 93257349be8..536866b9aaf 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class ssi_unearned_income_deemed_from_ineligible_spouse(Variable): @@ -14,12 +17,16 @@ class ssi_unearned_income_deemed_from_ineligible_spouse(Variable): def formula(person, period, parameters): # First, determine unearned income from the ineligible spouse. - # This is (a) in the law. - ineligible_spouse = person("is_ssi_ineligible_spouse", period) + # This is (a) in the law. The spouse is the co-resident husband or + # wife (416.1160(d)), whatever their tax-unit role. + spouse = _ssi_spouse_index(person, period) + safe_spouse = np.maximum(spouse, 0) + has_ineligible_spouse = (spouse >= 0) & person( + "is_ssi_ineligible_spouse", period + )[safe_spouse] unearned_income = person("ssi_unearned_income", period) - ineligible_spousal_unearned_income = ( - person.marital_unit.sum(ineligible_spouse * unearned_income) - - ineligible_spouse * unearned_income + ineligible_spousal_unearned_income = where( + has_ineligible_spouse, unearned_income[safe_spouse], 0 ) # Next, (b) determine and subtract allocations for ineligible children. diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py index 4e813069180..ea43e26017a 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class ssi_marital_earned_income(Variable): @@ -6,13 +9,14 @@ class ssi_marital_earned_income(Variable): entity = Person label = "Total SSI earned income for a marital unit" definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1802#p-416.1802(b)(1)" + ) def formula(person, period, parameters): + # 416.1802(b)(1): with an eligible spouse 'we will count your combined + # income'. Combine the two spouses, not every member of the unit. couple_computation = person("ssi_couple_computation_applies", period) earned_income = person("ssi_earned_income", period) - - return where( - couple_computation, - person.marital_unit.sum(earned_income), - earned_income, - ) + spouse = np.maximum(_ssi_spouse_index(person, period), 0) + return earned_income + where(couple_computation, earned_income[spouse], 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_unearned_income.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_unearned_income.py index 0fde0dee8bd..e4e786b7f4d 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_unearned_income.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_unearned_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class ssi_marital_unearned_income(Variable): @@ -6,13 +9,14 @@ class ssi_marital_unearned_income(Variable): entity = Person label = "Total SSI unearned income for a marital unit" definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1802#p-416.1802(b)(1)" + ) def formula(person, period, parameters): + # 416.1802(b)(1): with an eligible spouse 'we will count your combined + # income'. Combine the two spouses, not every member of the unit. couple_computation = person("ssi_couple_computation_applies", period) unearned_income = person("ssi_unearned_income", period) - - return where( - couple_computation, - person.marital_unit.sum(unearned_income), - unearned_income, - ) + spouse = np.maximum(_ssi_spouse_index(person, period), 0) + return unearned_income + where(couple_computation, unearned_income[spouse], 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py index fbd5f09ede5..51780f16add 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py @@ -67,3 +67,20 @@ def _ssi_spouse_index(person, period): """The row of each person's co-resident spouse, or -1.""" first, second = co_resident_parent_indices(person, period) return _ssi_established_spouse_indices(person, period, first, second) + + +def _ssi_eligible_apart_from_means(person, period): + """Aged, blind or disabled and meeting SSI's citizenship condition. + + 416.1801(c) makes an eligible spouse someone 'who is eligible for SSI', + and 416.1160(d) makes an ineligible spouse one who 'is not eligible for + SSI benefits'. Couples are classified before the income and resource + tests, which depend on the classification, so this tests the conditions + that do not: aged, blind or disabled status, and citizenship or + qualified-noncitizen status (as is_ssi_eligible requires). + """ + immigration_status = person("immigration_status", period) + is_citizen = immigration_status == immigration_status.possible_values.CITIZEN + return person("is_ssi_aged_blind_disabled", period) & ( + is_citizen | person("is_ssi_qualified_noncitizen", period) + ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.py index 6def145a7ea..08d6bbe113f 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_spouse.py @@ -1,5 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_eligible_apart_from_means, _ssi_spouse_index, ) @@ -26,11 +27,7 @@ def formula(person, period, parameters): # A spouse who fails SSI's citizenship or qualified-noncitizen # condition is 'not eligible for SSI benefits' (416.1160) whatever # their age or disability, so keeps the ineligible-spouse exclusions. - immigration_status = person("immigration_status", period) - could_be_eligible = person("is_ssi_aged_blind_disabled", period) & ( - (immigration_status == immigration_status.possible_values.CITIZEN) - | person("is_ssi_qualified_noncitizen", period) - ) + could_be_eligible = _ssi_eligible_apart_from_means(person, period) resources = where( could_be_eligible[spouse], person("ssi_countable_resources", period)[spouse], diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py index 8a593952788..a015fb64c94 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class meets_ssi_resource_test(Variable): @@ -25,9 +28,12 @@ def formula(person, period, parameters): "ssi_resources_deemed_from_ineligible_parent", ], ) + # 416.1205(b): an eligible couple's combined resources. The joint + # claim pairs two spouses, so add the spouse rather than the unit. + spouse = np.maximum(_ssi_spouse_index(person, period), 0) countable_resources = where( joint_claim, - person.marital_unit.sum(personal_resources), + personal_resources + personal_resources[spouse], personal_resources + deemed_resources, ) # 416.1205(a): an individual living with an ineligible spouse counts diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py index 5c74f441fb7..18109a9fa9c 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py @@ -10,6 +10,8 @@ class is_ssi_eligible_spouse(Variable): reference = "https://www.law.cornell.edu/uscode/text/42/1382c#b" def formula(person, period, parameters): - both_aged_blind_disabled = person("ssi_marital_both_eligible", period) + # Both members of a joint claim are eligible spouses. The member in + # the tax-unit spouse role carries this label and the other remains + # the eligible individual, so each couple keeps one of each. spouse = person("is_tax_unit_spouse", period) - return spouse & both_aged_blind_disabled + return spouse & person("ssi_claim_is_joint", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py index 7f1f4b45070..f7662d94ec7 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_eligible_apart_from_means, + _ssi_spouse_index, +) class is_ssi_ineligible_spouse(Variable): @@ -7,18 +11,17 @@ class is_ssi_ineligible_spouse(Variable): label = "Is an SSI-ineligible spouse" unit = USD definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/42/1382c#b" + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1382c#b", + "https://www.ecfr.gov/current/title-20/section-416.1160#p-416.1160(d)", + "https://www.ecfr.gov/current/title-20/section-416.1806", + ) def formula(person, period, parameters): - # Check if person is a spouse (either tax unit spouse or head) - is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) - - # Check SSI eligibility status - eligible = person("is_ssi_aged_blind_disabled", period) - eligible_spouse = person("is_ssi_eligible_spouse", period) - - # A person is an ineligible spouse if they: - # 1. Are a spouse (either tax unit spouse or head) - # 2. Are not SSI eligible themselves - # 3. Are not an SSI eligible spouse - return is_head_or_spouse & ~eligible_spouse & ~eligible + # 416.1160(d): 'Ineligible spouse means someone who lives with you as + # your husband or wife and is not eligible for SSI benefits.' The + # spouse test is the one joint claims and resource deeming use, so a + # spouse who is not a tax-unit head or spouse still counts. + eligible = _ssi_eligible_apart_from_means(person, period) + spouse = _ssi_spouse_index(person, period) + return ~eligible & (spouse >= 0) & eligible[np.maximum(spouse, 0)] diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py index bfe5cdf394a..4f37d566694 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( - SSIFederalLivingArrangement, +from policyengine_us.variables.gov.ssa.ssi._ssi_living_arrangement_amount import ( + ssi_living_arrangement_amount, ) @@ -14,11 +14,11 @@ class ssi_amount_if_eligible(Variable): def formula(person, period, parameters): p = parameters(period).gov.ssa.ssi.amount - arrangement = person("ssi_federal_living_arrangement", period) # 20 CFR 416.414(b): Benefit rate depends on couple computation: # 1. Couple computation applies → couple rate / 2 per person - # 2. No couple computation, deeming → couple rate (capped in ssi) + # 2. No couple computation, deeming → couple rate (limited by + # 416.1163(e)(2) in ssi_if_takes_up) # 3. No couple computation, no deeming → individual rate # # ssi_couple_computation_applies is false when only one spouse @@ -48,23 +48,4 @@ def formula(person, period, parameters): person("is_child", period.this_year), p.individual, base_amount ) - is_medical_facility = ( - arrangement == SSIFederalLivingArrangement.MEDICAL_TREATMENT_FACILITY - ) - - # 20 CFR § 416.1131: One-third reduction for another person's - # household. Applies to the applicable FBR (individual or couple - # based on deeming). - is_another_household = ( - arrangement == SSIFederalLivingArrangement.ANOTHER_PERSONS_HOUSEHOLD - ) - vtr_rate = p.one_third_reduction_rate - base_amount = where( - is_another_household, - base_amount * (1 - vtr_rate), - base_amount, - ) - - # 42 USC § 1382(e)(1)(A), 20 CFR § 416.414: Medical treatment - # facility, $30/month per person. - return where(is_medical_facility, p.medical_facility, base_amount) + return ssi_living_arrangement_amount(person, period, parameters, base_amount) diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py b/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py index 7f90b60888e..6ccb9d46071 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_eligible_apart_from_means, + _ssi_spouse_index, +) class ssi_claim_is_joint(Variable): @@ -6,10 +10,21 @@ class ssi_claim_is_joint(Variable): entity = Person label = "SSI claim is joint" definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1382c#b", + "https://www.ecfr.gov/current/title-20/section-416.1801", + "https://www.ecfr.gov/current/title-20/section-416.1802", + "https://www.ecfr.gov/current/title-20/section-416.1806", + ) def formula(person, period, parameters): - abd_person = person("is_ssi_aged_blind_disabled", period) - is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) - eligible_person = abd_person & is_head_or_spouse - - return person.marital_unit.sum(eligible_person) > 1 + # 416.1801(c): an eligible spouse is eligible for SSI, is the spouse + # of another person eligible for SSI, and lives in the same household + # with that person. Spouses are found by the test resource deeming + # uses (416.1806: husband or wife, living together), not by tax-unit + # head and spouse roles, which go to the two oldest adults of a tax + # unit when roles are not supplied. A spouse who fails the + # citizenship condition is an ineligible spouse (416.1160(d)). + eligible = _ssi_eligible_apart_from_means(person, period) + spouse = _ssi_spouse_index(person, period) + return eligible & (spouse >= 0) & eligible[np.maximum(spouse, 0)] diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_couple_computation_applies.py b/policyengine_us/variables/gov/ssa/ssi/ssi_couple_computation_applies.py index 9befe7a063f..a960bb7ea94 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_couple_computation_applies.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_couple_computation_applies.py @@ -2,6 +2,9 @@ from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( SSIFederalLivingArrangement, ) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class ssi_couple_computation_applies(Variable): @@ -31,6 +34,7 @@ def formula(person, period, parameters): in_facility = ( arrangement == SSIFederalLivingArrangement.MEDICAL_TREATMENT_FACILITY ) - one_in_facility = person.marital_unit.sum(in_facility) == 1 + spouse = np.maximum(_ssi_spouse_index(person, period.this_year), 0) + one_in_facility = in_facility != in_facility[spouse] return joint_claim & ~one_in_facility diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py index 7e6a85efd5c..cf4e97c9b55 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( + SSIFederalLivingArrangement, +) class ssi_if_takes_up(Variable): @@ -11,29 +14,37 @@ class ssi_if_takes_up(Variable): ) unit = USD definition_period = MONTH - reference = "https://www.law.cornell.edu/uscode/text/42/1382" + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1382", + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(e)(2)", + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(f)(5)", + ) def formula(person, period, parameters): if parameters(period).gov.ssa.ssi.abolish_ssi: return 0 - uncapped = person("uncapped_ssi", period) - # Apply floor: SSI cannot be negative - benefit = max_(0, uncapped) - - # Apply cap when spousal deeming: cannot exceed individual FBR - # per 20 CFR §416.1163 - # This cap is necessary when spouse's gross income is just above $483: - # - Deeming applies (uses couple FBR) - # - After exclusions, countable may be low - # - Benefit could exceed individual FBR without this cap - deeming_applies = person("is_ssi_spousal_deeming_applies", period.this_year) - p = parameters(period).gov.ssa.ssi.amount - capped_benefit = min_(benefit, p.individual) + benefit = max_(0, person("uncapped_ssi", period)) - return where( - deeming_applies, - capped_benefit, - benefit, + # 20 CFR 416.1163(e)(2): 'Your SSI benefit under the deeming rules + # cannot be higher than it would be if deeming did not apply. + # Therefore, your benefit is the lesser of the amount computed under + # the rules in paragraph (d)(2) of this section or the amount + # remaining after we subtract only your own countable income from an + # individual's Federal benefit rate.' + without_deeming = max_( + 0, person("uncapped_ssi_without_spousal_deeming", period) ) + deemed_benefit = min_(benefit, without_deeming) + # 416.1163(f)(5): while the $30 Federal benefit rate applies, we do + # not deem an ineligible spouse's income, so only the person's own + # countable income counts. + arrangement = person("ssi_federal_living_arrangement", period) + in_medical_facility = ( + arrangement == SSIFederalLivingArrangement.MEDICAL_TREATMENT_FACILITY + ) + deemed_benefit = where(in_medical_facility, without_deeming, deemed_benefit) + + deeming_applies = person("is_ssi_spousal_deeming_applies", period.this_year) + return where(deeming_applies, deemed_benefit, benefit) diff --git a/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.py b/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.py new file mode 100644 index 00000000000..de6ab525097 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi._ssi_living_arrangement_amount import ( + ssi_living_arrangement_amount, +) + + +class uncapped_ssi_without_spousal_deeming(Variable): + value_type = float + entity = Person + label = "Uncapped SSI without spousal deeming" + unit = USD + documentation = ( + "Individual federal benefit rate less only the person's own countable " + "income, the amount SSI under spousal deeming cannot exceed (can be " + "below zero)." + ) + definition_period = MONTH + defined_for = "is_ssi_eligible" + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(e)(2)", + ) + + def formula(person, period, parameters): + # 416.1163(e)(2): 'the amount remaining after we subtract only your + # own countable income from an individual's Federal benefit rate'. + # The rate takes the same living-arrangement adjustments as the rate + # under deeming. + p = parameters(period).gov.ssa.ssi.amount + amount = ssi_living_arrangement_amount(person, period, parameters, p.individual) + # Countable income adds income deemed from an ineligible spouse to the + # person's own countable income; remove it. + own_countable_income = person("ssi_countable_income", period) - person( + "ssi_income_deemed_from_ineligible_spouse", period + ) + return amount - own_countable_income From 2924cd90da613d82f9f1a6aeabeb73ae3a261f81 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:33:46 -0400 Subject: [PATCH 02/25] Test 416.1163(e)(2) against the 416.1163(g) Smith and Jones examples Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/ssa/ssi/ssi_if_takes_up.yaml | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml index d4cdce145dd..c07f23bae18 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml @@ -168,3 +168,71 @@ # ineligible spouse's income to you'. The claimant has no income of # their own, so SSI is the full $30. ssi: [30, 0] + +# 416.1163(g) Examples 3 and 2 (1986: individual rate $336, couple rate $504, +# deeming threshold $168). The examples stop at eligibility; the benefit +# follows from (e)(2). The examples deem July income for September +# (416.1163(e)(1)); with constant income the amounts are the same. Annual +# amounts are twelve times the monthly ones. +- name: Case 9, 416.1163(g) Example 3 (Mr. Smith) pays the lesser amount under 416.1163(e)(2). + period: 1986 + absolute_error_margin: 0.01 + input: + people: + mr_smith: + age: 50 + is_ssi_disabled: true + pension_income: 100 * 12 + mrs_smith: + age: 50 + employment_income: 201 * 12 + tax_units: + tax_unit: + members: [mr_smith, mrs_smith] + marital_units: + marital_unit: + members: [mr_smith, mrs_smith] + households: + household: + members: [mr_smith, mrs_smith] + output: + # Example 3: '$80 ... leaving $68. This gives the couple total countable + # income of $148.' Couple computation $504 - $148 = $356; without + # deeming $336 - $80 = $256; the lesser is $256. + ssi_countable_income: [1_776, 0] # $148 a month + uncapped_ssi: [4_272, 0] # $356 a month + uncapped_ssi_without_spousal_deeming: [3_072, 0] # $256 a month + ssi: [3_072, 0] # $256 a month + +- name: Case 10, 416.1163(g) Example 2 (Mr. Jones) keeps the couple computation when it is lower. + period: 1986 + absolute_error_margin: 0.01 + input: + people: + mr_jones: + age: 50 + is_ssi_disabled: true + mrs_jones: + age: 48 + employment_income: 401 * 12 + pension_income: 252 * 12 + christine: + age: 10 + tax_units: + tax_unit: + members: [mr_jones, mrs_jones, christine] + marital_units: + marital_unit: + members: [mr_jones, mrs_jones] + child_unit: + members: [christine] + households: + household: + members: [mr_jones, mrs_jones, christine] + output: + # Example 2: allocation $168 for Christine, leaving $84 unearned; + # '$64 countable unearned income and $168 countable earned income ... + # ($232)'. Couple computation $504 - $232 = $272; without deeming + # $336 - $0 = $336; the lesser is $272. + ssi_countable_income: [2_784, 0, 0] # $232 a month + ssi: [3_264, 0, 0] # $272 a month From 56579ca544d84819db208a026c39df3e27605aab Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:09:43 -0400 Subject: [PATCH 03/25] Keep SSI joint claims ABD-only and apply 416.1163(e)(2) in uncapped_ssi Review round 1 found three regressions. Requiring the citizenship condition in ssi_claim_is_joint broke California CAPI's couple rate and Medicaid's senior/disabled countable income, which read the joint claim as the SSI couple methodology, so the joint claim and ineligible-spouse status test only aged, blind or disabled status again (still through the co-resident spouse test). The resource test keeps #10040's treatment of a citizenship-ineligible spouse through _ssi_resource_couple. Alabama's supplement read uncapped_ssi > 0 and paid when SSI was zero, so the lesser-of rule moves into uncapped_ssi before the floor. The 416.1163(f)(5) facility rule covered only the payment and is dropped for a follow-up. The invariant test drops the citizenship axis, adds mixed household sizes, and suppresses Hypothesis's slow-data check. Co-Authored-By: Claude Opus 5.5 --- .../ssi-couples-and-deeming-cap.fixed.md | 2 +- .../test_ssi_spousal_deeming_invariants.py | 158 ++++++++++++------ ..._ssi_spousal_resource_deeming_applies.yaml | 35 ++++ .../gov/ssa/ssi/ssi_claim_is_joint.yaml | 37 +--- .../baseline/gov/ssa/ssi/ssi_if_takes_up.yaml | 22 +-- .../gov/states/al/dhr/ssp/integration.yaml | 35 ++++ .../deemed/from_ineligible_spouse/README.md | 5 +- .../resources/deemed/_ssi_spouses.py | 24 ++- ...is_ssi_spousal_resource_deeming_applies.py | 9 +- .../resources/meets_ssi_resource_test.py | 7 +- .../status/is_ssi_ineligible_spouse.py | 8 +- .../gov/ssa/ssi/ssi_amount_if_eligible.py | 2 +- .../gov/ssa/ssi/ssi_claim_is_joint.py | 20 +-- .../variables/gov/ssa/ssi/ssi_if_takes_up.py | 37 +--- .../variables/gov/ssa/ssi/uncapped_ssi.py | 17 +- 15 files changed, 255 insertions(+), 163 deletions(-) diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index 9e9e95f0888..7f519746601 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1 +1 @@ -SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate, and no spousal income is deemed while the $30 medical facility rate applies (416.1163(f)(5)). SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, and a spouse who fails the citizenship condition is an ineligible spouse: joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). +SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py index bff46b626b0..267517df435 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -10,12 +10,13 @@ rate. SSI must equal an independent numpy reading of 20 CFR 416.1163, never exceed the benefit without deeming (416.1163(e)(2)), and never rise when any income rises. -- A role grid of 144 three-adult households: a married couple living with a +- A role grid of 72 three-adult households: a married couple living with a third adult, over all nine assignments of tax-unit head and spouse roles - among the three adults, aged or not for each adult, and citizen or - undocumented for one spouse. Couple status and SSI must not depend on tax roles - (416.1801(c), 416.1806, 416.1160(d)), and joint claims must be symmetric and - disjoint from ineligible-spouse status. + among the three adults and aged or not for each adult, plus two-person and + one-person households in the same simulation. Couple status and SSI must + not depend on tax roles or on the third adult (416.1801(c), 416.1806, + 416.1160(d)), and joint claims must be symmetric and disjoint from + ineligible-spouse status. YAML covers worked cases. Python is needed to check the for-all properties over every ordered edge of the grids and to compare with an oracle that does @@ -27,7 +28,7 @@ import numpy as np import pytest -from hypothesis import given, settings +from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_us import Simulation @@ -150,7 +151,12 @@ def test_ssi_never_rises_with_income(income_grid, axis): assert np.all(np.diff(income_grid["ssi"], axis=axis) <= 0.01) -@settings(max_examples=3, deadline=None, derandomize=True) +@settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) @given( st.lists( st.tuples( @@ -170,12 +176,23 @@ def test_random_incomes_match_independent_oracle(rows): # Role grid: adults "a" and "b" are married; "c" lives with them in its own # marital unit. Every head/spouse assignment among the three is supplied. +# The same simulation also holds two-person and one-person households, so +# household positions differ across rows. ADULTS = ("a", "b", "c") ROLE_ASSIGNMENTS = [ (head, spouse) for head in ADULTS for spouse in (None, *ADULTS) if spouse != head ] AGED = (False, True) -B_STATUS = ("CITIZEN", "UNDOCUMENTED") +INCOME = {"a": 600, "b": 300, "c": 900} + + +def _adult(age, income, head=False, spouse=False): + return { + "age": {YEAR: age}, + "pension_income": {YEAR: 12 * income}, + "is_tax_unit_head": {YEAR: head}, + "is_tax_unit_spouse": {YEAR: spouse}, + } def _role_situation(): @@ -190,49 +207,65 @@ def _role_situation(): "spm_units", ) } - configurations = [] - income = {"a": 600, "b": 300, "c": 900} - for index, (aged, status, roles) in enumerate( - product(product(AGED, repeat=3), B_STATUS, ROLE_ASSIGNMENTS) - ): - head, spouse = roles - names = {adult: f"{adult}{index}" for adult in ADULTS} - for adult, is_aged in zip(ADULTS, aged): - person = { - "age": {YEAR: 70 if is_aged else 50}, - "pension_income": {YEAR: 12 * income[adult]}, - "is_tax_unit_head": {YEAR: adult == head}, - "is_tax_unit_spouse": {YEAR: adult == spouse}, - } - if adult == "b": - person["immigration_status"] = {YEAR: status} - situation["people"][names[adult]] = person - members = list(names.values()) + + def add_household(key, people, marital_units): + situation["people"].update(people) for plural in ("tax_units", "households", "families", "spm_units"): - situation[plural][f"u{index}"] = {"members": members} - situation["households"][f"u{index}"]["state_code"] = {YEAR: "TX"} - situation["marital_units"][f"couple{index}"] = { - "members": [names["a"], names["b"]] + situation[plural][key] = {"members": list(people)} + situation["households"][key]["state_code"] = {YEAR: "TX"} + for index, members in enumerate(marital_units): + situation["marital_units"][f"{key}_m{index}"] = {"members": members} + + configurations = list(product(product(AGED, repeat=3), ROLE_ASSIGNMENTS)) + for index, (aged, (head, spouse)) in enumerate(configurations): + names = {adult: f"{adult}{index}" for adult in ADULTS} + people = { + names[adult]: _adult( + 70 if is_aged else 50, + INCOME[adult], + head=adult == head, + spouse=adult == spouse, + ) + for adult, is_aged in zip(ADULTS, aged) + } + add_household(f"u{index}", people, [[names["a"], names["b"]], [names["c"]]]) + extras = list(product(AGED, repeat=2)) + for index, (a_aged, b_aged) in enumerate(extras): + couple = { + f"pa{index}": _adult(70 if a_aged else 50, INCOME["a"], head=True), + f"pb{index}": _adult(70 if b_aged else 50, INCOME["b"], spouse=True), } - situation["marital_units"][f"single{index}"] = {"members": [names["c"]]} - configurations.append((aged, status)) - return situation, configurations + add_household(f"pair{index}", couple, [list(couple)]) + single = {f"s{index}": _adult(70 if a_aged else 50, INCOME["c"], head=True)} + add_household(f"single{index}", single, [list(single)]) + return situation, configurations, extras @pytest.fixture(scope="module") def role_grid(): - situation, configurations = _role_situation() + situation, configurations, extras = _role_situation() simulation = Simulation(situation=situation) + grid_people = 3 * len(configurations) shape = (len(configurations) // len(ROLE_ASSIGNMENTS), len(ROLE_ASSIGNMENTS), 3) def calculate(variable, period): - return simulation.calculate(variable, period).reshape(shape) + values = simulation.calculate(variable, period) + return values[:grid_people].reshape(shape), values[grid_people:].reshape( + len(extras), 3 + ) + joint, joint_extra = calculate("ssi_claim_is_joint", YEAR) + ineligible, ineligible_extra = calculate("is_ssi_ineligible_spouse", YEAR) + ssi, ssi_extra = calculate("ssi", PERIOD) return { - "configurations": configurations[:: len(ROLE_ASSIGNMENTS)], - "joint": calculate("ssi_claim_is_joint", YEAR), - "ineligible_spouse": calculate("is_ssi_ineligible_spouse", YEAR), - "ssi": calculate("ssi", PERIOD), + "aged": [aged for aged, _ in configurations[:: len(ROLE_ASSIGNMENTS)]], + "joint": joint, + "ineligible_spouse": ineligible, + "ssi": ssi, + "extras": extras, + "joint_extra": joint_extra, + "ineligible_extra": ineligible_extra, + "ssi_extra": ssi_extra, } @@ -242,18 +275,37 @@ def test_couple_status_and_ssi_do_not_depend_on_tax_roles(role_grid): assert np.all(values == values[:, :1, :]), name -def test_joint_claims_follow_the_eligible_couple_definition(role_grid): - joint = role_grid["joint"] - ineligible_spouse = role_grid["ineligible_spouse"] - for index, (aged, status) in enumerate(role_grid["configurations"]): - a_eligible = aged[0] - b_eligible = aged[1] and status == "CITIZEN" - couple = a_eligible and b_eligible - assert np.all(joint[index, :, 0] == couple) - assert np.all(joint[index, :, 1] == couple) +def test_couple_status_matches_the_couple_definitions(role_grid): + # 416.1801(c) and 416.1160(d), with aged, blind or disabled status + # standing for SSI eligibility (ssi_claim_is_joint does not apply the + # citizenship condition). + for index, aged in enumerate(role_grid["aged"]): + couple = aged[0] and aged[1] + assert np.all(role_grid["joint"][index, :, :2] == couple) # The third adult is not married to either spouse. - assert not np.any(joint[index, :, 2]) - assert not np.any(ineligible_spouse[index, :, 2]) - assert np.all(ineligible_spouse[index, :, 0] == (b_eligible and not a_eligible)) - assert np.all(ineligible_spouse[index, :, 1] == (a_eligible and not b_eligible)) - assert not np.any(joint & ineligible_spouse) + assert not np.any(role_grid["joint"][index, :, 2]) + assert not np.any(role_grid["ineligible_spouse"][index, :, 2]) + ineligible = role_grid["ineligible_spouse"][index] + assert np.all(ineligible[:, 0] == (aged[1] and not aged[0])) + assert np.all(ineligible[:, 1] == (aged[0] and not aged[1])) + assert not np.any(role_grid["joint"] & role_grid["ineligible_spouse"]) + + +def test_role_grid_couples_match_the_same_couple_living_alone(role_grid): + # Columns are (pair a, pair b, single). A couple's status and SSI do not + # change when a third adult shares their household and tax unit. + joint_extra = role_grid["joint_extra"] + ssi_extra = role_grid["ssi_extra"] + for index, (a_aged, b_aged) in enumerate(role_grid["extras"]): + assert np.all(joint_extra[index, :2] == (a_aged and b_aged)) + assert not joint_extra[index, 2] + assert not role_grid["ineligible_extra"][index, 2] + rows = [ + row + for row, aged in enumerate(role_grid["aged"]) + if aged[:2] == (a_aged, b_aged) + ] + for row in rows: + np.testing.assert_allclose( + role_grid["ssi"][row, 0, :2], ssi_extra[index, :2], atol=0.01 + ) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml index 540cefa8061..d3bb2ad88cb 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml @@ -500,3 +500,38 @@ ssi_resources_deemed_from_ineligible_spouse: [0, 1001, 0] meets_ssi_resource_test: [true, true, false] +# The same rule for a couple in the usual tax-unit head and spouse roles: +# both are aged, so their claim is joint, but the spouse is not eligible for +# SSI (416.1801(c)(1), 416.1160(d)). Resources are deemed with the +# ineligible-spouse exclusion: 3,001 - 2,000 = 1,001 <= 3,000. +- name: An immigration-ineligible aged spouse in the tax-unit spouse role keeps ineligible-spouse exclusions + period: 2026-01 + input: + people: + claimant: + age: 70 + immigration_status: CITIZEN + is_tax_unit_head: true + ssi_countable_resources: + '2026': 0 + spouse: + age: 68 + immigration_status: UNDOCUMENTED + is_tax_unit_spouse: true + ssi_countable_resources: + '2026': 3001 + ssi_resource_deeming_excluded_military_resources: 2000 + tax_units: + tax_unit: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + output: + ssi_claim_is_joint: [true, true] + is_ssi_spousal_resource_deeming_applies: [true, true] + ssi_resources_deemed_from_ineligible_spouse: [1001, 0] + meets_ssi_resource_test: [true, false] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml index 6502a2b2efc..00448eb3134 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_claim_is_joint.yaml @@ -264,42 +264,7 @@ # $1,491 - ($500 + $300 - $20) = $711, $355.50 each. ssi: [0, 355.50, 355.50] -- name: Case 9, an aged spouse who fails the citizenship condition does not make a joint claim. - period: 2026-01 - absolute_error_margin: 0.01 - input: - people: - claimant: - age: 70 - immigration_status: CITIZEN - social_security_retirement: 500 * 12 - spouse: - age: 68 - immigration_status: UNDOCUMENTED - pension_income: 700 * 12 - tax_units: - tax_unit: - members: [claimant, spouse] - marital_units: - marital_unit: - members: [claimant, spouse] - households: - household: - members: [claimant, spouse] - state_code: FL - output: - # 416.1801(c) requires an eligible spouse to be 'eligible for SSI'; this - # spouse is not (416.1160(d) ineligible spouse), so the claimant is an - # eligible individual and the spouse's income is deemed (416.1163): - # $700 > $497. Couple countable income $500 + $700 - $20 = $1,180; - # $1,491 - $1,180 = $311. Without deeming, $994 - $480 = $514, so the - # lesser is $311 (416.1163(e)(2)). - ssi_claim_is_joint: [false, false] - is_ssi_ineligible_spouse: [false, true] - ssi_income_deemed_from_ineligible_spouse: [700, 0] - ssi: [311, 0] - -- name: Case 10, spouses who live in different households are not a joint claim. +- name: Case 9, spouses who live in different households are not a joint claim. period: 2026-01 input: people: diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml index c07f23bae18..8f61d6d5820 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_if_takes_up.yaml @@ -76,8 +76,8 @@ # Couple computation: $1,491 - $990.50 = $500.50. # Without deeming: $994 - ($743 - $20) = $271. ssi_countable_income: [990.50, 0] - uncapped_ssi: [500.50, 0] uncapped_ssi_without_spousal_deeming: [271, 0] + uncapped_ssi: [271, 0] ssi: [271, 0] - name: Case 6, the same couple with lower income and no deeming gets more SSI than Case 5. @@ -141,17 +141,17 @@ uncapped_ssi_without_spousal_deeming: [282.67, 0] ssi: [282.67, 0] -- name: Case 8, no spousal income is deemed while the 30 dollar medical facility rate applies. +- name: Case 8, a claimant whose own income exceeds the individual rate gets no SSI under deeming. period: 2026-01 absolute_error_margin: 0.01 input: people: claimant: age: 70 - ssi_federal_living_arrangement: MEDICAL_TREATMENT_FACILITY + social_security_retirement: 1_100 * 12 spouse: age: 60 - employment_income: 2_000 * 12 + employment_income: 600 * 12 tax_units: tax_unit: members: [claimant, spouse] @@ -163,11 +163,13 @@ members: [claimant, spouse] state_code: FL output: - # 416.1163(f)(5): 'If you become a resident of a medical care facility - # and the $30 Federal benefit rate applies, we do not deem your - # ineligible spouse's income to you'. The claimant has no income of - # their own, so SSI is the full $30. - ssi: [30, 0] + # Couple computation: $1,491 - ($1,080 + ($600 - $65) / 2) = $143.50. + # Without deeming: $994 - $1,080 = -$86, so the lesser is -$86 and the + # benefit is $0 (416.1163(e)(2)). Programs that read uncapped_ssi see + # the same shortfall. + uncapped_ssi_without_spousal_deeming: [-86, 0] + uncapped_ssi: [-86, 0] + ssi: [0, 0] # 416.1163(g) Examples 3 and 2 (1986: individual rate $336, couple rate $504, # deeming threshold $168). The examples stop at eligibility; the benefit @@ -200,8 +202,8 @@ # income of $148.' Couple computation $504 - $148 = $356; without # deeming $336 - $80 = $256; the lesser is $256. ssi_countable_income: [1_776, 0] # $148 a month - uncapped_ssi: [4_272, 0] # $356 a month uncapped_ssi_without_spousal_deeming: [3_072, 0] # $256 a month + uncapped_ssi: [3_072, 0] # $256 a month ssi: [3_072, 0] # $256 a month - name: Case 10, 416.1163(g) Example 2 (Mr. Jones) keeps the couple computation when it is lower. diff --git a/policyengine_us/tests/policy/baseline/gov/states/al/dhr/ssp/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/al/dhr/ssp/integration.yaml index 84e607ca01e..001de27f8bc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/al/dhr/ssp/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/al/dhr/ssp/integration.yaml @@ -513,3 +513,38 @@ al_ssp_payment_category: FOSTER_CARE al_ssp_eligible: [true] al_ssp: [110] + +- name: Case 18, no supplement when spousal deeming leaves no federal SSI. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + social_security_retirement: 1_100 * 12 + al_ssp_payment_category: IHC_LEVEL_A + al_receives_elderly_disabled_medicaid_waiver: true + al_meets_snf_criteria: true + spouse: + age: 60 + employment_income: 600 * 12 + tax_units: + tax_unit: + members: [claimant, spouse] + marital_units: + marital_unit: + members: [claimant, spouse] + spm_units: + spm_unit: + members: [claimant, spouse] + households: + household: + members: [claimant, spouse] + state_code: AL + output: + # 20 CFR 416.1163(e)(2): the couple computation leaves $143.50, but the + # claimant's own countable income ($1,080) exceeds the $994 individual + # rate, so federal SSI is $0 and the SSI-receipt condition fails. + ssi: [0, 0] + al_ssp_eligible: [false, false] + al_ssp: [0, 0] diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md index b839a548227..38751797a67 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md @@ -35,12 +35,11 @@ The implementation follows these steps according to the regulations: - The deemed amount is the difference between the combined countable income and the individual's countable income 6. **Limit the benefit to the amount without deeming** (§416.1163(e)(2)) - - The benefit is the lesser of the couple computation and the individual Federal benefit rate less only the individual's own countable income (`uncapped_ssi_without_spousal_deeming`), applied in `ssi_if_takes_up` - - While the $30 medical facility rate applies, no spousal income is deemed (§416.1163(f)(5)) + - The benefit is the lesser of the couple computation and the individual Federal benefit rate less only the individual's own countable income (`uncapped_ssi_without_spousal_deeming`), applied in `uncapped_ssi` before the zero floor ## Who is an ineligible spouse -An ineligible spouse is "someone who lives with you as your husband or wife and is not eligible for SSI benefits" (§416.1160(d)). The spouse is found by the co-resident spouse test in `eligibility/resources/deemed/_ssi_spouses.py`, which resource deeming and joint claims also use, not by tax-unit head and spouse roles. A spouse who is aged, blind or disabled but fails the citizenship or qualified-noncitizen condition is an ineligible spouse. +An ineligible spouse is "someone who lives with you as your husband or wife and is not eligible for SSI benefits" (§416.1160(d)). The spouse is found by the co-resident spouse test in `eligibility/resources/deemed/_ssi_spouses.py`, which resource deeming and joint claims also use, not by tax-unit head and spouse roles. For income, eligibility here means aged, blind or disabled status: a spouse who is aged, blind or disabled but fails the citizenship or qualified-noncitizen condition is still treated as part of a joint claim, because state supplements and Medicaid read the joint claim as the SSI couple methodology. The resource test treats that spouse as an ineligible spouse (`_ssi_resource_couple`). ## Exclusions Applied diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py index 51780f16add..2704ed25464 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py @@ -72,15 +72,27 @@ def _ssi_spouse_index(person, period): def _ssi_eligible_apart_from_means(person, period): """Aged, blind or disabled and meeting SSI's citizenship condition. - 416.1801(c) makes an eligible spouse someone 'who is eligible for SSI', - and 416.1160(d) makes an ineligible spouse one who 'is not eligible for - SSI benefits'. Couples are classified before the income and resource - tests, which depend on the classification, so this tests the conditions - that do not: aged, blind or disabled status, and citizenship or - qualified-noncitizen status (as is_ssi_eligible requires). + 416.1160(d) makes an ineligible spouse one who 'is not eligible for SSI + benefits'. The resource test runs before eligibility is known, so this + tests the conditions that do not depend on it: aged, blind or disabled + status, and citizenship or qualified-noncitizen status (as is_ssi_eligible + requires). """ immigration_status = person("immigration_status", period) is_citizen = immigration_status == immigration_status.possible_values.CITIZEN return person("is_ssi_aged_blind_disabled", period) & ( is_citizen | person("is_ssi_qualified_noncitizen", period) ) + + +def _ssi_resource_couple(person, period): + """Whether 416.1205(b) combines a joint claim's resources. + + A joint claim pairs two aged, blind or disabled spouses. When one of them + fails the citizenship condition, they are not an eligible couple + (416.1801(c)), so the other's resources are tested with the spouse's + deemed under 416.1202(a), with the ineligible-spouse exclusions. + """ + eligible = _ssi_eligible_apart_from_means(person, period) + spouse = np.maximum(_ssi_spouse_index(person, period), 0) + return person("ssi_claim_is_joint", period) & eligible & eligible[spouse] diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.py index 8a0c3c52e50..18e184d3091 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.py @@ -1,5 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_resource_couple, _ssi_spouse_index, ) @@ -22,13 +23,15 @@ def formula(person, period, parameters): # resources of the spouse' against the couple limit. Eligibility is # not known before the resource test, and 416.1205(b) tests an # eligible couple's combined resources against the same limit, so a - # spouse's resources count either way when the claim is not already - # joint. The spouse is identified as a parent's spouse is in parental + # spouse's resources count either way when the couple's are not + # already combined. The spouse is identified as a parent's spouse is in parental # deeming. 416.1167 retains an absent person's household membership: # enter a temporarily absent spouse in the household they remain in. + # A joint claim whose other member fails the citizenship condition + # is not an eligible couple (416.1801(c)), so it deems too. spouse = _ssi_spouse_index(person, period) return ( person("is_ssi_aged_blind_disabled", period) - & ~person("ssi_claim_is_joint", period) + & ~_ssi_resource_couple(person, period) & (spouse >= 0) ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py index a015fb64c94..67fde62db37 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/meets_ssi_resource_test.py @@ -1,5 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_resource_couple, _ssi_spouse_index, ) @@ -28,11 +29,11 @@ def formula(person, period, parameters): "ssi_resources_deemed_from_ineligible_parent", ], ) - # 416.1205(b): an eligible couple's combined resources. The joint - # claim pairs two spouses, so add the spouse rather than the unit. + # 416.1205(b): an eligible couple's combined resources. The couple + # is two spouses, so add the spouse rather than the marital unit. spouse = np.maximum(_ssi_spouse_index(person, period), 0) countable_resources = where( - joint_claim, + _ssi_resource_couple(person, period), personal_resources + personal_resources[spouse], personal_resources + deemed_resources, ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py index f7662d94ec7..bce250cdc5c 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_spouse.py @@ -1,6 +1,5 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_eligible_apart_from_means, _ssi_spouse_index, ) @@ -21,7 +20,8 @@ def formula(person, period, parameters): # 416.1160(d): 'Ineligible spouse means someone who lives with you as # your husband or wife and is not eligible for SSI benefits.' The # spouse test is the one joint claims and resource deeming use, so a - # spouse who is not a tax-unit head or spouse still counts. - eligible = _ssi_eligible_apart_from_means(person, period) + # spouse who is not a tax-unit head or spouse still counts. Like + # ssi_claim_is_joint, this tests aged, blind or disabled status only. + abd = person("is_ssi_aged_blind_disabled", period) spouse = _ssi_spouse_index(person, period) - return ~eligible & (spouse >= 0) & eligible[np.maximum(spouse, 0)] + return ~abd & (spouse >= 0) & abd[np.maximum(spouse, 0)] diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py index 4f37d566694..8bc3c8a92f2 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py @@ -18,7 +18,7 @@ def formula(person, period, parameters): # 20 CFR 416.414(b): Benefit rate depends on couple computation: # 1. Couple computation applies → couple rate / 2 per person # 2. No couple computation, deeming → couple rate (limited by - # 416.1163(e)(2) in ssi_if_takes_up) + # 416.1163(e)(2) in uncapped_ssi) # 3. No couple computation, no deeming → individual rate # # ssi_couple_computation_applies is false when only one spouse diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py b/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py index 6ccb9d46071..25189916d81 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_claim_is_joint.py @@ -1,6 +1,5 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_eligible_apart_from_means, _ssi_spouse_index, ) @@ -18,13 +17,14 @@ class ssi_claim_is_joint(Variable): ) def formula(person, period, parameters): - # 416.1801(c): an eligible spouse is eligible for SSI, is the spouse - # of another person eligible for SSI, and lives in the same household - # with that person. Spouses are found by the test resource deeming - # uses (416.1806: husband or wife, living together), not by tax-unit - # head and spouse roles, which go to the two oldest adults of a tax - # unit when roles are not supplied. A spouse who fails the - # citizenship condition is an ineligible spouse (416.1160(d)). - eligible = _ssi_eligible_apart_from_means(person, period) + # 416.1801(c): an eligible spouse is the spouse of another person + # eligible for SSI who lives in the same household with that person. + # Spouses are found by the test resource deeming uses (416.1806: + # husband or wife, living together), not by tax-unit head and spouse + # roles, which go to the two oldest adults of a tax unit when roles + # are not supplied. Both must be aged, blind or disabled. The + # citizenship condition is not applied here: state supplements and + # Medicaid read this as the SSI couple methodology. + abd = person("is_ssi_aged_blind_disabled", period) spouse = _ssi_spouse_index(person, period) - return eligible & (spouse >= 0) & eligible[np.maximum(spouse, 0)] + return abd & (spouse >= 0) & abd[np.maximum(spouse, 0)] diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py index cf4e97c9b55..7a51025bf3b 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py @@ -1,7 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( - SSIFederalLivingArrangement, -) class ssi_if_takes_up(Variable): @@ -14,37 +11,13 @@ class ssi_if_takes_up(Variable): ) unit = USD definition_period = MONTH - reference = ( - "https://www.law.cornell.edu/uscode/text/42/1382", - "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(e)(2)", - "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(f)(5)", - ) + reference = "https://www.law.cornell.edu/uscode/text/42/1382" def formula(person, period, parameters): if parameters(period).gov.ssa.ssi.abolish_ssi: return 0 - # Apply floor: SSI cannot be negative - benefit = max_(0, person("uncapped_ssi", period)) - - # 20 CFR 416.1163(e)(2): 'Your SSI benefit under the deeming rules - # cannot be higher than it would be if deeming did not apply. - # Therefore, your benefit is the lesser of the amount computed under - # the rules in paragraph (d)(2) of this section or the amount - # remaining after we subtract only your own countable income from an - # individual's Federal benefit rate.' - without_deeming = max_( - 0, person("uncapped_ssi_without_spousal_deeming", period) - ) - deemed_benefit = min_(benefit, without_deeming) - # 416.1163(f)(5): while the $30 Federal benefit rate applies, we do - # not deem an ineligible spouse's income, so only the person's own - # countable income counts. - arrangement = person("ssi_federal_living_arrangement", period) - in_medical_facility = ( - arrangement == SSIFederalLivingArrangement.MEDICAL_TREATMENT_FACILITY - ) - deemed_benefit = where(in_medical_facility, without_deeming, deemed_benefit) - - deeming_applies = person("is_ssi_spousal_deeming_applies", period.this_year) - return where(deeming_applies, deemed_benefit, benefit) + # Apply floor: SSI cannot be negative. Under spousal deeming, + # uncapped_ssi already applies 20 CFR 416.1163(e)(2), which also keeps + # the benefit at or below the individual Federal benefit rate. + return max_(0, person("uncapped_ssi", period)) diff --git a/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py b/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py index 04b751a49b7..b9541abddde 100644 --- a/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py +++ b/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py @@ -9,8 +9,23 @@ class uncapped_ssi(Variable): documentation = "Maximum SSI, less countable income (can be below zero)." definition_period = MONTH defined_for = "is_ssi_eligible" + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1382#b", + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(e)(2)", + ) def formula(person, period, parameters): amount = person("ssi_amount_if_eligible", period) countable_income = person("ssi_countable_income", period) - return amount - countable_income + uncapped = amount - countable_income + # 20 CFR 416.1163(e)(2): 'Your SSI benefit under the deeming rules + # cannot be higher than it would be if deeming did not apply. + # Therefore, your benefit is the lesser of the amount computed under + # the rules in paragraph (d)(2) of this section or the amount + # remaining after we subtract only your own countable income from an + # individual's Federal benefit rate.' Taking the lesser before the + # zero floor keeps this consistent with the payment for programs + # that read the shortfall below zero. + without_deeming = person("uncapped_ssi_without_spousal_deeming", period) + deeming_applies = person("is_ssi_spousal_deeming_applies", period.this_year) + return where(deeming_applies, min_(uncapped, without_deeming), uncapped) From f6c19e03efcd7eb0d35fa2f84bc238d99774b3cb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:14:49 -0400 Subject: [PATCH 04/25] Give each person in the SSI citizenship test their own units The test omitted tax and marital units, so the model grouped all nine disabled people together and made person0 and person1 a head-and-spouse couple, which the co-resident spouse test now reads as a joint claim. The test checks citizenship status, so each person is single. Co-Authored-By: Claude Opus 5.5 --- .../ssa/ssi/is_ssi_qualified_noncitizen.yaml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/is_ssi_qualified_noncitizen.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/is_ssi_qualified_noncitizen.yaml index f958c8690f7..6322cb9a473 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/is_ssi_qualified_noncitizen.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/is_ssi_qualified_noncitizen.yaml @@ -40,6 +40,47 @@ is_disabled: true meets_ssi_disability_criteria: true immigration_status: UNDOCUMENTED + # Each person is single and files alone. Without units, the model would + # make person0 and person1 the head and spouse of one tax unit, and so a + # married couple. + tax_units: + unit0: + members: [person0] + unit1: + members: [person1] + unit2: + members: [person2] + unit3: + members: [person3] + unit4: + members: [person4] + unit5: + members: [person5] + unit6: + members: [person6] + unit7: + members: [person7] + unit8: + members: [person8] + marital_units: + unit0: + members: [person0] + unit1: + members: [person1] + unit2: + members: [person2] + unit3: + members: [person3] + unit4: + members: [person4] + unit5: + members: [person5] + unit6: + members: [person6] + unit7: + members: [person7] + unit8: + members: [person8] output: is_ssi_qualified_noncitizen: [false, true, true, true, true, true, true, true, false] is_ssi_eligible_individual: [false, true, true, true, true, true, true, true, false] From 49116e991bf17f161063770cb963143011b236fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:50:08 -0400 Subject: [PATCH 05/25] Combine only the SSI spouses in the Massachusetts and Alaska supplements Review round 2 found that a situation omitting marital units, with three aged adults, now pays more in MA and AK. The joint claim now covers only the head-and-spouse couple, but both supplements still summed the whole marital unit. _ssi_couple_total adds the co-resident spouse's value, which equals the marital-unit sum for units of one or two people. MA's maximum and payment, AK's payment, and AK's claim type now use it, so the household gets the same supplements as with explicit marital units ($100.86, $100.86, $128.82 in MA; $264, $264, $362 in AK). Co-Authored-By: Claude Opus 5.5 --- .../test_ssi_spousal_deeming_invariants.py | 4 +- .../gov/states/ak/dpa/ssp/ak_ssp.yaml | 30 +++++++++++ .../ma/dta/ssp/ma_state_supplement.yaml | 30 +++++++++++ .../resources/deemed/_ssi_spouses.py | 12 +++++ .../variables/gov/states/ak/dpa/ssp/ak_ssp.py | 8 ++- .../states/ak/dpa/ssp/ak_ssp_claim_type.py | 54 +++++-------------- .../ma/dta/ssp/ma_maximum_state_supplement.py | 20 ++++--- .../states/ma/dta/ssp/ma_state_supplement.py | 8 ++- 8 files changed, 111 insertions(+), 55 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py index 267517df435..f723178335e 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -1,6 +1,8 @@ """Simulation-API invariants for SSI couples and spousal deeming. -Two vectorized simulations, each built once per module: +Two vectorized grid simulations, each built once per module, plus one small +simulation per Hypothesis example (three examples, more if a failure is +shrunk): - An income grid of 400 two-person households: an aged claimant with only unearned income and a spouse under 65 with unearned and earned income. The diff --git a/policyengine_us/tests/policy/baseline/gov/states/ak/dpa/ssp/ak_ssp.yaml b/policyengine_us/tests/policy/baseline/gov/states/ak/dpa/ssp/ak_ssp.yaml index eea223b983a..2d5e660c27c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ak/dpa/ssp/ak_ssp.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ak/dpa/ssp/ak_ssp.yaml @@ -117,3 +117,33 @@ # marital_unit.sum = 3_168 + 3_168 = 6_336 # Each gets 6_336 / 2 = 3_168 ak_ssp: [3_168, 3_168] + +# A situation that omits marital units puts the three adults in one marital +# unit. The husband and wife are the SSI joint claim (the tax-unit head and +# spouse) and split the $528 independent-living couple standard; the third +# adult gets the $362 individual standard, the same as with explicit +# marital units. +- name: Case 9, married couple and a third aged adult without marital units. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: 70 + is_tax_unit_head: true + wife: + age: 70 + is_tax_unit_spouse: true + third_adult: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: AK + output: + ak_ssp_claim_type: [COUPLE_BOTH_ELIGIBLE, COUPLE_BOTH_ELIGIBLE, INDIVIDUAL] + ak_ssp: [264, 264, 362] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/dta/ssp/ma_state_supplement.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/dta/ssp/ma_state_supplement.yaml index 37a46e30513..7ad97b775b5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/dta/ssp/ma_state_supplement.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/dta/ssp/ma_state_supplement.yaml @@ -19,3 +19,33 @@ meets_ssi_resource_test: false output: ma_state_supplement: 0 + +# A situation that omits marital units puts the three adults in one marital +# unit. The husband and wife are the SSI joint claim (the tax-unit head and +# spouse); the third adult is an individual. 106 CMR 327.330, full cost of +# living, aged: $100.86 each for a couple and $128.82 for an individual, +# the same as with explicit marital units. +- name: Married couple and a third aged adult without marital units. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: 70 + is_tax_unit_head: true + wife: + age: 70 + is_tax_unit_spouse: true + third_adult: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: MA + output: + ssi_claim_is_joint: [true, true, false] + ma_state_supplement: [100.86, 100.86, 128.82] diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py index 2704ed25464..ba5fb0f0b65 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py @@ -96,3 +96,15 @@ def _ssi_resource_couple(person, period): eligible = _ssi_eligible_apart_from_means(person, period) spouse = np.maximum(_ssi_spouse_index(person, period), 0) return person("ssi_claim_is_joint", period) & eligible & eligible[spouse] + + +def _ssi_couple_total(person, period, values): + """Each person's value plus their co-resident spouse's, if they have one. + + This equals the marital-unit sum for units of one or two people, and + adds only the spouse when a unit holds more people, as the default unit + of a situation that omits marital units can. ``period`` is the year of + the spouse test. + """ + spouse = _ssi_spouse_index(person, period) + return values + where(spouse >= 0, values[np.maximum(spouse, 0)], 0) diff --git a/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp.py b/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp.py index a7fe59d890d..8baaf19c27f 100644 --- a/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp.py +++ b/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ak_ssp(Variable): @@ -25,8 +28,11 @@ def formula(person, period): "ak_ssp_eligible", period ) claim_type_values = claim_type.possible_values + couple_supplement = _ssi_couple_total( + person, period.this_year, state_supplement + ) return where( claim_type == claim_type_values.COUPLE_BOTH_ELIGIBLE, - person.marital_unit.sum(state_supplement) / 2, + couple_supplement / 2, state_supplement, ) diff --git a/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp_claim_type.py b/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp_claim_type.py index 21f29e2aca3..116a3c128c2 100644 --- a/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp_claim_type.py +++ b/policyengine_us/variables/gov/states/ak/dpa/ssp/ak_ssp_claim_type.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class AKSSPClaimType(Enum): @@ -21,54 +24,23 @@ class ak_ssp_claim_type(Variable): def formula(person, period, parameters): joint_claim = person("ssi_claim_is_joint", period.this_year) - marital_unit_size = person.marital_unit.sum( - person("age", period.this_year) >= 0 - ) - living_arrangement = person("ak_ssp_living_arrangement", period) - living_arrangement_values = living_arrangement.possible_values + # The spouse is the co-resident husband or wife the SSI joint claim + # uses, so a third adult in a default marital unit is not counted. + spouse = _ssi_spouse_index(person, period.this_year) + has_spouse = spouse >= 0 + partner = np.where(has_spouse, spouse, np.arange(person.count)) # Both spouses must share the same living arrangement to be - # treated as a joint couple claim. New enum values added to - # AKSSPLivingArrangement must be appended to this check. - shared_living_arrangement = ( - ( - person.marital_unit.sum( - living_arrangement == living_arrangement_values.INDEPENDENT - ) - == marital_unit_size - ) - | ( - person.marital_unit.sum( - living_arrangement == living_arrangement_values.HOUSEHOLD_OF_ANOTHER - ) - == marital_unit_size - ) - | ( - person.marital_unit.sum( - living_arrangement == living_arrangement_values.ASSISTED_LIVING - ) - == marital_unit_size - ) - | ( - person.marital_unit.sum( - living_arrangement == living_arrangement_values.MEDICAID_FACILITY - ) - == marital_unit_size - ) - ) + # treated as a joint couple claim. + living_arrangement = person("ak_ssp_living_arrangement", period) + shared_living_arrangement = living_arrangement == living_arrangement[partner] is_eligible_individual = person("is_ssi_aged_blind_disabled", period.this_year) # COUPLE_ONE_ELIGIBLE requires exactly one spouse to be # individually ABD-eligible. If both spouses qualify but they # are not jointly claiming, they should not be misclassified # as a one-eligible couple. - spouse_is_eligible = ( - person.marital_unit.sum(is_eligible_individual.astype(int)) - - is_eligible_individual.astype(int) - ) > 0 + spouse_is_eligible = has_spouse & is_eligible_individual[partner] one_eligible_couple = ( - ~joint_claim - & is_eligible_individual - & ~spouse_is_eligible - & (marital_unit_size == 2) + ~joint_claim & is_eligible_individual & has_spouse & ~spouse_is_eligible ) couple_both_eligible = joint_claim & shared_living_arrangement return select( diff --git a/policyengine_us/variables/gov/states/ma/dta/ssp/ma_maximum_state_supplement.py b/policyengine_us/variables/gov/states/ma/dta/ssp/ma_maximum_state_supplement.py index f2d5556496d..6a419742aab 100644 --- a/policyengine_us/variables/gov/states/ma/dta/ssp/ma_maximum_state_supplement.py +++ b/policyengine_us/variables/gov/states/ma/dta/ssp/ma_maximum_state_supplement.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ma_maximum_state_supplement(Variable): @@ -82,14 +85,9 @@ def formula(person, period, parameters): [monthly_aged_rate, monthly_blind_rate, monthly_disabled_rate] ) - # 9. Summation across the marital unit. If we have a couple, we might want - # the total combined amount, then split it. Or we might directly keep it as per-person. - # Here, they sum for the entire marital unit, then later they do dividing if needed. - combined_amount_for_marital_unit = person.marital_unit.sum(monthly_rate) - - # 10. Finally, the code divides by 2 if it's a joint claim, presumably so that - # each person sees their half. If it's not joint, it just returns the full amount. - # This "per-person" approach might be confusing if the state has a special "couple rate" - # that isn't exactly double or half. But that's how the code is set up now. - divisor = where(joint_claim, 2, 1) - return combined_amount_for_marital_unit / divisor + # 9. For a joint claim, combine the two spouses' amounts and split + # them evenly; otherwise keep the person's own amount. Combining the + # spouses rather than the marital unit keeps a third adult in a + # default marital unit out of the couple's amount. + couple_amount = _ssi_couple_total(person, period.this_year, monthly_rate) + return where(joint_claim, couple_amount / 2, monthly_rate) diff --git a/policyengine_us/variables/gov/states/ma/dta/ssp/ma_state_supplement.py b/policyengine_us/variables/gov/states/ma/dta/ssp/ma_state_supplement.py index 784a66aef7b..a15e7e1a531 100644 --- a/policyengine_us/variables/gov/states/ma/dta/ssp/ma_state_supplement.py +++ b/policyengine_us/variables/gov/states/ma/dta/ssp/ma_state_supplement.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ma_state_supplement(Variable): @@ -19,8 +22,11 @@ def formula(person, period, parameters): meets_resource_test = person("meets_ssi_resource_test", period) eligible = abd & meets_resource_test joint_claim = person("ssi_claim_is_joint", period.this_year) + couple_supplement = _ssi_couple_total( + person, period.this_year, state_supplement + ) return where( joint_claim, - person.marital_unit.sum(state_supplement) / 2, + couple_supplement / 2, state_supplement * eligible, ) From 71e9ab8ade4e7d44c566d525fe84389fc4472579 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 22:48:56 -0400 Subject: [PATCH 06/25] Isolate state supplement calculations to co-resident spouses --- .../ssi-couples-and-deeming-cap.fixed.md | 2 +- .../ssi/ssi_state_supplement_three_adult.yaml | 1552 +++++++++++++++++ .../gov/ssa/ssi/_ssi_state_supplement.py | 5 +- .../states/co/ssa/oap/co_oap_deemed_income.py | 7 +- .../co/ssa/oap/co_oap_deeming_exempt.py | 7 +- .../co/ssa/oap/co_oap_own_countable_income.py | 5 +- .../eligibility/ct_ssp_resource_eligible.py | 11 +- .../dc/dhcf/ossp/dc_ossp_payment_amount.py | 18 +- .../gov/states/de/dhss/ssp/de_ssp.py | 9 +- .../fl/dcf/oss/fl_oss_couple_rate_applies.py | 23 +- .../hi/dhs/oss/hi_oss_couple_rate_applies.py | 36 +- .../gov/states/in/fssa/ssp/in_ssp_sapn.py | 11 +- .../states/ky/dcbs/ssp/ky_ssp_claim_type.py | 14 +- .../gov/states/me/dhhs/ssp/me_ssp.py | 13 +- .../mi/mdhhs/ssp/mi_ssp_couple_eligible.py | 26 +- .../mn_msa_gross_income_eligible.py | 14 +- .../eligibility/mn_msa_net_income_eligible.py | 17 +- .../eligibility/mn_msa_resource_eligible.py | 7 +- .../gov/states/mn/dhs/msa/mn_msa_person.py | 19 +- .../mn/dhs/msa/payment/mn_msa_guardian_fee.py | 7 +- .../msa/payment/mn_msa_payment_category.py | 10 +- .../eligibility/mo_ssp_resource_eligible.py | 10 +- .../dhhs/aabd/ne_aabd_couple_rate_applies.py | 13 +- .../nj/njdhs/ssp/nj_ssp_living_arrangement.py | 14 +- .../ny/otda/ssp/ny_ssp_living_arrangement.py | 8 +- .../variables/gov/states/oh/dbh/rss/oh_rss.py | 9 +- .../oh/dbh/rss/oh_rss_countable_income.py | 19 +- .../gov/states/ok/dhs/ssp/_ok_ssp_income.py | 11 +- .../variables/gov/states/ok/dhs/ssp/ok_ssp.py | 16 +- .../gov/states/or/odhs/ssp/or_ssp.py | 9 +- .../gov/states/sd/dss/ssp/sd_ssp_care.py | 20 +- .../states/sd/dss/ssp/sd_ssp_independent.py | 14 +- .../gov/states/ut/dws/ssp/ut_dws_ssp.py | 7 +- .../va/dars/ag/va_ag_countable_income.py | 18 +- .../gov/states/va/dars/ag/va_ag_eligible.py | 14 +- .../gov/states/vt/dcf/aabd/vt_aabd_ep.py | 16 +- .../dcf/aabd/vt_aabd_ep_countable_income.py | 29 +- .../vt/dcf/aabd/vt_aabd_ep_is_spouse.py | 8 +- .../dcf/aabd/vt_aabd_ep_resources_eligible.py | 12 +- .../wa/dshs/ssp/wa_ssp_payment_category.py | 10 +- .../gov/states/wi/dhs/ssp/wi_ssi_e.py | 5 +- .../gov/states/wi/dhs/ssp/wi_ssp_regular.py | 10 +- 42 files changed, 1945 insertions(+), 140 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index 7f519746601..ac613fff028 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1 +1 @@ -SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). +SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the co-resident spouses when a situation omits marital units and includes a third adult. diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml new file mode 100644 index 00000000000..878ea463356 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml @@ -0,0 +1,1552 @@ +# State supplement regressions for an eligible couple sharing a household +# and tax unit with a third adult, without explicit marital units. +# Each case cites the state rule and computes its expectation independently. + +# co/ssa/oap/co_oap_three_adult.yaml +# 9 CCR 2503-5 3.533(A)(2)(c): married clients split the $20 disregard. +# 3.534(C) exempts spouses receiving SSI/OAP within the $1,032 OAP limit. +# https://www.sos.state.co.us/CCR/GenerateRulePdf.do?fileName=9+CCR+2503-5&ruleVersionId=12428#page=76 +# SSI = (1,491 - (300 + 200 - 20)) / 2 = $505.50 each; third = $614. +# Own OAP incomes = $290/$190/$380. No spouse income is deemed. +# Monthly OAP = 1,032 - 505.50 - 290/190 = $236.50/$336.50; +# third = 1,032 - 614 - 380 = $38, raised to the $189 payment floor. +- name: CO OAP counts only the spouse in a default marital unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + husband: + age: 70 + pension_income: 3_600 + is_tax_unit_head: true + wife: + age: 70 + pension_income: 2_400 + is_tax_unit_spouse: true + third_adult: + age: 70 + pension_income: 4_800 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: CO + output: + co_oap_own_countable_income: [3_480, 2_280, 4_560] + co_oap_deemed_income: [0, 0, 0] + co_oap: [2_838, 4_038, 2_268] + +# ct/dss/ssp/third_adult.yaml +# Conn. Gen. Stat. 17b-600(c): a co-resident couple has a $2,400 combined asset limit; a single person has $1,600. Couple resources are $1,000 + $1,000 = $2,000; the third adult has $1,000 separately. +# https://www.cga.ct.gov/current/pub/chap_319s.htm#sec_17b-600 +- name: Connecticut excludes a third adult from the couple resource test + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + ssi_countable_resources: + 2026: 1000 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + ssi_countable_resources: + 2026: 1000 + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + ssi_countable_resources: + 2026: 1000 + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: CT + output: + ct_ssp_resource_eligible: [true, true, true] + +# dc/dhcf/ossp/dc_ossp_three_adult.yaml +- name: DC foster-care couple amount excludes a third aged adult in the default marital unit. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + dc_ossp_in_adult_foster_care: true + dc_ossp_facility_size: 30 + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + dc_ossp_in_adult_foster_care: true + dc_ossp_facility_size: 30 + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + dc_ossp_in_adult_foster_care: true + dc_ossp_facility_size: 30 + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: DC + output: + ssi_claim_is_joint: [true, true, false] + dc_ossp_eligible: [true, true, true] + # DC DHCF's 2026 OSSP table, OS-A (adult foster care, at most 50 beds): + # https://dhcf.dc.gov/service/optional-state-supplement-payment-program + # $1,717 / 2 = $858.50 per spouse; the individual rate is $681. + dc_ossp_payment_amount: [858.5, 858.5, 681] + dc_ossp: [858.5, 858.5, 681] + +# de/dhss/ssp/third_adult.yaml +# POMS SI 01415.058 C.2: certified residential care pays $448 per couple and $140 per individual. With no excess income, each spouse receives $448 / 2 = $224; the third adult receives $140. +# https://secure.ssa.gov/poms.nsf/lnx/0501415058#c2 +- name: Delaware pays a couple rate without including a third resident + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: DE + de_ssp_living_arrangement: CERTIFIED_RESIDENTIAL_CARE_HOME + output: + de_ssp: [224, 224, 140] + +# fl/dcf/oss/third_adult.yaml +# DCF Appendix A-12 (January 2025), OSS Redesign: the couple provider rate is $1,885.80, or $942.90 each; the individual rate is $991.40. Both spouses occupy an ALF on the same track; the third adult does not occupy a qualifying facility. +# https://www.myflfamilies.com/sites/default/files/2024-12/Appendix%20A-12.pdf +- name: Florida tests the two spouses facility placement without a third adult + period: 2025-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2025: 70 + is_tax_unit_head: + 2025: true + fl_oss_program_track: REDESIGN + fl_oss_community_care_type: ALF + wife: + age: + 2025: 70 + is_tax_unit_spouse: + 2025: true + fl_oss_program_track: REDESIGN + fl_oss_community_care_type: ALF + third_adult: + age: + 2025: 70 + is_tax_unit_dependent: + 2025: true + fl_oss_program_track: REDESIGN + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2025: FL + output: + fl_oss_couple_rate_applies: [true, true, false] + fl_oss_provider_rate: [942.9, 942.9, 991.4] + +# hi/dhs/oss/third_adult.yaml +# POMS SI 01415.058 E.2: community care pays $1,995 per eligible couple, so each spouse receives $997.50. The third adult is outside a qualifying care setting and receives $0. +# https://secure.ssa.gov/poms.nsf/lnx/0501415058#e2 +- name: Hawaii compares only spouses when applying the community care couple rate + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + hi_oss_care_facility_type: COMMUNITY_CARE + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + hi_oss_care_facility_type: COMMUNITY_CARE + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: HI + output: + hi_oss_couple_rate_applies: [true, true, false] + hi_oss_payment_amount: [997.5, 997.5, 0] + +# in/fssa/ssp/third_adult.yaml +# Medicaid Policy Manual 5005.05.00: SAPN fills the $52 personal-needs allowance, capped at $22 per person. Each spouse has $30 SSI and no other budgeted income: $52 - $30 = $22 each. The third adult is not SAPN-eligible; that adult's $100 monthly income is outside the couple budget. +# https://www.in.gov/fssa/ompp/files/Medicaid_PM_5000.pdf#page=2 +- name: Indiana combines only spouses budgeted income for SAPN + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + in_ssp_sapn_eligible: true + ssi: 30 + ssi_countable_income: + 2026: 0 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + in_ssp_sapn_eligible: true + ssi: 30 + ssi_countable_income: + 2026: 0 + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + in_ssp_sapn_eligible: false + ssi: 0 + ssi_countable_income: + 2026: 1_200 + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: IN + output: + in_ssp_sapn: [22, 22, 0] + +# ky/dcbs/ssp/third_adult.yaml +# 921 KAR 2:015 Section 9(1)(c): the caretaker standard is $1,606 for an eligible couple both receiving care, or $803 per spouse, and $1,056 for an individual. No countable income reduces these standards. +# https://apps.legislature.ky.gov/law/kar/titles/921/002/015/ +- name: Kentucky recognizes a caretaker couple alongside another aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + ky_ssp_category: CARETAKER + ky_ssp_care_receivers: BOTH + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + ky_ssp_category: CARETAKER + ky_ssp_care_receivers: BOTH + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + ky_ssp_category: CARETAKER + ky_ssp_care_receivers: BOTH + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: KY + output: + ky_ssp_claim_type: [COUPLE_BOTH_ELIGIBLE, COUPLE_BOTH_ELIGIBLE, INDIVIDUAL] + ky_ssp_payment_standard: [803, 803, 1056] + ky_ssp: [803, 803, 1056] + +# me/dhhs/ssp/me_ssp_three_adult.yaml +- name: Maine couple amount excludes a third aged adult in the default marital unit. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: ME + output: + ssi_claim_is_joint: [true, true, false] + me_ssp_eligible: [true, true, true] + # Maine's 2026 approved state plan, maximum state supplement: + # independent living is $15/couple and $10/individual. + # https://www.medicaid.gov/medicaid/spa/downloads/ME-25-0016.pdf + # $15 / 2 = $7.50 per spouse; the third adult receives $10. + me_ssp: [7.5, 7.5, 10] + +- name: Maine couple category ignores the third adult's care arrangement. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + me_ssp_living_arrangement: ADULT_FOSTER_HOME + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: ME + output: + # The same plan pays $49/individual in adult foster care. + # https://www.medicaid.gov/medicaid/spa/downloads/ME-25-0016.pdf + # The couple remains $15 / 2 = $7.50 each; the third adult gets $49. + me_ssp: [7.5, 7.5, 49] + +# mi/mdhhs/ssp/mi_ssp_three_adult.yaml +- name: Michigan couple amount excludes a third aged adult in the default marital unit. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: MI + output: + ssi_claim_is_joint: [true, true, false] + mi_ssp_couple_eligible: [true, true, false] + # Michigan RFT 248, independent living, effective January 1, 2026: + # https://mdhhs-pres-prod.michigan.gov/olmweb/ex/RF/Public/RFT/248.pdf#page=2 + # $21 / 2 = $10.50 per spouse, plus $14 for the individual = $35. + mi_ssp_person: [10.5, 10.5, 14] + mi_ssp: 35 + +# mn/dhs/msa/mn_msa_three_adult.yaml +- name: Minnesota couple income excludes a third aged adult at the same address. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + # The couple forms a separate household at the shared address. + mn_msa_living_arrangement: COUPLE_LIVING_ALONE + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + mn_msa_living_arrangement: COUPLE_LIVING_ALONE + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + mn_msa_living_arrangement: INDIVIDUAL_LIVING_ALONE + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: MN + output: + # CM 0020.21 combines only spouses' income. A separate household within + # a shared address can receive the living-alone standard (PDF pages 3-4). + # https://www.dhs.state.mn.us/main/groups/county_access/documents/pub/mndhs-073585.pdf#page=3 + # Couple: (1,582 - (1,491 - 20)) / 2 = 55.50 each. + # Individual: 1,055 - (994 - 20) = 81. + mn_msa_net_income_eligible: [true, true, true] + mn_msa_person: [55.5, 55.5, 81] + +- name: Minnesota non-SSI resource test excludes the third adult's resources. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + ssi: 0 + ssi_countable_resources: + 2026: 1_000 + mn_msa_living_arrangement: COUPLE_LIVING_WITH_OTHERS + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + ssi: 0 + ssi_countable_resources: + 2026: 1_000 + mn_msa_living_arrangement: COUPLE_LIVING_WITH_OTHERS + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + ssi: 0 + ssi_countable_resources: + 2026: 9_000 + mn_msa_living_arrangement: INDIVIDUAL_LIVING_WITH_OTHERS + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: MN + output: + # Minn. Stat. 256P.02 Subd. 2 limits each assistance unit to $10,000: + # the couple has $2,000; the individual has $9,000. + # https://www.revisor.mn.gov/statutes/cite/256P.02#stat.256P.02.2 + mn_msa_resource_eligible: [true, true, true] + # CM 0020.21, with no countable income: 1,058 / 2 = 529 each; + # the third adult's individual living-with-others standard is 755.33. + # https://www.dhs.state.mn.us/main/groups/county_access/documents/pub/mndhs-073585.pdf#page=4 + mn_msa_person: [529, 529, 755.33] + +- name: Minnesota guardian fee cap uses only the recipient and spouse's gross income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + mn_msa_gross_income: 1_000 + mn_msa_has_guardian: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + mn_msa_gross_income: 500 + mn_msa_has_guardian: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + mn_msa_gross_income: 1_000 + mn_msa_has_guardian: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: MN + output: + # Minn. Stat. 256D.44 Subd. 5(d): min(5% of AU gross income, $100). + # https://www.revisor.mn.gov/statutes/cite/256D.44#stat.256D.44.5 + # Couple: min((1,000 + 500) * .05, 100) = 75; individual: 1,000 * .05 = 50. + mn_msa_guardian_fee: [75, 75, 50] + +# mo/dss/ssp/third_adult.yaml +# DSS Manual 1035.005.00 counts property owned by either spouse. MHABD Appendix J (July 2026) allows $12,441 per couple and $6,220.50 per individual. The couple has $7,000 + $1,000 = $8,000; the third adult has $1,000. +# https://dssmanuals.mo.gov/december-1973-eligibility-requirements/1035-000-00/1035-005-00/ +- name: Missouri identifies co-resident spouses for the SNC resource limit + period: 2026-07 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + mo_ssp_living_arrangement: RCF_LEVEL_I + ssi_countable_resources: + 2026: 7000 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + mo_ssp_living_arrangement: RCF_LEVEL_I + ssi_countable_resources: + 2026: 1000 + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + mo_ssp_living_arrangement: RCF_LEVEL_I + ssi_countable_resources: + 2026: 1000 + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: MO + output: + mo_ssp_resource_eligible: [true, true, true] + +# mt/dphhs/ssp/mt_ssp_three_adult.yaml +# POMS SI 01415.058 H.2.b: certified assisted/group/community care pays +# $193 per eligible couple and $94 per individual; $193 / 2 = $96.50. +# https://secure.ssa.gov/poms.nsf/lnx/0501415058#h2b +# No marital units: only the head and spouse form the eligible couple. +- name: MT couple rate excludes a third aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + mt_ssp_payment_category: ASSISTED_LIVING_OR_GROUP_OR_COMMUNITY_HOME + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + mt_ssp_payment_category: ASSISTED_LIVING_OR_GROUP_OR_COMMUNITY_HOME + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + mt_ssp_payment_category: ASSISTED_LIVING_OR_GROUP_OR_COMMUNITY_HOME + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: MT + output: + mt_ssp: [96.5, 96.5, 94] + +# ne/dhhs/aabd/third_adult.yaml +# 469 NAC 3-006.02B3a(1)(a) and 469-000-211: spouses living independently share the $887 two-person standard, $443.50 each with no shelter costs. The third adult in assisted living uses the $1,236 individual standard, giving $1,236 - $994 SSI = $242. +# https://dhhs.ne.gov/Documents/Title-469-Complete.pdf#page=130 +- name: Nebraska independently budgets the couple despite a third care resident + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + ne_aabd_alternate_living_arrangement: INDEPENDENT + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + ne_aabd_alternate_living_arrangement: INDEPENDENT + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + ne_aabd_alternate_living_arrangement: ASSISTED_LIVING_FACILITY + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: NE + output: + ne_aabd_couple_rate_applies: [true, true, false] + ne_aabd_standard_of_need: [443.5, 443.5, 1236] + ne_aabd: [0, 0, 242] + +# nj/njdhs/ssp/nj_ssp_three_adult.yaml +# POMS SI 01415.058 J.2: independent living pays $25.35 per couple +# and $31.25 per individual. $25.35 / 2 = $12.675; federal payment +# rounding (POMS SI 02001.010) rounds each spouse up to $12.68. +# https://secure.ssa.gov/poms.nsf/lnx/0501415058#j2 +# No marital units: only the head and spouse form the eligible couple. +- name: NJ couple rate excludes a third aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: NJ + output: + nj_ssp: [12.68, 12.68, 31.25] + +# POMS SI 01415.025 NY B.2 and SI 01415.058 J.1: the higher variation is +# for someone living ONLY with an ineligible spouse. A third adult rules it +# out; the ordinary individual independent-living supplement is $31.25. +# https://secure.ssa.gov/poms.nsf/lnx/0501415025NY +- name: NJ third adult rules out living only with an ineligible spouse + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 60 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: NJ + output: + nj_ssp_living_arrangement: [LIVING_ALONE_OR_WITH_OTHERS, LIVING_ALONE_OR_WITH_OTHERS, LIVING_ALONE_OR_WITH_OTHERS] + nj_ssp: [31.25, 0, 31.25] + +# nv/dwss/oss/nv_oss_three_adult.yaml +# POMS SI 01415.058 I.2: independent aged living pays +# $74.46 per couple and $36.40 per individual; $74.46 / 2 = $37.23. +# https://secure.ssa.gov/poms.nsf/lnx/0501415058#i2 +# No marital units: only the head and spouse form the eligible couple. +- name: NV couple rate excludes a third aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: NV + output: + nv_oss: [37.23, 37.23, 36.4] + +# ny/otda/ssp/ny_ssp_three_adult.yaml +# 18 NYCRR 398-4.5(a)(1)-(2): an unrelated third adult means living +# with others. The official 2026 chart pays $46 per couple and $23 per +# individual; $46 / 2 = $23 for each spouse. +# https://opwdd.ny.gov/system/files/documents/2025/11/25-inf-06-attachment-1.pdf +# No marital units: only the head and spouse form the eligible couple. +- name: NY couple rate excludes a third aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: NY + output: + ny_ssp_living_arrangement: [LIVING_WITH_OTHERS, LIVING_WITH_OTHERS, LIVING_WITH_OTHERS] + ny_ssp: [23, 23, 23] + +# oh/dbh/rss/third_adult.yaml +# OAC 5160:1-5-01(E)(2) combines spouses in the same RSS living arrangement. OAC 5160:1-3-03.2 applies one $20 exclusion: ($300 + $200 - $20) / 2 = $240 each; the third adult has $400 - $20 = $380. The $1,600 allowable fee plus $200 personal needs gives grants of $1,560, $1,560 and $1,420. +# https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-5-01 +- name: Ohio pools only the RSS couple income + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + oh_rss_eligible: true + ssi: 0 + ssi_unearned_income: + 2026: 3600 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + oh_rss_eligible: true + ssi: 0 + ssi_unearned_income: + 2026: 2400 + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + oh_rss_eligible: true + ssi: 0 + ssi_unearned_income: + 2026: 4800 + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: OH + output: + oh_rss_countable_income: [240, 240, 380] + oh_rss: [1560, 1560, 1420] + +# ok/dhs/ssp/ok_ssp_three_adult.yaml +- name: Oklahoma couple countable income excludes a third aged adult. + period: 2026-07 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + social_security_retirement: + 2026: 1_200 + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: OK + output: + # OKDHS Appendix C-1 Schedule VIII.A: eligible couple standard 1,571; + # individual standard 1,034; maximum $40 per recipient. + # https://oklahoma.gov/content/dam/ok/en/okdhs/documents/searchcenter/okdhsformresults/c-1.pdf#page=3 + # Couple income is SSI 1,491, so (1,571 - 1,491) / 2 = 40 each. + # Third adult: (100 - 20) + SSI (994 - 80) = 994; 1,034 - 994 = 40. + ok_ssp_countable_income: [1_491, 1_491, 994] + ok_ssp: [40, 40, 40] + +# or/odhs/ssp/third_adult.yaml +# OAR 461-155-0630(3) tops up each SSI spouse in community care to the one-person OSIPM standard, applying only the other spouses excess. OAR 461-155-0250(3) sets $994 in 2026. Both spouses are below it: $994 - $800 = $194 and $994 - $900 = $94. The unrelated third adult receives no couple payment. +# https://ch461rules.odhs.oregon.gov/rules/461-155-0630.pdf#page=2 +- name: Oregon excludes another care resident from the spouses community care calculation + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + or_ssp_category: COMMUNITY_CARE + ssi: 100 + or_ssp_countable_income: 800 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + or_ssp_category: COMMUNITY_CARE + ssi: 100 + or_ssp_countable_income: 900 + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + or_ssp_category: COMMUNITY_CARE + ssi: 100 + or_ssp_countable_income: 1500 + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: OR + output: + or_ssp: [194, 94, 0] + +# pa/dhs/ssp/pa_ssp_three_adult.yaml +# POMS SI 01415.058 K.2: independent living pays $33.30 per couple +# and $22.10 per individual; $33.30 / 2 = $16.65. +# https://secure.ssa.gov/poms.nsf/lnx/0501415058#k2 +# No marital units: only the head and spouse form the eligible couple. +- name: PA couple rate excludes a third aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: PA + output: + pa_ssp: [16.65, 16.65, 22.1] + +# ri/dhs/ssp/ri_ssp_three_adult.yaml +# R.I. Gen. Laws 40-6-27(a)(1)(i),(iii): living-alone state +# standards are $79.38 per couple and $39.92 per individual. +# $79.38 / 2 = $39.69; no federal one-third reduction applies. +# https://webserver.rilegislature.gov/Statutes/TITLE40/40-6/40-6-27.htm +# No marital units: only the head and spouse form the eligible couple. +- name: RI couple rate excludes a third aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: RI + output: + ri_ssp: [39.69, 39.69, 39.92] + +# sd/dss/ssp/sd_ssp_three_adult.yaml +- name: South Dakota couple with only SSI ignores the third adult's other income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + social_security_retirement: + 2026: 1_200 + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: SD + output: + # POMS SI DEN01415.011 B.5.b: a couple with no income other than SSI + # receives $8 and $7. The unrelated individual's $100/month is separate. + # https://secure.ssa.gov/poms.nsf/lnx/0501415011DEN + # Listing that individual first also must not consume the $8 spouse slot. + sd_ssp_independent: [0, 8, 7] + sd_ssp: [0, 8, 7] + +- name: South Dakota joint care entry excludes the third adult's income and resources. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + ssi_countable_resources: + 2026: 500 + sd_ssp_care_joint_entry: true + sd_ssp_care_certified: true + sd_ssp_care_setting: ADULT_FOSTER_CARE + sd_ssp_approved_care_cost: 1_500 + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + ssi_countable_resources: + 2026: 500 + sd_ssp_care_joint_entry: true + sd_ssp_care_certified: true + sd_ssp_care_setting: ADULT_FOSTER_CARE + sd_ssp_approved_care_cost: 1_500 + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + social_security_retirement: + 2026: 1_200 + ssi_countable_resources: + 2026: 3_000 + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: SD + output: + # ARSD 67:46:05:30 tests the entering couple's resources: 500 + 500 < 3,000. + # https://sdlegislature.gov/Rules/Administrative/67:46:05:30 + # ARSD 67:46:04:10(6) and 67:46:06:03 share only spouses' gross income. + # https://sdlegislature.gov/Rules/Administrative/67:46:04:10 + # Each spouse's contribution is SSI 1,491 / 2 = 745.50; + # care payment = approved charge 1,500 - 745.50 = 754.50. + sd_ssp_care: [754.5, 754.5, 0] + sd_ssp: [754.5, 754.5, 0] + +# ut/dws/ssp/ut_dws_ssp_three_adult.yaml +- name: Utah couple amount excludes a third aged adult in the default marital unit. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: UT + output: + ssi_claim_is_joint: [true, true, false] + ut_dws_ssp_eligible: [true, true, true] + # Utah DWS Eligibility Manual 205-10, effective January 1, 2026: + # https://jobs.utah.gov/infosource/eligibilitymanual/200_Program_Eligibility_Requirements/205-10_State_Supplemental_Payments_to_SSI_Recipients_-_General_Information.htm + # Own-household couple $5.75 / 2 = $2.875 each; individual $3.91. + ut_dws_ssp: [2.875, 2.875, 3.91] + ut_ssp: [2.875, 2.875, 3.91] + +- name: Utah household-of-another couple amount excludes the third aged adult. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + ssi_lives_in_another_persons_household: true + ssi_receives_shelter_from_others_in_household: true + ssi_others_pay_all_meals: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + ssi_lives_in_another_persons_household: true + ssi_receives_shelter_from_others_in_household: true + ssi_others_pay_all_meals: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + ssi_lives_in_another_persons_household: true + ssi_receives_shelter_from_others_in_household: true + ssi_others_pay_all_meals: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: UT + output: + # The same 205-10 table pays $12.19 to a couple in another's household. + # https://jobs.utah.gov/infosource/eligibilitymanual/200_Program_Eligibility_Requirements/205-10_State_Supplemental_Payments_to_SSI_Recipients_-_General_Information.htm + # $12.19 / 2 = $6.095 per spouse; individual $3.91. + ut_dws_ssp: [6.095, 6.095, 3.91] + ut_ssp: [6.095, 6.095, 3.91] + +# va/dars/ag/va_ag_three_adult.yaml +- name: Virginia initial-month couple excludes the third adult's income and resources. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + social_security_retirement: + 2026: 24_000 + ssi_countable_resources: + 2026: 1_000 + va_ag_certified: true + va_ag_is_joint_initial_month: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + ssi_countable_resources: + 2026: 1_000 + va_ag_certified: true + va_ag_is_joint_initial_month: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + social_security_retirement: + 2026: 24_000 + ssi_countable_resources: + 2026: 2_000 + va_ag_certified: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: VA + output: + # AG Manual Chapter B section 6: the initial-month AU contains only the + # applicant and spouse; Chapter D uses SSI income exclusions. + # https://townhall.virginia.gov/l/GetFile.cfm?File=C:%5CTownHall%5Cdocroot%5CGuidanceDocs%5C262%5CGDoc_DARS_5346_v2.pdf#page=34 + # Couple resources 1,000 + 1,000 = 2,000 <= 3,000; individual 2,000 <= 2,000. + # Couple: (2,000 - 20) / 2 = 990 each; individual: 2,000 - 20 = 1,980. + va_ag_eligible: [true, true, true] + va_ag_countable_income: [990, 990, 1_980] + # January 2026 rate letter: care 2,130 plus personal needs 87 = 2,217. + # https://www.dss.virginia.gov/media/vdss/licensing/documents/assisted-living-facilities/additional-resources/ARC2026_ag_rate_letter.pdf + # Grants: 2,217 - 990 = 1,227 each; third adult 2,217 - 1,980 = 237. + va_ag: [1_227, 1_227, 237] + +# vt/dcf/aabd/vt_aabd_three_adult.yaml +# POMS SI 01415.058 M.2: independent living pays $105.80 per couple +# and $55.68 per individual; $105.80 / 2 = $52.90. +# https://secure.ssa.gov/poms.nsf/lnx/0501415058#m2 +# No marital units: only the head and spouse form the eligible couple. +- name: VT couple rate excludes a third aged adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: VT + output: + vt_aabd: [52.9, 52.9, 55.68] + +# Vermont AABD rules 2780(A), 2783.1, 2784(C), 2786: one EP grant per +# eligible couple, using the highest level certified on either spouse. +# https://outside.vermont.gov/dept/DCF/Shared%20Documents/ESD/Rules/2700-AABD.pdf#page=44 +# Couple maximum = 1,491 + 105.80 + 183.84 = $1,780.64. With no own +# income, SSI + federal AABD = 1,491 + 105.80 = $1,596.80. EP is +# floor(1,780.64 - 1,596.80) / 2 = $91.50 each. Uncertified third gets $0. +- name: VT essential-person certification and income exclude the third adult + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 70 + is_tax_unit_head: + 2026: true + vt_aabd_ep_level: FULL + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + third_adult: + age: + 2026: 70 + is_tax_unit_dependent: + 2026: true + pension_income: + 2026: 12_000 + tax_units: + tax_unit: + members: [husband, wife, third_adult] + households: + household: + members: [husband, wife, third_adult] + state_code: + 2026: VT + output: + vt_aabd_ep_is_spouse: [false, false, false] + vt_aabd_ep: [91.5, 91.5, 0] + +# wa/dshs/ssp/wa_ssp_three_adult.yaml +- name: Washington historical ineligible-spouse category excludes a third aged adult. + period: 2022-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2022: 40 + is_tax_unit_head: + 2022: true + meets_ssi_disability_criteria: + 2022: true + spouse: + age: + 2022: 40 + is_tax_unit_spouse: + 2022: true + dependent: + age: + 2022: 68 + is_tax_unit_dependent: + 2022: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2022: WA + output: + # WAC 388-474-0012(2)(b) includes an SSI recipient with an ineligible spouse. + # https://app.leg.wa.gov/wac/default.aspx?cite=388-474-0012 + # WSR 20-23-053 set $38.25 for that category and for aged recipients. + # https://lawfilesext.leg.wa.gov/law/wsr/2020/23/20-23-053.htm + # Head qualifies through the actual spouse; the third adult through age. + wa_ssp_payment_category: [STANDARD, NONE, STANDARD] + wa_ssp: [38.25, 0, 38.25] + +# wi/dhs/ssp/wi_ssp_three_adult.yaml +- name: Wisconsin couple amount excludes a third aged adult in the default marital unit. + period: 2026-05 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: WI + output: + ssi_claim_is_joint: [true, true, false] + wi_ssp_eligible: [true, true, true] + # Wisconsin DMS Operations Memo 26-12, effective May 1, 2026: + # https://www.dhs.wisconsin.gov/dms/memos/ops/dms-ops-2026-12.pdf#page=2 + # Eligible couple $145.26 / 2 = $72.63 each; individual $92.16. + wi_ssp_regular: [72.63, 72.63, 92.16] + wi_ssp: [72.63, 72.63, 92.16] + +- name: Wisconsin SSI-E couple amount excludes the third certified adult. + period: 2026-05 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + wi_ssi_e_certified: true + spouse: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + wi_ssi_e_certified: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + wi_ssi_e_certified: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: WI + output: + # The memo's SSI-E rates are $379.90/couple and $105.59/individual. + # https://www.dhs.wisconsin.gov/dms/memos/ops/dms-ops-2026-12.pdf#page=2 + # $379.90 / 2 = $189.95 each; plus regular $72.63 = $262.58 each. + # Third adult: $105.59 + $92.16 = $197.75. + wi_ssi_e: [189.95, 189.95, 105.59] + wi_ssp: [262.58, 262.58, 197.75] + +- name: Wisconsin ineligible-spouse rate applies only to the actual eligible spouse. + period: 2026-05 + absolute_error_margin: 0.01 + input: + people: + head: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + spouse: + age: + 2026: 40 + is_tax_unit_spouse: + 2026: true + dependent: + age: + 2026: 68 + is_tax_unit_dependent: + 2026: true + tax_units: + unit: + members: [head, spouse, dependent] + spm_units: + unit: + members: [head, spouse, dependent] + households: + home: + members: [head, spouse, dependent] + state_code: + 2026: WI + output: + is_ssi_ineligible_spouse: [false, true, false] + wi_ssp_eligible: [true, false, true] + # The memo pays $143.47 to an eligible person with an ineligible spouse. + # https://www.dhs.wisconsin.gov/dms/memos/ops/dms-ops-2026-12.pdf#page=2 + # The third adult has no spouse and keeps the $92.16 individual amount. + wi_ssp_regular: [143.47, 0, 92.16] + wi_ssp: [143.47, 0, 92.16] diff --git a/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py b/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py index 5e0c84da3f3..12f89b15a03 100644 --- a/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( SSIFederalLivingArrangement, ) @@ -18,7 +21,7 @@ def ssi_state_supplement( SI 02001.005 and SI 02001.010. Callers apply state-specific eligibility. """ joint = person("ssi_couple_computation_applies", period) - couple_total = person.marital_unit.sum(couple_rate / 2) + couple_total = _ssi_couple_total(person, period.this_year, couple_rate / 2) standard = where(joint, couple_total / 2, individual_rate) countable = person("ssi_countable_income", period) federal_amount = person("ssi_amount_if_eligible", period) diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py index a1c81d3db4d..628a66190ec 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.co.ssa.oap._precision import ( cents, ssi_paid, @@ -74,10 +77,8 @@ def formula(person, period, parameters): # through the marital unit. child_allocations = person.household.sum(child_allocation) * deemable - marital_unit = person.marital_unit - def spouse(values): - return marital_unit.sum(values) - values + return _ssi_couple_total(person, year, values) - values # 3.534(A): deduct $65 from the spouse's gross earned income and halve # the rest. Unlike SSI (20 CFR 416.1163) there is no deeming threshold. diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py index 227011917f2..dddb094af6c 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.co.ssa.oap._precision import ( PRECISION, ssi_paid, @@ -42,7 +45,9 @@ def formula(person, period, parameters): year = period.this_year eligible = person("co_oap_eligible", year) receives_oap = eligible & (p.grant_standard - income > PRECISION) - spouse_of_client = person.marital_unit.sum(eligible) - eligible > 0 + spouse_of_client = ( + _ssi_couple_total(person, year, eligible.astype(int)) - eligible > 0 + ) candidate = spouse_of_client & within_limit exempt = candidate & (receives_ssi | receives_oap) # Medicaid decides only for a candidate not exempt otherwise. Skipping diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py index eefc5ab45f7..63b009239b1 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) from policyengine_us.variables.gov.states.co.ssa.oap._precision import ( cents, ssi_paid, @@ -32,7 +35,7 @@ def formula(person, period, parameters): ) # 3.533(A)(2)(c)(1): a married client's $20 disregard is split equally # with the spouse, so that no more than $20 applies to the couple. - married = person.marital_unit.nb_persons() == 2 + married = _ssi_spouse_index(person, period.this_year) >= 0 disregard = where(married, p.disregards.unearned / 2, p.disregards.unearned) # 3.533(A)(2)(c): the disregard applies to unearned income other than # SSI; min_ gives 0 to a client whose only unearned income is SSI diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/eligibility/ct_ssp_resource_eligible.py b/policyengine_us/variables/gov/states/ct/dss/ssp/eligibility/ct_ssp_resource_eligible.py index f4e5cf330f0..ce3eca91f88 100644 --- a/policyengine_us/variables/gov/states/ct/dss/ssp/eligibility/ct_ssp_resource_eligible.py +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/eligibility/ct_ssp_resource_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ct_ssp_resource_eligible(Variable): @@ -19,15 +22,17 @@ def formula(person, period, parameters): personal_resources = person("ssi_countable_resources", period.this_year) is_joint_claim = person("ssi_claim_is_joint", period.this_year) has_ineligible_spouse = ( - person.marital_unit.sum( - person("is_ssi_ineligible_spouse", period.this_year) + _ssi_couple_total( + person, + period.this_year, + person("is_ssi_ineligible_spouse", period.this_year).astype(int), ) > 0 ) applies_couple_limit = is_joint_claim | has_ineligible_spouse resources = where( applies_couple_limit, - person.marital_unit.sum(personal_resources), + _ssi_couple_total(person, period.this_year, personal_resources), personal_resources, ) limit = where( diff --git a/policyengine_us/variables/gov/states/dc/dhcf/ossp/dc_ossp_payment_amount.py b/policyengine_us/variables/gov/states/dc/dhcf/ossp/dc_ossp_payment_amount.py index ad11e748446..0f5e9b57519 100644 --- a/policyengine_us/variables/gov/states/dc/dhcf/ossp/dc_ossp_payment_amount.py +++ b/policyengine_us/variables/gov/states/dc/dhcf/ossp/dc_ossp_payment_amount.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class dc_ossp_payment_amount(Variable): @@ -17,17 +21,13 @@ def formula(person, period, parameters): living_arrangement = person("dc_ossp_living_arrangement", period) eligible = person("dc_ossp_eligible", period) joint_claim = person("ssi_claim_is_joint", period.this_year) - both_eligible = person.marital_unit.sum(eligible) == 2 + both_eligible = ( + _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 + ) # Couple rate requires joint SSI claim, both spouses OSSP-eligible, # and both in the same arrangement category. - is_os_a = living_arrangement == living_arrangement.possible_values.OS_A - is_os_b = living_arrangement == living_arrangement.possible_values.OS_B - is_os_g = living_arrangement == living_arrangement.possible_values.OS_G - both_same = ( - (person.marital_unit.sum(is_os_a) == 2) - | (person.marital_unit.sum(is_os_b) == 2) - | (person.marital_unit.sum(is_os_g) == 2) - ) + spouse = _ssi_spouse_index(person, period.this_year) + both_same = living_arrangement == living_arrangement[np.maximum(spouse, 0)] is_couple = joint_claim & both_eligible & both_same p = parameters(period).gov.states.dc.dhcf.ossp.payment diff --git a/policyengine_us/variables/gov/states/de/dhss/ssp/de_ssp.py b/policyengine_us/variables/gov/states/de/dhss/ssp/de_ssp.py index 6126735db21..7c9f51fc1f1 100644 --- a/policyengine_us/variables/gov/states/de/dhss/ssp/de_ssp.py +++ b/policyengine_us/variables/gov/states/de/dhss/ssp/de_ssp.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class de_ssp(Variable): @@ -17,13 +20,15 @@ def formula(person, period, parameters): p = parameters(period).gov.states.de.dhss.ssp.amount joint_claim = person("ssi_claim_is_joint", period.this_year) eligible = person("de_ssp_eligible", period) - is_couple = joint_claim & (person.marital_unit.sum(eligible) == 2) + is_couple = joint_claim & ( + _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 + ) per_person_amount = where(is_couple, p.couple / 2, p.individual) uncapped_ssi = person("uncapped_ssi", period) reduction = max_(0, -uncapped_ssi) supplement = max_(0, per_person_amount - reduction) return where( is_couple, - person.marital_unit.sum(supplement) / 2, + _ssi_couple_total(person, period.this_year, supplement) / 2, supplement, ) diff --git a/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_couple_rate_applies.py b/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_couple_rate_applies.py index d228149a17b..57fcaf13bcc 100644 --- a/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_couple_rate_applies.py +++ b/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_couple_rate_applies.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class fl_oss_couple_rate_applies(Variable): @@ -20,20 +23,30 @@ def formula(person, period, parameters): # couple with mismatched or missing tracks has no valid # couple rate. joint_claim = person("ssi_claim_is_joint", period) - unit_size = person.marital_unit.nb_persons() both_ssi_eligible = ( - person.marital_unit.sum(person("is_ssi_eligible", period)) == unit_size + _ssi_couple_total( + person, period.this_year, person("is_ssi_eligible", period).astype(int) + ) + == 2 ) living_arrangement = person("fl_oss_living_arrangement", period) in_facility = living_arrangement != living_arrangement.possible_values.NONE - both_in_facility = person.marital_unit.sum(in_facility) == unit_size + both_in_facility = ( + _ssi_couple_total(person, period.this_year, in_facility.astype(int)) == 2 + ) program_track = person("fl_oss_program_track", period) TRACK = program_track.possible_values both_on_redesign = ( - person.marital_unit.sum(program_track == TRACK.REDESIGN) == unit_size + _ssi_couple_total( + person, period.this_year, (program_track == TRACK.REDESIGN).astype(int) + ) + == 2 ) both_on_protected = ( - person.marital_unit.sum(program_track == TRACK.PROTECTED) == unit_size + _ssi_couple_total( + person, period.this_year, (program_track == TRACK.PROTECTED).astype(int) + ) + == 2 ) both_on_same_track = both_on_redesign | both_on_protected return joint_claim & both_ssi_eligible & both_in_facility & both_on_same_track diff --git a/policyengine_us/variables/gov/states/hi/dhs/oss/hi_oss_couple_rate_applies.py b/policyengine_us/variables/gov/states/hi/dhs/oss/hi_oss_couple_rate_applies.py index 28b6a32b3f5..5c99c9daede 100644 --- a/policyengine_us/variables/gov/states/hi/dhs/oss/hi_oss_couple_rate_applies.py +++ b/policyengine_us/variables/gov/states/hi/dhs/oss/hi_oss_couple_rate_applies.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.hi.dhs.oss.hi_oss_living_arrangement import ( HIOSSLivingArrangement, ) @@ -19,15 +22,36 @@ def formula(person, period, parameters): joint_claim = person("ssi_claim_is_joint", period.this_year) is_eligible = person("is_ssi_eligible", period) both_ssi_eligible = ( - person.marital_unit.sum(is_eligible) == person.marital_unit.nb_persons() + _ssi_couple_total(person, period.this_year, is_eligible.astype(int)) == 2 ) la = person("hi_oss_living_arrangement", period) LA = HIOSSLivingArrangement - unit_size = person.marital_unit.nb_persons() both_same_facility = ( - (person.marital_unit.sum(la == LA.COMMUNITY_CARE) == unit_size) - | (person.marital_unit.sum(la == LA.DOMICILIARY_CARE_I) == unit_size) - | (person.marital_unit.sum(la == LA.DOMICILIARY_CARE_II) == unit_size) - | (person.marital_unit.sum(la == LA.MEDICAID_INSTITUTION) == unit_size) + ( + _ssi_couple_total( + person, period.this_year, (la == LA.COMMUNITY_CARE).astype(int) + ) + == 2 + ) + | ( + _ssi_couple_total( + person, period.this_year, (la == LA.DOMICILIARY_CARE_I).astype(int) + ) + == 2 + ) + | ( + _ssi_couple_total( + person, period.this_year, (la == LA.DOMICILIARY_CARE_II).astype(int) + ) + == 2 + ) + | ( + _ssi_couple_total( + person, + period.this_year, + (la == LA.MEDICAID_INSTITUTION).astype(int), + ) + == 2 + ) ) return joint_claim & both_ssi_eligible & both_same_facility diff --git a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py index a70a664f6df..33545090117 100644 --- a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py +++ b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class in_ssp_sapn(Variable): @@ -21,7 +24,9 @@ def formula(person, period, parameters): p = parameters(period).gov.states["in"].fssa.ssp joint_claim = person("ssi_claim_is_joint", period.this_year) eligible = person("in_ssp_sapn_eligible", period) - both_eligible = person.marital_unit.sum(eligible) == 2 + both_eligible = ( + _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 + ) is_couple = joint_claim & both_eligible ssi_monthly = person("ssi", period) countable_income = person("ssi_countable_income", period) @@ -33,7 +38,9 @@ def formula(person, period, parameters): min_(pna - ssi_monthly - countable_income, max_individual), 0, ) - couple_budgeted = person.marital_unit.sum(ssi_monthly + countable_income) + couple_budgeted = _ssi_couple_total( + person, period.this_year, ssi_monthly + countable_income + ) couple_amount = max_( min_(2 * pna - couple_budgeted, max_couple), 0, diff --git a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.py b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.py index 6f67eb9df34..5f939328efd 100644 --- a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.py +++ b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class KYSSPClaimType(Enum): @@ -32,13 +36,13 @@ def formula(person, period, parameters): category = person("ky_ssp_category", period) in_qualifying_category = category != category.possible_values.NONE ssp_categorically_eligible = is_ssi_eligible & is_adult & in_qualifying_category - eligible_count = person.marital_unit.sum(ssp_categorically_eligible) - marital_unit_size = person.marital_unit.nb_persons() + eligible_count = _ssi_couple_total( + person, period.this_year, ssp_categorically_eligible.astype(int) + ) + married = _ssi_spouse_index(person, period.this_year) >= 0 both_eligible = (eligible_count == 2) & ssp_categorically_eligible one_eligible_couple = ( - (eligible_count == 1) - & ssp_categorically_eligible - & (marital_unit_size == 2) + (eligible_count == 1) & ssp_categorically_eligible & married ) return select( [both_eligible, one_eligible_couple], diff --git a/policyengine_us/variables/gov/states/me/dhhs/ssp/me_ssp.py b/policyengine_us/variables/gov/states/me/dhhs/ssp/me_ssp.py index 10b931b902a..a1975edb950 100644 --- a/policyengine_us/variables/gov/states/me/dhhs/ssp/me_ssp.py +++ b/policyengine_us/variables/gov/states/me/dhhs/ssp/me_ssp.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class me_ssp(Variable): @@ -18,13 +22,14 @@ class me_ssp(Variable): def formula(person, period, parameters): joint_claim = person("ssi_claim_is_joint", period.this_year) eligible = person("me_ssp_eligible", period) - both_eligible = person.marital_unit.sum(eligible) == 2 + both_eligible = ( + _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 + ) category = person("me_ssp_payment_category", period) # Both spouses must share the same payment category to be treated # as a joint couple claim. - shared_category = person.marital_unit.max(category) == person.marital_unit.min( - category - ) + spouse = _ssi_spouse_index(person, period.this_year) + shared_category = category == category[np.maximum(spouse, 0)] couple_applies = joint_claim & both_eligible & shared_category return where( couple_applies, diff --git a/policyengine_us/variables/gov/states/mi/mdhhs/ssp/mi_ssp_couple_eligible.py b/policyengine_us/variables/gov/states/mi/mdhhs/ssp/mi_ssp_couple_eligible.py index f356ddf099f..f668519bc8c 100644 --- a/policyengine_us/variables/gov/states/mi/mdhhs/ssp/mi_ssp_couple_eligible.py +++ b/policyengine_us/variables/gov/states/mi/mdhhs/ssp/mi_ssp_couple_eligible.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) from policyengine_us.variables.gov.states.mi.mdhhs.ssp.mi_ssp_living_arrangement import ( MISSPLivingArrangement, ) @@ -19,22 +23,12 @@ class mi_ssp_couple_eligible(Variable): def formula(person, period, parameters): joint_claim = person("ssi_claim_is_joint", period.this_year) eligible = person("mi_ssp_eligible", period) - # Summing the same variable used in defined_for is safe here; if a - # future contributor swaps to summing a different variable across - # the marital unit, the defined_for filter could silently halve - # the result. - both_eligible = person.marital_unit.sum(eligible) == 2 + both_eligible = ( + _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 + ) category = person("mi_ssp_payment_category", period) - qualifying_arrangements = [ - value - for value in MISSPLivingArrangement - if value != MISSPLivingArrangement.NONE - ] - both_same_arrangement = ( - sum( - person.marital_unit.sum(category == value) == 2 - for value in qualifying_arrangements - ) - > 0 + spouse = _ssi_spouse_index(person, period.this_year) + both_same_arrangement = (category != MISSPLivingArrangement.NONE) & ( + category == category[np.maximum(spouse, 0)] ) return joint_claim & both_eligible & both_same_arrangement diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_gross_income_eligible.py b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_gross_income_eligible.py index 27de6fc0bc3..ef759e6bc79 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_gross_income_eligible.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_gross_income_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class mn_msa_gross_income_eligible(Variable): @@ -21,9 +24,16 @@ def formula(person, period, parameters): p = parameters(period).gov.states.mn.dhs.msa.eligibility.income_limit ssi_individual_fbr = parameters(period).gov.ssa.ssi.amount.individual categorically_eligible = person("is_ssi_aged_blind_disabled", period.this_year) - both_eligible = person.marital_unit.sum(categorically_eligible) == 2 + both_eligible = ( + _ssi_couple_total( + person, period.this_year, categorically_eligible.astype(int) + ) + == 2 + ) gross = person("mn_msa_gross_income", period) - au_income = where(both_eligible, person.marital_unit.sum(gross), gross) + au_income = where( + both_eligible, _ssi_couple_total(person, period.this_year, gross), gross + ) cap = ssi_individual_fbr * where( both_eligible, p.couple_fbr_multiplier, p.individual_fbr_multiplier ) diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py index bd8a807be09..ceefa00797f 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) @@ -55,8 +58,8 @@ def formula(person, period, parameters): # SSI track: count FBR as unearned, apply $20 once at AU level for # couples (matches mn_msa_person.py SSI track formula). couple_ssi_countable = max_( - person.marital_unit.sum(ssi_fbr) - + person.marital_unit.sum(raw_unearned) + _ssi_couple_total(person, period.this_year, ssi_fbr) + + _ssi_couple_total(person, period.this_year, raw_unearned) - disregard, 0, ) @@ -83,8 +86,8 @@ def formula(person, period, parameters): "ssi_unearned_income_deemed_from_ineligible_parent", ], ) - couple_earned = person.marital_unit.sum(annual_earned) - couple_unearned = person.marital_unit.sum(annual_unearned) + couple_earned = _ssi_couple_total(person, period.this_year, annual_earned) + couple_unearned = _ssi_couple_total(person, period.this_year, annual_unearned) couple_non_ssi_countable = ( _apply_ssi_exclusions(couple_earned, couple_unearned, parameters, period) / MONTHS_IN_YEAR @@ -98,11 +101,13 @@ def formula(person, period, parameters): ) # For couples both ssi_countable and non_ssi_countable are already - # AU-aggregate (computed via marital_unit.sum); the couple standard + # aggregated over the recipient and spouse; the couple standard # is the AU total, so compare directly. countable = where(receives_ssi, ssi_countable, non_ssi_countable) special_needs = person("mn_msa_special_needs_total", period) need_standard = person("mn_msa_assistance_standard", period) + where( - is_couple, person.marital_unit.sum(special_needs), special_needs + is_couple, + _ssi_couple_total(person, period.this_year, special_needs), + special_needs, ) return countable <= need_standard diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_resource_eligible.py b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_resource_eligible.py index dcbab010738..70214720794 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_resource_eligible.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_resource_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class mn_msa_resource_eligible(Variable): @@ -25,5 +28,7 @@ def formula(person, period, parameters): p = parameters(period).gov.states.mn.dhs.msa.eligibility receives_ssi = (person("ssi", period) > 0) | person("receives_ssi", period) countable_resources = person("ssi_countable_resources", period.this_year) - unit_resources = person.marital_unit.sum(countable_resources) + unit_resources = _ssi_couple_total( + person, period.this_year, countable_resources + ) return receives_ssi | (unit_resources <= p.asset_limit.non_ssi_track) diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py b/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py index 4019169214c..844b39ab6ff 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) @@ -30,7 +33,7 @@ def formula(person, period, parameters): # The SSI track substitutes the federal SSI FBR for the recipient's # post-disregard SSI payment so MSA tops up against the FBR rather # than the federal payment. FLA-D recipients are exempt from the $20 - # general disregard. Couples are computed at the marital unit then + # general disregard. Couples are computed using only the two spouses then # split 50/50 to avoid wasting disregards on asymmetric income. arrangement = person("mn_msa_payment_category", period) LA = arrangement.possible_values @@ -43,7 +46,9 @@ def formula(person, period, parameters): need_standard = standard + special_needs # Couple income is tested at the assistance-unit level, but modeled # special needs remain attached to the spouse with the allowance. - couple_special_needs = person.marital_unit.sum(special_needs) + couple_special_needs = _ssi_couple_total( + person, period.this_year, special_needs + ) special_needs_share = special_needs / max_(couple_special_needs, 1) ssi = person("ssi", period) receives_ssi = ssi > 0 @@ -60,11 +65,11 @@ def formula(person, period, parameters): ) disregard = where(is_medicaid_facility, 0, p.general) - # SSI track: aggregate FBR + unearned at marital unit for couples, + # SSI track: aggregate FBR + unearned for the two spouses, # apply $20 once, then split supplement 50/50. couple_ssi_countable = max_( - person.marital_unit.sum(ssi_fbr) - + person.marital_unit.sum(raw_unearned) + _ssi_couple_total(person, period.this_year, ssi_fbr) + + _ssi_couple_total(person, period.this_year, raw_unearned) - disregard, 0, ) @@ -97,8 +102,8 @@ def formula(person, period, parameters): "ssi_unearned_income_deemed_from_ineligible_parent", ], ) - couple_earned = person.marital_unit.sum(annual_earned) - couple_unearned = person.marital_unit.sum(annual_unearned) + couple_earned = _ssi_couple_total(person, period.this_year, annual_earned) + couple_unearned = _ssi_couple_total(person, period.this_year, annual_unearned) couple_non_ssi_countable_annual = _apply_ssi_exclusions( couple_earned, couple_unearned, parameters, period ) diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_guardian_fee.py b/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_guardian_fee.py index 514d50a00b3..0bfaf98b1bc 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_guardian_fee.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_guardian_fee.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class mn_msa_guardian_fee(Variable): @@ -20,7 +23,7 @@ def formula(person, period, parameters): # monthly income up to a maximum of $100 per month. Per CM 0017, # for SSI recipients count the full SSI FBR as gross unearned # income (even when the actual SSI check is less). Aggregate to - # the marital unit before applying the cap. + # the recipient and spouse before applying the cap. # Minnesota DHS correspondence (2026-10-19) reports that approved # guardian fees are $100 in practice regardless of income. # PolicyEngine follows the statute's 5%/$100 cap, which governs. @@ -28,7 +31,7 @@ def formula(person, period, parameters): has_guardian = person("mn_msa_has_guardian", period) guardian_is_county_staff = person("mn_msa_guardian_is_county_staff", period) gross = person("mn_msa_gross_income", period) - unit_gross = person.marital_unit.sum(gross) + unit_gross = _ssi_couple_total(person, period.this_year, gross) unit_fee = min_(unit_gross * p.rate, p.max_amount) # Minn. Stat. § 256D.44 Subd. 5(d) allows no fee for county agency staff. return has_guardian * ~guardian_is_county_staff * unit_fee diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_payment_category.py b/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_payment_category.py index 308dbcba87c..10f67c30c98 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_payment_category.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/payment/mn_msa_payment_category.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.mn.dhs.msa.mn_msa_living_arrangement import ( MNMSALivingArrangement, ) @@ -36,7 +39,12 @@ def formula(person, period, parameters): ) apply_housing_standard = where( is_couple, - person.marital_unit.any(housing_assistance_eligible), + ( + _ssi_couple_total( + person, period.this_year, housing_assistance_eligible.astype(int) + ) + > 0 + ), housing_assistance_eligible, ) housing_standard_arrangement = select( diff --git a/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py b/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py index 5ec42a8f935..6e741c2c4a7 100644 --- a/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py +++ b/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class mo_ssp_resource_eligible(Variable): @@ -32,9 +36,11 @@ def formula(person, period, parameters): # Spouses separated to receive medical or nursing home care also use # the couple limit; that separation is not modeled. personal_resources = person("ssi_countable_resources", period.this_year) - married = person.marital_unit.nb_persons() == 2 + married = _ssi_spouse_index(person, period.this_year) >= 0 countable_resources = where( - married, person.marital_unit.sum(personal_resources), personal_resources + married, + _ssi_couple_total(person, period.this_year, personal_resources), + personal_resources, ) p = parameters(period).gov.states.mo.dss.ssp.eligibility.resource_limit diff --git a/policyengine_us/variables/gov/states/ne/dhhs/aabd/ne_aabd_couple_rate_applies.py b/policyengine_us/variables/gov/states/ne/dhhs/aabd/ne_aabd_couple_rate_applies.py index f69afb0d78d..ad89d42c1d6 100644 --- a/policyengine_us/variables/gov/states/ne/dhhs/aabd/ne_aabd_couple_rate_applies.py +++ b/policyengine_us/variables/gov/states/ne/dhhs/aabd/ne_aabd_couple_rate_applies.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ne_aabd_couple_rate_applies(Variable): @@ -21,12 +24,16 @@ def formula(person, period): # is_ssi_eligible is used (not ne_aabd_eligible) to avoid a # circular dependency on the benefit calculation. joint_claim = person("ssi_claim_is_joint", period.this_year) - unit_size = person.marital_unit.nb_persons() both_ssi_eligible = ( - person.marital_unit.sum(person("is_ssi_eligible", period)) == unit_size + _ssi_couple_total( + person, period.this_year, person("is_ssi_eligible", period).astype(int) + ) + == 2 ) living_arrangement = person("ne_aabd_living_arrangement", period) LA = living_arrangement.possible_values is_independent = living_arrangement == LA.INDEPENDENT - both_independent = person.marital_unit.sum(is_independent) == unit_size + both_independent = ( + _ssi_couple_total(person, period.this_year, is_independent.astype(int)) == 2 + ) return joint_claim & both_ssi_eligible & both_independent diff --git a/policyengine_us/variables/gov/states/nj/njdhs/ssp/nj_ssp_living_arrangement.py b/policyengine_us/variables/gov/states/nj/njdhs/ssp/nj_ssp_living_arrangement.py index f652d78b1c9..5166fa07988 100644 --- a/policyengine_us/variables/gov/states/nj/njdhs/ssp/nj_ssp_living_arrangement.py +++ b/policyengine_us/variables/gov/states/nj/njdhs/ssp/nj_ssp_living_arrangement.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.nj.njdhs.ssp.nj_ssp_care_setting import ( NJSSPCareSetting, ) @@ -42,10 +45,15 @@ def formula(person, period, parameters): other_medical = ( person("ssi_lives_in_medical_treatment_facility", period) & ~medical ) - ineligible_spouse = person.marital_unit.any( - person("is_ssi_ineligible_spouse", period.this_year) + ineligible_spouse = ( + _ssi_couple_total( + person, + period.this_year, + person("is_ssi_ineligible_spouse", period.this_year).astype(int), + ) + > 0 ) - no_others = person.household.nb_persons() == person.marital_unit.nb_persons() + no_others = person.household.nb_persons() == 2 return select( [ (care == NJSSPCareSetting.EXCLUDED) | other_medical, diff --git a/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_living_arrangement.py b/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_living_arrangement.py index 116ef0272ff..23fdfded77e 100644 --- a/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_living_arrangement.py +++ b/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_living_arrangement.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( SSIFederalLivingArrangement, ) @@ -65,7 +68,10 @@ def formula(person, period, parameters): & (care == NYSSPCareSetting.NONE) ) housemates = person.household.sum(housemate) - housemate - spouse = person.marital_unit.sum(housemate) - housemate + spouse = ( + _ssi_couple_total(person, period.this_year, housemate.astype(int)) + - housemate + ) alone = housemates == where(joint, spouse, 0) minor = person("ny_ssp_is_minor", period.this_year) return select( diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py index ff1cde2f8e8..914e9d08d38 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class oh_rss(Variable): @@ -32,7 +35,7 @@ def formula(person, period, parameters): # SSI reform carries over to RSS. limits = parameters(period).gov.ssa.ssi.eligibility.resources.limit eligible = person("oh_rss_eligible", period) - couple = person.marital_unit.sum(eligible) == 2 + couple = _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 # NOTE: When only one spouse qualifies, 5160:1-5-01(E)(3) applies the # individual standard, so the model tests own resources against # $2,000. Ohio's Medicaid resource rules, 5160:1-3-05.1(B)(9)(b) and @@ -42,7 +45,9 @@ def formula(person, period, parameters): # the SSI resource test both combine the couple's resources, so a # couple above $3,000 fails the Medicaid condition. resources = person("ssi_countable_resources", period.this_year) - resources = where(couple, person.marital_unit.sum(resources), resources) + resources = where( + couple, _ssi_couple_total(person, period.this_year, resources), resources + ) limit = where(couple, limits.couple, limits.individual) payment = max_( p.allowable_fee diff --git a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py index 8988542e5be..bffd9507540 100644 --- a/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py +++ b/policyengine_us/variables/gov/states/oh/dbh/rss/oh_rss_countable_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) @@ -19,7 +22,7 @@ class oh_rss_countable_income(Variable): def formula(person, period, parameters): # RSS does not deem income from an ineligible spouse or parent. eligible = person("oh_rss_eligible", period) - couple = person.marital_unit.sum(eligible) == 2 + couple = _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 earned = max_( person("ssi_earned_income", period) - person("ssi_blind_or_disabled_working_student_exclusion", period), @@ -32,8 +35,12 @@ def formula(person, period, parameters): - person("dividend_income", period), 0, ) - earned = where(couple, person.marital_unit.sum(earned), earned) - unearned = where(couple, person.marital_unit.sum(unearned), unearned) + earned = where( + couple, _ssi_couple_total(person, period.this_year, earned), earned + ) + unearned = where( + couple, _ssi_couple_total(person, period.this_year, unearned), unearned + ) # NOTE: PolicyEngine's federal SSI still counts interest and dividends # (#10079), so for an SSI recipient with that income the SSI counted # here is understated and RSS is overstated by the same amount. @@ -41,7 +48,11 @@ def formula(person, period, parameters): # status, so a spouse who declines SSI is still budgeted jointly # (#10138). federal_ssi = person("ssi", period) - federal_ssi = where(couple, person.marital_unit.sum(federal_ssi), federal_ssi) + federal_ssi = where( + couple, + _ssi_couple_total(person, period.this_year, federal_ssi), + federal_ssi, + ) # Ohio applies the same $20 general, $65 earned and one-half earned # income exclusions (5160:1-3-03.2(D)(2)(l), (E)(5), (E)(7)), so the SSI # exclusion parameters are used and an SSI reform carries over to RSS. diff --git a/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py b/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py index b6b072a38e3..a738c6f9464 100644 --- a/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py +++ b/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) @@ -69,16 +72,16 @@ def ok_ssp_income(person, period, parameters, *, combined): allocation = ~eligible * person("ok_ssp_spouse_child_allocation", period) earned = max_(earned - max_(allocation - unearned, 0), 0) unearned = max_(unearned - allocation, 0) - earned = person.marital_unit.sum(earned) - unearned = person.marital_unit.sum(unearned) + earned = _ssi_couple_total(person, period.this_year, earned) + unearned = _ssi_couple_total(person, period.this_year, unearned) # 317:35-5-42(l)(2) excludes an ineligible spouse's SSI. Its exclusion # of "any income that was counted or excluded in figuring the amount # of that payment" is not modeled; the spouse's other income stays # counted. - benefit = person.marital_unit.sum(benefit * eligible) + benefit = _ssi_couple_total(person, period.this_year, benefit * eligible) # 317:35-5-42(b)(8)(A) counts the one-third value once, for the # individual or the eligible couple, never for an ineligible spouse. - shelter = person.marital_unit.sum(shelter * eligible) + shelter = _ssi_couple_total(person, period.this_year, shelter * eligible) else: unearned = unearned + ism countable = ( diff --git a/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp.py b/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp.py index 83cccfe156d..288b98c1645 100644 --- a/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp.py +++ b/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class ok_ssp(Variable): @@ -23,7 +27,7 @@ def formula(person, period, parameters): individual_standard = ssi.individual + p.maximum_payment couple_standard = ssi.couple + 2 * p.maximum_payment eligible = person("ok_ssp_eligible", period) - joint = person.marital_unit.sum(eligible) == 2 + joint = _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 # NOTE: OAC 340:15-1-2 defines an ineligible spouse as one who is not # aged, blind or disabled, and is silent on an aged, blind or disabled # spouse who is not eligible. 340:15-1-4(10) applies 317:35-5-42, whose @@ -37,7 +41,12 @@ def formula(person, period, parameters): arrangement == arrangement.possible_values.MEDICAL_TREATMENT_FACILITY ) apart = in_facility | person("is_incarcerated", period) - ineligible_spouse = person.marital_unit.sum(~eligible & ~apart) > 0 + spouse = _ssi_spouse_index(person, period.this_year) + ineligible_spouse = ( + (spouse >= 0) + & ~eligible[np.maximum(spouse, 0)] + & ~apart[np.maximum(spouse, 0)] + ) own = person("ok_ssp_own_countable_income", period) combined = person("ok_ssp_countable_income", period) standard = where( @@ -51,7 +60,8 @@ def formula(person, period, parameters): # spouse the odd dollar without naming which, so person-level amounts # follow member order while the couple total does not. first_share = np.floor(gap / 2) - first = person.marital_unit.reference_entity.members_position == 0 + position = person.marital_unit.reference_entity.members_position + first = position < position[np.maximum(spouse, 0)] payment = where(joint, where(first, first_share, gap - first_share), gap) # NOTE: 340:15-1-5(c)(6) tests a blind or disabled minor's income plus # deemed parental income against the SSP standard for an individual, diff --git a/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py b/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py index 80da0a570b1..fe94ac405bf 100644 --- a/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py +++ b/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class or_ssp(Variable): @@ -40,9 +43,11 @@ def formula(person, period, parameters): # Community care and SSI restoration amounts depend on the SSI payment, # so those paths require modeled SSI rather than reported receipt. care = (category == c.COMMUNITY_CARE) & (ssi > 0) - couple = person.marital_unit.sum(care) == 2 + couple = _ssi_couple_total(person, period.this_year, care.astype(int)) == 2 own_excess = max_(income - standard, 0) - spouse_excess = person.marital_unit.sum(own_excess) - own_excess + spouse_excess = ( + _ssi_couple_total(person, period.this_year, own_excess) - own_excess + ) couple_payment = max_(standard - income - spouse_excess, 0) interim = (category == c.DISABILITY_INTERIM) | (category == c.NATURALIZATION) restoring_ssi = ( diff --git a/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py b/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py index ca8c5d309fb..ad904365c30 100644 --- a/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py +++ b/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class sd_ssp_care(Variable): @@ -24,9 +27,18 @@ def formula(person, period, parameters): resources = person("ssi_countable_resources", period.this_year) # Joint entry applies only when both spouses are flagged. joint_entry = ( - person.marital_unit.sum(person("sd_ssp_care_joint_entry", period)) > 1 + _ssi_couple_total( + person, + period.this_year, + person("sd_ssp_care_joint_entry", period).astype(int), + ) + > 1 + ) + resources = where( + joint_entry, + _ssi_couple_total(person, period.this_year, resources), + resources, ) - resources = where(joint_entry, person.marital_unit.sum(resources), resources) limit = where(joint_entry, p.joint_entry_resource_limit, p.resource_limit) immigration = person("immigration_status", period.this_year) citizenship = (immigration == immigration.possible_values.CITIZEN) | person( @@ -73,9 +85,7 @@ def formula(person, period, parameters): # assisted living or adult foster care is not modeled. 67:46:04:10(7) # and 67:46:05:42(7) count the couple's combined income and resources # in that month; this formula uses the entering spouse's own. - shared_income = person.marital_unit.sum(own_income) / ( - person.marital_unit.nb_persons() - ) + shared_income = _ssi_couple_total(person, period.this_year, own_income) / 2 income = where(joint_entry, shared_income, own_income) net_income = max_(income - person("sd_ssp_care_income_deductions", period), 0) # 67:46:06:09: income is not applied in a partial month unless the diff --git a/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_independent.py b/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_independent.py index 804b695e93b..83927c85334 100644 --- a/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_independent.py +++ b/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_independent.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class sd_ssp_independent(Variable): @@ -53,10 +57,14 @@ def formula(person, period, parameters): # ARSD 67:12:14:03(2) reads per person ("The person is receiving other # income"). couple = person("ssi_claim_is_joint", period.this_year) & ( - person.marital_unit.sum(qualifies) > 1 + _ssi_couple_total(person, period.this_year, qualifies.astype(int)) > 1 ) - other_income = where(couple, person.marital_unit.sum(own_income), own_income) - first = person.marital_unit.reference_entity.members_position == 0 + other_income = where( + couple, _ssi_couple_total(person, period.this_year, own_income), own_income + ) + spouse = _ssi_spouse_index(person, period.this_year) + position = person.marital_unit.reference_entity.members_position + first = position < position[np.maximum(spouse, 0)] amount = where( couple, where(first, p.couple_first_amount, p.couple_second_amount), diff --git a/policyengine_us/variables/gov/states/ut/dws/ssp/ut_dws_ssp.py b/policyengine_us/variables/gov/states/ut/dws/ssp/ut_dws_ssp.py index 2f4d5ad757a..ee91515985c 100644 --- a/policyengine_us/variables/gov/states/ut/dws/ssp/ut_dws_ssp.py +++ b/policyengine_us/variables/gov/states/ut/dws/ssp/ut_dws_ssp.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( SSIFederalLivingArrangement, ) @@ -21,7 +24,7 @@ class ut_dws_ssp(Variable): "https://www.ssa.gov/policy/docs/progdesc/ssi_st_asst/2011/ut.html", ) documentation = """ - Spouses are the members of a marital unit. Set + Couple amounts include only the recipient and their co-resident spouse. Set ssi_lives_in_another_persons_household, ssi_receives_shelter_from_others_in_household and ssi_others_pay_all_meals for both spouses to place a couple in the household of another. @@ -33,7 +36,7 @@ def formula(person, period, parameters): # receives half. A person whose spouse does not qualify receives the # single person amount. eligible = person("ut_dws_ssp_eligible", period) - couple = person.marital_unit.sum(eligible) == 2 + couple = _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 # 205-10 pays couples "who live in the household of another" and # couples "who live alone or with others in their own household". SSA # paid the same rows by federal living arrangement codes B and A diff --git a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py index 314011cd7d3..7f0f7bb954d 100644 --- a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py +++ b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) @@ -21,7 +24,12 @@ class va_ag_countable_income(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.va.dars.ag joint = ( - person.marital_unit.sum(person("va_ag_is_joint_initial_month", period)) == 2 + _ssi_couple_total( + person, + period.this_year, + person("va_ag_is_joint_initial_month", period).astype(int), + ) + == 2 ) # NOTE: AG Manual Chapter H's exclusion list (p. 370) does not name the # SSI student earned income exclusion, while Chapter D (p. 123) @@ -36,8 +44,12 @@ def formula(person, period, parameters): - add(person, period, p.excluded_unearned_sources), 0, ) - earned = where(joint, person.marital_unit.sum(earned), earned) - unearned = where(joint, person.marital_unit.sum(unearned), unearned) + earned = where( + joint, _ssi_couple_total(person, period.this_year, earned), earned + ) + unearned = where( + joint, _ssi_couple_total(person, period.this_year, unearned), unearned + ) non_ssi_countable = ( _apply_ssi_exclusions( earned * MONTHS_IN_YEAR, unearned * MONTHS_IN_YEAR, parameters, period diff --git a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py index acc268b6f8e..714b7ea671e 100644 --- a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py +++ b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class va_ag_eligible(Variable): @@ -15,10 +18,17 @@ def formula(person, period, parameters): citizen = immigration == immigration.possible_values.CITIZEN immigrant = person("is_ssi_qualified_noncitizen", period.this_year) joint = ( - person.marital_unit.sum(person("va_ag_is_joint_initial_month", period)) == 2 + _ssi_couple_total( + person, + period.this_year, + person("va_ag_is_joint_initial_month", period).astype(int), + ) + == 2 ) resources = person("ssi_countable_resources", period.this_year) - resources = where(joint, person.marital_unit.sum(resources), resources) + resources = where( + joint, _ssi_couple_total(person, period.this_year, resources), resources + ) limits = parameters(period).gov.ssa.ssi.eligibility.resources.limit receives_ssi = (person("ssi", period) > 0) | person("receives_ssi", period) resource_eligible = receives_ssi | ( diff --git a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep.py b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep.py index 19a5268f52e..05f9690c703 100644 --- a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep.py +++ b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class vt_aabd_ep(Variable): @@ -36,9 +40,15 @@ def formula(person, period, parameters): # different levels for each spouse; use the highest level certified for # either spouse. certified = abd_eligible & valid_level - person_eligible = abd_eligible & person.marital_unit.any(certified) - rate = person.marital_unit.max(where(certified, p.ep.rate[level], 0)) - count = person.marital_unit.sum(person_eligible) + person_eligible = abd_eligible & ( + _ssi_couple_total(person, period.this_year, certified.astype(int)) > 0 + ) + rates = where(certified, p.ep.rate[level], 0) + spouse_index = _ssi_spouse_index(person, period.this_year) + rate = max_( + rates, where(spouse_index >= 0, rates[np.maximum(spouse_index, 0)], 0) + ) + count = _ssi_couple_total(person, period.this_year, person_eligible.astype(int)) couple = count > 1 # Rule 2784(B)-(C): the individual maximum is the combined couple # payment, the another-household maximum is the combined independent diff --git a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_countable_income.py b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_countable_income.py index 1cc1084e95b..fb8e1764152 100644 --- a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_countable_income.py +++ b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_countable_income.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class vt_aabd_ep_countable_income(Variable): @@ -40,13 +44,15 @@ def formula(person, period, parameters): # reading ssi_earned_income; the ssi term below keeps SSA's treatment. gross_earned = add(person, period, ssi_income.sources.earned) earned = in_household * max_(gross_earned - p.employment_expense, 0) - abd_earned = person.marital_unit.sum(earned * abd) - other_earned = person.marital_unit.sum(earned * ~abd) + abd_earned = _ssi_couple_total(person, period.this_year, earned * abd) + other_earned = _ssi_couple_total(person, period.this_year, earned * ~abd) # NOTE: Rule 2786 withholds the $20 exclusion from VA pension only; with # no pension-specific input, all veterans benefits are treated as pension. veterans = max_(person("veterans_benefits", period), 0) - unearned = person.marital_unit.sum( - in_household * max_(person("ssi_unearned_income", period) - veterans, 0) + unearned = _ssi_couple_total( + person, + period.this_year, + in_household * max_(person("ssi_unearned_income", period) - veterans, 0), ) general = exclusions.general remaining_general = max_(general - unearned, 0) @@ -58,11 +64,18 @@ def formula(person, period, parameters): abd_earned = max_(abd_earned - remaining_general_abd - exclusions.earned, 0) * ( 1 - exclusions.earned_share ) - needs_based = person.marital_unit.sum( + needs_based = _ssi_couple_total( + person, + period.this_year, in_household - * (veterans + person("ssi", period) + person("vt_aabd_federal", period)) + * (veterans + person("ssi", period) + person("vt_aabd_federal", period)), ) - additional = person.marital_unit.max( - max_(person("vt_aabd_ep_additional_countable_income", period), 0) + additional_income = max_( + person("vt_aabd_ep_additional_countable_income", period), 0 + ) + spouse_index = _ssi_spouse_index(person, period.this_year) + additional = max_( + additional_income, + where(spouse_index >= 0, additional_income[np.maximum(spouse_index, 0)], 0), ) return unearned + abd_earned + other_earned + needs_based + additional diff --git a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_is_spouse.py b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_is_spouse.py index c9ea361fbc5..b3e23c5b969 100644 --- a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_is_spouse.py +++ b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_is_spouse.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class vt_aabd_ep_is_spouse(Variable): @@ -12,10 +15,9 @@ class vt_aabd_ep_is_spouse(Variable): def formula(person, period, parameters): abd = person("is_ssi_aged_blind_disabled", period.this_year) - # A marital unit has at most two members, so a non-ABD member of an - # ABD person's marital unit is that person's spouse. + # Only a co-resident spouse can be an ineligible-spouse essential person. return ( abd - & person.marital_unit.any(~abd) + & (_ssi_couple_total(person, period.this_year, (~abd).astype(int)) > 0) & ~person("vt_aabd_ep_nonspouse_essential_person", period) ) diff --git a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_resources_eligible.py b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_resources_eligible.py index b05591f9e52..6067bb5caab 100644 --- a/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_resources_eligible.py +++ b/policyengine_us/variables/gov/states/vt/dcf/aabd/vt_aabd_ep_resources_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class vt_aabd_ep_resources_eligible(Variable): @@ -26,4 +29,11 @@ def formula(person, period, parameters): arrangement == la.HOUSEHOLD_OF_ANOTHER ) # Rule 2785 tests the assistance group's combined resources once. - return person.marital_unit.any((receives | passes_state_test) & in_household) + return ( + _ssi_couple_total( + person, + period.this_year, + ((receives | passes_state_test) & in_household).astype(int), + ) + > 0 + ) diff --git a/policyengine_us/variables/gov/states/wa/dshs/ssp/wa_ssp_payment_category.py b/policyengine_us/variables/gov/states/wa/dshs/ssp/wa_ssp_payment_category.py index bb0a06b522c..589798c1d24 100644 --- a/policyengine_us/variables/gov/states/wa/dshs/ssp/wa_ssp_payment_category.py +++ b/policyengine_us/variables/gov/states/wa/dshs/ssp/wa_ssp_payment_category.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class WAStateSupplementaryPaymentCategory(Enum): @@ -43,12 +46,9 @@ def formula(person, period, parameters): "is_ssi_disabled", period.this_year ) - marital_unit = person.marital_unit - ssi_recipient_count = marital_unit.sum(base_eligible) + spouse = _ssi_spouse_index(person, period.this_year) has_ineligible_spouse = ( - (marital_unit.nb_persons() == 2) - & (ssi_recipient_count == 1) - & base_eligible + (spouse >= 0) & base_eligible & ~base_eligible[np.maximum(spouse, 0)] ) in_standard_category = aged | blind | disabled_qualifies | has_ineligible_spouse diff --git a/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssi_e.py b/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssi_e.py index b011abddf89..952ced28647 100644 --- a/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssi_e.py +++ b/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssi_e.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class wi_ssi_e(Variable): @@ -37,5 +40,5 @@ def formula(person, period, parameters): & person("wi_ssp_eligible", period) & ~household_of_another ) - both = person.marital_unit.sum(certified) == 2 + both = _ssi_couple_total(person, period.this_year, certified.astype(int)) == 2 return certified * where(both, p.couple / 2, p.individual) diff --git a/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssp_regular.py b/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssp_regular.py index 05b9a4b6fcc..5f76f16ef30 100644 --- a/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssp_regular.py +++ b/policyengine_us/variables/gov/states/wi/dhs/ssp/wi_ssp_regular.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class wi_ssp_regular(Variable): @@ -13,13 +16,16 @@ class wi_ssp_regular(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.wi.dhs.ssp eligible = person("wi_ssp_eligible", period) - eligible_couple = person.marital_unit.sum(eligible) == 2 + eligible_couple = ( + _ssi_couple_total(person, period.this_year, eligible.astype(int)) == 2 + ) # The one-eligible-spouse rate requires an SSI-ineligible spouse. A spouse # who is SSI-eligible but excluded from the state payment leaves the # recipient on the individual rate. ineligible_spouse = person("is_ssi_ineligible_spouse", period.this_year) has_ineligible_spouse = ( - person.marital_unit.sum(ineligible_spouse) - ineligible_spouse + _ssi_couple_total(person, period.this_year, ineligible_spouse.astype(int)) + - ineligible_spouse ) > 0 arrangement = person("ssi_federal_living_arrangement", period) other_household = ( From 07a1f336a9c6fc160e4f4b623dd03f0b2354b70a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 22:58:11 -0400 Subject: [PATCH 07/25] Check every state supplement against explicit spouse units --- .../test_ssi_spousal_deeming_invariants.py | 354 +++++++++++++++++- 1 file changed, 350 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py index f723178335e..163b5b29172 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -1,8 +1,7 @@ """Simulation-API invariants for SSI couples and spousal deeming. -Two vectorized grid simulations, each built once per module, plus one small -simulation per Hypothesis example (three examples, more if a failure is -shrunk): +Two vectorized grid simulations, each built once per module, plus small +simulations for the generated income and state-supplement properties: - An income grid of 400 two-person households: an aged claimant with only unearned income and a spouse under 65 with unearned and earned income. The @@ -19,6 +18,12 @@ not depend on tax roles or on the third adult (416.1801(c), 416.1806, 416.1160(d)), and joint claims must be symmetric and disjoint from ineligible-spouse status. +- Every implemented state supplement, comparing a couple and third adult + with the API's default marital unit against explicit couple/single units. + Hypothesis varies income, age, resources and care membership; explicit + examples retain the payment, exclusion, eligibility and resource edges. + Each comparison uses two independent three-person simulations sharing the + same read-only system as the grids. YAML covers worked cases. Python is needed to check the for-all properties over every ordered edge of the grids and to compare with an oracle that does @@ -27,10 +32,12 @@ """ from itertools import product +from pathlib import Path import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +import yaml +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_us import Simulation @@ -311,3 +318,342 @@ def test_role_grid_couples_match_the_same_couple_living_alone(role_grid): np.testing.assert_allclose( role_grid["ssi"][row, 0, :2], ssi_extra[index, :2], atol=0.01 ) + + +# Every implemented state supplement, including programs paid to an SPM unit +# rather than directly to a person. Annual programs are compared annually. +SUPPLEMENT_PROGRAMS = { + "AK": ("ak_ssp",), + "AL": ("al_ssp",), + "CA": ("ca_state_supplement", "ca_capi"), + "CO": ("co_state_supplement", "co_oap"), + "CT": ("ct_ssp",), + "DC": ("dc_ossp",), + "DE": ("de_ssp",), + "FL": ("fl_oss",), + "GA": ("ga_ssp",), + "HI": ("hi_oss",), + "ID": ("id_aabd",), + "IL": ("il_aabd",), + "IN": ("in_ssp",), + "KS": ("ks_sspp",), + "KY": ("ky_ssp",), + "LA": ("la_oss",), + "MA": ("ma_state_supplement",), + "MD": ("md_paa",), + "ME": ("me_ssp",), + "MI": ("mi_ssp",), + "MN": ("mn_msa",), + "MO": ("mo_ssp",), + "MT": ("mt_ssp",), + "NC": ("nc_sa",), + "NE": ("ne_aabd",), + "NH": ("nh_ssp",), + "NJ": ("nj_ssp",), + "NM": ("nm_ssi_state_supplement",), + "NV": ("nv_oss",), + "NY": ("ny_ssp",), + "OH": ("oh_rss",), + "OK": ("ok_ssp",), + "OR": ("or_ssp",), + "PA": ("pa_ssp",), + "RI": ("ri_ssp",), + "SC": ("sc_ssi_state_supplement",), + "SD": ("sd_ssp",), + "TX": ("tx_ssi_state_supplement",), + "UT": ("ut_ssp",), + "VA": ("va_ag",), + "VT": ("vt_aabd",), + "WA": ("wa_ssp",), + "WI": ("wi_ssp",), + "WY": ("wy_ssp",), +} + + +def test_state_supplement_inventory_matches_program_registry(): + """The property includes every implemented supplement in the registry.""" + registry_path = Path(__file__).resolve().parents[2] / "programs.yaml" + registry = yaml.safe_load(registry_path.read_text()) + supplements = next( + program + for program in registry["programs"] + if program["id"] == "ssi_state_supplement" + ) + implemented = { + entry["state"]: entry["variable"] + for entry in supplements["state_implementations"] + if entry.get("variable") + } + assert implemented.keys() == SUPPLEMENT_PROGRAMS.keys() + for state, variable in implemented.items(): + assert variable in SUPPLEMENT_PROGRAMS[state], (state, variable) + + +# Conditions that exercise care-only supplements and CAPI. These are inputs, +# not substitute payment amounts or eligibility results. The builder reads +# each variable's entity and period: Delaware's arrangement belongs to the +# household, while most states' care settings belong to the person. +SUPPLEMENT_CARE_INPUTS = { + "AL": { + "al_receives_elderly_disabled_medicaid_waiver": True, + "al_meets_snf_criteria": True, + "al_ssp_care_setting": "PRIVATE_HOME_IHC", + }, + "CA": { + "immigration_status_str": "LEGAL_PERMANENT_RESIDENT", + "ssi_qualifying_quarters_earnings": 0, + }, + "DC": {"dc_ossp_in_adult_foster_care": True}, + "DE": {"de_ssp_living_arrangement": "CERTIFIED_RESIDENTIAL_CARE_HOME"}, + "FL": {"fl_oss_program_track": "REDESIGN", "fl_oss_community_care_type": "ALF"}, + "HI": {"hi_oss_care_facility_type": "COMMUNITY_CARE"}, + "ID": {"id_aabd_living_arrangement": "ROOM_AND_BOARD"}, + "IN": { + "ssi_lives_in_medical_treatment_facility": True, + "ssi_medicaid_pays_majority_of_care": True, + "medicaid_enrolled": True, + }, + "KS": { + "ssi_lives_in_medical_treatment_facility": True, + "ssi_medicaid_pays_majority_of_care": True, + }, + "KY": {"ky_ssp_category": "CARETAKER", "ky_ssp_care_receivers": "BOTH"}, + "LA": { + "ssi_lives_in_medical_treatment_facility": True, + "ssi_medicaid_pays_majority_of_care": True, + }, + "MD": {"md_paa_living_arrangement": "CARE_HOME_LEVEL_A"}, + "ME": {"me_ssp_living_arrangement": "ADULT_FOSTER_HOME"}, + "MN": { + "mn_msa_living_arrangement": ( + "COUPLE_LIVING_WITH_OTHERS", + "COUPLE_LIVING_WITH_OTHERS", + "INDIVIDUAL_LIVING_WITH_OTHERS", + ) + }, + "MO": { + "mo_ssp_living_arrangement": "RCF_LEVEL_I", + "mo_snc_facility_base_charge": 2_000, + }, + "MT": {"mt_ssp_payment_category": "ASSISTED_LIVING_OR_GROUP_OR_COMMUNITY_HOME"}, + "NC": {"nc_sa_care_setting": "ADULT_CARE_HOME"}, + "NE": {"ne_aabd_alternate_living_arrangement": "ASSISTED_LIVING_FACILITY"}, + "NM": {"is_in_adult_residential_care": True}, + "OH": {"oh_rss_certified": True}, + "OR": {"or_ssp_category": "COMMUNITY_CARE"}, + "SC": {"is_in_residential_care_facility": True}, + "SD": { + "sd_ssp_care_joint_entry": True, + "sd_ssp_care_certified": True, + "sd_ssp_care_setting": "ADULT_FOSTER_CARE", + "sd_ssp_approved_care_cost": 1_500, + }, + "TX": { + "is_in_medicaid_facility": True, + "ssi_lives_in_medical_treatment_facility": True, + "ssi_medicaid_pays_majority_of_care": True, + }, + "VA": {"va_ag_certified": True, "va_ag_is_joint_initial_month": True}, + "VT": { + "vt_aabd_ep_level": ("PARTIAL_34", "PARTIAL_67", "FULL"), + "vt_aabd_ep_nonspouse_essential_person": True, + }, + "WI": {"wi_ssi_e_certified": True}, +} + +# Also reach California SSP with citizens in nonmedical care, the under-60 +# Colorado program and the old Washington category for a disabled claimant +# with an ineligible spouse. +SUPPLEMENT_CASES = [(state, YEAR, "standard") for state in SUPPLEMENT_PROGRAMS] + [ + ("CA", YEAR, "ssi_ssp"), + ("CO", YEAR, "and_cs"), + ("WA", 2022, "disabled"), +] +SUPPLEMENT_SCENARIO_INPUTS = { + "ssi_ssp": { + "immigration_status_str": "CITIZEN", + "ssi_qualifying_quarters_earnings": 40, + "in_out_of_home_care_facility": True, + }, + "and_cs": { + "age": (50, 52, 70), + "is_disabled": (True, True, False), + "meets_ssi_disability_criteria": (True, True, False), + }, + "disabled": { + "age": (40, 40, 70), + "is_disabled": (True, False, False), + "meets_ssi_disability_criteria": (True, False, False), + }, +} + + +def _supplement_situation( + system, + state, + pensions, + ages, + resources, + care, + third_care, + explicit, + year=YEAR, + scenario="standard", +): + names = ("claimant", "spouse", "third_adult") + situation = {"people": {}} + for index, name in enumerate(names): + situation["people"][name] = { + "age": {year: ages[index]}, + "pension_income": {year: 12 * pensions[index]}, + "ssi_countable_resources": {year: resources[index]}, + "is_tax_unit_head": {year: index == 0}, + "is_tax_unit_spouse": {year: index == 1}, + "is_tax_unit_dependent": {year: index == 2}, + } + groups = { + "household": "households", + "tax_unit": "tax_units", + "family": "families", + "spm_unit": "spm_units", + } + for singular, plural in groups.items(): + situation[plural] = {singular: {"members": list(names)}} + situation["households"]["household"]["state_code"] = {year: state} + + def set_inputs(inputs, members): + for name, value in inputs.items(): + variable = system.variables[name] + period = f"{year}-01" if variable.definition_period == "month" else year + if variable.entity.is_person: + for index, member in enumerate(names): + if member in members: + selected = value[index] if isinstance(value, tuple) else value + situation["people"][member][name] = {period: selected} + else: + entity = variable.entity.key + situation[groups[entity]][entity][name] = {period: value} + + if care: + set_inputs( + SUPPLEMENT_CARE_INPUTS.get(state, {}), names if third_care else names[:2] + ) + set_inputs(SUPPLEMENT_SCENARIO_INPUTS.get(scenario, {}), names) + if explicit: + situation["marital_units"] = { + "couple": {"members": list(names[:2])}, + "single": {"members": [names[2]]}, + } + return situation + + +def _supplement_amounts( + state, + pensions, + ages, + resources, + care, + third_care, + explicit, + year=YEAR, + scenario="standard", +): + # Reuse the same read-only system as the existing grids. Only simulations + # and their caches are independent. Each has three people, so omitting + # marital_units really uses the API's default unit, without mixing other + # households or states into that default group. + system = Simulation.default_tax_benefit_system_instance + simulation = Simulation( + situation=_supplement_situation( + system, + state, + pensions, + ages, + resources, + care, + third_care, + explicit, + year, + scenario, + ) + ) + return { + name: simulation.calculate( + name, + f"{year}-01" + if system.variables[name].definition_period == "month" + else year, + ) + for name in SUPPLEMENT_PROGRAMS[state] + } + + +@pytest.mark.parametrize(("state", "year", "scenario"), SUPPLEMENT_CASES) +@settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@example( + pensions=(0, 0, 0), + ages=(65, 65, 65), + resources=(0, 0, 0), + care=True, + third_care=True, +) +@example( + pensions=(0, 497, 994), + ages=(65, 64, 90), + resources=(0, 0, 2_001), + care=True, + third_care=False, +) +@example( + pensions=(20, 65, 1_491), + ages=(90, 65, 64), + resources=(2_000, 0, 3_001), + care=True, + third_care=True, +) +@example( + pensions=(994, 1_491, 20), + ages=(65, 90, 65), + resources=(3_000, 0, 0), + care=False, + third_care=False, +) +@example( + pensions=(0, 0, 0), + ages=(65, 65, 90), + resources=(7_000, 1_000, 5_000), + care=True, + third_care=True, +) +@given( + pensions=st.tuples(*(st.integers(0, 1_600) for _ in range(3))), + ages=st.tuples(*(st.sampled_from((64, 65, 90)) for _ in range(3))), + resources=st.tuples(*(st.integers(0, 8_000) for _ in range(3))), + care=st.booleans(), + third_care=st.booleans(), +) +def test_state_supplements_do_not_depend_on_omitted_marital_units( + state, year, scenario, pensions, ages, resources, care, third_care +): + """A third adult never becomes a spouse through the default group. + + YAML checks each state's independently calculated payment. This compares + two API representations of the same people and marriage, including + ineligible spouses, a third adult in a different care category, income + exclusions/deeming edges, and the individual/couple resource limits. + """ + default = _supplement_amounts( + state, pensions, ages, resources, care, third_care, False, year, scenario + ) + explicit = _supplement_amounts( + state, pensions, ages, resources, care, third_care, True, year, scenario + ) + for name in SUPPLEMENT_PROGRAMS[state]: + np.testing.assert_allclose( + default[name], explicit[name], atol=0.01, err_msg=f"{state}: {name}" + ) From df8cbd76644b80d018f40b0e963957ced6ee99d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 23:38:19 -0400 Subject: [PATCH 08/25] Preserve Colorado OAP spouse rules across households --- .../ssi-couples-and-deeming-cap.fixed.md | 2 +- .../co/ssa/oap/co_oap_deemed_income.yaml | 4 +++ .../gov/states/co/ssa/oap/_spouses.py | 26 +++++++++++++++++++ .../states/co/ssa/oap/co_oap_deemed_income.py | 6 ++--- .../co/ssa/oap/co_oap_deeming_exempt.py | 6 ++--- .../co/ssa/oap/co_oap_own_countable_income.py | 6 ++--- 6 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index ac613fff028..9864bd06a87 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1 +1 @@ -SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the co-resident spouses when a situation omits marital units and includes a third adult. +SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains its treatment of declared spouses living in separate households. diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_deemed_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_deemed_income.yaml index 470e7e55d0d..89de3a010ab 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_deemed_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_deemed_income.yaml @@ -400,6 +400,8 @@ members: [spouse, child] state_code: CO output: + # 3.533(A)(2)(c): the declared spouses still split the $20 disregard. + co_oap_own_countable_income: [0, 23_880, 0] co_oap_deemed_income: [5_652, 0, 0] co_oap: [6_732, 0, 0] @@ -433,6 +435,8 @@ members: [spouse] state_code: NY output: + # (1,000 - 65 - 10) / 2 * 12 = $5,550 for the spouse. + co_oap_own_countable_income: [0, 5_550] co_oap_deeming_exempt: [false, true] co_oap_deemed_income: [0, 0] co_oap: [12_384, 0] diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py b/policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py new file mode 100644 index 00000000000..539e35b7914 --- /dev/null +++ b/policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) + + +def co_oap_couple_total(person, year, values): + """OAP spouses' values, including declared spouses living apart. + + 9 CCR 2503-5 3.534(B)-(C) does not require spouses to share a + household. A two-person marital unit therefore retains both spouses; + larger default groups use SSI's identified spouse pair instead. + """ + return where( + person.marital_unit.nb_persons() == 2, + person.marital_unit.sum(values), + _ssi_couple_total(person, year, values), + ) + + +def co_oap_is_married(person, year): + """Marriage for OAP's shared $20 disregard (3.533(A)(2)(c)).""" + return (person.marital_unit.nb_persons() == 2) | ( + _ssi_spouse_index(person, year) >= 0 + ) diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py index 628a66190ec..ca6eeb4bedd 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deemed_income.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, +from policyengine_us.variables.gov.states.co.ssa.oap._spouses import ( + co_oap_couple_total, ) from policyengine_us.variables.gov.states.co.ssa.oap._precision import ( cents, @@ -78,7 +78,7 @@ def formula(person, period, parameters): child_allocations = person.household.sum(child_allocation) * deemable def spouse(values): - return _ssi_couple_total(person, year, values) - values + return co_oap_couple_total(person, year, values) - values # 3.534(A): deduct $65 from the spouse's gross earned income and halve # the rest. Unlike SSI (20 CFR 416.1163) there is no deeming threshold. diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py index dddb094af6c..7a8e846d960 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_deeming_exempt.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, +from policyengine_us.variables.gov.states.co.ssa.oap._spouses import ( + co_oap_couple_total, ) from policyengine_us.variables.gov.states.co.ssa.oap._precision import ( PRECISION, @@ -46,7 +46,7 @@ def formula(person, period, parameters): eligible = person("co_oap_eligible", year) receives_oap = eligible & (p.grant_standard - income > PRECISION) spouse_of_client = ( - _ssi_couple_total(person, year, eligible.astype(int)) - eligible > 0 + co_oap_couple_total(person, year, eligible.astype(int)) - eligible > 0 ) candidate = spouse_of_client & within_limit exempt = candidate & (receives_ssi | receives_oap) diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py index 63b009239b1..54d04d0358e 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_own_countable_income.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_spouse_index, +from policyengine_us.variables.gov.states.co.ssa.oap._spouses import ( + co_oap_is_married, ) from policyengine_us.variables.gov.states.co.ssa.oap._precision import ( cents, @@ -35,7 +35,7 @@ def formula(person, period, parameters): ) # 3.533(A)(2)(c)(1): a married client's $20 disregard is split equally # with the spouse, so that no more than $20 applies to the couple. - married = _ssi_spouse_index(person, period.this_year) >= 0 + married = co_oap_is_married(person, period.this_year) disregard = where(married, p.disregards.unearned / 2, p.disregards.unearned) # 3.533(A)(2)(c): the disregard applies to unearned income other than # SSI; min_ gives 0 to a client whose only unearned income is SSI From b6b014e49c65f4e64b7237a71c7ed35fc9c94b8c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 23:39:59 -0400 Subject: [PATCH 09/25] Clarify spouse and SSI inputs in state test fixtures --- .../baseline/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.yaml | 5 +++++ ..._elderly_homeowner_or_renter_credit_pre_one_claimant.yaml | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.yaml index 568acb1b1ac..a0d9f8aa916 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_claim_type.yaml @@ -145,15 +145,20 @@ - name: Case 7, SSI-eligible minor with SSI-eligible adult spouse — minor fails §4(1)(c) age test. period: 2026-01 + # Explicit roles avoid inferring the minor spouse as a tax dependent. input: people: person1: age: 50 + is_tax_unit_head: + 2026: true is_ssi_aged_blind_disabled: true meets_ssi_resource_test: true ky_ssp_category: CARETAKER person2: age: 17 + is_tax_unit_spouse: + 2026: true is_ssi_aged_blind_disabled: true meets_ssi_resource_test: true ky_ssp_category: CARETAKER diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.yaml index 98ed9ddbb9c..1cd03dd11b5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.yaml @@ -7,14 +7,17 @@ age: 66 taxable_pension_income: 10_000 rent: 4_800 + ssi: 0 person2: age: 70 taxable_pension_income: 15_000 rent: 7_200 + ssi: 0 person3: age: 40 taxable_interest_income: 5_000 rent: 3_000 + ssi: 0 tax_units: tax_unit1: members: [person1] @@ -27,6 +30,7 @@ members: [person1, person2, person3] state_name: MT output: + # SSI is pinned at 0 to isolate the credit and claimant-limit arithmetic. # 2024 Schedule 2EC. Line 18 = 10_000 + 15_000 + 5_000 = 30_000 for the # whole household; line 20 = 17_400; line 21 = 0.05; line 22 = 870. # person1: line 25 = 720; line 27 = max(720 - 870, 0) = 0. From 200a31fb39c789c3782b9e92920d273153d98859 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 06:57:53 -0400 Subject: [PATCH 10/25] Preserve state supplement spouse budgets across separate households --- .../ssi-couples-and-deeming-cap.fixed.md | 2 +- .../gov/states/mo/dss/ssp/mo_ssp.yaml | 106 ++++++++++++++ .../gov/states/or/odhs/ssp/or_ssp.yaml | 50 +++++++ .../gov/states/sd/dss/ssp/sd_ssp.yaml | 129 ++++++++++++++++++ .../baseline/gov/states/va/dars/ag/va_ag.yaml | 128 +++++++++++++++++ .../variables/gov/states/_spouses.py | 28 ++++ .../gov/states/co/ssa/oap/_spouses.py | 17 +-- .../eligibility/mo_ssp_resource_eligible.py | 17 ++- .../gov/states/or/odhs/ssp/or_ssp.py | 11 +- .../gov/states/sd/dss/ssp/sd_ssp_care.py | 14 +- .../va/dars/ag/va_ag_countable_income.py | 13 +- .../gov/states/va/dars/ag/va_ag_eligible.py | 10 +- 12 files changed, 480 insertions(+), 45 deletions(-) create mode 100644 policyengine_us/variables/gov/states/_spouses.py diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index 9864bd06a87..ce63b55ed78 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1 +1 @@ -SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains its treatment of declared spouses living in separate households. +SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains declared spouses living apart; Missouri retains their combined resources for medical separation, Virginia and South Dakota retain joint care-entry budgets, and Oregon retains the community-care spouse payment across facilities. diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/ssp/mo_ssp.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/ssp/mo_ssp.yaml index d7b94b2f51d..d3f3a7a6313 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/ssp/mo_ssp.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/ssp/mo_ssp.yaml @@ -632,3 +632,109 @@ # A citizen would receive min($1,300 - $1,200, $156) = $100 plus the PNA. mo_ssp_eligible: false mo_ssp: 0 + +# RSMo 208.010.1 and DSS Manual 1035.005.00 retain the combined property +# test for spouses separated to obtain medical or nursing care. +# https://revisor.mo.gov/main/OneSection.aspx?section=208.010 +# https://dssmanuals.mo.gov/december-1973-eligibility-requirements/1035-000-00/1035-005-00/ +# MHABD Appendix J (effective July 2026): SNC couple limit $12,441, +# SNF grant cap $390 plus $50 personal needs allowance. +# https://dssmanuals.mo.gov/wp-content/uploads/2022/07/mhabd-appendix-j.pdf +# $7,000 + $1,000 = $8,000 passes; $1,000 + $15,000 = $16,000 fails. +# $2,000 facility charge - $1,200 pension = $800 need; min($800, $390) +# + $50 = $440 for the eligible claimant. SSI is calculated naturally. +- name: MO medical separation retains the couple resource cap and SNC payment + period: 2026-07 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: + 2026: 75 + is_tax_unit_head: + 2026: true + ssi_countable_resources: + 2026: 7000 + pension_income: + 2026: 14400 + mo_ssp_living_arrangement: SNF_OR_ICF_NON_MEDICAID + mo_snc_facility_base_charge: 2000 + community_spouse: + age: + 2026: 73 + is_tax_unit_spouse: + 2026: true + ssi_countable_resources: + 2026: 1000 + tax_units: + joint_return: + members: [claimant, community_spouse] + marital_units: + declared_marriage: + members: [claimant, community_spouse] + spm_units: + claimant_unit: + members: [claimant] + community_unit: + members: [community_spouse] + households: + nursing_facility: + members: [claimant] + state_code: + 2026: MO + community_home: + members: [community_spouse] + state_code: + 2026: MO + output: + mo_ssp_resource_eligible: [true, true] + mo_ssp_eligible: [true, false] + mo_ssp: [440, 0] + +- name: MO medical separation retains resources held by the community spouse + period: 2026-07 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: + 2026: 75 + is_tax_unit_head: + 2026: true + ssi_countable_resources: + 2026: 1000 + pension_income: + 2026: 14400 + mo_ssp_living_arrangement: SNF_OR_ICF_NON_MEDICAID + mo_snc_facility_base_charge: 2000 + community_spouse: + age: + 2026: 73 + is_tax_unit_spouse: + 2026: true + ssi_countable_resources: + 2026: 15000 + tax_units: + joint_return: + members: [claimant, community_spouse] + marital_units: + declared_marriage: + members: [claimant, community_spouse] + spm_units: + claimant_unit: + members: [claimant] + community_unit: + members: [community_spouse] + households: + nursing_facility: + members: [claimant] + state_code: + 2026: MO + community_home: + members: [community_spouse] + state_code: + 2026: MO + output: + mo_ssp_resource_eligible: [false, true] + mo_ssp_eligible: [false, false] + mo_ssp: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/odhs/ssp/or_ssp.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/odhs/ssp/or_ssp.yaml index 66d110843ce..3ad1e367c13 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/or/odhs/ssp/or_ssp.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/or/odhs/ssp/or_ssp.yaml @@ -600,3 +600,53 @@ # 994 - 180.42 = 813.58. or_ssp_countable_income: 180.42 or_ssp: 813.58 + +# OAR 461-155-0630(3) pays married SSI recipients who each receive community +# care services, including at different facilities. OAR 461-001-0000(62) +# defines spouse by legal marriage. Hub ruling: +# us-or:ssp-community-care-spouses-separate-facilities. +# https://ch461rules.odhs.oregon.gov/rules/461-155-0630.pdf#page=2 +# https://ch461rules.odhs.oregon.gov/rules/461-001-0000.pdf#page=15 +# OAR 461-140-0040(5)(e)(D) excludes income inaccessible due to incapacity. +# https://ch461rules.odhs.oregon.gov/rules/461-140-0040.pdf#page=4 +# SSI $994 - inaccessible $194/$94 = countable income $800/$900. Neither +# spouse has excess over $994, so payments are $994 - $800 = $194 and +# $994 - $900 = $94, under the one-person OSIPM standard. +- name: Review OR married SSI recipients in separate community-care facilities + period: 2026-10 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + or_ssp_category: COMMUNITY_CARE + or_ssp_inaccessible_income: 194 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + or_ssp_category: COMMUNITY_CARE + or_ssp_inaccessible_income: 94 + tax_units: + joint_tax_unit: + members: [husband, wife] + marital_units: + declared_marriage: + members: [husband, wife] + households: + husband_foster_home: + members: [husband] + state_code: + 2026: OR + wife_foster_home: + members: [wife] + state_code: + 2026: OR + output: + ssi: [994, 994] + or_ssp_countable_income: [800, 900] + or_ssp: [194, 94] diff --git a/policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp.yaml b/policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp.yaml index 9d34f6f7e1d..5f858156351 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp.yaml @@ -543,3 +543,132 @@ sd_ssp_care: 2026-01: 771.6 2026-02: 1_166.48 + +# ARSD 67:46:04:10(6) shares income equally for spouses entering care in the +# same month, whatever their current room or household. ARSD 67:46:06:03 +# imports that income rule into the payment; 67:46:05:30 sets a $3,000 joint +# resource limit when both spouses begin long-term care in the same month. +# https://sdlegislature.gov/Rules/Administrative/67:46:04:10 +# https://sdlegislature.gov/Rules/Administrative/67:46:06:03 +# https://sdlegislature.gov/Rules/Administrative/67:46:05:30 +# ($1,800 + $1,200) / 2 = $1,500 income each; $2,000 approved care cost +# - $1,500 = $500 payment each. $1,600 + $1,600 = $3,200 resources exceed +# the $3,000 joint limit, so neither spouse receives a payment in that case. +- name: Review SD joint care entry shares income across separate facility rooms + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + pension_income: + 2026: 21600 + ssi_countable_resources: + 2026: 500 + sd_ssp_care_joint_entry: true + sd_ssp_care_certified: true + sd_ssp_care_setting: ADULT_FOSTER_CARE + sd_ssp_approved_care_cost: 2000 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + pension_income: + 2026: 14400 + ssi_countable_resources: + 2026: 500 + sd_ssp_care_joint_entry: true + sd_ssp_care_certified: true + sd_ssp_care_setting: ADULT_FOSTER_CARE + sd_ssp_approved_care_cost: 2000 + tax_units: + joint_tax_unit: + members: + - husband + - wife + marital_units: + declared_marriage: + members: + - husband + - wife + households: + husband_care_room: + members: + - husband + state_code: + 2026: SD + wife_care_room: + members: + - wife + state_code: + 2026: SD + output: + sd_ssp_care: + - 500 + - 500 + ssi: + - 0 + - 0 +- name: Review SD joint entry resources exceed couple limit across facility households + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + pension_income: + 2026: 21600 + ssi_countable_resources: + 2026: 1600 + sd_ssp_care_joint_entry: true + sd_ssp_care_certified: true + sd_ssp_care_setting: ADULT_FOSTER_CARE + sd_ssp_approved_care_cost: 2000 + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + pension_income: + 2026: 14400 + ssi_countable_resources: + 2026: 1600 + sd_ssp_care_joint_entry: true + sd_ssp_care_certified: true + sd_ssp_care_setting: ADULT_FOSTER_CARE + sd_ssp_approved_care_cost: 2000 + tax_units: + joint_tax_unit: + members: + - husband + - wife + marital_units: + declared_marriage: + members: + - husband + - wife + households: + husband_care_room: + members: + - husband + state_code: + 2026: SD + wife_care_room: + members: + - wife + state_code: + 2026: SD + output: + sd_ssp_care: + - 0 + - 0 + ssi: + - 0 + - 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml b/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml index 0f21c77ff63..89f8ab4b928 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml @@ -404,3 +404,131 @@ va_ag: 2026-01: 558 2026-02: 1_223 + +# AG Manual Chapter B section 6 (PDF pp. 34-35) tests marriage/co-residence +# at the first moment of the application month; entering separate facilities +# later in that month retains the joint budget recorded by the input. +# https://townhall.virginia.gov/l/GetFile.cfm?File=C:%5CTownHall%5Cdocroot%5CGuidanceDocs%5C262%5CGDoc_DARS_5346_v2.pdf#page=34 +# Joint countable income = ($1,200 + $1,200 - $20) / 2 = $1,190 each; +# $2,130 maintenance + $87 personal needs - $1,190 = $1,027 grant each. +# Joint resources $1,600 + $1,600 = $3,200 exceed the $3,000 couple limit. +- name: Review VA spouses enter separate facilities during joint initial month + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + pension_income: + 2026: 14400 + ssi_countable_resources: + 2026: 1000 + va_ag_certified: true + va_ag_is_joint_initial_month: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + pension_income: + 2026: 14400 + ssi_countable_resources: + 2026: 1000 + va_ag_certified: true + va_ag_is_joint_initial_month: true + tax_units: + joint_tax_unit: + members: + - husband + - wife + marital_units: + declared_marriage: + members: + - husband + - wife + households: + husband_facility: + members: + - husband + state_code: + 2026: VA + wife_facility: + members: + - wife + state_code: + 2026: VA + output: + va_ag: + - 1027 + - 1027 + ssi: + - 0 + - 0 + va_ag_eligible: + - true + - true + va_ag_countable_income: + - 1190 + - 1190 +- name: Review VA joint initial-month resources exceed couple limit across facility + households + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + husband: + age: + 2026: 72 + is_tax_unit_head: + 2026: true + pension_income: + 2026: 14400 + ssi_countable_resources: + 2026: 1600 + va_ag_certified: true + va_ag_is_joint_initial_month: true + wife: + age: + 2026: 70 + is_tax_unit_spouse: + 2026: true + pension_income: + 2026: 14400 + ssi_countable_resources: + 2026: 1600 + va_ag_certified: true + va_ag_is_joint_initial_month: true + tax_units: + joint_tax_unit: + members: + - husband + - wife + marital_units: + declared_marriage: + members: + - husband + - wife + households: + husband_facility: + members: + - husband + state_code: + 2026: VA + wife_facility: + members: + - wife + state_code: + 2026: VA + output: + va_ag: + - 0 + - 0 + ssi: + - 0 + - 0 + va_ag_eligible: + - false + - false diff --git a/policyengine_us/variables/gov/states/_spouses.py b/policyengine_us/variables/gov/states/_spouses.py new file mode 100644 index 00000000000..8a47977186d --- /dev/null +++ b/policyengine_us/variables/gov/states/_spouses.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) + + +def state_couple_total(person, year, values): + """Spouse totals for state rules that retain a declared marriage apart. + + A two-person marital unit declares the spouses even when they occupy + different households. Larger default groups instead use SSI's identified + spouse pair, so an unrelated adult cannot enter the spouse's budget. + Callers apply their own medical-separation, entry-month or care conditions; + this helper does not change federal SSI's co-residence test. + """ + return where( + person.marital_unit.nb_persons() == 2, + person.marital_unit.sum(values), + _ssi_couple_total(person, year, values), + ) + + +def state_is_married(person, year): + """Declared spouses, including apart, or the identified default-group pair.""" + return (person.marital_unit.nb_persons() == 2) | ( + _ssi_spouse_index(person, year) >= 0 + ) diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py b/policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py index 539e35b7914..d518191f4ec 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/_spouses.py @@ -1,7 +1,6 @@ -from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, - _ssi_spouse_index, +from policyengine_us.variables.gov.states._spouses import ( + state_couple_total, + state_is_married, ) @@ -12,15 +11,9 @@ def co_oap_couple_total(person, year, values): household. A two-person marital unit therefore retains both spouses; larger default groups use SSI's identified spouse pair instead. """ - return where( - person.marital_unit.nb_persons() == 2, - person.marital_unit.sum(values), - _ssi_couple_total(person, year, values), - ) + return state_couple_total(person, year, values) def co_oap_is_married(person, year): """Marriage for OAP's shared $20 disregard (3.533(A)(2)(c)).""" - return (person.marital_unit.nb_persons() == 2) | ( - _ssi_spouse_index(person, year) >= 0 - ) + return state_is_married(person, year) diff --git a/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py b/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py index 6e741c2c4a7..08a3ea8582f 100644 --- a/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py +++ b/policyengine_us/variables/gov/states/mo/dss/ssp/eligibility/mo_ssp_resource_eligible.py @@ -1,7 +1,7 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, - _ssi_spouse_index, +from policyengine_us.variables.gov.states._spouses import ( + state_couple_total, + state_is_married, ) @@ -31,15 +31,14 @@ def formula(person, period, parameters): ) # The couple limit applies to property of either spouse when the - # claimant is married and living with the spouse (§ 1035.005.00), so - # the test follows the claimant's marital unit rather than the SPM unit. - # Spouses separated to receive medical or nursing home care also use - # the couple limit; that separation is not modeled. + # claimant is married and living with the spouse (§ 1035.005.00). + # RSMo 208.010.1 also treats spouses separated to obtain medical or + # nursing care as living together, retaining the declared marriage. personal_resources = person("ssi_countable_resources", period.this_year) - married = _ssi_spouse_index(person, period.this_year) >= 0 + married = state_is_married(person, period.this_year) countable_resources = where( married, - _ssi_couple_total(person, period.this_year, personal_resources), + state_couple_total(person, period.this_year, personal_resources), personal_resources, ) diff --git a/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py b/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py index fe94ac405bf..fde81e3f807 100644 --- a/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py +++ b/policyengine_us/variables/gov/states/or/odhs/ssp/or_ssp.py @@ -1,7 +1,5 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, -) +from policyengine_us.variables.gov.states._spouses import state_couple_total class or_ssp(Variable): @@ -43,10 +41,13 @@ def formula(person, period, parameters): # Community care and SSI restoration amounts depend on the SSI payment, # so those paths require modeled SSI rather than reported receipt. care = (category == c.COMMUNITY_CARE) & (ssi > 0) - couple = _ssi_couple_total(person, period.this_year, care.astype(int)) == 2 + # OAR 461-155-0630(3) applies when each spouse receives SSI and + # community-based care services, including in separate facilities. + # Hub ruling: us-or:ssp-community-care-spouses-separate-facilities. + couple = state_couple_total(person, period.this_year, care.astype(int)) == 2 own_excess = max_(income - standard, 0) spouse_excess = ( - _ssi_couple_total(person, period.this_year, own_excess) - own_excess + state_couple_total(person, period.this_year, own_excess) - own_excess ) couple_payment = max_(standard - income - spouse_excess, 0) interim = (category == c.DISABILITY_INTERIM) | (category == c.NATURALIZATION) diff --git a/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py b/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py index ad904365c30..ee557fa57f1 100644 --- a/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py +++ b/policyengine_us/variables/gov/states/sd/dss/ssp/sd_ssp_care.py @@ -1,7 +1,5 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, -) +from policyengine_us.variables.gov.states._spouses import state_couple_total class sd_ssp_care(Variable): @@ -25,9 +23,11 @@ class sd_ssp_care(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.sd.dss.ssp resources = person("ssi_countable_resources", period.this_year) - # Joint entry applies only when both spouses are flagged. + # ARSD 67:46:04:10(6) and 67:46:05:30 preserve a joint budget when + # both spouses enter long-term care in the same month, including + # placement in different rooms or households. joint_entry = ( - _ssi_couple_total( + state_couple_total( person, period.this_year, person("sd_ssp_care_joint_entry", period).astype(int), @@ -36,7 +36,7 @@ def formula(person, period, parameters): ) resources = where( joint_entry, - _ssi_couple_total(person, period.this_year, resources), + state_couple_total(person, period.this_year, resources), resources, ) limit = where(joint_entry, p.joint_entry_resource_limit, p.resource_limit) @@ -85,7 +85,7 @@ def formula(person, period, parameters): # assisted living or adult foster care is not modeled. 67:46:04:10(7) # and 67:46:05:42(7) count the couple's combined income and resources # in that month; this formula uses the entering spouse's own. - shared_income = _ssi_couple_total(person, period.this_year, own_income) / 2 + shared_income = state_couple_total(person, period.this_year, own_income) / 2 income = where(joint_entry, shared_income, own_income) net_income = max_(income - person("sd_ssp_care_income_deductions", period), 0) # 67:46:06:09: income is not applied in a partial month unless the diff --git a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py index 7f0f7bb954d..c5164013ec4 100644 --- a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py +++ b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py @@ -1,7 +1,5 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, -) +from policyengine_us.variables.gov.states._spouses import state_couple_total from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) @@ -23,8 +21,11 @@ class va_ag_countable_income(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.va.dars.ag + # Chapter B section 6 tests co-residence at the first moment of the + # application month; subsequent placement in separate facilities does + # not end the joint initial-month budget recorded by this input. joint = ( - _ssi_couple_total( + state_couple_total( person, period.this_year, person("va_ag_is_joint_initial_month", period).astype(int), @@ -45,10 +46,10 @@ def formula(person, period, parameters): 0, ) earned = where( - joint, _ssi_couple_total(person, period.this_year, earned), earned + joint, state_couple_total(person, period.this_year, earned), earned ) unearned = where( - joint, _ssi_couple_total(person, period.this_year, unearned), unearned + joint, state_couple_total(person, period.this_year, unearned), unearned ) non_ssi_countable = ( _apply_ssi_exclusions( diff --git a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py index 714b7ea671e..878cb7898ff 100644 --- a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py +++ b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_eligible.py @@ -1,7 +1,5 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, -) +from policyengine_us.variables.gov.states._spouses import state_couple_total class va_ag_eligible(Variable): @@ -17,8 +15,10 @@ def formula(person, period, parameters): immigration = person("immigration_status", period.this_year) citizen = immigration == immigration.possible_values.CITIZEN immigrant = person("is_ssi_qualified_noncitizen", period.this_year) + # Chapter B section 6 preserves the joint initial-month resource + # budget when the spouses subsequently enter separate facilities. joint = ( - _ssi_couple_total( + state_couple_total( person, period.this_year, person("va_ag_is_joint_initial_month", period).astype(int), @@ -27,7 +27,7 @@ def formula(person, period, parameters): ) resources = person("ssi_countable_resources", period.this_year) resources = where( - joint, _ssi_couple_total(person, period.this_year, resources), resources + joint, state_couple_total(person, period.this_year, resources), resources ) limits = parameters(period).gov.ssa.ssi.eligibility.resources.limit receives_ssi = (person("ssi", period) > 0) | person("receives_ssi", period) From a53311ad7b201b8f9aa7b37185fbde55b34b4382 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 07:43:19 -0400 Subject: [PATCH 11/25] Test separated spouses against unrelated household adults Exercise five state supplement budgets across two households and correct Connecticut and Pennsylvania source comments. --- .../test_ssi_spousal_deeming_invariants.py | 208 ++++++++++++++++++ .../ssi/ssi_state_supplement_three_adult.yaml | 14 +- 2 files changed, 218 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py index 163b5b29172..b2e92f8f932 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -24,6 +24,12 @@ examples retain the payment, exclusion, eligibility and resource edges. Each comparison uses two independent three-person simulations sharing the same read-only system as the grids. +- Declared spouses who live in separate households in Colorado, Missouri, + Oregon, South Dakota and Virginia, comparing their supplements before and + after an unrelated adult joins either household. The marriage, separate + tax/family/SPM budgets, and all benefit inputs stay the same. Six explicit + examples and two generated examples use two small independent simulations + apiece per state, sharing the same read-only system. YAML covers worked cases. Python is needed to check the for-all properties over every ordered edge of the grids and to compare with an oracle that does @@ -657,3 +663,205 @@ def test_state_supplements_do_not_depend_on_omitted_marital_units( np.testing.assert_allclose( default[name], explicit[name], atol=0.01, err_msg=f"{state}: {name}" ) + + +# These states preserve a declared marriage for at least one separated-spouse +# supplement budget. The cited YAML cases check that those budgets retain +# both spouses; this property separately checks that a household member who +# is not a spouse cannot alter them. +# CO: 9 CCR 2503-5 3.533(A)(2)(c), 3.534(B)-(C). +# MO: RSMo 208.010.1; DSS Manual 1035.005.00 (medical/nursing separation). +# OR: OAR 461-155-0630(3), hub ruling +# us-or:ssp-community-care-spouses-separate-facilities. +# SD: ARSD 67:46:04:10(6), 67:46:05:30 (joint care-entry month). +# VA: AG Manual Chapter B section 6 (first moment of the initial month). +SEPARATED_SUPPLEMENT_STATES = ("CO", "MO", "OR", "SD", "VA") +SEPARATED_PERIOD = "2026-07" + + +def _separated_supplement_situation( + system, + state, + pensions, + ages, + resources, + care, + joint_entry, + third_care, + third_household, + add_third, +): + situation = _supplement_situation( + system, state, pensions, ages, resources, care, third_care, True + ) + names = ("claimant", "spouse", "third_adult") + if not add_third: + del situation["people"][names[2]] + names = names[:2] + + for person in situation["people"].values(): + # Use assets as a primitive input, so SSI resource eligibility is + # derived alongside federal income, couple status and payments. + person["bank_account_assets"] = person.pop("ssi_countable_resources") + person["is_tax_unit_head"] = {YEAR: True} + person["is_tax_unit_spouse"] = {YEAR: False} + person["is_tax_unit_dependent"] = {YEAR: False} + for values in person.values(): + if PERIOD in values: + values[SEPARATED_PERIOD] = values.pop(PERIOD) + + # Copy entity inputs, then give everyone independent nonmarital budgets. + # An unrelated adult shares only the chosen household. The marriage is + # always the same explicit two-person unit spanning the two households. + for plural in ("tax_units", "families", "spm_units"): + original = next(iter(situation[plural].values())) + inputs = {name: value for name, value in original.items() if name != "members"} + situation[plural] = {name: {**inputs, "members": [name]} for name in names} + household = situation["households"]["household"] + household_inputs = { + name: value for name, value in household.items() if name != "members" + } + situation["households"] = { + name: {**household_inputs, "members": [name]} for name in names[:2] + } + if add_third: + situation["households"][names[third_household]]["members"].append(names[2]) + situation["marital_units"] = {"couple": {"members": list(names[:2])}} + if add_third: + situation["marital_units"]["single"] = {"members": [names[2]]} + + entry_input = { + "SD": "sd_ssp_care_joint_entry", + "VA": "va_ag_is_joint_initial_month", + }.get(state) + for index, name in enumerate(names): + person = situation["people"][name] + if entry_input: + person[entry_input] = { + SEPARATED_PERIOD: joint_entry and care and (index < 2 or third_care) + } + if care and (index < 2 or third_care): + if state == "SD": + person["sd_ssp_approved_care_cost"] = {SEPARATED_PERIOD: 2_000} + elif state == "OR": + # OAR 461-140-0040(5)(e)(D): inaccessible income is excluded. + # This primitive keeps the community-care payment positive + # when modeled SSI would otherwise fill the federal standard. + person["or_ssp_inaccessible_income"] = { + SEPARATED_PERIOD: (194, 94, 0)[index] + } + return situation + + +@pytest.mark.parametrize("state", SEPARATED_SUPPLEMENT_STATES) +@settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@example( + pensions=(0, 0, 1_491), + ages=(65, 65, 90), + resources=(7_000, 1_000, 5_000), + care=True, + joint_entry=True, + third_care=False, + third_household=0, +) +@example( + pensions=(20, 65, 0), + ages=(90, 65, 64), + resources=(1_000, 15_000, 0), + care=True, + joint_entry=False, + third_care=True, + third_household=1, +) +@example( + pensions=(1_200, 1_200, 3_000), + ages=(65, 65, 90), + resources=(0, 0, 3_001), + care=True, + joint_entry=True, + third_care=False, + third_household=0, +) +@example( + pensions=(1_800, 1_200, 20), + ages=(65, 90, 65), + resources=(1_600, 1_400, 2_001), + care=True, + joint_entry=True, + third_care=True, + third_household=1, +) +@example( + pensions=(0, 0, 994), + ages=(65, 65, 65), + resources=(1_600, 1_600, 0), + care=True, + joint_entry=True, + third_care=True, + third_household=0, +) +@example( + pensions=(994, 1_491, 20), + ages=(65, 64, 90), + resources=(2_000, 0, 0), + care=False, + joint_entry=False, + third_care=False, + third_household=1, +) +@given( + pensions=st.tuples(*(st.integers(0, 3_000) for _ in range(3))), + ages=st.tuples(*(st.sampled_from((64, 65, 90)) for _ in range(3))), + resources=st.tuples(*(st.integers(0, 16_000) for _ in range(3))), + care=st.booleans(), + joint_entry=st.booleans(), + third_care=st.booleans(), + third_household=st.integers(0, 1), +) +def test_separated_spouses_supplements_ignore_unrelated_household_adult( + state, pensions, ages, resources, care, joint_entry, third_care, third_household +): + """A third adult cannot change either separately housed spouse's grant. + + YAML checks the states' independent legal computations. This comparison + needs the simulation API to add a person while preserving the explicit + marriage across two households and the spouses' independent other units. + Both simulations share only the read-only system; caches stay separate. + """ + system = Simulation.default_tax_benefit_system_instance + amounts = [] + for add_third in (False, True): + simulation = Simulation( + situation=_separated_supplement_situation( + system, + state, + pensions, + ages, + resources, + care, + joint_entry, + third_care, + third_household, + add_third, + ) + ) + amounts.append( + { + name: simulation.calculate( + name, + SEPARATED_PERIOD + if system.variables[name].definition_period == "month" + else YEAR, + )[:2] + for name in SUPPLEMENT_PROGRAMS[state] + } + ) + for name in SUPPLEMENT_PROGRAMS[state]: + np.testing.assert_allclose( + amounts[0][name], amounts[1][name], atol=0.01, err_msg=f"{state}: {name}" + ) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml index 878ea463356..935610f75a7 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_state_supplement_three_adult.yaml @@ -40,8 +40,12 @@ co_oap: [2_838, 4_038, 2_268] # ct/dss/ssp/third_adult.yaml -# Conn. Gen. Stat. 17b-600(c): a co-resident couple has a $2,400 combined asset limit; a single person has $1,600. Couple resources are $1,000 + $1,000 = $2,000; the third adult has $1,000 separately. -# https://www.cga.ct.gov/current/pub/chap_319s.htm#sec_17b-600 +# CT DSS State Supplement page and fact sheet: $2,400 combined assets for a +# married couple; $1,600 for a single person. $1,000 + $1,000 = $2,000 passes; +# the third adult's $1,000 is tested separately. Section 17b-600 in chapter +# 319mm authorizes the program but does not specify these dollar limits. +# https://portal.ct.gov/dss/knowledge-base/articles/cash-assistance/state-supplement-cash-assistance?language=en_US +# https://portal.ct.gov/-/media/departments-and-agencies/dss/fact-sheets-and-issue-briefs/fact-sheets/agedblinddiabledfactsheet.pdf - name: Connecticut excludes a third adult from the couple resource test period: 2026-01 absolute_error_margin: 0.01 @@ -989,9 +993,11 @@ or_ssp: [194, 94, 0] # pa/dhs/ssp/pa_ssp_three_adult.yaml -# POMS SI 01415.058 K.2: independent living pays $33.30 per couple +# PA DHS budget schedule: independent living pays $33.30 per couple # and $22.10 per individual; $33.30 / 2 = $16.65. -# https://secure.ssa.gov/poms.nsf/lnx/0501415058#k2 +# 55 Pa. Code chapter 299 supplies the individual/couple SSI program rules. +# https://www.pa.gov/content/dam/copapwp-pagov/en/dhs/documents/docs/publications/documents/budget-information/c_259013.pdf +# https://www.pacodeandbulletin.gov/secure/pacode/data/055/chapter299/chap299toc.html # No marital units: only the head and spouse form the eligible couple. - name: PA couple rate excludes a third aged adult period: 2026-01 From 6a3d5d2a2cbd8a1209662a3fba24e407e5fa3f22 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 01:18:40 -0400 Subject: [PATCH 12/25] Preserve Iowa supplement spouse budgets and residential care entry treatment --- .../ssi-couples-and-deeming-cap.fixed.md | 2 +- .../gov/states/ia/hhs/ssa/ia_ssa.yaml | 410 +++++++++++++++++- .../ia/hhs/ssa/_ia_ssa_smme_spouse_income.py | 28 +- .../gov/states/ia/hhs/ssa/_ia_ssa_spouses.py | 10 + .../variables/gov/states/ia/hhs/ssa/ia_ssa.py | 16 +- .../hhs/ssa/ia_ssa_care_countable_income.py | 72 ++- .../ssa/ia_ssa_care_initial_partial_month.py | 2 +- .../gov/states/ia/hhs/ssa/ia_ssa_category.py | 6 +- .../gov/states/ia/hhs/ssa/ia_ssa_eligible.py | 20 +- .../gov/states/ia/hhs/ssa/ia_ssa_federal.py | 26 +- .../states/ia/hhs/ssa/ia_ssa_has_spouse.py | 13 +- .../states/ia/hhs/ssa/ia_ssa_ihhrc_amount.py | 5 +- .../ia/hhs/ssa/ia_ssa_ihhrc_participation.py | 9 +- .../ia/hhs/ssa/ia_ssa_rcf_couple_budget.py | 48 +- .../ia_ssa_rcf_received_ssi_before_entry.py | 12 + .../ssa/ia_ssa_rcf_shares_room_with_spouse.py | 12 + .../hhs/ssa/ia_ssa_smme_countable_income.py | 9 +- .../ia_ssa_smme_spousal_deeming_applies.py | 8 +- 18 files changed, 642 insertions(+), 66 deletions(-) create mode 100644 policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_spouses.py create mode 100644 policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_received_ssi_before_entry.py create mode 100644 policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_shares_room_with_spouse.py diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index ce63b55ed78..cff2de31cd5 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1 +1 @@ -SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains declared spouses living apart; Missouri retains their combined resources for medical separation, Virginia and South Dakota retain joint care-entry budgets, and Oregon retains the community-care spouse payment across facilities. +SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains declared spouses living apart; Missouri retains their combined resources for medical separation, Virginia and South Dakota retain joint care-entry budgets, and Oregon retains the community-care spouse payment across facilities. Iowa pools only the identified spouses across its state supplement paths, retains its prior-SSI residential-care entry budget across facilities, and requires an explicit shared-room input for the same-month entry rule without prior SSI. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml b/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml index 87e06dc698d..b4c7bcd3f6e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml @@ -1677,6 +1677,7 @@ # Both spouses enter on January 10: January 10 through 31. ia_ssa_care_partial_month_days: 22 ia_ssa_care_initial_partial_month: true + ia_ssa_rcf_shares_room_with_spouse: true person2: age: 2026: 70 @@ -3153,7 +3154,7 @@ ia_ssa_smme_countable_income: [2_247.5, 457.5] ia_ssa: [1, 0] -- name: Case 101, without marital units an SMME applicant working above the SGA level in a household of three adults is budgeted alone. +- name: Case 101, an unmarried SMME applicant working above the SGA level in a household of three adults is budgeted alone. period: 2026-04 absolute_error_margin: 0.01 input: @@ -3177,6 +3178,13 @@ person3: age: 2026: 30 + marital_units: + first: + members: [person1] + second: + members: [person2] + third: + members: [person3] tax_units: tax_unit: members: [person1, person2, person3] @@ -3189,8 +3197,9 @@ state_code: 2026: IA output: - # With no marital units, the default unit holds all three people, so - # ia_ssa_has_spouse is false and each member is budgeted alone: + # Explicit single-person marital units establish that these adults are + # unmarried, even though two adults receive inferred tax head/spouse roles. + # Each member is budgeted alone: # (3,277 - 20 - 65) / 2 = 1,596, at least the one-person 1,596. If # person2's 520 pension were deemed it would be 2,106, below the two-person # 2,164. person2's own income is 520 - 20 = 500. @@ -3711,3 +3720,398 @@ ssi: [0, 29, 0, 0, 0] ia_ssa_smme_countable_income: [2_207.5, 2_207.5, 0, 0, 0] ia_ssa: [1, 0, 0, 0, 0] + +- name: Case 111, prior-SSI spouses entering separate facilities retain only the entry-month couple budget. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security_retirement: 14_400 + ssi_countable_resources: 1_000 + ia_ssa_category: + 2026-01: RESIDENTIAL_CARE + 2026-02: RESIDENTIAL_CARE + ia_ssa_care_partial_month_days: + 2026-01: 22 + ia_ssa_care_initial_partial_month: + 2026-01: true + ia_ssa_rcf_received_ssi_before_entry: + 2026-01: true + person2: + age: 70 + social_security_retirement: 20_400 + ssi_countable_resources: 1_500 + ia_ssa_category: + 2026-01: RESIDENTIAL_CARE + 2026-02: RESIDENTIAL_CARE + ia_ssa_care_partial_month_days: + 2026-01: 22 + ia_ssa_care_initial_partial_month: + 2026-01: true + marital_units: + couple: + members: [person1, person2] + tax_units: + first: + members: [person1] + second: + members: [person2] + households: + facility1: + members: [person1] + state_code: IA + facility2: + members: [person2] + state_code: IA + output: + # 6-B Spouses Living Separately retains a couple in the prior-SSI entry + # month even across facilities. (1,200 + 1,700) / 2 = 1,450 care income; + # each gets half the couple home allowance, 745.50. Participation is + # 1,450 - 130 - 745.50 = 574.50, against 22 * 38.47 = 846.34. + # February uses each resident's income and 28 * 38.47 = 1,077.16. + ia_ssa_has_spouse: + 2026-01: [false, false] + ia_ssa_rcf_couple_budget: + 2026-01: [true, true] + 2026-02: [false, false] + ia_ssa_eligible: + 2026-01: [true, true] + ia_ssa_care_countable_income: + 2026-01: [1_450, 1_450] + 2026-02: [1_200, 1_700] + ia_ssa: + 2026-01: [271.84, 271.84] + 2026-02: [7.16, 0] + +- name: Case 112, current individual SSI does not establish prior SSI for spouses entering separate facilities. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + person2: + age: 70 + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + marital_units: + couple: + members: [person1, person2] + tax_units: + first: + members: [person1] + second: + members: [person2] + households: + facility1: + members: [person1] + state_code: IA + facility2: + members: [person2] + state_code: IA + output: + ssi: [994, 994] + ia_ssa_rcf_couple_budget: [false, false] + ia_ssa_care_countable_income: [994, 994] + ia_ssa: [1_192.57, 1_192.57] + +- name: Case 113, a third adult does not enter the family-life home couple budget in a default marital unit. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + ia_ssa_category: FAMILY_LIFE_HOME + ssi_countable_resources: 1_000 + person2: + age: 70 + ia_ssa_category: FAMILY_LIFE_HOME + ssi_countable_resources: 1_000 + person3: + age: 70 + is_blind: true + social_security_retirement: 120_000 + ia_ssa_category: FAMILY_LIFE_HOME + ia_ssa_dependent_countable_income: 10_000 + ssi_countable_resources: 100_000 + tax_units: + couple: + members: [person1, person2] + other: + members: [person3] + households: + home: + members: [person1, person2, person3] + state_code: IA + output: + # The actual spouses share the $801 federal supplement and $20 top-up; + # the third adult's income, blindness and resources do not enter theirs. + ia_ssa_has_spouse: [true, true, false] + ia_ssa_eligible: [true, true, false] + ia_ssa_care_countable_income: [745.5, 745.5, 10_000] + ia_ssa_federal: [400.5, 400.5, 0] + ia_ssa: [410.5, 410.5, 0] + +- name: Case 114, dependent-person certification and dependent income use only the actual spouses. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + ia_ssa_category: DEPENDENT_PERSON + ia_ssa_dependent_countable_income: 100 + person2: + age: 70 + ia_ssa_dependent_countable_income: 200 + person3: + age: 70 + ia_ssa_category: DEPENDENT_PERSON + ia_ssa_dependent_countable_income: 10_000 + ssi_countable_resources: 100_000 + tax_units: + couple: + members: [person1, person2] + other: + members: [person3] + households: + home: + members: [person1, person2, person3] + state_code: IA + output: + # The certified spouse establishes the couple category. Its dependent + # income is max(100, 200), not the unrelated adult's 10,000. + ia_ssa_federal: [259, 259, 0] + ia_ssa: [259, 259, 0] + +- name: Case 115, in-home care pools only the actual spouse's income costs and deductions. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security_retirement: 18_000 + ia_ssa_category: IN_HOME_CARE + ia_ssa_ihhrc_monthly_cost: 400 + ia_ssa_care_additional_deductions: 100 + person2: + age: 70 + social_security_retirement: 6_000 + ia_ssa_category: IN_HOME_CARE + ia_ssa_ihhrc_monthly_cost: 400 + ia_ssa_care_additional_deductions: 50 + person3: + age: 70 + social_security_retirement: 120_000 + ia_ssa_category: IN_HOME_CARE + ia_ssa_ihhrc_monthly_cost: 400 + ia_ssa_care_additional_deductions: 10_000 + tax_units: + couple: + members: [person1, person2] + other: + members: [person3] + households: + home: + members: [person1, person2, person3] + state_code: IA + output: + # Couple participation = 2,000 - 1,491 - 100 - 50 = 359; approved care + # costs total 800, leaving 441 split equally. The third adult is alone. + ia_ssa_care_countable_income: [2_000, 2_000, 10_000] + ia_ssa_ihhrc_participation: [359, 359, 0] + ia_ssa_ihhrc_amount: [220.5, 220.5, 400] + ia_ssa: [220.5, 220.5, 400] + +- name: Case 116, an Iowa couple with separate tax units still pools in-home care income and resources. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security_retirement: 18_000 + ssi_countable_resources: 1_000 + ia_ssa_category: IN_HOME_CARE + ia_ssa_ihhrc_monthly_cost: 400 + person2: + age: 70 + social_security_retirement: 6_000 + ssi_countable_resources: 1_000 + ia_ssa_category: IN_HOME_CARE + ia_ssa_ihhrc_monthly_cost: 400 + marital_units: + couple: + members: [person1, person2] + tax_units: + first: + members: [person1] + second: + members: [person2] + households: + home: + members: [person1, person2] + state_code: IA + output: + is_tax_unit_spouse: [false, false] + ia_ssa_has_spouse: [true, true] + ia_ssa_eligible: [true, true] + ia_ssa_care_countable_income: [2_000, 2_000] + ia_ssa_ihhrc_participation: [509, 509] + ia_ssa: [145.5, 145.5] + +- name: Case 117, an unrelated adult in a medical facility does not stop Iowa SMME spouse income pooling. + period: 2026-04 + absolute_error_margin: 0.01 + input: + people: + person1: + age: + 2026: 50 + meets_ssi_disability_criteria: + 2026: true + employment_income: + 2026: 30_000 + ia_ssa_category: SMME + ia_ssa_smme_coverage_eligible: true + person2: + age: + 2026: 70 + social_security_retirement: + 2026: 16_800 + ia_ssa_category: SMME + ia_ssa_smme_coverage_eligible: true + person3: + age: + 2026: 70 + social_security_retirement: + 2026: 120_000 + ssi_lives_in_medical_treatment_facility: true + ia_ssa_category: SMME + ia_ssa_smme_coverage_eligible: true + tax_units: + couple: + members: [person1, person2] + other: + members: [person3] + households: + home: + members: [person1, person2, person3] + state_code: + 2026: IA + output: + # SMME combines 1,400 - 20 + (2,500 - 65) / 2 = 2,597.50. The unrelated + # adult's medical stay and income neither stop deeming nor enter the sum. + ia_ssa_smme_spousal_deeming_applies: [true, false, false] + ia_ssa_smme_countable_income: [2_597.5, 2_597.5, 9_980] + ia_ssa: [1, 1, 1] + +- name: Case 118, retained low-income residential-care entry uses couple SSI despite separate current facilities. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security_retirement: 2_100 + ia_ssa_category: + 2026-01: RESIDENTIAL_CARE + 2026-02: RESIDENTIAL_CARE + ia_ssa_care_partial_month_days: + 2026-01: 22 + ia_ssa_care_initial_partial_month: + 2026-01: true + ia_ssa_rcf_received_ssi_before_entry: + 2026-01: true + person2: + age: 70 + social_security_retirement: 1_200 + ia_ssa_category: + 2026-01: RESIDENTIAL_CARE + 2026-02: RESIDENTIAL_CARE + ia_ssa_care_partial_month_days: + 2026-01: 22 + ia_ssa_care_initial_partial_month: + 2026-01: true + marital_units: + couple: + members: [person1, person2] + tax_units: + first: + members: [person1] + second: + members: [person2] + households: + facility1: + members: [person1] + state_code: IA + facility2: + members: [person2] + state_code: IA + output: + # https://hhs.iowa.gov/media/3987/download?inline=#page=69 + # Manual 6-B PDF p.69: the entry budget retains couple SSI of + # 1,491 - (175 + 100 - 20) = 1,236, so care income is + # (175 + 100 + 1,236) / 2 = 755.50. The half-couple home allowance + # 745.50 plus personal needs 130 leaves no client participation. + # Federal SSI's static current households yield individual amounts; + # Iowa's entry budget must not sum those amounts as couple SSI. + ssi: + 2026-01: [839, 914] + ia_ssa_rcf_couple_budget: + 2026-01: [true, true] + 2026-02: [false, false] + ia_ssa_care_countable_income: + 2026-01: [755.5, 755.5] + 2026-02: [1_014, 1_014] + ia_ssa: + 2026-01: [846.34, 846.34] + 2026-02: [193.16, 193.16] + +- name: Case 119, prior SSI does not make a co-resident parent and dependent adult child an RCF couple. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: + 2026: 70 + person_id: + 2026: 1 + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + ia_ssa_rcf_received_ssi_before_entry: true + person2: + age: + 2026: 66 + person_id: + 2026: 2 + parent_1_id: + 2026: 1 + is_tax_unit_dependent: + 2026: true + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + tax_units: + family: + members: [person1, person2] + households: + facility: + members: [person1, person2] + state_code: IA + output: + # Both adults meet the age condition and one received SSI before entry, + # but the default two-person marital unit does not establish marriage. + # SSI rejects this parent/dependent-child pair; sharing one household + # cannot activate Iowa's exception for separated spouses. + ia_ssa_has_spouse: [false, false] + ssi_claim_is_joint: [false, false] + ia_ssa_rcf_couple_budget: [false, false] + ia_ssa_care_countable_income: [994, 994] + ia_ssa: [1_192.57, 1_192.57] diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_smme_spouse_income.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_smme_spouse_income.py index 58014e0debf..d7025ab87d9 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_smme_spouse_income.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_smme_spouse_income.py @@ -1,16 +1,18 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_spouses import ( + ia_ssa_spouse_value, +) def ia_ssa_smme_spouse_is_not_ineligible(person, period): - """Whether the other member of a two-person marital unit is not an + """Whether the co-resident spouse is not an ineligible spouse under 8-E p.12: a person SSI treats as aged, blind or disabled, or an SMME applicant working above the SGA level, who receives full Medicaid under a group that does not rely on State Supplementary Assistance (6-B PDF page 76), such as MEPD (8-F PDF page 114) or 1619(b) (8-F PDF page 88). For an - applicant whom SSI does not count, any such member is the spouse. - Callers gate on ia_ssa_has_spouse, so the marital-unit sum less the - person's own value is the spouse's.""" + applicant whom SSI does not count, that person's spouse is still found + by marriage rather than SSI eligibility or tax-unit role.""" abd = person("is_ssi_aged_blind_disabled", period.this_year) sga_applicant = ( person("meets_ssi_disability_criteria", period.this_year) @@ -18,7 +20,7 @@ def ia_ssa_smme_spouse_is_not_ineligible(person, period): & person("ia_ssa_smme_coverage_eligible", period) ) status = abd | sga_applicant - return person.marital_unit.sum(status) - status > 0 + return ia_ssa_spouse_value(person, period, status) def ia_ssa_smme_spouse_is_sga_applicant(person, period): @@ -26,13 +28,12 @@ def ia_ssa_smme_spouse_is_sga_applicant(person, period): an SMME applicant working above the SGA level whose SMME income combines the couple's income. SSI deems from that applicant, but under 8-E p.12 the applicant is not an ineligible spouse, so the couple is combined. - The applicant's flag requires ia_ssa_has_spouse, so the marital-unit sum - less the person's own value is the spouse's.""" + Unrelated household adults never enter this spouse test.""" applicant = person("ia_ssa_smme_spousal_deeming_applies", period) & person( "ia_ssa_smme_coverage_eligible", period ) abd = person("is_ssi_aged_blind_disabled", period.this_year) - return abd & (person.marital_unit.sum(applicant) - applicant > 0) + return abd & ia_ssa_spouse_value(person, period, applicant) def ia_ssa_smme_spouse_income(person, period): @@ -41,8 +42,7 @@ def ia_ssa_smme_spouse_income(person, period): first (20 CFR 416.1163(b)(3)). A spouse who is not an ineligible spouse is combined as for an eligible couple, with no allocations and after the spouse's own student earned income exclusion (20 CFR 416.1112(c)(3)). - Callers gate on ia_ssa_has_spouse, so the marital-unit sum less the - person's own value is the spouse's. As in SSI's own deeming, support and + Only the identified co-resident spouse contributes. As in SSI's own deeming, support and alimony the spouse pays are not deducted (20 CFR 416.1161(a)(10), 8-E p.12; issue 10454), children are found through the applicant's tax unit, no allocation is skipped for a child who receives public @@ -53,9 +53,11 @@ def ia_ssa_smme_spouse_income(person, period): unearned = person("ssi_unearned_income", period) exclusion = person("ssi_blind_or_disabled_working_student_exclusion", period) not_ineligible = ia_ssa_smme_spouse_is_not_ineligible(person, period) - spouse_exclusion = (person.marital_unit.sum(exclusion) - exclusion) * not_ineligible - spouse_earned = max_(person.marital_unit.sum(earned) - earned - spouse_exclusion, 0) - spouse_unearned = person.marital_unit.sum(unearned) - unearned + spouse_exclusion = ia_ssa_spouse_value(person, period, exclusion) * not_ineligible + spouse_earned = max_( + ia_ssa_spouse_value(person, period, earned) - spouse_exclusion, 0 + ) + spouse_unearned = ia_ssa_spouse_value(person, period, unearned) allocations = ( add(person.tax_unit, period, ["ssi_ineligible_child_allocation"]) * ~not_ineligible diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_spouses.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_spouses.py new file mode 100644 index 00000000000..e4fae3d4c37 --- /dev/null +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/_ia_ssa_spouses.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) + + +def ia_ssa_spouse_value(person, period, values): + """The co-resident spouse's value, excluding unrelated household adults.""" + spouse = _ssi_spouse_index(person, period.this_year) + return where(spouse >= 0, values[np.maximum(spouse, 0)], np.zeros_like(values)) diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa.py index b6529263b3b..f6f1301741c 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa.py @@ -1,4 +1,10 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_spouses import ( + ia_ssa_spouse_value, +) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_smme_spouse_income import ( ia_ssa_smme_spouse_is_sga_applicant, ) @@ -68,7 +74,9 @@ def formula(person, period, parameters): # living expenses from the previous living arrangement, but do not # disregard an amount that is over the SSI standard". The model follows # the rule's fixed amount (ia_ssa.yaml Cases 52-53). - has_spouse = person("ia_ssa_has_spouse", period) + has_spouse = person("ia_ssa_has_spouse", period) | person( + "ia_ssa_rcf_couple_budget", period + ) entry = where(has_spouse, federal.amount.couple, federal.amount.individual) entry = where( person("ia_ssa_rcf_couple_budget", period), entry / 2, entry @@ -95,10 +103,12 @@ def formula(person, period, parameters): if source not in excluded: has_other_income = has_other_income | (person(source, period) > 0) has_other_income = where( - joint, person.marital_unit.any(has_other_income), has_other_income + joint, + has_other_income | ia_ssa_spouse_value(person, period, has_other_income), + has_other_income, ) flh = category == c.FAMILY_LIFE_HOME - recipients = person.marital_unit.sum(flh) + recipients = _ssi_couple_total(person, period.this_year, flh.astype(int)) top_up = ( ~has_other_income * receives_ssi diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_countable_income.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_countable_income.py index 087810af14f..db306fbb4f0 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_countable_income.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_countable_income.py @@ -1,4 +1,11 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states._spouses import ( + state_couple_total, + state_is_married, +) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) @@ -11,7 +18,7 @@ class ia_ssa_care_countable_income(Variable): definition_period = MONTH unit = USD defined_for = StateCode.IA - documentation = "Care income without the $20 general exclusion: SSI countable income plus SSI. In-home care counts an adult's spouse's income. An eligible family-life home couple, and a residential care couple in the month of entry, split the couple's income. After the month of entry, a married resident counts own income plus the SSI individual amount from it. A child in in-home care receives income deemed from parents under the Iowa method. Interest and dividends are excluded. First-month deeming from an ineligible spouse, and deductions absent from the federal SSI income inputs, are not modeled." + documentation = "Care income without the $20 general exclusion: SSI countable income plus SSI. In-home care counts an adult's spouse's income. An eligible family-life home couple, and a residential care couple in the month of entry, split the couple's income. A retained prior-SSI entry couple now living apart uses the SSI couple amount calculated from its combined income. After the month of entry, a married resident counts own income plus the SSI individual amount from it. A child in in-home care receives income deemed from parents under the Iowa method. Interest and dividends are excluded. First-month deeming from an ineligible spouse, and deductions absent from the federal SSI income inputs, are not modeled." reference = ( # 441 IAC 52.1(3)"a" "https://www.legis.iowa.gov/docs/iac/chapter/441.52.pdf#page=1", @@ -84,9 +91,8 @@ def formula(person, period, parameters): # income is below the SSI rate "must now apply for and accept SSI # benefits" (p.61). The model therefore assumes SSA pays each spouse as # an individual from that month, and credits that amount even to a - # resident who does not take up SSI. Federal SSI has no input for - # spouses who live apart and keeps the couple or deemed amount, so the - # SSI individual amount is computed here from the resident's own income + # resident who does not take up SSI. The SSI individual amount is + # computed here from the resident's own income # (ia_ssa.yaml Cases 57, 58, 60 and 73). individual_ssi = max_( ssi.amount.individual @@ -99,11 +105,61 @@ def formula(person, period, parameters): / MONTHS_IN_YEAR, 0, ) - separated = rcf & spouse & ~person("ia_ssa_care_initial_partial_month", period) + separated = ( + rcf + & state_is_married(person, period.this_year) + & ~person("ia_ssa_care_initial_partial_month", period) + ) benefit = where(separated, individual_ssi, person("ssi", period)) - earned = where(combine, person.marital_unit.sum(earned), earned) - unearned = where(combine, person.marital_unit.sum(unearned), unearned) - benefit = where(combine, person.marital_unit.sum(benefit), benefit) + # RCF's prior-SSI entry budget retains the marriage across facilities; + # other care paths pool only with the spouse living in the home. + earned = where( + combine, + where( + rcf, + state_couple_total(person, period.this_year, earned), + _ssi_couple_total(person, period.this_year, earned), + ), + earned, + ) + unearned = where( + combine, + where( + rcf, + state_couple_total(person, period.this_year, unearned), + _ssi_couple_total(person, period.this_year, unearned), + ), + unearned, + ) + benefit = where( + combine, + where( + rcf, + state_couple_total(person, period.this_year, benefit), + _ssi_couple_total(person, period.this_year, benefit), + ), + benefit, + ) + # 6-B's entry-month example (PDF p.69) retains couple SSI of 1,236 + # for pensions of 175 and 100: 1,491 - (275 - 20). Current separate + # households make federal SSI individual immediately in this static + # model, so pooling those payments would overstate entry-month care + # income. Reconstruct only this retained Iowa entry budget, as the + # individual RCF amount above is reconstructed after entry. This uses + # the regular RCF SSI rate and the income inputs already used for + # care; it does not change federal timing or the documented gaps. + retained_entry_ssi = max_( + ssi.amount.couple + - _apply_ssi_exclusions( + earned * MONTHS_IN_YEAR, + unearned * MONTHS_IN_YEAR, + parameters, + period, + ) + / MONTHS_IN_YEAR, + 0, + ) + benefit = where(rcf & combine & ~spouse, retained_entry_ssi, benefit) income = ( _apply_ssi_exclusions( earned * MONTHS_IN_YEAR, diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_initial_partial_month.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_initial_partial_month.py index 9ebe3315ce4..d4adba7bf90 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_initial_partial_month.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_care_initial_partial_month.py @@ -8,7 +8,7 @@ class ia_ssa_care_initial_partial_month(Variable): definition_period = MONTH defined_for = StateCode.IA default_value = False - documentation = "Set for a first month of in-home health-related care service that is less than a full month, or for the month a resident enters a residential care facility after living at home earlier that month. In-home care then requires no client participation. Residential care deducts the SSI benefit rate for one person, or for a couple when a spouse lived in the home. For a married resident, the month also sets the couple budget (ia_ssa_rcf_couple_budget) when either spouse received SSI or both spouses enter that month; later months budget each spouse alone. Key the entry to its month and enter the covered days in ia_ssa_care_partial_month_days." + documentation = "Set for a first month of in-home health-related care service that is less than a full month, or for the month a resident enters a residential care facility after living at home earlier that month. In-home care then requires no client participation. Residential care deducts the SSI benefit rate for one person, or for a couple when a spouse lived in the home. For a married resident, the month also sets the couple budget (ia_ssa_rcf_couple_budget) when either spouse received SSI before entry or both spouses enter the same room that month; later months budget each spouse alone. Spouses in separate facilities must record prior receipt in ia_ssa_rcf_received_ssi_before_entry; same-room entry without prior SSI requires ia_ssa_rcf_shares_room_with_spouse. Key the entry to its month and enter the covered days in ia_ssa_care_partial_month_days." reference = ( # 441 IAC 52.1(3)"a"(6) "https://www.legis.iowa.gov/docs/iac/chapter/441.52.pdf#page=2", diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_category.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_category.py index c85f31d07de..d437b3e39d3 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_category.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_category.py @@ -30,6 +30,10 @@ class ia_ssa_category(Variable): resources, which 441 IAC 51.5(2) counts with the recipient's; the model tests only the recipient's and spouse's resources. For residential care, set ia_ssa_care_initial_partial_month in the month - of entry; a married resident is budgeted alone from the next month. + of entry; a married resident is budgeted alone from the next month. If + spouses enter separate facilities, ia_ssa_rcf_received_ssi_before_entry + records prior SSI receipt for their entry-month couple budget. Without + prior SSI, the same-month couple entry rule also requires + ia_ssa_rcf_shares_room_with_spouse. NONE also excludes the blind allowance; otherwise it may accompany care. """ diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_eligible.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_eligible.py index 9bdb207b59b..e3390ac1274 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_eligible.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_eligible.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states._spouses import state_couple_total +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ia_ssa_eligible(Variable): @@ -33,16 +37,14 @@ def formula(person, period, parameters): # resident lives apart from the spouse after the month of entry (6-B # p.60), so only the couple-budget entry month pools resources # (ia_ssa.yaml Case 61). - # NOTE: The spouse who stays at home still pools resources with the - # resident after the month of entry, though p.12 would test that - # spouse alone. Federal SSI in the model has no input for spouses who - # live apart and keeps them a couple, and that spouse's SSA income - # comes from federal SSI, so this belongs with the federal treatment of - # spouses living apart. - couple = person("ia_ssa_has_spouse", period) & ( - community | person("ia_ssa_rcf_couple_budget", period) + entry_couple = person("ia_ssa_rcf_couple_budget", period) + couple = (person("ia_ssa_has_spouse", period) & community) | entry_couple + combined = where( + entry_couple, + state_couple_total(person, period.this_year, resource), + _ssi_couple_total(person, period.this_year, resource), ) - resource = where(couple, person.marital_unit.sum(resource), resource) + resource = where(couple, combined, resource) resource = resource + person("ia_ssa_resources_deemed_from_parents", period) # 6-B Resources prints "$2,000 for an individual" and "$3,000 for a # married couple living together" under 20 CFR 416.1202; these equal diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_federal.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_federal.py index 920d3952c4f..7b4aef0dc93 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_federal.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_federal.py @@ -1,4 +1,10 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_spouses import ( + ia_ssa_spouse_value, +) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.ssa.ssi._ssi_state_supplement import ( ssi_state_supplement, ) @@ -31,14 +37,22 @@ def formula(person, period, parameters): # not apply as part of a joint claim (issue 10243). joint = person("ssi_couple_computation_applies", period) blind = person("is_blind", period.this_year) - blind_count = person.marital_unit.sum(blind) + blind_count = _ssi_couple_total(person, period.this_year, blind.astype(int)) dependent = person("ia_ssa_dependent_countable_income", period) - dependent = where(joint, person.marital_unit.max(dependent), dependent) + dependent = where( + joint, + max_(dependent, ia_ssa_spouse_value(person, period, dependent)), + dependent, + ) recipient_income = person("ssi_countable_income", period) + person( "ssi", period ) group_income = ( - where(joint, person.marital_unit.sum(recipient_income), recipient_income) + where( + joint, + _ssi_couple_total(person, period.this_year, recipient_income), + recipient_income, + ) + dependent ) need = ( @@ -60,7 +74,9 @@ def formula(person, period, parameters): # couple applies the couple level (integration.yaml Cases 11-12), # whatever the other spouse's category. certified = category == c.DEPENDENT_PERSON - certified = where(joint, person.marital_unit.any(certified), certified) + certified = where( + joint, certified | ia_ssa_spouse_value(person, period, certified), certified + ) dp = certified & (dependent < p.dependent_amount) & (group_income < need) # 6-B: the family-life home standard is "$1,156", and the maximum # supplement is "$162 ($1,156 - $994 SSI standard)". SSA pays at most @@ -108,7 +124,7 @@ def formula(person, period, parameters): + 2 * blind * p.blind_amount ) recipient = (individual_rate > 0) & ~medical & person("ia_ssa_eligible", period) - count = person.marital_unit.sum(recipient) + count = _ssi_couple_total(person, period.this_year, recipient.astype(int)) payment = ssi_state_supplement( person, period, diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_has_spouse.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_has_spouse.py index a5b4b549cd7..2dcb0111977 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_has_spouse.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_has_spouse.py @@ -7,7 +7,7 @@ class ia_ssa_has_spouse(Variable): value_type = bool entity = Person - label = "Iowa SSA spouse in the marital unit" + label = "Iowa SSA spouse living with the person" definition_period = MONTH defined_for = StateCode.IA reference = ( @@ -17,10 +17,7 @@ class ia_ssa_has_spouse(Variable): def formula(person, period, parameters): # 6-B Resources p.12: "When the applicant is living with a spouse, - # count the spouse's resources." A two-person marital unit is a couple - # only when SSI's spouse test pairs its members, so the default unit of - # a situation without marital units does not pair a parent with an - # adult child the parent claims (ia_ssa.yaml Case 65). A default unit - # of three or more people budgets each member alone. - couple_unit = person.marital_unit.nb_persons() == 2 - return couple_unit & (_ssi_spouse_index(person, period) >= 0) + # count the spouse's resources." SSI's spouse lookup also identifies + # the actual couple in a default marital unit with unrelated adults, + # and excludes a parent and the adult child they claim as a dependent. + return _ssi_spouse_index(person, period.this_year) >= 0 diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_amount.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_amount.py index 0c695d18a33..03ccbb0dc88 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_amount.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_amount.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ia_ssa_ihhrc_amount(Variable): @@ -30,7 +33,7 @@ def formula(person, period, parameters): # A couple's approved costs share one participation; others are alone. total_cost = where( person("ia_ssa_has_spouse", period), - person.marital_unit.sum(covered_cost), + _ssi_couple_total(person, period.this_year, covered_cost), covered_cost, ) payable = max_(total_cost - person("ia_ssa_ihhrc_participation", period), 0) diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_participation.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_participation.py index 08130590781..f3b6cbaceed 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_participation.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_ihhrc_participation.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ia_ssa_ihhrc_participation(Variable): @@ -28,7 +31,11 @@ def formula(person, period, parameters): deductions = max_(person("ia_ssa_care_additional_deductions", period), 0) has_spouse = person("ia_ssa_has_spouse", period) maintenance = where(has_spouse, ssi.amount.couple, ssi.amount.individual) - deductions = where(has_spouse, person.marital_unit.sum(deductions), deductions) + deductions = where( + has_spouse, + _ssi_couple_total(person, period.this_year, deductions), + deductions, + ) # 441 IAC 177.2(1)"f"(3) disregards "The amount of the SSI standard for # a dependent plus any established unmet medical needs for each # dependent living in the home. Any income of the dependent shall be diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_couple_budget.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_couple_budget.py index 25173adcd27..f4b4539c380 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_couple_budget.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_couple_budget.py @@ -1,4 +1,11 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states._spouses import ( + state_couple_total, + state_is_married, +) +from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_spouses import ( + ia_ssa_spouse_value, +) class ia_ssa_rcf_couple_budget(Variable): @@ -23,8 +30,7 @@ def formula(person, period, parameters): # spouses separately beginning in the month of entry." 441 IAC 51.4(3) # makes "Partners in a marriage who both enter the same room of the # residential care facility in the same month" a couple for "the - # initial month"; spouses who both enter in the same month are taken - # to share a room (ia_ssa.yaml Cases 57, 59 and 60). A resident with an + # initial month"; sharing a room is an explicit input. A resident with an # ineligible spouse is budgeted alone (ia_ssa.yaml Case 73); SSI's # first-month deemed income from that spouse (6-B p.60) is not modeled. # NOTE: A spouse who enters after the other spouse's month of entry @@ -36,10 +42,38 @@ def formula(person, period, parameters): category = person("ia_ssa_category", period) rcf = category == category.possible_values.RESIDENTIAL_CARE entering = rcf & person("ia_ssa_care_initial_partial_month", period) - couple = person("ssi_couple_computation_applies", period) & person( - "ia_ssa_has_spouse", period + year = period.this_year + # The prior-SSI entry-month rule retains the declared marriage even + # when the spouses have moved to separate facilities. Ordinary Iowa + # spouse pooling still requires co-residence (ia_ssa_has_spouse). + couple = state_is_married(person, year) & ( + state_couple_total( + person, year, person("is_ssi_aged_blind_disabled", year).astype(int) + ) + == 2 ) + # Keep the existing SSI computation guard for spouses still living + # together. The state entry exception supplies only the spouse link + # federal SSI drops when the spouses occupy separate households. A + # rejected co-resident pair (such as a parent and adult child in the + # default marital unit) does not establish that the people live apart. + household = person.household.reference_entity.members_entity_id + apart = state_couple_total(person, year, household) != 2 * household + couple = couple & (apart | person("ssi_couple_computation_applies", period)) receives_ssi = (person("ssi", period) > 0) | person("receives_ssi", period) - couple_received_ssi = person.marital_unit.any(receives_ssi) - both_enter = person.marital_unit.sum(entering) == 2 - return entering & couple & (couple_received_ssi | both_enter) + # Separated spouses need an explicit pre-entry receipt fact. For + # co-resident legacy situations, current receipt still approximates + # pre-entry receipt; a newly awarded current-month payment cannot + # establish prior receipt for spouses now in separate households. + receives_ssi = person("ia_ssa_rcf_received_ssi_before_entry", period) | ( + person("ia_ssa_has_spouse", period) & receives_ssi + ) + couple_received_ssi = ( + state_couple_total(person, year, receives_ssi.astype(int)) > 0 + ) + both_enter = state_couple_total(person, year, entering.astype(int)) == 2 + room = person("ia_ssa_rcf_shares_room_with_spouse", period) + same_room = person("ia_ssa_has_spouse", period) & ( + room | ia_ssa_spouse_value(person, period, room) + ) + return entering & couple & (couple_received_ssi | (both_enter & same_room)) diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_received_ssi_before_entry.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_received_ssi_before_entry.py new file mode 100644 index 00000000000..8e5f15ce31a --- /dev/null +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_received_ssi_before_entry.py @@ -0,0 +1,12 @@ +from policyengine_us.model_api import * + + +class ia_ssa_rcf_received_ssi_before_entry(Variable): + value_type = bool + entity = Person + label = "Iowa SSA residential care resident received SSI before entry" + definition_period = MONTH + defined_for = StateCode.IA + default_value = False + documentation = "Receipt of SSI before the residential-care entry recorded in this month, rather than an individual SSI award after the spouses separate. Receipt by either spouse establishes the prior-SSI entry condition for the couple." + reference = "https://hhs.iowa.gov/media/3987/download?inline=#page=60" diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_shares_room_with_spouse.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_shares_room_with_spouse.py new file mode 100644 index 00000000000..5f1adf73fc5 --- /dev/null +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_rcf_shares_room_with_spouse.py @@ -0,0 +1,12 @@ +from policyengine_us.model_api import * + + +class ia_ssa_rcf_shares_room_with_spouse(Variable): + value_type = bool + entity = Person + label = "Iowa SSA residential care resident shares a room with the spouse" + definition_period = MONTH + defined_for = StateCode.IA + default_value = False + documentation = "Both spouses occupy the same room of the residential care facility. Reporting this fact for either co-resident spouse applies it to both. Without prior SSI receipt, the entry-month couple budget requires this fact as well as entry by both spouses in that month." + reference = "https://www.legis.iowa.gov/docs/iac/chapter/441.51.pdf#page=1" diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_countable_income.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_countable_income.py index 031a5593f34..7de641454fa 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_countable_income.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_countable_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_smme_spouse_income import ( ia_ssa_smme_spouse_income, ia_ssa_smme_spouse_is_sga_applicant, @@ -94,11 +97,13 @@ def formula(person, period, parameters): # combined income, not SSI's deeming from the applicant (Cases 98 and # 102). applicant = deemed & person("ia_ssa_smme_coverage_eligible", period) - combined = person.marital_unit.sum(where(applicant, income, 0)) + combined = _ssi_couple_total( + person, period.this_year, where(applicant, income, 0) + ) income = where( ia_ssa_smme_spouse_is_sga_applicant(person, period), combined, income ) # NOTE: For an eligible couple, ssi_countable_income takes off only # half of a student spouse's earned income exclusion (issue 10456). joint = person("ssi_couple_computation_applies", period) - return where(joint, person.marital_unit.sum(income), income) + return where(joint, _ssi_couple_total(person, period.this_year, income), income) diff --git a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_spousal_deeming_applies.py b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_spousal_deeming_applies.py index b9c434b3e4a..000a75ae777 100644 --- a/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_spousal_deeming_applies.py +++ b/policyengine_us/variables/gov/states/ia/hhs/ssa/ia_ssa_smme_spousal_deeming_applies.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_spouses import ( + ia_ssa_spouse_value, +) from policyengine_us.variables.gov.states.ia.hhs.ssa._ia_ssa_smme_spouse_income import ( ia_ssa_smme_spouse_income, ia_ssa_smme_spouse_is_not_ineligible, @@ -53,9 +56,8 @@ def formula(person, period, parameters): # the same household" with the spouse. A spouse in a residential care # facility after the month of entry, whom ia_ssa_care_countable_income # budgets alone, is still treated as a household member here. - apart = person.marital_unit.any( - person("ssi_lives_in_medical_treatment_facility", period) - ) + medical = person("ssi_lives_in_medical_treatment_facility", period) + apart = medical | ia_ssa_spouse_value(person, period, medical) # 8-E p.12: "An ineligible spouse is a spouse who is not eligible for # SSI or non-MAGI-related Medicaid, or State Supplementary Assistance." # Those groups are for aged, blind or disabled people (8-F PDF pages From 9446b01a2a3941d0bf34dbf35546d40ec1b0d3e0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 01:19:01 -0400 Subject: [PATCH 13/25] Generate separated-spouse supplement budgets and clarify the eligible-spouse label --- .../test_ssi_spousal_deeming_invariants.py | 175 ++++++++++++++++-- .../status/is_ssi_eligible_spouse.py | 6 +- 2 files changed, 165 insertions(+), 16 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py index b2e92f8f932..b7f9dbe6941 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -25,11 +25,13 @@ Each comparison uses two independent three-person simulations sharing the same read-only system as the grids. - Declared spouses who live in separate households in Colorado, Missouri, - Oregon, South Dakota and Virginia, comparing their supplements before and + Oregon, South Dakota, Virginia and Iowa, comparing their supplements before and after an unrelated adult joins either household. The marriage, separate tax/family/SPM budgets, and all benefit inputs stay the same. Six explicit examples and two generated examples use two small independent simulations - apiece per state, sharing the same read-only system. + apiece per state, sharing the same read-only system. Generated examples + enter the states' care budgets; independent budget checks also detect a + lost spouse when both simulations make the same error. YAML covers worked cases. Python is needed to check the for-all properties over every ordered edge of the grids and to compare with an oracle that does @@ -339,6 +341,7 @@ def test_role_grid_couples_match_the_same_couple_living_alone(role_grid): "FL": ("fl_oss",), "GA": ("ga_ssp",), "HI": ("hi_oss",), + "IA": ("ia_ssa",), "ID": ("id_aabd",), "IL": ("il_aabd",), "IN": ("in_ssp",), @@ -413,6 +416,7 @@ def test_state_supplement_inventory_matches_program_registry(): "DE": {"de_ssp_living_arrangement": "CERTIFIED_RESIDENTIAL_CARE_HOME"}, "FL": {"fl_oss_program_track": "REDESIGN", "fl_oss_community_care_type": "ALF"}, "HI": {"hi_oss_care_facility_type": "COMMUNITY_CARE"}, + "IA": {"ia_ssa_category": "FAMILY_LIFE_HOME"}, "ID": {"id_aabd_living_arrangement": "ROOM_AND_BOARD"}, "IN": { "ssi_lives_in_medical_treatment_facility": True, @@ -473,9 +477,30 @@ def test_state_supplement_inventory_matches_program_registry(): SUPPLEMENT_CASES = [(state, YEAR, "standard") for state in SUPPLEMENT_PROGRAMS] + [ ("CA", YEAR, "ssi_ssp"), ("CO", YEAR, "and_cs"), + ("IA", YEAR, "ia_dependent"), ("WA", 2022, "disabled"), ] SUPPLEMENT_SCENARIO_INPUTS = { + "ia_rcf": { + "ia_ssa_category": "RESIDENTIAL_CARE", + "ia_ssa_care_initial_partial_month": True, + "ia_ssa_rcf_received_ssi_before_entry": True, + }, + "ia_in_home": { + "ia_ssa_category": "IN_HOME_CARE", + "ia_ssa_ihhrc_monthly_cost": 400, + }, + "ia_smme": { + "ia_ssa_category": "SMME", + "ia_ssa_smme_coverage_eligible": True, + # Exercise the SMME payment rather than only its zero-income denial. + "pension_income": (26_400, 26_400, 36_000), + "ssi_countable_resources": (1_000, 1_000, 0), + }, + "ia_dependent": { + "ia_ssa_category": "DEPENDENT_PERSON", + "ia_ssa_dependent_countable_income": 0, + }, "ssi_ssp": { "immigration_status_str": "CITIZEN", "ssi_qualifying_quarters_earnings": 40, @@ -544,7 +569,14 @@ def set_inputs(inputs, members): set_inputs( SUPPLEMENT_CARE_INPUTS.get(state, {}), names if third_care else names[:2] ) - set_inputs(SUPPLEMENT_SCENARIO_INPUTS.get(scenario, {}), names) + if state == "IA": + if care: + set_inputs( + SUPPLEMENT_SCENARIO_INPUTS.get(scenario, {}), + names if third_care else names[:2], + ) + else: + set_inputs(SUPPLEMENT_SCENARIO_INPUTS.get(scenario, {}), names) if explicit: situation["marital_units"] = { "couple": {"members": list(names[:2])}, @@ -607,6 +639,7 @@ def _supplement_amounts( resources=(0, 0, 0), care=True, third_care=True, + iowa_scenario="standard", ) @example( pensions=(0, 497, 994), @@ -614,6 +647,7 @@ def _supplement_amounts( resources=(0, 0, 2_001), care=True, third_care=False, + iowa_scenario="ia_rcf", ) @example( pensions=(20, 65, 1_491), @@ -621,6 +655,7 @@ def _supplement_amounts( resources=(2_000, 0, 3_001), care=True, third_care=True, + iowa_scenario="ia_in_home", ) @example( pensions=(994, 1_491, 20), @@ -628,6 +663,7 @@ def _supplement_amounts( resources=(3_000, 0, 0), care=False, third_care=False, + iowa_scenario="standard", ) @example( pensions=(0, 0, 0), @@ -635,6 +671,7 @@ def _supplement_amounts( resources=(7_000, 1_000, 5_000), care=True, third_care=True, + iowa_scenario="ia_smme", ) @given( pensions=st.tuples(*(st.integers(0, 1_600) for _ in range(3))), @@ -642,9 +679,10 @@ def _supplement_amounts( resources=st.tuples(*(st.integers(0, 8_000) for _ in range(3))), care=st.booleans(), third_care=st.booleans(), + iowa_scenario=st.sampled_from(("standard", "ia_rcf", "ia_in_home", "ia_smme")), ) def test_state_supplements_do_not_depend_on_omitted_marital_units( - state, year, scenario, pensions, ages, resources, care, third_care + state, year, scenario, pensions, ages, resources, care, third_care, iowa_scenario ): """A third adult never becomes a spouse through the default group. @@ -653,6 +691,8 @@ def test_state_supplements_do_not_depend_on_omitted_marital_units( ineligible spouses, a third adult in a different care category, income exclusions/deeming edges, and the individual/couple resource limits. """ + if state == "IA" and scenario == "standard": + scenario = iowa_scenario default = _supplement_amounts( state, pensions, ages, resources, care, third_care, False, year, scenario ) @@ -675,7 +715,8 @@ def test_state_supplements_do_not_depend_on_omitted_marital_units( # us-or:ssp-community-care-spouses-separate-facilities. # SD: ARSD 67:46:04:10(6), 67:46:05:30 (joint care-entry month). # VA: AG Manual Chapter B section 6 (first moment of the initial month). -SEPARATED_SUPPLEMENT_STATES = ("CO", "MO", "OR", "SD", "VA") +# IA: 6-B Spouses Living Separately p.60 (prior-SSI RCF entry month). +SEPARATED_SUPPLEMENT_STATES = ("CO", "MO", "OR", "SD", "VA", "IA") SEPARATED_PERIOD = "2026-07" @@ -692,7 +733,15 @@ def _separated_supplement_situation( add_third, ): situation = _supplement_situation( - system, state, pensions, ages, resources, care, third_care, True + system, + state, + pensions, + ages, + resources, + care, + third_care, + True, + scenario="ia_rcf" if state == "IA" else "standard", ) names = ("claimant", "spouse", "third_adult") if not add_third: @@ -750,9 +799,77 @@ def _separated_supplement_situation( person["or_ssp_inaccessible_income"] = { SEPARATED_PERIOD: (194, 94, 0)[index] } + elif state == "IA": + person["ia_ssa_care_initial_partial_month"] = { + SEPARATED_PERIOD: joint_entry + } return situation +def _assert_separated_care_budget(simulation, state, pensions, amounts): + """An independent spouse-budget check in the existing simulations. + + Removing the spouse in both representations would satisfy the unrelated + adult comparison. These generated ranges make the legally shared budget + observable: low resources, eligible ages and unequal pensions; Oregon + instead keeps modeled SSI positive for both community-care residents. + No extra simulation or parameter-tree construction is needed. + """ + if state == "MO": + # RSMo 208.010.1 retains the couple resource test during medical + # separation. The claimant is above the individual limit, while the + # combined resources remain below the couple limit. + assert np.all( + simulation.calculate("mo_ssp_resource_eligible", SEPARATED_PERIOD)[:2] + ) + elif state == "OR": + # OAR 461-155-0630(3): two SSI community-care spouses qualify even in + # separate facilities. Their own income after the documented + # inaccessible amounts is below the individual standard, so neither + # spouse has an excess to subtract from the other's payment. + own = np.asarray(pensions[:2]) + federal = np.maximum(INDIVIDUAL_FBR - np.maximum(own - GENERAL_EXCLUSION, 0), 0) + assert np.all(federal > 0) + expected = INDIVIDUAL_FBR - (own + federal - np.array([194, 94])) + np.testing.assert_allclose(amounts["or_ssp"], expected, atol=0.01) + elif state == "SD": + # ARSD 67:46:04:10(6), 67:46:06:03: both initial-month residents + # contribute half their combined income. These pensions eliminate + # SSI and stay below the entered $2,000 approved foster-care cost. + expected = 2_000 - sum(pensions[:2]) / 2 + np.testing.assert_allclose(amounts["sd_ssp"], expected, atol=0.01) + elif state == "VA": + # AG Manual Chapter B section 6: initial-month spouses share the + # combined income after one $20 exclusion. Neither receives SSI in + # these ranges, so the AG's own countable-income budget applies. + expected = (sum(pensions[:2]) - GENERAL_EXCLUSION) / 2 + np.testing.assert_allclose( + simulation.calculate("va_ag_countable_income", SEPARATED_PERIOD)[:2], + expected, + atol=0.01, + ) + elif state == "IA": + # 6-B p.60 retains the prior-SSI couple's RCF entry-month budget. + # Care does not apply the general exclusion; with current SSI zero, + # each resident counts half the combined pension income. + assert np.all( + simulation.calculate("ia_ssa_rcf_couple_budget", SEPARATED_PERIOD)[:2] + ) + np.testing.assert_allclose( + simulation.calculate("ia_ssa_care_countable_income", SEPARATED_PERIOD)[:2], + sum(pensions[:2]) / 2, + atol=0.01, + ) + else: + # 9 CCR 2503-5 3.533(A)(2)(c): Colorado divides a married client's + # $20 unearned-income disregard even when the spouses live apart. + np.testing.assert_allclose( + simulation.calculate("co_oap_own_countable_income", SEPARATED_PERIOD)[:2], + np.asarray(pensions[:2]) - GENERAL_EXCLUSION / 2, + atol=0.01, + ) + + @pytest.mark.parametrize("state", SEPARATED_SUPPLEMENT_STATES) @settings( max_examples=2, @@ -768,6 +885,7 @@ def _separated_supplement_situation( joint_entry=True, third_care=False, third_household=0, + check_care_budget=False, ) @example( pensions=(20, 65, 0), @@ -777,6 +895,7 @@ def _separated_supplement_situation( joint_entry=False, third_care=True, third_household=1, + check_care_budget=False, ) @example( pensions=(1_200, 1_200, 3_000), @@ -786,6 +905,7 @@ def _separated_supplement_situation( joint_entry=True, third_care=False, third_household=0, + check_care_budget=False, ) @example( pensions=(1_800, 1_200, 20), @@ -795,6 +915,7 @@ def _separated_supplement_situation( joint_entry=True, third_care=True, third_household=1, + check_care_budget=False, ) @example( pensions=(0, 0, 994), @@ -804,6 +925,7 @@ def _separated_supplement_situation( joint_entry=True, third_care=True, third_household=0, + check_care_budget=False, ) @example( pensions=(994, 1_491, 20), @@ -813,26 +935,51 @@ def _separated_supplement_situation( joint_entry=False, third_care=False, third_household=1, + check_care_budget=False, ) @given( - pensions=st.tuples(*(st.integers(0, 3_000) for _ in range(3))), - ages=st.tuples(*(st.sampled_from((64, 65, 90)) for _ in range(3))), - resources=st.tuples(*(st.integers(0, 16_000) for _ in range(3))), - care=st.booleans(), - joint_entry=st.booleans(), + pensions=st.tuples( + st.integers(1_100, 1_400), st.integers(1_600, 1_800), st.integers(0, 3_000) + ), + ages=st.tuples( + st.sampled_from((65, 90)), + st.sampled_from((65, 90)), + st.sampled_from((64, 65, 90)), + ), + resources=st.tuples( + st.integers(0, 1_000), st.integers(0, 1_000), st.integers(0, 16_000) + ), + care=st.just(True), + joint_entry=st.just(True), third_care=st.booleans(), third_household=st.integers(0, 1), + check_care_budget=st.just(True), ) def test_separated_spouses_supplements_ignore_unrelated_household_adult( - state, pensions, ages, resources, care, joint_entry, third_care, third_household + state, + pensions, + ages, + resources, + care, + joint_entry, + third_care, + third_household, + check_care_budget, ): """A third adult cannot change either separately housed spouse's grant. - YAML checks the states' independent legal computations. This comparison + YAML checks worked legal computations. The generated cases always enter + a care or joint-entry budget and check it independently, so losing the + spouse identically in both simulations is also detected. This comparison needs the simulation API to add a person while preserving the explicit marriage across two households and the spouses' independent other units. Both simulations share only the read-only system; caches stay separate. """ + if check_care_budget: + if state == "OR": + pensions = (pensions[0] - 1_000, pensions[1] - 1_000, pensions[2]) + elif state == "MO": + resources = (resources[0] + 6_500, resources[1], resources[2]) system = Simulation.default_tax_benefit_system_instance amounts = [] for add_third in (False, True): @@ -861,6 +1008,8 @@ def test_separated_spouses_supplements_ignore_unrelated_household_adult( for name in SUPPLEMENT_PROGRAMS[state] } ) + if check_care_budget: + _assert_separated_care_budget(simulation, state, pensions, amounts[-1]) for name in SUPPLEMENT_PROGRAMS[state]: np.testing.assert_allclose( amounts[0][name], amounts[1][name], atol=0.01, err_msg=f"{state}: {name}" diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py index 18109a9fa9c..f60bef2b143 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_eligible_spouse.py @@ -10,8 +10,8 @@ class is_ssi_eligible_spouse(Variable): reference = "https://www.law.cornell.edu/uscode/text/42/1382c#b" def formula(person, period, parameters): - # Both members of a joint claim are eligible spouses. The member in - # the tax-unit spouse role carries this label and the other remains - # the eligible individual, so each couple keeps one of each. + # This label follows the tax-unit spouse role within a joint claim. + # A married couple with no tax-unit spouse role has no member + # carrying the eligible-spouse label. spouse = person("is_tax_unit_spouse", period) return spouse & person("ssi_claim_is_joint", period) From 0a79258e530d23074050d091cf8d351b473d8916 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 23:02:19 -0400 Subject: [PATCH 14/25] Exclude known nonspouses from retained state budgets and pin deeming edges --- .../ssi-couples-and-deeming-cap.fixed.md | 1 + .../test_ssi_spousal_deeming_invariants.py | 194 +++++++++++++++++- .../gov/states/ia/hhs/ssa/ia_ssa.yaml | 99 +++++++++ .../variables/gov/states/_spouses.py | 33 ++- 4 files changed, 312 insertions(+), 15 deletions(-) diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index cff2de31cd5..ac3dfef9fae 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1 +1,2 @@ SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains declared spouses living apart; Missouri retains their combined resources for medical separation, Virginia and South Dakota retain joint care-entry budgets, and Oregon retains the community-care spouse payment across facilities. Iowa pools only the identified spouses across its state supplement paths, retains its prior-SSI residential-care entry budget across facilities, and requires an explicit shared-room input for the same-month entry rule without prior SSI. +Retained state spouse budgets now reject explicit parent links and tax-dependent pairs across households. Non-MAGI Medicaid income, premium, resource and spenddown budgets and California CAPI use only the identified spouses when marital units are omitted. Regressions pin claimant-wage deeming continuity and the 2026 wage/self-employment cases. diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py index b7f9dbe6941..a20de886966 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -32,6 +32,9 @@ apiece per state, sharing the same read-only system. Generated examples enter the states' care budgets; independent budget checks also detect a lost spouse when both simulations make the same error. +- Separated parent/child and dependent pairs in the retained-marriage state + programs, comparing the default marital unit with explicit singletons. + Generated incomes and resources exercise both relationship exclusions. YAML covers worked cases. Python is needed to check the for-all properties over every ordered edge of the grids and to compare with an oracle that does @@ -39,6 +42,7 @@ constructs a model per household. This file routes to the Rest/core group. """ +from copy import deepcopy from itertools import product from pathlib import Path @@ -47,8 +51,13 @@ import yaml from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period as make_period from policyengine_us import Simulation +from policyengine_us.variables.gov.states._spouses import ( + state_couple_total, + state_is_married, +) YEAR = 2026 PERIOD = "2026-01" @@ -70,7 +79,7 @@ def _countable(unearned, earned): return unearned - general + earned_after -def _oracle_ssi(claimant_unearned, spouse_unearned, spouse_earned): +def _oracle_ssi(claimant_unearned, spouse_unearned, spouse_earned, claimant_earned=0): """Monthly SSI for an aged claimant with an ineligible spouse. 416.1163(d)(1): no deeming when the spouse's income is not more than the @@ -80,19 +89,21 @@ def _oracle_ssi(claimant_unearned, spouse_unearned, spouse_earned): own countable income. """ alone = np.maximum( - INDIVIDUAL_FBR - - _countable(claimant_unearned, np.zeros_like(claimant_unearned)), + INDIVIDUAL_FBR - _countable(claimant_unearned, claimant_earned), 0, ) couple = np.maximum( - COUPLE_FBR - _countable(claimant_unearned + spouse_unearned, spouse_earned), + COUPLE_FBR + - _countable( + claimant_unearned + spouse_unearned, claimant_earned + spouse_earned + ), 0, ) deems = spouse_unearned + spouse_earned > COUPLE_FBR - INDIVIDUAL_FBR return np.where(deems, np.minimum(couple, alone), alone) -def _income_situation(rows): +def _income_situation(rows, claimant_earnings=None): situation = { plural: {} for plural in ( @@ -109,6 +120,9 @@ def _income_situation(rows): situation["people"][claimant] = { "age": {YEAR: 70}, "pension_income": {YEAR: 12 * claimant_unearned}, + "employment_income": { + YEAR: 12 * claimant_earnings[index] if claimant_earnings else 0 + }, } situation["people"][spouse] = { "age": {YEAR: 60}, @@ -128,8 +142,8 @@ def _income_situation(rows): return situation -def _income_ssi(rows): - simulation = Simulation(situation=_income_situation(rows)) +def _income_ssi(rows, claimant_earnings=None): + simulation = Simulation(situation=_income_situation(rows, claimant_earnings)) ssi = simulation.calculate("ssi", PERIOD) without_deeming = simulation.calculate( "uncapped_ssi_without_spousal_deeming", PERIOD @@ -168,6 +182,31 @@ def test_ssi_never_rises_with_income(income_grid, axis): assert np.all(np.diff(income_grid["ssi"], axis=axis) <= 0.01) +def test_claimant_wages_preserve_the_cap_with_and_without_deeming(): + """The two #10138 cases share the same individual countable-income cap.""" + spouse_earnings = (6_000, 5_900) + situation = _income_situation( + [(0, 0, income / 12) for income in spouse_earnings], + claimant_earnings=(1_000, 1_000), + ) + for index, income in enumerate(spouse_earnings): + claimant = situation["people"][f"c{index}"] + spouse = situation["people"][f"s{index}"] + claimant["age"] = {YEAR: 67} + spouse["age"] = {YEAR: 45} + spouse.pop("employment_income") + spouse["self_employment_income"] = {YEAR: income} + simulation = Simulation(situation=situation) + np.testing.assert_array_equal( + simulation.calculate("is_ssi_spousal_deeming_applies", YEAR)[0::2], + [True, False], + ) + # $994 - ($1,000 - $20 - $65) / 2 = $536.50 monthly, $6,438 annually. + np.testing.assert_allclose( + simulation.calculate("ssi", YEAR)[0::2], [6_438, 6_438], atol=0.01 + ) + + @settings( max_examples=3, deadline=None, @@ -180,15 +219,32 @@ def test_ssi_never_rises_with_income(income_grid, axis): st.integers(0, 1_300), st.integers(0, 900), st.integers(0, 3_500), + st.integers(0, 2_500), ), min_size=40, max_size=40, ) ) def test_random_incomes_match_independent_oracle(rows): - claimant_ssi, _, _ = _income_ssi(rows) - expected = _oracle_ssi(*np.array(rows, dtype=float).T) + # Add the three sides of the $497 threshold to the same simulation, so + # generated claimant wages exercise 416.1163(e)(2) without more setup. + income_rows = [row[:3] for row in rows] + claimant_earnings = [row[3] for row in rows] + for claimant_unearned, _, _, claimant_earned in rows: + for spouse_unearned in (496, 497, 498): + income_rows.append((claimant_unearned, spouse_unearned, 0)) + claimant_earnings.append(claimant_earned) + claimant_ssi, _, _ = _income_ssi(income_rows, claimant_earnings) + expected = _oracle_ssi( + *np.array(income_rows, dtype=float).T, np.asarray(claimant_earnings) + ) np.testing.assert_allclose(claimant_ssi, expected, atol=0.01) + threshold = claimant_ssi[len(rows) :].reshape(len(rows), 3) + np.testing.assert_allclose(threshold[:, 0], threshold[:, 1], atol=0.01) + # One extra dollar of spouse income may reduce SSI by at most a dollar; + # starting deeming cannot cause a jump when the claimant earns wages. + assert np.all(np.diff(threshold, axis=1) <= 0.01) + assert np.all(np.diff(threshold, axis=1) >= -1.01) # Role grid: adults "a" and "b" are married; "c" lives with them in its own @@ -720,6 +776,51 @@ def test_state_supplements_do_not_depend_on_omitted_marital_units( SEPARATED_PERIOD = "2026-07" +def _retained_state_pair_situation(): + situation = _income_situation([(0, 0, 0)]) + names = ("c0", "s0") + for name in names: + situation["people"][name].update( + { + "is_tax_unit_head": {YEAR: True}, + "is_tax_unit_spouse": {YEAR: False}, + "is_tax_unit_dependent": {YEAR: False}, + } + ) + for plural in ("tax_units", "families", "spm_units", "households"): + situation[plural] = {name: {"members": [name]} for name in names} + for household in situation["households"].values(): + household["state_code"] = {YEAR: "IA"} + return situation + + +def test_retained_state_spouse_helpers_keep_separated_marriage(): + """Exercise the helpers without calculating a state's whole grant chain.""" + simulation = Simulation(situation=_retained_state_pair_situation()) + person = simulation.get_population("people") + year = make_period(YEAR) + np.testing.assert_array_equal(state_is_married(person, year), [True, True]) + np.testing.assert_array_equal( + state_couple_total(person, year, np.array([175, 100])), [275, 275] + ) + + +def test_retained_state_spouse_helpers_exclude_cross_household_parent(): + """A parent link alone excludes a pair across households and tax units.""" + situation = _retained_state_pair_situation() + del situation["marital_units"] + situation["people"]["c0"]["person_id"] = {YEAR: 1} + situation["people"]["s0"]["person_id"] = {YEAR: 2} + situation["people"]["s0"]["parent_1_id"] = {YEAR: 1} + simulation = Simulation(situation=situation) + person = simulation.get_population("people") + year = make_period(YEAR) + np.testing.assert_array_equal(state_is_married(person, year), [False, False]) + np.testing.assert_array_equal( + state_couple_total(person, year, np.array([175, 100])), [175, 100] + ) + + def _separated_supplement_situation( system, state, @@ -1014,3 +1115,78 @@ def test_separated_spouses_supplements_ignore_unrelated_household_adult( np.testing.assert_allclose( amounts[0][name], amounts[1][name], atol=0.01, err_msg=f"{state}: {name}" ) + + +@pytest.mark.parametrize("state", ("MO", "OR", "SD", "VA", "IA")) +@pytest.mark.parametrize("relationship", ("parent", "dependent")) +@settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + pensions=st.tuples(st.integers(0, 300), st.integers(400, 600)), + resources=st.tuples(st.integers(1_501, 2_000), st.integers(1_501, 2_000)), + child_age=st.integers(65, 70), + parent_first=st.booleans(), +) +def test_separated_known_nonspouses_match_explicit_singletons( + state, relationship, pensions, resources, child_age, parent_first +): + """A default two-person unit cannot marry known nonspouses living apart. + + Parent links and dependency are tested separately, so either exclusion + being dropped is observable. Both representations have the same entry + and care facts; only their marital-unit representation differs. + """ + system = Simulation.default_tax_benefit_system_instance + default = _separated_supplement_situation( + system, + state, + (*pensions, 0), + (child_age + 30, child_age, 65), + (*resources, 0), + care=True, + joint_entry=True, + third_care=False, + third_household=0, + add_third=False, + ) + del default["marital_units"] + names = ("claimant", "spouse") + parent, child = names if parent_first else names[::-1] + default["people"][parent]["age"] = {YEAR: child_age + 30} + default["people"][child]["age"] = {YEAR: child_age} + if relationship == "parent": + # Keep independent tax units: only the cross-household parent link + # proves this pair cannot be spouses. + default["people"][parent]["person_id"] = {YEAR: 1} + default["people"][child]["person_id"] = {YEAR: 2} + default["people"][child]["parent_1_id"] = {YEAR: 1} + else: + # A dependent and the person claiming them cannot be spouses, even + # without a supplied parent link and after moving to separate homes. + default["tax_units"] = {"family": {"members": list(names)}} + default["people"][parent]["is_tax_unit_head"] = {YEAR: True} + default["people"][child]["is_tax_unit_head"] = {YEAR: False} + default["people"][child]["is_tax_unit_dependent"] = {YEAR: True} + explicit = deepcopy(default) + explicit["marital_units"] = {name: {"members": [name]} for name in names} + amounts = [] + for situation in (default, explicit): + simulation = Simulation(situation=situation) + amounts.append( + { + name: simulation.calculate(name, SEPARATED_PERIOD) + for name in SUPPLEMENT_PROGRAMS[state] + } + ) + if state == "IA": + assert not np.any( + simulation.calculate("ia_ssa_rcf_couple_budget", SEPARATED_PERIOD) + ) + for name in SUPPLEMENT_PROGRAMS[state]: + np.testing.assert_allclose( + amounts[0][name], amounts[1][name], atol=0.01, err_msg=f"{state}: {name}" + ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml b/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml index b4c7bcd3f6e..a550e32a47d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ia/hhs/ssa/ia_ssa.yaml @@ -4115,3 +4115,102 @@ ia_ssa_rcf_couple_budget: [false, false] ia_ssa_care_countable_income: [994, 994] ia_ssa: [1_192.57, 1_192.57] + +- name: Case 120, separated parent and dependent adult child retain individual RCF budgets with omitted marital units. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: + 2026: 96 + person_id: + 2026: 1 + ssi_countable_resources: + 2026: 1_800 + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + ia_ssa_rcf_received_ssi_before_entry: true + child: + age: + 2026: 66 + person_id: + 2026: 2 + parent_1_id: + 2026: 1 + is_tax_unit_dependent: + 2026: true + ssi_countable_resources: + 2026: 1_800 + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + tax_units: + family: + members: [parent, child] + households: + first_facility: + members: [parent] + state_code: IA + second_facility: + members: [child] + state_code: IA + output: + # A named parent remains a parent across households. Neither person's + # resources enter the other's individual $2,000 resource test. + ia_ssa_has_spouse: [false, false] + ssi_claim_is_joint: [false, false] + ia_ssa_rcf_couple_budget: [false, false] + ia_ssa_eligible: [true, true] + ia_ssa_care_countable_income: [994, 994] + ia_ssa: [1_192.57, 1_192.57] + +- name: Case 121, explicit singleton marital units give the same separated parent and child RCF payments. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: + 2026: 96 + person_id: + 2026: 1 + ssi_countable_resources: + 2026: 1_800 + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + ia_ssa_rcf_received_ssi_before_entry: true + child: + age: + 2026: 66 + person_id: + 2026: 2 + parent_1_id: + 2026: 1 + is_tax_unit_dependent: + 2026: true + ssi_countable_resources: + 2026: 1_800 + ia_ssa_category: RESIDENTIAL_CARE + ia_ssa_care_initial_partial_month: true + marital_units: + parent: + members: [parent] + child: + members: [child] + tax_units: + family: + members: [parent, child] + households: + first_facility: + members: [parent] + state_code: IA + second_facility: + members: [child] + state_code: IA + output: + ia_ssa_has_spouse: [false, false] + ssi_claim_is_joint: [false, false] + ia_ssa_rcf_couple_budget: [false, false] + ia_ssa_eligible: [true, true] + ia_ssa_care_countable_income: [994, 994] + ia_ssa: [1_192.57, 1_192.57] diff --git a/policyengine_us/variables/gov/states/_spouses.py b/policyengine_us/variables/gov/states/_spouses.py index 8a47977186d..e16c38860ba 100644 --- a/policyengine_us/variables/gov/states/_spouses.py +++ b/policyengine_us/variables/gov/states/_spouses.py @@ -1,21 +1,44 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + _parent_indices, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( _ssi_couple_total, _ssi_spouse_index, ) +def _state_retained_pair(person, year): + """A two-person marriage, excluding known parent/dependent pairs. + + Resolve parent links within the marital unit, including across households + and tax units. The existing parent resolver keeps its ambiguous-id fallback. + As in SSI's spouse test, a possible default unit cannot make someone a + spouse of the person who claims them as a tax dependent. + """ + first, second = _parent_indices(person, year, person.marital_unit) + parent_link = person.marital_unit.any((first >= 0) | (second >= 0)) + tax_unit = person.tax_unit.reference_entity.members_entity_id + same_tax_unit = person.marital_unit.min(tax_unit) == person.marital_unit.max( + tax_unit + ) + dependent_count = person.marital_unit.sum(person("is_tax_unit_dependent", year)) + claims = (person.marital_unit.count == 1) & same_tax_unit & (dependent_count == 1) + return (person.marital_unit.nb_persons() == 2) & ~parent_link & ~claims + + def state_couple_total(person, year, values): """Spouse totals for state rules that retain a declared marriage apart. - A two-person marital unit declares the spouses even when they occupy - different households. Larger default groups instead use SSI's identified + A two-person marital unit declares the spouses unless parent/dependent + facts rule them out, even when they occupy different households. + Larger default groups instead use SSI's identified spouse pair, so an unrelated adult cannot enter the spouse's budget. Callers apply their own medical-separation, entry-month or care conditions; this helper does not change federal SSI's co-residence test. """ return where( - person.marital_unit.nb_persons() == 2, + _state_retained_pair(person, year), person.marital_unit.sum(values), _ssi_couple_total(person, year, values), ) @@ -23,6 +46,4 @@ def state_couple_total(person, year, values): def state_is_married(person, year): """Declared spouses, including apart, or the identified default-group pair.""" - return (person.marital_unit.nb_persons() == 2) | ( - _ssi_spouse_index(person, year) >= 0 - ) + return _state_retained_pair(person, year) | (_ssi_spouse_index(person, year) >= 0) From ecd9dcbe5cd00b4b8c334be0b4bdd8dd64903837 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 23:02:29 -0400 Subject: [PATCH 15/25] Limit non-MAGI Medicaid budgets to identified spouses --- .../medicaid_ssi_couple_three_adult.yaml | 280 ++++++++++++++++++ ..._recipient_income_eligible_for_medicaid.py | 7 +- ...ional_senior_or_disabled_asset_eligible.py | 8 +- ...onal_senior_or_disabled_income_eligible.py | 5 +- ...ptional_senior_or_disabled_income_limit.py | 5 +- .../is_medically_needy_for_medicaid.py | 15 +- ...ed_income_deemed_from_ineligible_spouse.py | 10 +- ...nal_senior_or_disabled_countable_income.py | 5 +- 8 files changed, 319 insertions(+), 16 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml new file mode 100644 index 00000000000..225ab9e193f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml @@ -0,0 +1,280 @@ +# Omitted marital units form one default group. Only the identified spouses +# may enter these non-MAGI Medicaid budgets; the third adult is unrelated. +- name: California excludes an unrelated adult's premiums from a couple budget. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 36_000 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 24_000 + medicare_enrolled: false + third: + age: 67 + pension_income: 12_000 + health_insurance_premiums: 12_000 + medicare_enrolled: false + ssi_federal_living_arrangement: + 2026-01: MEDICAL_TREATMENT_FACILITY + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: CA + output: + ssi_couple_computation_applies: + 2026-01: [true, true, false] + # (36,000 + 24,000 - 240) / 2; the third adult's 12,000 is not deducted. + medicaid_optional_senior_or_disabled_countable_income: [29_880, 29_880, 0] + +- name: Optional ABD income and assets exclude an unrelated adult and retain the couple limits. + period: 2026 + input: + people: + head: + age: 70 + is_tax_unit_head: true + medicaid_optional_senior_or_disabled_countable_income: 8_946 + ssi_countable_resources: 1_200 + spouse: + age: 68 + is_tax_unit_spouse: true + medicaid_optional_senior_or_disabled_countable_income: 8_946 + ssi_countable_resources: 1_200 + third: + age: 70 + medicaid_optional_senior_or_disabled_countable_income: 50_000 + ssi_countable_resources: 50_000 + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: WV + output: + # West Virginia's 2026 annual SSI rates: couple 17,892; individual 11,928. + medicaid_optional_senior_or_disabled_income_limit: [17_892, 17_892, 11_928] + is_optional_senior_or_disabled_income_eligible: [true, true, false] + # Couple assets 2,400 are below 3,000; the third adult's 50,000 is excluded. + is_optional_senior_or_disabled_asset_eligible: [true, true, false] + +- name: Section 209b spenddown excludes an unrelated adult's medical expenses. + period: 2026 + input: + people: + head: + age: 70 + is_tax_unit_head: true + is_optional_senior_or_disabled_income_eligible: false + medicaid_optional_senior_or_disabled_countable_income: 20_000 + medicaid_medically_needy_medical_expenses: 5_000 + medicaid_optional_senior_or_disabled_income_limit: 25_000 + spouse: + age: 68 + is_tax_unit_spouse: true + is_optional_senior_or_disabled_income_eligible: false + medicaid_optional_senior_or_disabled_countable_income: 20_000 + medicaid_medically_needy_medical_expenses: 5_000 + medicaid_optional_senior_or_disabled_income_limit: 25_000 + third: + age: 70 + is_optional_senior_or_disabled_income_eligible: false + medicaid_optional_senior_or_disabled_countable_income: 0 + medicaid_medically_needy_medical_expenses: 12_000 + medicaid_optional_senior_or_disabled_income_limit: 25_000 + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: IL + output: + # 2 x (20,000 - 5,000) = 30,000, above 25,000; subtracting the third + # adult's expenses would incorrectly bring the couple down to 18,000. + is_209b_ssi_recipient_income_eligible_for_medicaid: [false, false, true] + +- name: Medically needy income and assets exclude an unrelated adult. + period: 2026 + input: + people: + head: + age: 70 + is_tax_unit_head: true + is_adult_for_medicaid: false + is_optional_senior_or_disabled_for_medicaid: false + is_ssi_recipient_for_medicaid: false + ssi_countable_income: 10_000 + medicaid_medically_needy_medical_expenses: 6_500 + ssi_countable_resources: 1_000 + spouse: + age: 68 + is_tax_unit_spouse: true + is_adult_for_medicaid: false + is_optional_senior_or_disabled_for_medicaid: false + is_ssi_recipient_for_medicaid: false + ssi_countable_income: 10_000 + medicaid_medically_needy_medical_expenses: 6_500 + ssi_countable_resources: 1_000 + third: + age: 70 + is_adult_for_medicaid: false + is_optional_senior_or_disabled_for_medicaid: false + is_ssi_recipient_for_medicaid: false + ssi_countable_income: 50_000 + ssi_countable_resources: 50_000 + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: MA + output: + # 7,000 income <= 7,800 couple limit; 2,000 assets <= 3,000. The + # unrelated adult's income and assets do not enter either comparison. + is_medically_needy_for_medicaid: [true, true, false] + +- name: Missouri spouse deeming excludes an unrelated third adult's income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_tax_unit_head: true + ssi_unearned_income: 17_400 + spouse: + age: 60 + is_tax_unit_spouse: true + ssi_earned_income: 4_800 + third: + age: 60 + ssi_earned_income: 120_000 + ssi_unearned_income: 120_000 + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: MO + output: + # The SSI spouse-status mask already excludes an unrelated singleton. + is_ssi_ineligible_spouse: [false, true, false] + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [2_004, 0, 0] + +- name: Missouri spouse deeming excludes another couple's ineligible spouse. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_tax_unit_head: true + ssi_unearned_income: 17_400 + spouse: + age: 60 + is_tax_unit_spouse: true + ssi_earned_income: 4_800 + other_head: + age: 70 + is_tax_unit_head: true + other_spouse: + age: 60 + is_tax_unit_spouse: true + ssi_earned_income: 12_000 + ssi_unearned_income: 12_000 + tax_units: + couple: + members: [head, spouse] + other_couple: + members: [other_head, other_spouse] + families: + couple: + members: [head, spouse] + other_couple: + members: [other_head, other_spouse] + spm_units: + couple: + members: [head, spouse] + other_couple: + members: [other_head, other_spouse] + households: + household: + members: [head, spouse, other_head, other_spouse] + state_code: MO + output: + is_ssi_ineligible_spouse: [false, true, false, true] + # First couple: floor((400 - 65) / 2 + 1,450 - 20) - 1,430 = 167 + # monthly. Second: floor((1,000 - 65) / 2 + 1,000 - 20) = 1,447. + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [2_004, 0, 17_364, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py index da396f055d4..fecbb5a1ce1 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class is_209b_ssi_recipient_income_eligible_for_medicaid(Variable): @@ -28,8 +31,8 @@ def formula(person, period, parameters): medical_expenses = person("medicaid_medically_needy_medical_expenses", period) # SSI financial responsibility rules (42 CFR 435.602): the unit is # the individual or the married couple, not the tax filing unit. - income_after_spenddown = person.marital_unit.sum( - personal_income - medical_expenses + income_after_spenddown = _ssi_couple_total( + person, period, personal_income - medical_expenses ) # The limit is per person (Missouri's blind standard can differ diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py index b4745bda5ad..e3023a1fd25 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class is_optional_senior_or_disabled_asset_eligible(Variable): @@ -19,8 +23,8 @@ def formula(person, period, parameters): # the individual or the married couple, not the tax filing unit. personal_assets = person("ssi_countable_resources", period) # $ # Inputs are stored as float32, so compare whole cents with the limit. - assets = np.round(person.marital_unit.sum(personal_assets), 2) - is_couple = person.marital_unit.nb_persons() == 2 + assets = np.round(_ssi_couple_total(person, period, personal_assets), 2) + is_couple = _ssi_spouse_index(person, period) >= 0 state = person.household("state_code_str", period) # Parameters diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py index 859510b9a7b..b9167e50ffb 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class is_optional_senior_or_disabled_income_eligible(Variable): @@ -29,7 +32,7 @@ def formula(person, period, parameters): ) # SSI financial responsibility rules (42 CFR 435.602): the unit is # the individual or the married couple, not the tax filing unit. - income = person.marital_unit.sum(personal_income) + income = _ssi_couple_total(person, period, personal_income) income_limit = person( "medicaid_optional_senior_or_disabled_income_limit", period ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py index 6e676553384..3f2a4b3a3a9 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py @@ -1,5 +1,8 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.hhs.tax_unit_fpg import fpg +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class medicaid_optional_senior_or_disabled_income_limit(Variable): @@ -25,7 +28,7 @@ def formula(person, period, parameters): # Non-MAGI Medicaid follows the SSI financial responsibility rules # (42 CFR 435.602): the unit is the individual, or the married # couple living together, whatever their filing status. - is_couple = person.marital_unit.nb_persons() == 2 + is_couple = _ssi_spouse_index(person, period) >= 0 state = person.household("state_code_str", period) p = parameters( period diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py index 01e6b76825e..b9b3dfc3b98 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class is_medically_needy_for_medicaid(Variable): @@ -39,7 +43,6 @@ def formula(person, period, parameters): # SSI financial responsibility rules (42 CFR 435.602): the unit is # the individual or the married couple, plus parents for a child, # not the tax filing unit. - marital_unit = person.marital_unit # 42 CFR 435.831(d) deducts expenses of financially responsible # relatives. For a child these are the ineligible parents whose # income SSI deems (20 CFR 416.1165); split their expenses among the @@ -54,13 +57,15 @@ def formula(person, period, parameters): parental_expense_share = where( eligible_child, parental_expenses / max_(eligible_children, 1), 0 ) - income = marital_unit.sum( - personal_income - medical_expenses - parental_expense_share + income = _ssi_couple_total( + person, + period, + personal_income - medical_expenses - parental_expense_share, ) - assets = marital_unit.sum(personal_assets) + assets = _ssi_couple_total(person, period, personal_assets) ma = parameters(period).gov.hhs.medicaid mn = ma.eligibility.categories.medically_needy - is_joint = marital_unit.nb_persons() == 2 + is_joint = _ssi_spouse_index(person, period) >= 0 state = person.household("state_code_str", period) income_limit = MONTHS_IN_YEAR * where( is_joint, diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py index a692202dac3..d5958cdec9f 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py @@ -2,6 +2,9 @@ from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( _apply_medicaid_optional_senior_or_disabled_exclusions, ) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse( @@ -39,19 +42,18 @@ def formula(person, period, parameters): # deeming methodology: the spouse's income counts without the # FBR-differential threshold or ineligible-child allocations. is_mo = state == "MO" - marital_unit = person.marital_unit ineligible_spouse = person("is_ssi_ineligible_spouse", period) spouse_gross_earned = ( - marital_unit.sum(ineligible_spouse * individual_earned) + _ssi_couple_total(person, period, ineligible_spouse * individual_earned) - ineligible_spouse * individual_earned ) spouse_gross_unearned = ( - marital_unit.sum(ineligible_spouse * individual_unearned) + _ssi_couple_total(person, period, ineligible_spouse * individual_unearned) - ineligible_spouse * individual_unearned ) spouse_earned = where(is_mo, spouse_gross_earned, spouse_earned) spouse_unearned = where(is_mo, spouse_gross_unearned, spouse_unearned) - has_ineligible_spouse = marital_unit.sum(ineligible_spouse) > 0 + has_ineligible_spouse = _ssi_couple_total(person, period, ineligible_spouse) > 0 deeming_applies = deeming_applies | (is_mo & has_ineligible_spouse) alone_countable = _apply_medicaid_optional_senior_or_disabled_exclusions( diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py index 3bf485dfcba..4c537a0d75e 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py @@ -2,6 +2,9 @@ from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( _apply_medicaid_optional_senior_or_disabled_exclusions, ) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class medicaid_optional_senior_or_disabled_countable_income(Variable): @@ -93,7 +96,7 @@ def formula(person, period, parameters): ) premiums = where( couple_computation, - person.marital_unit.sum(own_premiums), + _ssi_couple_total(person, period, own_premiums), own_premiums, ) deducts_premiums = p.deducts_health_insurance_premiums[state].astype(bool) From 9ee38b7660678831dc4068876c6c74478a9bac63 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 23:02:47 -0400 Subject: [PATCH 16/25] Align merged California CAPI readers with SSI spouse pairs --- .../ca/cdss/capi/ca_capi_three_adult.yaml | 257 ++++++++++++++++++ .../ca/cdss/capi/_ca_capi_ssi_ssp_standard.py | 34 ++- .../ca/cdss/capi/ca_capi_payment_standard.py | 9 +- .../gov/states/ca/cdss/capi/ca_capi_person.py | 9 +- 4 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml new file mode 100644 index 00000000000..eda138751f5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml @@ -0,0 +1,257 @@ +# Marital units are deliberately omitted. Tax roles identify the actual +# spouses, and the unrelated adult has separate tax and SPM units. +# MPP 49-050.12/.13 and 49-055.32/.4 apply only the actual couple's standard, +# income and SSI/SSP payments. MPP 49-050.26 separates a facility spouse. +# https://www.cdss.ca.gov/Portals/9/CAPI/CAPI_Regulations-Accessible.pdf + +- name: CAPI ignores an unrelated adult's facility residence and payment-standard share. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_tax_unit_head: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + spouse: + age: 68 + is_tax_unit_spouse: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + third: + age: 40 + ca_in_medical_care_facility: true + tax_units: + couple: + members: [head, spouse] + other: + members: [third] + spm_units: + couple: + members: [head, spouse] + other: + members: [third] + households: + home: + members: [head, spouse, third] + state_code: CA + output: + # The unrelated facility input does not separate these spouses. Only + # their two shares of the aged couple standard count: 2,098.83 / 2. + ssi_couple_computation_applies: [true, true, false] + ca_capi_payment_standard: [1_049.415, 1_049.415, 0] + ca_capi_person: [1_049.415, 1_049.415, 0] + +- name: CAPI excludes an unrelated blind recipient from the couple's category count. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_tax_unit_head: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + spouse: + age: 60 + is_blind: true + is_tax_unit_spouse: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + third: + age: 70 + is_blind: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + tax_units: + couple: + members: [head, spouse] + other: + members: [third] + spm_units: + couple: + members: [head, spouse] + other: + members: [third] + households: + home: + members: [head, spouse, third] + state_code: CA + living_arrangements_allow_for_food_preparation: false + output: + # One blind spouse: 2,238.44 / 2 = 1,119.22. Only one actual spouse is + # aged or disabled, so the couple food allowance does not apply. The + # unrelated blind, aged recipient takes the higher individual aged + # no-cooking standard: 1,233.94 + 128.87 = 1,362.81. + ca_capi_payment_standard: [1_119.22, 1_119.22, 1_362.81] + ca_capi_person: [1_119.22, 1_119.22, 1_362.81] + +- name: CAPI excludes an unrelated CAPI recipient from a mixed couple's membership count. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_tax_unit_head: true + ssi: 994 + ssi_countable_income: 0 + spouse: + age: 68 + is_tax_unit_spouse: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + pension_income: 5_040 + ssi_countable_income: 4_800 + third: + age: 70 + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + tax_units: + couple: + members: [head, spouse] + other: + members: [third] + spm_units: + couple: + members: [head, spouse] + ca_state_supplement: 239.94 + other: + members: [third] + households: + home: + members: [head, spouse, third] + state_code: CA + output: + # The CAPI spouse gets the whole mixed-couple standard, then subtracts + # the pair's income and citizen spouse's SSI/SSP: + # 2,098.83 - 400 - 994 - 239.94 = 464.89. + ca_capi_payment_standard: [0, 2_098.83, 1_233.94] + ca_capi_person: [0, 464.89, 1_233.94] + +- name: CAPI mixed-couple income excludes an unrelated adult's SSI payment. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_tax_unit_head: true + ssi: 994 + ssi_countable_income: 0 + spouse: + age: 68 + is_tax_unit_spouse: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + pension_income: 5_040 + ssi_countable_income: 4_800 + ca_capi_payment_standard: 2_098.83 + third: + age: 70 + ssi: 500 + ssi_countable_income: 0 + ca_state_supplement_eligible_person: false + tax_units: + couple: + members: [head, spouse] + other: + members: [third] + spm_units: + couple: + members: [head, spouse] + ca_state_supplement: 239.94 + other: + members: [third] + households: + home: + members: [head, spouse, third] + state_code: CA + output: + # Hold the standard and other income fixed to isolate the SSI reader. + ca_capi_person: [0, 464.89, 0] + +- name: CAPI mixed-couple income excludes an unrelated adult's state-supplement eligibility. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_tax_unit_head: true + ssi: 994 + ssi_countable_income: 0 + spouse: + age: 68 + is_tax_unit_spouse: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + pension_income: 5_040 + ssi_countable_income: 4_800 + ca_capi_payment_standard: 2_098.83 + third: + age: 70 + ssi: 0 + ssi_countable_income: 0 + ca_state_supplement_eligible_person: true + tax_units: + couple: + members: [head, spouse] + other: + members: [third] + spm_units: + couple: + members: [head, spouse] + ca_state_supplement: 239.94 + other: + members: [third] + ca_state_supplement: 200 + households: + home: + members: [head, spouse, third] + state_code: CA + output: + # Only the actual spouse's eligible share of the pair's SSP counts. + ca_capi_person: [0, 464.89, 0] + +- name: CAPI mixed-couple income excludes an unrelated adult's countable income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + head: + age: 72 + is_tax_unit_head: true + ssi: 994 + ssi_countable_income: 0 + spouse: + age: 68 + is_tax_unit_spouse: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + pension_income: 5_040 + ssi_countable_income: 4_800 + ca_capi_payment_standard: 2_098.83 + third: + age: 70 + ssi: 0 + ssi_countable_income: 6_000 + ca_state_supplement_eligible_person: false + tax_units: + couple: + members: [head, spouse] + other: + members: [third] + spm_units: + couple: + members: [head, spouse] + ca_state_supplement: 239.94 + other: + members: [third] + households: + home: + members: [head, spouse, third] + state_code: CA + output: + ca_capi_person: [0, 464.89, 0] diff --git a/policyengine_us/variables/gov/states/ca/cdss/capi/_ca_capi_ssi_ssp_standard.py b/policyengine_us/variables/gov/states/ca/cdss/capi/_ca_capi_ssi_ssp_standard.py index 41408ff9b62..d49ce296040 100644 --- a/policyengine_us/variables/gov/states/ca/cdss/capi/_ca_capi_ssi_ssp_standard.py +++ b/policyengine_us/variables/gov/states/ca/cdss/capi/_ca_capi_ssi_ssp_standard.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.status.ssi_federal_living_arrangement import ( SSIFederalLivingArrangement, ) @@ -23,7 +27,9 @@ def ca_capi_couple_computation(person, period): only the California facility input marks it. """ in_facility = ca_capi_in_title_xix_facility(person, period) - one_in_facility = person.marital_unit.sum(in_facility) == 1 + one_in_facility = ( + _ssi_couple_total(person, period.this_year, in_facility.astype(int)) == 1 + ) return person("ssi_couple_computation_applies", period) & ~one_in_facility @@ -35,11 +41,15 @@ def ca_capi_spouse_ssi_ssp(person, period): among the unit's members eligible for it; a citizen disabled child's supplement in the unit is then attributed to the spouse. """ - spouse = ~person("ca_capi_eligible_person", period.this_year) - spouse_ssi = person.marital_unit.sum(person("ssi", period) * spouse) + spouse_index = _ssi_spouse_index(person, period.this_year) + safe_spouse = np.maximum(spouse_index, 0) + spouse = (spouse_index >= 0) & ~person("ca_capi_eligible_person", period.this_year)[ + safe_spouse + ] + spouse_ssi = person("ssi", period)[safe_spouse] * spouse ssp_eligible = person("ca_state_supplement_eligible_person", period) - spouse_ssp_share = person.marital_unit.sum(ssp_eligible * spouse) / max_( - person.spm_unit.sum(ssp_eligible), 1 + spouse_ssp_share = ( + ssp_eligible[safe_spouse] * spouse / max_(person.spm_unit.sum(ssp_eligible), 1) ) spouse_ssp = spouse_ssp_share * person.spm_unit("ca_state_supplement", period) return spouse_ssi + spouse_ssp @@ -95,9 +105,13 @@ def ca_capi_ssi_ssp_standard(person, period, parameters): ) joint = ca_capi_couple_computation(person, period) - blind_count = where(joint, person.marital_unit.sum(blind), 2 * blind) + blind_count = where( + joint, _ssi_couple_total(person, period.this_year, blind.astype(int)), 2 * blind + ) aged_or_disabled_count = where( - joint, person.marital_unit.sum(aged_or_disabled), 2 * aged_or_disabled + joint, + _ssi_couple_total(person, period.this_year, aged_or_disabled.astype(int)), + 2 * aged_or_disabled, ) two_blind = (blind_count > 1) * p.blind.married.two_blind one_blind = (blind_count == 1) * p.blind.married.one_blind @@ -135,5 +149,9 @@ def ca_capi_ssi_ssp_standard(person, period, parameters): ], default=max_(community_couple / 2, fbr.couple / 2), ) - couple = where(joint, person.marital_unit.sum(couple_share), 2 * couple_share) + couple = where( + joint, + _ssi_couple_total(person, period.this_year, couple_share), + 2 * couple_share, + ) return individual, couple diff --git a/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_payment_standard.py b/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_payment_standard.py index a7a86fb37f2..1c4a1fd3871 100644 --- a/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_payment_standard.py +++ b/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_payment_standard.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.ca.cdss.capi._ca_capi_ssi_ssp_standard import ( ca_capi_couple_computation, ca_capi_spouse_ssi_ssp, @@ -26,8 +29,10 @@ def formula(person, period, parameters): individual, couple = ca_capi_ssi_ssp_standard(person, period, parameters) p = parameters(period).gov.states.ca.cdss.capi.payment_standard_offset joint = ca_capi_couple_computation(person, period) - capi_members = person.marital_unit.sum( - person("ca_capi_eligible_person", period.this_year) + capi_members = _ssi_couple_total( + person, + period.this_year, + person("ca_capi_eligible_person", period.this_year).astype(int), ) # Each member of a CAPI couple takes one-half (MPP 49-050.12, 49-055.4). both_capi = joint & (capi_members > 1) diff --git a/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_person.py b/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_person.py index 8c21e8748a4..3c26fea316d 100644 --- a/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_person.py +++ b/policyengine_us/variables/gov/states/ca/cdss/capi/ca_capi_person.py @@ -2,6 +2,9 @@ from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) from policyengine_us.variables.gov.states.ca.cdss.capi._ca_capi_ssi_ssp_standard import ( ca_capi_couple_computation, ca_capi_in_title_xix_facility, @@ -67,9 +70,11 @@ def formula(person, period, parameters): # can still pay such a spouse SSI that SSA would not (issue 10481). capi_eligible = person("ca_capi_eligible_person", period.this_year) joint = ca_capi_couple_computation(person, period) - couple_with_spouse = joint & ~(person.marital_unit.sum(capi_eligible) > 1) + couple_with_spouse = joint & ~( + _ssi_couple_total(person, period.this_year, capi_eligible.astype(int)) > 1 + ) spouse_ssi_ssp = ca_capi_spouse_ssi_ssp(person, period) - couple_income = person.marital_unit.sum(countable_income) + couple_income = _ssi_couple_total(person, period.this_year, countable_income) # A spouse counts as an eligible spouse only while receiving SSI/SSP # (MPP 49-005(e)(3), 49-050.13). A spouse paid neither is an # ineligible spouse (MPP 49-005(i)(2)), whose income is deemed. From 16ec6c52d98872bfc3b20f64a1344605c610d907 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 23:52:48 -0400 Subject: [PATCH 17/25] Make spouse supplement eligibility explicit in CAPI fixtures --- .../baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml index eda138751f5..38e5b56a930 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/capi/ca_capi_three_adult.yaml @@ -139,6 +139,7 @@ age: 72 is_tax_unit_head: true ssi: 994 + ca_state_supplement_eligible_person: true ssi_countable_income: 0 spouse: age: 68 @@ -181,6 +182,7 @@ age: 72 is_tax_unit_head: true ssi: 994 + ca_state_supplement_eligible_person: true ssi_countable_income: 0 spouse: age: 68 @@ -224,6 +226,7 @@ age: 72 is_tax_unit_head: true ssi: 994 + ca_state_supplement_eligible_person: true ssi_countable_income: 0 spouse: age: 68 From 4e4136e6ea66deab720b03677ac2e34c6895476a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 02:33:13 -0400 Subject: [PATCH 18/25] Preserve co-resident parent identity in retained spouse checks --- .../test_ssi_spousal_deeming_invariants.py | 29 +++++++++++++++++++ .../variables/gov/states/_spouses.py | 14 ++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py index a20de886966..a7b7ea58073 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_invariants.py @@ -821,6 +821,35 @@ def test_retained_state_spouse_helpers_exclude_cross_household_parent(): ) +def test_retained_state_spouse_helpers_prefer_co_resident_parent_identity(): + """A co-resident parent's id cannot identify the remote spouse instead. + + Ids may repeat across households when every tax unit stays within its + household and no claiming tax unit is set. The declared marriage spans + households, but the claimant's parent link names the co-resident parent. + """ + situation = _retained_state_pair_situation() + situation["people"]["c0"].update(person_id={YEAR: 1}, parent_1_id={YEAR: 2}) + situation["people"]["s0"]["person_id"] = {YEAR: 2} + situation["people"]["parent"] = { + "age": {YEAR: 95}, + "person_id": {YEAR: 2}, + "is_tax_unit_head": {YEAR: True}, + "is_tax_unit_spouse": {YEAR: False}, + "is_tax_unit_dependent": {YEAR: False}, + } + for plural in ("tax_units", "families", "spm_units", "marital_units"): + situation[plural]["parent"] = {"members": ["parent"]} + situation["households"]["c0"]["members"].append("parent") + simulation = Simulation(situation=situation) + person = simulation.get_population("people") + year = make_period(YEAR) + np.testing.assert_array_equal(state_is_married(person, year), [True, True, False]) + np.testing.assert_array_equal( + state_couple_total(person, year, np.array([175, 100, 500])), [275, 275, 500] + ) + + def _separated_supplement_situation( system, state, diff --git a/policyengine_us/variables/gov/states/_spouses.py b/policyengine_us/variables/gov/states/_spouses.py index e16c38860ba..902b2be46c2 100644 --- a/policyengine_us/variables/gov/states/_spouses.py +++ b/policyengine_us/variables/gov/states/_spouses.py @@ -1,6 +1,7 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( _parent_indices, + co_resident_parent_indices, ) from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( _ssi_couple_total, @@ -12,11 +13,22 @@ def _state_retained_pair(person, year): """A two-person marriage, excluding known parent/dependent pairs. Resolve parent links within the marital unit, including across households - and tax units. The existing parent resolver keeps its ambiguous-id fallback. + and tax units. A link naming a co-resident keeps that identity when the + same id is reused by a spouse in another household. The existing parent + resolver keeps its ambiguous-id fallback. As in SSI's spouse test, a possible default unit cannot make someone a spouse of the person who claims them as a tax dependent. """ first, second = _parent_indices(person, year, person.marital_unit) + home_first, home_second = co_resident_parent_indices(person, year) + marital_unit = person.marital_unit.reference_entity.members_entity_id + + def prefer_co_resident(slot, home): + in_unit = np.where(marital_unit[np.maximum(home, 0)] == marital_unit, home, -1) + return np.where(home >= 0, in_unit, slot) + + first = prefer_co_resident(first, home_first) + second = prefer_co_resident(second, home_second) parent_link = person.marital_unit.any((first >= 0) | (second >= 0)) tax_unit = person.tax_unit.reference_entity.members_entity_id same_tax_unit = person.marital_unit.min(tax_unit) == person.marital_unit.max( From 1649fc41eca4349f122617d8ab353f5fab28047f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:37:49 +0000 Subject: [PATCH 19/25] Return non-MAGI Medicaid readers to main's marital-unit rules One SSI spouse test cannot stand in for each state's non-MAGI family, resource and needs-standard rules, so six readers go back to their merge-base content; a state-by-state design follows in a separate PR. Missouri's deemed-income reader keeps the identified spouse, and its two three-adult cases move into that variable's YAML file. Co-Authored-By: Claude Opus 5.5 --- ..._income_deemed_from_ineligible_spouse.yaml | 89 ++++++ .../medicaid_ssi_couple_three_adult.yaml | 280 ------------------ ..._recipient_income_eligible_for_medicaid.py | 7 +- ...ional_senior_or_disabled_asset_eligible.py | 8 +- ...onal_senior_or_disabled_income_eligible.py | 5 +- ...ptional_senior_or_disabled_income_limit.py | 5 +- .../is_medically_needy_for_medicaid.py | 15 +- ...nal_senior_or_disabled_countable_income.py | 5 +- 8 files changed, 101 insertions(+), 313 deletions(-) delete mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml index 1724495a64d..d6e7f128f88 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml @@ -120,3 +120,92 @@ # (17,004 - 20 * 12 - 65 * 12) / 2 = 7,992 deemed and countable. medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [7_992, 0, 0] medicaid_optional_senior_or_disabled_countable_income: [7_992, 0, 0] + +# Omitted marital units form one default group. Only the identified spouse's +# income enters Missouri's gross-income deeming; other adults do not. +- name: Missouri spouse deeming excludes an unrelated third adult's income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_tax_unit_head: true + ssi_unearned_income: 17_400 + spouse: + age: 60 + is_tax_unit_spouse: true + ssi_earned_income: 4_800 + third: + age: 60 + ssi_earned_income: 120_000 + ssi_unearned_income: 120_000 + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: MO + output: + # The SSI spouse-status mask already excludes an unrelated singleton. + is_ssi_ineligible_spouse: [false, true, false] + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [2_004, 0, 0] + +- name: Missouri spouse deeming excludes another couple's ineligible spouse. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_tax_unit_head: true + ssi_unearned_income: 17_400 + spouse: + age: 60 + is_tax_unit_spouse: true + ssi_earned_income: 4_800 + other_head: + age: 70 + is_tax_unit_head: true + other_spouse: + age: 60 + is_tax_unit_spouse: true + ssi_earned_income: 12_000 + ssi_unearned_income: 12_000 + tax_units: + couple: + members: [head, spouse] + other_couple: + members: [other_head, other_spouse] + families: + couple: + members: [head, spouse] + other_couple: + members: [other_head, other_spouse] + spm_units: + couple: + members: [head, spouse] + other_couple: + members: [other_head, other_spouse] + households: + household: + members: [head, spouse, other_head, other_spouse] + state_code: MO + output: + is_ssi_ineligible_spouse: [false, true, false, true] + # First couple: floor((400 - 65) / 2 + 1,450 - 20) - 1,430 = 167 + # monthly. Second: floor((1,000 - 65) / 2 + 1,000 - 20) = 1,447. + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [2_004, 0, 17_364, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml deleted file mode 100644 index 225ab9e193f..00000000000 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_ssi_couple_three_adult.yaml +++ /dev/null @@ -1,280 +0,0 @@ -# Omitted marital units form one default group. Only the identified spouses -# may enter these non-MAGI Medicaid budgets; the third adult is unrelated. -- name: California excludes an unrelated adult's premiums from a couple budget. - period: 2026 - absolute_error_margin: 0.01 - input: - people: - head: - age: 67 - is_tax_unit_head: true - pension_income: 36_000 - medicare_enrolled: false - spouse: - age: 67 - is_tax_unit_spouse: true - pension_income: 24_000 - medicare_enrolled: false - third: - age: 67 - pension_income: 12_000 - health_insurance_premiums: 12_000 - medicare_enrolled: false - ssi_federal_living_arrangement: - 2026-01: MEDICAL_TREATMENT_FACILITY - tax_units: - couple: - members: [head, spouse] - third: - members: [third] - families: - couple: - members: [head, spouse] - third: - members: [third] - spm_units: - couple: - members: [head, spouse] - third: - members: [third] - households: - household: - members: [head, spouse, third] - state_code: CA - output: - ssi_couple_computation_applies: - 2026-01: [true, true, false] - # (36,000 + 24,000 - 240) / 2; the third adult's 12,000 is not deducted. - medicaid_optional_senior_or_disabled_countable_income: [29_880, 29_880, 0] - -- name: Optional ABD income and assets exclude an unrelated adult and retain the couple limits. - period: 2026 - input: - people: - head: - age: 70 - is_tax_unit_head: true - medicaid_optional_senior_or_disabled_countable_income: 8_946 - ssi_countable_resources: 1_200 - spouse: - age: 68 - is_tax_unit_spouse: true - medicaid_optional_senior_or_disabled_countable_income: 8_946 - ssi_countable_resources: 1_200 - third: - age: 70 - medicaid_optional_senior_or_disabled_countable_income: 50_000 - ssi_countable_resources: 50_000 - tax_units: - couple: - members: [head, spouse] - third: - members: [third] - families: - couple: - members: [head, spouse] - third: - members: [third] - spm_units: - couple: - members: [head, spouse] - third: - members: [third] - households: - household: - members: [head, spouse, third] - state_code: WV - output: - # West Virginia's 2026 annual SSI rates: couple 17,892; individual 11,928. - medicaid_optional_senior_or_disabled_income_limit: [17_892, 17_892, 11_928] - is_optional_senior_or_disabled_income_eligible: [true, true, false] - # Couple assets 2,400 are below 3,000; the third adult's 50,000 is excluded. - is_optional_senior_or_disabled_asset_eligible: [true, true, false] - -- name: Section 209b spenddown excludes an unrelated adult's medical expenses. - period: 2026 - input: - people: - head: - age: 70 - is_tax_unit_head: true - is_optional_senior_or_disabled_income_eligible: false - medicaid_optional_senior_or_disabled_countable_income: 20_000 - medicaid_medically_needy_medical_expenses: 5_000 - medicaid_optional_senior_or_disabled_income_limit: 25_000 - spouse: - age: 68 - is_tax_unit_spouse: true - is_optional_senior_or_disabled_income_eligible: false - medicaid_optional_senior_or_disabled_countable_income: 20_000 - medicaid_medically_needy_medical_expenses: 5_000 - medicaid_optional_senior_or_disabled_income_limit: 25_000 - third: - age: 70 - is_optional_senior_or_disabled_income_eligible: false - medicaid_optional_senior_or_disabled_countable_income: 0 - medicaid_medically_needy_medical_expenses: 12_000 - medicaid_optional_senior_or_disabled_income_limit: 25_000 - tax_units: - couple: - members: [head, spouse] - third: - members: [third] - families: - couple: - members: [head, spouse] - third: - members: [third] - spm_units: - couple: - members: [head, spouse] - third: - members: [third] - households: - household: - members: [head, spouse, third] - state_code: IL - output: - # 2 x (20,000 - 5,000) = 30,000, above 25,000; subtracting the third - # adult's expenses would incorrectly bring the couple down to 18,000. - is_209b_ssi_recipient_income_eligible_for_medicaid: [false, false, true] - -- name: Medically needy income and assets exclude an unrelated adult. - period: 2026 - input: - people: - head: - age: 70 - is_tax_unit_head: true - is_adult_for_medicaid: false - is_optional_senior_or_disabled_for_medicaid: false - is_ssi_recipient_for_medicaid: false - ssi_countable_income: 10_000 - medicaid_medically_needy_medical_expenses: 6_500 - ssi_countable_resources: 1_000 - spouse: - age: 68 - is_tax_unit_spouse: true - is_adult_for_medicaid: false - is_optional_senior_or_disabled_for_medicaid: false - is_ssi_recipient_for_medicaid: false - ssi_countable_income: 10_000 - medicaid_medically_needy_medical_expenses: 6_500 - ssi_countable_resources: 1_000 - third: - age: 70 - is_adult_for_medicaid: false - is_optional_senior_or_disabled_for_medicaid: false - is_ssi_recipient_for_medicaid: false - ssi_countable_income: 50_000 - ssi_countable_resources: 50_000 - tax_units: - couple: - members: [head, spouse] - third: - members: [third] - families: - couple: - members: [head, spouse] - third: - members: [third] - spm_units: - couple: - members: [head, spouse] - third: - members: [third] - households: - household: - members: [head, spouse, third] - state_code: MA - output: - # 7,000 income <= 7,800 couple limit; 2,000 assets <= 3,000. The - # unrelated adult's income and assets do not enter either comparison. - is_medically_needy_for_medicaid: [true, true, false] - -- name: Missouri spouse deeming excludes an unrelated third adult's income. - period: 2026 - absolute_error_margin: 0.01 - input: - people: - head: - age: 70 - is_tax_unit_head: true - ssi_unearned_income: 17_400 - spouse: - age: 60 - is_tax_unit_spouse: true - ssi_earned_income: 4_800 - third: - age: 60 - ssi_earned_income: 120_000 - ssi_unearned_income: 120_000 - tax_units: - couple: - members: [head, spouse] - third: - members: [third] - families: - couple: - members: [head, spouse] - third: - members: [third] - spm_units: - couple: - members: [head, spouse] - third: - members: [third] - households: - household: - members: [head, spouse, third] - state_code: MO - output: - # The SSI spouse-status mask already excludes an unrelated singleton. - is_ssi_ineligible_spouse: [false, true, false] - medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [2_004, 0, 0] - -- name: Missouri spouse deeming excludes another couple's ineligible spouse. - period: 2026 - absolute_error_margin: 0.01 - input: - people: - head: - age: 70 - is_tax_unit_head: true - ssi_unearned_income: 17_400 - spouse: - age: 60 - is_tax_unit_spouse: true - ssi_earned_income: 4_800 - other_head: - age: 70 - is_tax_unit_head: true - other_spouse: - age: 60 - is_tax_unit_spouse: true - ssi_earned_income: 12_000 - ssi_unearned_income: 12_000 - tax_units: - couple: - members: [head, spouse] - other_couple: - members: [other_head, other_spouse] - families: - couple: - members: [head, spouse] - other_couple: - members: [other_head, other_spouse] - spm_units: - couple: - members: [head, spouse] - other_couple: - members: [other_head, other_spouse] - households: - household: - members: [head, spouse, other_head, other_spouse] - state_code: MO - output: - is_ssi_ineligible_spouse: [false, true, false, true] - # First couple: floor((400 - 65) / 2 + 1,450 - 20) - 1,430 = 167 - # monthly. Second: floor((1,000 - 65) / 2 + 1,000 - 20) = 1,447. - medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [2_004, 0, 17_364, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py index fecbb5a1ce1..da396f055d4 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py @@ -1,7 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, -) class is_209b_ssi_recipient_income_eligible_for_medicaid(Variable): @@ -31,8 +28,8 @@ def formula(person, period, parameters): medical_expenses = person("medicaid_medically_needy_medical_expenses", period) # SSI financial responsibility rules (42 CFR 435.602): the unit is # the individual or the married couple, not the tax filing unit. - income_after_spenddown = _ssi_couple_total( - person, period, personal_income - medical_expenses + income_after_spenddown = person.marital_unit.sum( + personal_income - medical_expenses ) # The limit is per person (Missouri's blind standard can differ diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py index e3023a1fd25..b4745bda5ad 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_asset_eligible.py @@ -1,8 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, - _ssi_spouse_index, -) class is_optional_senior_or_disabled_asset_eligible(Variable): @@ -23,8 +19,8 @@ def formula(person, period, parameters): # the individual or the married couple, not the tax filing unit. personal_assets = person("ssi_countable_resources", period) # $ # Inputs are stored as float32, so compare whole cents with the limit. - assets = np.round(_ssi_couple_total(person, period, personal_assets), 2) - is_couple = _ssi_spouse_index(person, period) >= 0 + assets = np.round(person.marital_unit.sum(personal_assets), 2) + is_couple = person.marital_unit.nb_persons() == 2 state = person.household("state_code_str", period) # Parameters diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py index b9167e50ffb..859510b9a7b 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_optional_senior_or_disabled_income_eligible.py @@ -1,7 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, -) class is_optional_senior_or_disabled_income_eligible(Variable): @@ -32,7 +29,7 @@ def formula(person, period, parameters): ) # SSI financial responsibility rules (42 CFR 435.602): the unit is # the individual or the married couple, not the tax filing unit. - income = _ssi_couple_total(person, period, personal_income) + income = person.marital_unit.sum(personal_income) income_limit = person( "medicaid_optional_senior_or_disabled_income_limit", period ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py index 3f2a4b3a3a9..6e676553384 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medicaid_optional_senior_or_disabled_income_limit.py @@ -1,8 +1,5 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.hhs.tax_unit_fpg import fpg -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_spouse_index, -) class medicaid_optional_senior_or_disabled_income_limit(Variable): @@ -28,7 +25,7 @@ def formula(person, period, parameters): # Non-MAGI Medicaid follows the SSI financial responsibility rules # (42 CFR 435.602): the unit is the individual, or the married # couple living together, whatever their filing status. - is_couple = _ssi_spouse_index(person, period) >= 0 + is_couple = person.marital_unit.nb_persons() == 2 state = person.household("state_code_str", period) p = parameters( period diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py index b9b3dfc3b98..01e6b76825e 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py @@ -1,8 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, - _ssi_spouse_index, -) class is_medically_needy_for_medicaid(Variable): @@ -43,6 +39,7 @@ def formula(person, period, parameters): # SSI financial responsibility rules (42 CFR 435.602): the unit is # the individual or the married couple, plus parents for a child, # not the tax filing unit. + marital_unit = person.marital_unit # 42 CFR 435.831(d) deducts expenses of financially responsible # relatives. For a child these are the ineligible parents whose # income SSI deems (20 CFR 416.1165); split their expenses among the @@ -57,15 +54,13 @@ def formula(person, period, parameters): parental_expense_share = where( eligible_child, parental_expenses / max_(eligible_children, 1), 0 ) - income = _ssi_couple_total( - person, - period, - personal_income - medical_expenses - parental_expense_share, + income = marital_unit.sum( + personal_income - medical_expenses - parental_expense_share ) - assets = _ssi_couple_total(person, period, personal_assets) + assets = marital_unit.sum(personal_assets) ma = parameters(period).gov.hhs.medicaid mn = ma.eligibility.categories.medically_needy - is_joint = _ssi_spouse_index(person, period) >= 0 + is_joint = marital_unit.nb_persons() == 2 state = person.household("state_code_str", period) income_limit = MONTHS_IN_YEAR * where( is_joint, diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py index 4c537a0d75e..3bf485dfcba 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py @@ -2,9 +2,6 @@ from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( _apply_medicaid_optional_senior_or_disabled_exclusions, ) -from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, -) class medicaid_optional_senior_or_disabled_countable_income(Variable): @@ -96,7 +93,7 @@ def formula(person, period, parameters): ) premiums = where( couple_computation, - _ssi_couple_total(person, period, own_premiums), + person.marital_unit.sum(own_premiums), own_premiums, ) deducts_premiums = p.deducts_health_insurance_premiums[state].astype(bool) From 580edb405cca78f83917528b699fe94550531f69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:37:49 +0000 Subject: [PATCH 20/25] Limit Montana LIHEAP SSI coverage and standards to identified spouses With SSI now paying only the actual spouses, a default marital unit let an unrelated adult share the couple's categorical coverage, so an aged couple living with an adult on a $60,000 pension got $641. Cover only the identified spouse of a recipient and count only the couple's recipients for the couple standard. Co-Authored-By: Claude Opus 5.5 --- .../mt_liheap_categorical_eligibility.yaml | 81 +++++++++++++++++++ .../gov/states/mt/dphhs/liheap/mt_liheap.yaml | 49 +++++++++++ .../payment/mt_liheap_benefit_income.yaml | 78 ++++++++++++++++++ .../mt_liheap_categorical_eligibility.py | 9 ++- .../payment/mt_liheap_benefit_income.py | 8 +- 5 files changed, 222 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.yaml index 4f31fb35e9d..d22f07509e0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.yaml @@ -359,3 +359,84 @@ state_code: MT output: mt_liheap_categorical_eligibility: NONE + +# ARM 37.70.401(23), numbered (24) from May 23, 2026: a spouse whose income was +# considered for an SSI grant is covered. With marital units omitted, the +# spouse without SSI of their own is still identified by tax roles and stays +# covered; the unrelated adult receives SSI in their own right. +- name: Case 12, a spouse without SSI stays covered when marital units are omitted. + period: 2026 + input: + people: + head: + age: 70 + immigration_status: CITIZEN + is_tax_unit_head: true + ssi: 6_000 + spouse: + age: 66 + immigration_status: CITIZEN + is_tax_unit_spouse: true + employment_income: 15_000 + ssi: 0 + outsider: + age: 72 + immigration_status: CITIZEN + is_tax_unit_head: true + ssi: 11_604 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + spm_units: + spm_unit: + members: [head, spouse, outsider] + snap: 0 + tanf: 0 + households: + household: + members: [head, spouse, outsider] + state_code: MT + output: + mt_liheap_categorical_eligibility: SSI_TANF + +# An unrelated adult whose income was not considered for anyone's SSI is not +# covered, even when omitted marital units put them in the SSI couple's +# default marital unit. +- name: Case 13, an unrelated adult without SSI in a default marital unit defeats SSI categorical eligibility. + period: 2026 + input: + people: + head: + age: 70 + immigration_status: CITIZEN + is_tax_unit_head: true + ssi: 8_700 + spouse: + age: 68 + immigration_status: CITIZEN + is_tax_unit_spouse: true + ssi: 8_700 + outsider: + age: 70 + immigration_status: CITIZEN + is_tax_unit_head: true + pension_income: 60_000 + ssi: 0 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + spm_units: + spm_unit: + members: [head, spouse, outsider] + snap: 0 + tanf: 0 + households: + household: + members: [head, spouse, outsider] + state_code: MT + output: + mt_liheap_categorical_eligibility: NONE diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/mt_liheap.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/mt_liheap.yaml index b0bf357614e..23c41278065 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/mt_liheap.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/mt_liheap.yaml @@ -531,3 +531,52 @@ output: mt_liheap_eligible: [true, true] mt_liheap: [0, 0] + +# An aged married couple with no income shares a heating household with an +# unrelated adult on a $60,000 pension; marital units are omitted. SSI pays +# only the spouses, and the unrelated adult's income was not considered for +# their SSI, so the household is not SSI categorical (ARM 37.70.401(23), +# manual section 3.11). Its countable income, which includes the couple's +# SSI and the pension, exceeds the $54,470 limit, so it receives nothing. +- name: Case 16, an unrelated adult in a default marital unit does not share an SSI couple's categorical eligibility. + period: 2026 + input: + people: + head: + age: 70 + is_tax_unit_head: true + spouse: + age: 70 + is_tax_unit_spouse: true + outsider: + age: 70 + is_tax_unit_head: true + pension_income: 60_000 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + families: + couple: + members: [head, spouse] + outsider: + members: [outsider] + spm_units: + heating_unit: + members: [head, spouse, outsider] + snap: 0 + tanf: 0 + heating_type: NATURAL_GAS + heating_expense: 1_800 + households: + home: + members: [head, spouse, outsider] + state_code: MT + county: YELLOWSTONE_COUNTY_MT + bedrooms: 2 + output: + mt_liheap_receives_ssi: [true, true, false] + mt_liheap_categorical_eligibility: NONE + mt_liheap_eligible: false + mt_liheap: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.yaml index 80cc3f58120..39231d162ab 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.yaml @@ -197,3 +197,81 @@ output: mt_liheap_categorical_eligibility: SNAP mt_liheap_benefit_income: 24_000 + +# Marital units omitted: everyone shares one default marital unit, and the +# tax roles identify the spouses. A married couple who both receive SSI share +# the FFY2026 couple standard: $1,450 x 12 = $17,400. +- name: Case 7, a married couple both on SSI with marital units omitted uses the couple standard. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + immigration_status: CITIZEN + is_tax_unit_head: true + ssi: 8_700 + spouse: + age: 68 + immigration_status: CITIZEN + is_tax_unit_spouse: true + ssi: 8_700 + tax_units: + couple: + members: [head, spouse] + spm_units: + spm_unit: + members: [head, spouse] + snap: 0 + tanf: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_liheap_categorical_eligibility: SSI_TANF + mt_liheap_benefit_income: 17_400 + +# Appendix A 9.8: an unmarried SSI recipient living with others takes the +# individual standard. With marital units omitted, the unrelated adult shares +# the couple's default marital unit but is not a spouse, so the couple keeps +# the couple standard and the outsider the individual one: +# ($1,450 + $967) x 12 = $29,004. Counting all three recipients in one unit +# would instead split the couple standard three ways: $17,400. +- name: Case 8, an unrelated SSI recipient in a default marital unit keeps the individual standard. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + immigration_status: CITIZEN + is_tax_unit_head: true + ssi: 8_700 + spouse: + age: 68 + immigration_status: CITIZEN + is_tax_unit_spouse: true + ssi: 8_700 + outsider: + age: 72 + immigration_status: CITIZEN + is_tax_unit_head: true + ssi: 11_604 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + spm_units: + spm_unit: + members: [head, spouse, outsider] + snap: 0 + tanf: 0 + households: + household: + members: [head, spouse, outsider] + state_code: MT + output: + mt_liheap_categorical_eligibility: SSI_TANF + mt_liheap_benefit_income: 29_004 diff --git a/policyengine_us/variables/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.py b/policyengine_us/variables/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.py index d8ed0849806..06ab4657014 100644 --- a/policyengine_us/variables/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.py +++ b/policyengine_us/variables/gov/states/mt/dphhs/liheap/eligibility/mt_liheap_categorical_eligibility.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class MTLIHEAPCategoricalEligibility(Enum): @@ -55,7 +58,11 @@ def formula(spm_unit, period, parameters): axis=0, ) tanf_member = spm_unit.project(receives_tanf) & in_tanf_unit - spouse_of_ssi_recipient = person.marital_unit.any(receives_ssi) + # SSA considers only the co-resident spouse's income, so an unrelated + # adult left in a default marital unit is not covered. + spouse_of_ssi_recipient = ( + _ssi_couple_total(person, period, receives_ssi.astype(int)) - receives_ssi + ) > 0 ssi_recipient_child = receives_ssi & (person("age", period) < p.minor_child_age) # SSA considers a parent's income for a child's SSI. The head or spouse # of the SSI child's own tax unit stands in for the parent; an adult diff --git a/policyengine_us/variables/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.py b/policyengine_us/variables/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.py index 8e22fcdfc75..ff438be58f7 100644 --- a/policyengine_us/variables/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.py +++ b/policyengine_us/variables/gov/states/mt/dphhs/liheap/payment/mt_liheap_benefit_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class mt_liheap_benefit_income(Variable): @@ -30,8 +33,9 @@ def formula(spm_unit, period, parameters): p_ssi = parameters(ssi_date).gov.ssa.ssi.amount person = spm_unit.members receives_ssi = person("mt_liheap_receives_ssi", period) - # A married couple who both receive SSI share the couple standard. - marital_recipients = person.marital_unit.sum(receives_ssi) + # A married couple who both receive SSI share the couple standard; + # an unrelated recipient in the same default marital unit does not. + marital_recipients = _ssi_couple_total(person, period, receives_ssi.astype(int)) couple = receives_ssi & (marital_recipients > 1) monthly_ssi_standard = where( couple, From 5ed98e3a7ce7fe4025c47d263c3308b2e64d6f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:37:49 +0000 Subject: [PATCH 21/25] Keep unrelated adults out of California WDP and Mississippi HMW couple budgets Corrected spouse deeming made California's WDP add an unrelated adult's income from an oversized default marital unit, and Mississippi's HMW size gate tested a couple's halves separately against the one-person limit. Both now use the identified co-resident spouse. Co-Authored-By: Claude Opus 5.5 --- .../ssi-couples-and-deeming-cap.fixed.md | 2 +- .../eligibility/ca_wdp_income_eligible.yaml | 125 ++++++++++++++++++ .../dom/hmw/eligibility/ms_hmw_eligible.yaml | 42 ++++++ .../eligibility/ms_hmw_income_eligible.yaml | 63 +++++++++ .../wdp/eligibility/ca_wdp_income_eligible.py | 10 +- .../hmw/eligibility/ms_hmw_income_eligible.py | 12 +- 6 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.yaml diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index ac3dfef9fae..3158d3a52b5 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1,2 +1,2 @@ SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains declared spouses living apart; Missouri retains their combined resources for medical separation, Virginia and South Dakota retain joint care-entry budgets, and Oregon retains the community-care spouse payment across facilities. Iowa pools only the identified spouses across its state supplement paths, retains its prior-SSI residential-care entry budget across facilities, and requires an explicit shared-room input for the same-month entry rule without prior SSI. -Retained state spouse budgets now reject explicit parent links and tax-dependent pairs across households. Non-MAGI Medicaid income, premium, resource and spenddown budgets and California CAPI use only the identified spouses when marital units are omitted. Regressions pin claimant-wage deeming continuity and the 2026 wage/self-employment cases. +Retained state spouse budgets now reject explicit parent links and tax-dependent pairs across households. Missouri's non-MAGI Medicaid spouse deeming, California CAPI, Montana LIHEAP's SSI categorical coverage and SSI standards, California's Working Disabled Program income test and the Healthier Mississippi Waiver income test use only the identified spouses when marital units are omitted. Regressions pin claimant-wage deeming continuity and the 2026 wage/self-employment cases. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.yaml new file mode 100644 index 00000000000..27f0637fa36 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.yaml @@ -0,0 +1,125 @@ +# A blind and disabled worker aged 35 earns $60,000; the spouse aged 40 has a +# $6,000 pension and is not aged, blind or disabled, so SSI deems the spouse's +# income and the FPL unit has two people. The worker's countable income is +# (60,000 - 240 - 780) / 2 = 29,490 and the spouse's 6,000 - 240 = 5,760, so +# 35,250 is below 250% of the 2026 two-person guideline: +# 2.5 x (15,960 + 5,680) = 54,100. + +- name: Case 1, an unrelated adult in a default marital unit does not enter the worker's budget. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 35 + employment_income: 60_000 + ssi_countable_resources: 3_500 + is_blind: true + is_disabled: true + is_tax_unit_head: true + spouse: + age: 40 + pension_income: 6_000 + is_tax_unit_spouse: true + outsider: + age: 70 + pension_income: 100_000 + is_tax_unit_head: true + tax_units: + couple: + members: [worker, spouse] + other: + members: [outsider] + spm_units: + spm_unit: + members: [worker, spouse, outsider] + families: + family: + members: [worker, spouse, outsider] + households: + household: + members: [worker, spouse, outsider] + state_code: CA + output: + is_ssi_spousal_deeming_applies: [true, false, false] + ca_wdp_countable_income: [29_490, 5_760, 99_760] + # Adding the unrelated adult's 99,760 would give 135,010, above 54,100. + ca_wdp_income_eligible: [true, true, false] + ca_wdp_eligible: [true, false, false] + medicaid_category: [WORKING_DISABLED_BUY_IN, NONE, NONE] + +- name: Case 2, the same household with explicit marital units gives the same result. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 35 + employment_income: 60_000 + ssi_countable_resources: 3_500 + is_blind: true + is_disabled: true + is_tax_unit_head: true + spouse: + age: 40 + pension_income: 6_000 + is_tax_unit_spouse: true + outsider: + age: 70 + pension_income: 100_000 + is_tax_unit_head: true + marital_units: + couple: + members: [worker, spouse] + other: + members: [outsider] + tax_units: + couple: + members: [worker, spouse] + other: + members: [outsider] + spm_units: + spm_unit: + members: [worker, spouse, outsider] + families: + family: + members: [worker, spouse, outsider] + households: + household: + members: [worker, spouse, outsider] + state_code: CA + output: + is_ssi_spousal_deeming_applies: [true, false, false] + ca_wdp_income_eligible: [true, true, false] + ca_wdp_eligible: [true, false, false] + +# Without the unrelated adult, the deemed spouse's income still counts: a +# $25,000 pension gives 25,000 - 240 = 24,760, and 29,490 + 24,760 = 54,250 +# is above 54,100, although the worker's own 29,490 is below it. +- name: Case 3, a couple alone counts the deemed spouse's income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 35 + employment_income: 60_000 + ssi_countable_resources: 3_500 + is_blind: true + is_disabled: true + is_tax_unit_head: true + spouse: + age: 40 + pension_income: 25_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [worker, spouse] + households: + household: + members: [worker, spouse] + state_code: CA + output: + is_ssi_spousal_deeming_applies: [true, false] + ca_wdp_countable_income: [29_490, 24_760] + ca_wdp_income_eligible: [false, true] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_eligible.yaml index f5717e1ceab..456a0d9040a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_eligible.yaml @@ -33,3 +33,45 @@ ms_hmw_other_coverage_ineligible: true output: ms_hmw_eligible: false + +# Two blind spouses aged 50, one with a $30,000 pension, share a home with an +# unrelated adult aged 70 on a $100,000 pension; marital units are omitted. +# SSI's couple computation gives each spouse (30,000 - 240) / 2 = 14,880 +# countable income. Together, 29,760 exceeds 135% of the two-person guideline +# ($29,214), so neither spouse qualifies, as with explicit marital units. +- name: Case 4, a blind couple living with an unrelated adult is tested as a couple. + period: 2026 + input: + people: + head: + age: 50 + is_blind: true + is_tax_unit_head: true + spouse: + age: 50 + is_blind: true + is_tax_unit_spouse: true + pension_income: 30_000 + outsider: + age: 70 + is_tax_unit_head: true + pension_income: 100_000 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + spm_units: + spm_unit: + members: [head, spouse, outsider] + families: + family: + members: [head, spouse, outsider] + households: + household: + members: [head, spouse, outsider] + state_code: MS + output: + medicaid_optional_senior_or_disabled_countable_income: [14_880, 14_880, 99_760] + ms_hmw_income_eligible: [false, false, false] + ms_hmw_eligible: [false, false, false] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml index fb1e702360d..2dddc2d41a3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml @@ -86,3 +86,66 @@ members: [person2] output: ms_hmw_income_eligible: [true, true] + +# Marital units omitted: the couple and an unrelated adult share one default +# marital unit. The tax roles identify the spouses, so the couple is still +# tested together against 135% of the two-person guideline ($29,214), and the +# unrelated adult's income stays out. 14,880 x 2 = 29,760 > 29,214, although +# each spouse's 14,880 alone would pass the one-person $21,546 limit. +- name: Case 6, a couple in a default marital unit with an unrelated adult is tested as a couple. + period: 2026 + input: + people: + head: + age: 50 + is_tax_unit_head: true + medicaid_optional_senior_or_disabled_countable_income: 14_880 + spouse: + age: 50 + is_tax_unit_spouse: true + medicaid_optional_senior_or_disabled_countable_income: 14_880 + outsider: + age: 70 + is_tax_unit_head: true + medicaid_optional_senior_or_disabled_countable_income: 0 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + households: + household: + members: [head, spouse, outsider] + state_code: MS + output: + ms_hmw_income_eligible: [false, false, true] + +# Same default group: 14,000 x 2 = 28,000 <= 29,214 passes, and the unrelated +# adult's 99,760 is neither pooled with the couple nor offset by theirs. +- name: Case 7, an unrelated adult's income does not enter a couple's budget in a default marital unit. + period: 2026 + input: + people: + head: + age: 50 + is_tax_unit_head: true + medicaid_optional_senior_or_disabled_countable_income: 14_000 + spouse: + age: 50 + is_tax_unit_spouse: true + medicaid_optional_senior_or_disabled_countable_income: 14_000 + outsider: + age: 70 + is_tax_unit_head: true + medicaid_optional_senior_or_disabled_countable_income: 99_760 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + households: + household: + members: [head, spouse, outsider] + state_code: MS + output: + ms_hmw_income_eligible: [true, true, false] diff --git a/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py index bbf6698e347..540cc9cc9d3 100644 --- a/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py +++ b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, +) class ca_wdp_income_eligible(Variable): @@ -19,7 +22,12 @@ class ca_wdp_income_eligible(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.ca.chhs.wdp.eligibility.income personal_income = person("ca_wdp_countable_income", period) - spouse_income = person.marital_unit.sum(personal_income) - personal_income + # Only the identified co-resident spouse's income; an unrelated adult + # left in a default marital unit does not enter the budget. + spouse_income = ( + _ssi_couple_total(person, period.this_year, personal_income) + - personal_income + ) spousal_deeming_applies = person("is_ssi_spousal_deeming_applies", period) countable_income = personal_income + where( spousal_deeming_applies, diff --git a/policyengine_us/variables/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.py b/policyengine_us/variables/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.py index 66aa9fedd21..3b9b4b3a7f3 100644 --- a/policyengine_us/variables/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.py +++ b/policyengine_us/variables/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class ms_hmw_income_eligible(Variable): @@ -17,9 +21,11 @@ def formula(person, period, parameters): personal_income = person( "medicaid_optional_senior_or_disabled_countable_income", period ) - marital_unit = person.marital_unit - couple = marital_unit.nb_persons() == 2 - income = where(couple, marital_unit.sum(personal_income), personal_income) + # Pool only the identified co-resident spouse's income, so a couple + # left in a default marital unit with an unrelated adult is still + # tested as a couple, and the unrelated adult's income stays out. + couple = _ssi_spouse_index(person, period) >= 0 + income = _ssi_couple_total(person, period, personal_income) state_group = person.household("state_group_str", period) fpg = parameters(period).gov.hhs.fpg unit_fpg = fpg.first_person[state_group] + ( From 8b2a55d06182368f55460d980f80eb8fce81220d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:43:55 +0000 Subject: [PATCH 22/25] Keep an unrelated adult's premiums out of California couple budgets ssi_marital_unearned_income now adds only the identified spouse, but the premium deduction still summed the whole marital unit. With marital units omitted, a couple's budget dropped an unrelated adult's income yet kept that adult's premiums, which could make an over-limit couple eligible. 22 CCR 50555.2(a) deducts premiums of persons in the family, so the couple's budget now takes the spouses' premiums plus those of unmarried tax dependents in their marital unit. Restores the California third-adult premium case removed in 1649fc41ec, adds the eligibility witness and a dependent-child case, and adds a Mississippi HMW case that tests an unrelated adult against the one-person limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6Gw6tVczK4bv4t1WX6 --- ...l_senior_or_disabled_countable_income.yaml | 135 ++++++++++++++++++ .../eligibility/ms_hmw_income_eligible.yaml | 31 ++++ ...nal_senior_or_disabled_countable_income.py | 19 ++- 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml index 5606b16f437..0d77950a088 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml @@ -727,3 +727,138 @@ # 21,480 is under the 2026 guideline for two (21,640). A 75-dollar # disregard would leave 21,780, over the guideline. is_optional_senior_or_disabled_income_eligible: [true, true] + +# Omitted marital units form one default group. 22 CCR 50555.2(a) deducts +# premiums paid for any person in the family, so a couple's budget takes the +# spouses' and their dependents' premiums but not an unrelated adult's. The +# unrelated adult heads their own tax unit; without that input they would be +# read as a tax dependent, since is_tax_unit_head is supplied for the couple. +- name: California excludes an unrelated adult's premiums from a couple budget. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 36_000 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 24_000 + medicare_enrolled: false + third: + is_tax_unit_head: true + age: 67 + pension_income: 12_000 + health_insurance_premiums: 12_000 + medicare_enrolled: false + ssi_federal_living_arrangement: + 2026-01: MEDICAL_TREATMENT_FACILITY + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: CA + output: + ssi_couple_computation_applies: + 2026-01: [true, true, false] + # (36,000 + 24,000 - 240) / 2; the third adult's 12,000 is not deducted. + medicaid_optional_senior_or_disabled_countable_income: [29_880, 29_880, 0] + +- name: California couple is not made eligible by an unrelated adult's premiums. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 24_120 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 12_120 + medicare_enrolled: false + third: + is_tax_unit_head: true + age: 50 + pension_income: 15_000 + health_insurance_premiums: 15_000 + medicare_enrolled: false + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: CA + output: + # (24,120 + 12,120 - 240) / 2 = 18,000 each. The couple's 36,000 exceeds + # the 29,863.20 couple limit. Deducting the third adult's 15,000 would + # leave 10,500 each, and the 21,000 sum would pass the individual limit. + medicaid_optional_senior_or_disabled_countable_income: [18_000, 18_000, 0] + is_optional_senior_or_disabled_for_medicaid: [false, false, false] + # The 50-year-old third adult qualifies through the adult expansion group. + medicaid_category: [NONE, NONE, ADULT] + +- name: California deducts a dependent child's premiums from a couple budget. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 36_000 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 24_000 + medicare_enrolled: false + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 12_000 + medicare_enrolled: false + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: CA + output: + ssi_couple_computation_applies: + 2026-01: [true, true, false] + # (36,000 + 24,000 - 240 - 12,000) / 2, as on main. + medicaid_optional_senior_or_disabled_countable_income: [23_880, 23_880, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml index 2dddc2d41a3..94a8a631d14 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/dom/hmw/eligibility/ms_hmw_income_eligible.yaml @@ -149,3 +149,34 @@ state_code: MS output: ms_hmw_income_eligible: [true, true, false] + +# Same default group: the unrelated adult has no identified spouse, so their +# own 24,760 is tested against the one-person $21,546 limit and fails, not +# against the two-person $29,214 limit, which it would pass. +- name: Case 8, an unrelated adult in a default marital unit gets the one-person limit. + period: 2026 + input: + people: + head: + age: 50 + is_tax_unit_head: true + medicaid_optional_senior_or_disabled_countable_income: 14_000 + spouse: + age: 50 + is_tax_unit_spouse: true + medicaid_optional_senior_or_disabled_countable_income: 14_000 + outsider: + age: 70 + is_tax_unit_head: true + medicaid_optional_senior_or_disabled_countable_income: 24_760 + tax_units: + couple: + members: [head, spouse] + outsider: + members: [outsider] + households: + household: + members: [head, spouse, outsider] + state_code: MS + output: + ms_hmw_income_eligible: [true, true, false] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py index 3bf485dfcba..0df879d2dd1 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py @@ -2,6 +2,10 @@ from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( _apply_medicaid_optional_senior_or_disabled_exclusions, ) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) class medicaid_optional_senior_or_disabled_countable_income(Variable): @@ -91,11 +95,18 @@ def formula(person, period, parameters): max_(paid_premiums - paid_part_b, 0) + full_part_b, paid_premiums, ) - premiums = where( - couple_computation, - person.marital_unit.sum(own_premiums), - own_premiums, + # 22 CCR 50555.2(a) deducts premiums paid by and purchased for any + # person in the family. A couple's budget takes both spouses' + # premiums and those of unmarried tax dependents in their marital + # unit, but not an unrelated adult's, whose income also stays out of + # ssi_marital_unearned_income when a situation omits marital units. + dependent_without_spouse = person("is_tax_unit_dependent", period) & ( + _ssi_spouse_index(person, period) < 0 ) + family_premiums = _ssi_couple_total( + person, period, own_premiums + ) + person.marital_unit.sum(where(dependent_without_spouse, own_premiums, 0)) + premiums = where(couple_computation, family_premiums, own_premiums) deducts_premiums = p.deducts_health_insurance_premiums[state].astype(bool) # MC 176 (ACWDL 00-57) lines 16-17 subtract the additional disregard # and the premiums from income after the SSI exclusions. From cfcac6b60412fa8f5e15fd6865c45952dd00833b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:31:33 +0000 Subject: [PATCH 23/25] Keep an unrelated adult's premiums out of a California couple budget When a situation sets tax roles only for the couple, core leaves an unrelated adult with neither head nor spouse, so is_tax_unit_dependent reads true for them and their premiums were deducted from the couple's income. Require the dependent to share a tax unit with a spouse in the couple computation. Add that case, and a dependent couple's case that pins the spouse-index term so each spouse's premium counts once. Co-Authored-By: Claude Opus 5.5 --- ...l_senior_or_disabled_countable_income.yaml | 86 +++++++++++++++++++ ...nal_senior_or_disabled_countable_income.py | 9 +- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml index e708f5591c7..6fa39e952b1 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml @@ -863,6 +863,92 @@ # (36,000 + 24,000 - 240 - 12,000) / 2, as on main. medicaid_optional_senior_or_disabled_countable_income: [23_880, 23_880, 0] +- name: California couple is not made eligible by the premiums of an unrelated adult with no tax role. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 24_120 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 12_120 + medicare_enrolled: false + third: + # No tax-role flag: once the couple's roles are set, the third adult + # reads as a tax dependent of their own tax unit. + age: 50 + pension_income: 15_000 + health_insurance_premiums: 15_000 + medicare_enrolled: false + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + families: + couple: + members: [head, spouse] + third: + members: [third] + spm_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: CA + output: + is_tax_unit_dependent: [false, false, true] + # (24,120 + 12,120 - 240) / 2 = 18,000 each. The third adult shares no + # tax unit with the couple, so their 15,000 of premiums is not deducted. + medicaid_optional_senior_or_disabled_countable_income: [18_000, 18_000, 0] + is_optional_senior_or_disabled_for_medicaid: [false, false, false] + +- name: California counts a dependent couple's premiums once. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + child: + age: 45 + is_tax_unit_head: true + employment_income: 80_000 + medicare_enrolled: false + dad: + age: 80 + is_tax_unit_dependent: true + pension_income: 36_000 + health_insurance_premiums: 6_000 + medicare_enrolled: false + mom: + age: 78 + is_tax_unit_dependent: true + pension_income: 24_000 + health_insurance_premiums: 6_000 + medicare_enrolled: false + tax_units: + tax_unit: + members: [child, dad, mom] + marital_units: + child: + members: [child] + parents: + members: [dad, mom] + households: + household: + members: [child, dad, mom] + state_code: CA + output: + # (36,000 + 24,000 - 240 - 12,000) / 2; each premium counts once. + medicaid_optional_senior_or_disabled_countable_income: [0, 23_880, 23_880] + - name: An aged permanent resident without 40 SSI quarters has their pension counted. period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py index 93ce87febb4..55b200cd28b 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py @@ -101,8 +101,13 @@ def formula(person, period, parameters): # premiums and those of unmarried tax dependents in their marital # unit, but not an unrelated adult's, whose income also stays out of # ssi_marital_unearned_income when a situation omits marital units. - dependent_without_spouse = person("is_tax_unit_dependent", period) & ( - _ssi_spouse_index(person, period) < 0 + # A situation that sets tax roles for the couple only leaves an + # unrelated adult with neither role, so also require the dependent + # to share a tax unit with a spouse in the couple computation. + dependent_without_spouse = ( + person("is_tax_unit_dependent", period) + & (_ssi_spouse_index(person, period) < 0) + & person.tax_unit.any(couple_computation) ) family_premiums = _ssi_couple_total( person, period, own_premiums From c6e7751af2ef3fd38141557c707dbdfeb624a01a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:31:33 +0000 Subject: [PATCH 24/25] Find a CAPI spouse through the SSI spouse test in ca_state_supplement The marital-unit sums treated an unrelated CAPI-eligible member of a default marital unit as a citizen couple's spouse, which made the couple a mixed couple and raised the supplement. Use the same spouse index and couple total as the other SSI readers this PR converts, and add the omitted-marital-units case. Co-Authored-By: Claude Opus 5.5 --- .../state_supplement/ca_state_supplement.yaml | 48 +++++++++++++++++++ .../state_supplement/ca_state_supplement.py | 17 +++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml index a8f5eeddeb1..5aba5de18fc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml @@ -660,3 +660,51 @@ ssi: [245.50, 0] ca_state_supplement: 488.44 ca_capi_person: [0, 0] + +- name: Case 17, 2025 aged couple sharing an SPM unit with an unrelated CAPI-eligible adult, marital units omitted, keeps the couple computation. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + is_tax_unit_head: true + immigration_status: CITIZEN + social_security_retirement: 6_000 # 500 a month + person2: + age: 70 + is_tax_unit_spouse: true + immigration_status: CITIZEN + pension_income: 6_000 # 500 a month + person3: + age: 72 + is_tax_unit_head: true + immigration_status: LEGAL_PERMANENT_RESIDENT + years_since_us_entry: 10 + ssi_qualifying_quarters_earnings: 0 + pension_income: 6_000 # 500 a month + tax_units: + couple: + members: [person1, person2] + third: + members: [person3] + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: CA + living_arrangements_allow_for_food_preparation: false + output: + # Omitting marital_units puts all three people in one default marital + # unit. person3 is CAPI-eligible but is not a spouse of either citizen, + # so the couple is not a mixed couple and keeps SSI's couple computation. + ca_capi_eligible_person: [false, false, true] + # (500 + 500 - 20) / 2 = 490 a month each; person3 has 500 - 20 = 480. + ssi_countable_income: [5_880, 5_880, 5_760] + ssi: [2_820, 2_820, 0] # (1,450 / 2 - 490) * 12 + ca_state_supplement_payment_standard: 27_786.84 # 2,315.57 * 12 + # 2,315.57 - 2 * 235 - 2 * 490 = 865.57 a month, as with explicit marital + # units. Pairing either citizen with person3 would make it 885.09. + ca_state_supplement: 10_386.84 # 865.57 * 12 diff --git a/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py b/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py index ef86e4890a4..a2f56cf775e 100644 --- a/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py +++ b/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py @@ -2,6 +2,10 @@ from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( _apply_ssi_exclusions, ) +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_total, + _ssi_spouse_index, +) from policyengine_us.variables.gov.states.ca.cdss.capi._ca_capi_ssi_ssp_standard import ( ca_capi_ssi_ssp_standard, ) @@ -47,13 +51,14 @@ def formula(spm_unit, period, parameters): # the totals keep CAPI at zero when the spouse's income is deemed. # NOTE: SSI couple status ignores SSI's noncitizen test, so the # model's SSI is still the couple computation (issue 10481). - head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) # The exclusion needs a spouse who can receive CAPI, so key on CAPI # eligibility rather than on SSI's noncitizen test alone (issue 10482). + # Find the spouse as SSI's couple tests do, so the default marital + # unit of a situation that omits marital units does not pair a + # recipient with an unrelated member's CAPI eligibility or income. capi_eligible = person("ca_capi_eligible_person", period.this_year) - spouse_capi_eligible = ( - person.marital_unit.sum(capi_eligible & head_or_spouse) > 0 - ) + spouse = _ssi_spouse_index(person, period.this_year) + spouse_capi_eligible = (spouse >= 0) & capi_eligible[np.maximum(spouse, 0)] in_mixed_couple = ( person("ssi_couple_computation_applies", period) & meets_ssi_immigration_test @@ -76,7 +81,9 @@ def formula(spm_unit, period, parameters): _apply_ssi_exclusions(earned, unearned, parameters, period) / MONTHS_IN_YEAR ) ssi_countable_income = person("ssi_countable_income", period) - couple_income = person.marital_unit.sum(ssi_countable_income) + couple_income = _ssi_couple_total( + person, period.this_year, ssi_countable_income + ) individual_standard, couple_standard = ca_capi_ssi_ssp_standard( person, period, parameters ) From 0327519344de32ec062c3385f7a747717d53f677 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:44:29 +0000 Subject: [PATCH 25/25] Keep an unrelated adult and another couple's dependent out of a couple's budgets With marital units omitted, the default marital unit can hold an unrelated adult or another couple's dependent. The couple's income already leaves them out, but the medically needy and 209(b) spend-down budgets still deducted their medical expenses, and California's premium deduction still took the premiums of a dependent in another couple's tax unit. A New Jersey couple beside an unrelated adult with large medical expenses became medically needy, a Virginia couple passed the 209(b) spend-down, and a California couple passed the premium-deducted income test. Main and explicit marital units deny all three. _ssi_couple_budget_total adds to the spouse pair only unmarried tax dependents who share a couple's marital unit and the budgeted person's tax unit. The three readers use it for a couple in the couple computation and keep the marital-unit sum otherwise. Also add Case 18 to ca_state_supplement.yaml, which kills a revert of the couple-income sum that Case 17 misses, correct Case 17's comment, narrow the _ssi_couple_total docstring, pin medicaid_category in the no-role premium case, and update the changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GuAZNBedib7D5hnAB --- .../ssi-couples-and-deeming-cap.fixed.md | 2 +- .../is_209b_ssi_recipient_for_medicaid.yaml | 36 +++++++ .../is_medically_needy_for_medicaid.yaml | 39 ++++++++ ...l_senior_or_disabled_countable_income.yaml | 93 ++++++++++++++++++- .../state_supplement/ca_state_supplement.yaml | 48 +++++++++- ..._recipient_income_eligible_for_medicaid.py | 10 +- .../is_medically_needy_for_medicaid.py | 12 ++- ...nal_senior_or_disabled_countable_income.py | 21 ++--- .../resources/deemed/_ssi_spouses.py | 26 +++++- 9 files changed, 259 insertions(+), 28 deletions(-) diff --git a/changelog.d/ssi-couples-and-deeming-cap.fixed.md b/changelog.d/ssi-couples-and-deeming-cap.fixed.md index 3158d3a52b5..d613e096969 100644 --- a/changelog.d/ssi-couples-and-deeming-cap.fixed.md +++ b/changelog.d/ssi-couples-and-deeming-cap.fixed.md @@ -1,2 +1,2 @@ SSI under spousal income deeming is now the lesser of the couple computation and the individual Federal benefit rate less the person's own countable income (20 CFR 416.1163(e)(2)), not the couple computation capped at the individual rate; uncapped_ssi applies the same rule, so state supplements that read it see the federal shortfall. SSI couples are now identified by the co-resident spouse test that resource deeming uses rather than by tax-unit head and spouse roles, for joint claims, combined couple income and resources, and income deeming from an ineligible spouse (416.1801(c), 416.1806, 416.1160(d)). State supplement couple rates, income, resources, care categories and spouse allowances now use only the actual spouses when a situation omits marital units and includes a third adult. Colorado OAP retains declared spouses living apart; Missouri retains their combined resources for medical separation, Virginia and South Dakota retain joint care-entry budgets, and Oregon retains the community-care spouse payment across facilities. Iowa pools only the identified spouses across its state supplement paths, retains its prior-SSI residential-care entry budget across facilities, and requires an explicit shared-room input for the same-month entry rule without prior SSI. -Retained state spouse budgets now reject explicit parent links and tax-dependent pairs across households. Missouri's non-MAGI Medicaid spouse deeming, California CAPI, Montana LIHEAP's SSI categorical coverage and SSI standards, California's Working Disabled Program income test and the Healthier Mississippi Waiver income test use only the identified spouses when marital units are omitted. Regressions pin claimant-wage deeming continuity and the 2026 wage/self-employment cases. +Retained state spouse budgets now reject explicit parent links and tax-dependent pairs across households. Missouri's non-MAGI Medicaid spouse deeming, California's non-MAGI premium deduction, the medically needy and Section 209(b) spend-down budgets, California CAPI and the California state supplement, Montana LIHEAP's SSI categorical coverage and SSI standards, California's Working Disabled Program income test and the Healthier Mississippi Waiver income test use only the identified spouses when marital units are omitted. Regressions pin claimant-wage deeming continuity and the 2026 wage/self-employment cases. diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_for_medicaid.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_for_medicaid.yaml index 9ced49446da..05c62aebb10 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_for_medicaid.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_for_medicaid.yaml @@ -223,3 +223,39 @@ output: # 16,800 combined is under the 17,892 couple standard. is_209b_ssi_recipient_for_medicaid: [true, true] + +- name: Case 19, Virginia couple's spend-down budget leaves out an unrelated adult's medical expenses when marital units are omitted. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 8_870 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 8_870 + medicare_enrolled: false + third: + age: 50 + is_tax_unit_head: true + pension_income: 30_000 + other_medical_expenses: 5_000 + medicare_enrolled: false + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: VA + output: + ssi: [196, 196, 0] + # The couple's budget is 17,500, above 12,768 (0.80 x 15,960). Deducting + # the third adult's 5,000 would leave 12,500, under it. + is_209b_ssi_recipient_for_medicaid: [false, false, false] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.yaml index a3934f084f7..bbb18f86966 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.yaml @@ -940,3 +940,42 @@ # 2027 still reads the 195,000 couple limit; 31,000 first applies in 2028. is_in_medicaid_medically_needy_category: [true, true] is_medically_needy_for_medicaid: [true, true] + +- name: Case 37, New Jersey couple's spend-down budget leaves out an unrelated adult's medical expenses when marital units are omitted. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 15_120 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 15_120 + medicare_enrolled: false + third: + age: 50 + is_tax_unit_head: true + pension_income: 30_000 + other_medical_expenses: 28_000 + medicare_enrolled: false + tax_units: + couple: + members: [head, spouse] + third: + members: [third] + households: + household: + members: [head, spouse, third] + state_code: NJ + output: + # (15,120 + 15,120 - 240) / 2 = 15,000 each; the couple's income leaves + # out the third adult's pension. + ssi_countable_income: [15_000, 15_000, 0] + # The couple's budget is 30,000, above the 4,404 (367 x 12) limit that + # applies because the default marital unit holds three people. Deducting + # the third adult's 28,000 would leave 2,000, under it. + is_medically_needy_for_medicaid: [false, false, false] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml index 6fa39e952b1..0497784b14c 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.yaml @@ -863,7 +863,44 @@ # (36,000 + 24,000 - 240 - 12,000) / 2, as on main. medicaid_optional_senior_or_disabled_countable_income: [23_880, 23_880, 0] -- name: California couple is not made eligible by the premiums of an unrelated adult with no tax role. +- name: California leaves a dependent child in their own marital unit out of a couple budget. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 36_000 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 24_000 + medicare_enrolled: false + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 12_000 + medicare_enrolled: false + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + couple: + members: [head, spouse] + child: + members: [child] + households: + household: + members: [head, spouse, child] + state_code: CA + output: + # (36,000 + 24,000 - 240) / 2 = 29,880 each, as on main: the child's + # explicit marital unit holds no spouse in the couple computation. + medicaid_optional_senior_or_disabled_countable_income: [29_880, 29_880, 0] + +- name: An unrelated adult with no tax role does not make a California couple eligible through their premiums. period: 2026 absolute_error_margin: 0.01 input: @@ -879,8 +916,8 @@ pension_income: 12_120 medicare_enrolled: false third: - # No tax-role flag: once the couple's roles are set, the third adult - # reads as a tax dependent of their own tax unit. + # No tax-role flag: once a situation sets the couple's roles, the + # third adult reads as a tax dependent of their own tax unit. age: 50 pension_income: 15_000 health_insurance_premiums: 15_000 @@ -907,9 +944,57 @@ output: is_tax_unit_dependent: [false, false, true] # (24,120 + 12,120 - 240) / 2 = 18,000 each. The third adult shares no - # tax unit with the couple, so their 15,000 of premiums is not deducted. + # tax unit with the couple, so the reader does not deduct their 15,000 of + # premiums. medicaid_optional_senior_or_disabled_countable_income: [18_000, 18_000, 0] is_optional_senior_or_disabled_for_medicaid: [false, false, false] + medicaid_category: [NONE, NONE, ADULT] + +- name: California leaves another couple's dependent's premiums out of a couple budget. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + pension_income: 24_120 + medicare_enrolled: false + spouse: + age: 67 + is_tax_unit_spouse: true + pension_income: 12_120 + medicare_enrolled: false + head2: + age: 67 + is_tax_unit_head: true + medicare_enrolled: false + spouse2: + age: 67 + is_tax_unit_spouse: true + medicare_enrolled: false + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 15_000 + medicare_enrolled: false + tax_units: + couple: + members: [head, spouse] + second: + members: [head2, spouse2, child] + households: + household: + members: [head, spouse, head2, spouse2, child] + state_code: CA + output: + # (24,120 + 12,120 - 240) / 2 = 18,000 each. The child shares no tax unit + # with the first couple, so the reader does not deduct their 15,000 of + # premiums from that couple's budget, and 36,000 exceeds 22,024.80. + medicaid_optional_senior_or_disabled_countable_income: [18_000, 18_000, 0, 0, 0] + # The second couple stays ineligible because the income reader sums the + # whole default unit, as on main. + is_optional_senior_or_disabled_for_medicaid: [false, false, false, false, false] - name: California counts a dependent couple's premiums once. period: 2026 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml index 5aba5de18fc..e62f824d078 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml @@ -706,5 +706,51 @@ ssi: [2_820, 2_820, 0] # (1,450 / 2 - 490) * 12 ca_state_supplement_payment_standard: 27_786.84 # 2,315.57 * 12 # 2,315.57 - 2 * 235 - 2 * 490 = 865.57 a month, as with explicit marital - # units. Pairing either citizen with person3 would make it 885.09. + # units. Marital-unit sums read person3 as a CAPI-eligible spouse of both + # citizens and added person3's 480 to the couple total, which gave + # 2,315.57 - 470 - 2 * 480.24 = 885.09. ca_state_supplement: 10_386.84 # 865.57 * 12 + +- name: Case 18, 2025 mixed couple sharing a default marital unit with an unrelated citizen, marital units omitted, counts only the couple's income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + is_tax_unit_head: true + immigration_status: CITIZEN + social_security_retirement: 6_000 # 500 a month + person2: + age: 68 + is_tax_unit_spouse: true + immigration_status: LEGAL_PERMANENT_RESIDENT + ssi_qualifying_quarters_earnings: 39 + pension_income: 6_000 # 500 a month + person3: + age: 72 + is_tax_unit_head: true + immigration_status: CITIZEN + pension_income: 12_000 # 1,000 a month + tax_units: + couple: + members: [person1, person2] + third: + members: [person3] + spm_units: + couple: + members: [person1, person2] + third: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: CA + living_arrangements_allow_for_food_preparation: false + output: + # The couple's countable total excludes person3's income, so person1 gets + # Case 11's mixed-couple supplement: 620.81 a month. person3 alone gets + # 355.81 a month. Summing the whole default marital unit would add + # person3's 980 to the couple total: max(480, 1,960 - 979.76) = 980.24, + # leaving 120.57 a month for the couple. + ca_state_supplement: [7_449.72, 4_269.72] diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py index da396f055d4..88583681bcc 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/is_209b_ssi_recipient_income_eligible_for_medicaid.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_budget_total, +) class is_209b_ssi_recipient_income_eligible_for_medicaid(Variable): @@ -28,8 +31,11 @@ def formula(person, period, parameters): medical_expenses = person("medicaid_medically_needy_medical_expenses", period) # SSI financial responsibility rules (42 CFR 435.602): the unit is # the individual or the married couple, not the tax filing unit. - income_after_spenddown = person.marital_unit.sum( - personal_income - medical_expenses + budget = personal_income - medical_expenses + income_after_spenddown = where( + person("ssi_couple_computation_applies", period), + _ssi_couple_budget_total(person, period, budget), + person.marital_unit.sum(budget), ) # The limit is per person (Missouri's blind standard can differ diff --git a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py index 633c16213f9..6a1e8007981 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py +++ b/policyengine_us/variables/gov/hhs/medicaid/eligibility/categories/medically_needy/is_medically_needy_for_medicaid.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_couple_budget_total, +) class is_medically_needy_for_medicaid(Variable): @@ -54,8 +57,13 @@ def formula(person, period, parameters): parental_expense_share = where( eligible_child, parental_expenses / max_(eligible_children, 1), 0 ) - income = marital_unit.sum( - personal_income - medical_expenses - parental_expense_share + budget = personal_income - medical_expenses - parental_expense_share + # A couple's budget leaves out an unrelated member of a default marital + # unit, whose income ssi_countable_income also leaves out. + income = where( + person("ssi_couple_computation_applies", period), + _ssi_couple_budget_total(person, period, budget), + marital_unit.sum(budget), ) assets = marital_unit.sum(personal_assets) ma = parameters(period).gov.hhs.medicaid diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py index 55b200cd28b..bd666da1741 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py @@ -3,8 +3,7 @@ _apply_medicaid_optional_senior_or_disabled_exclusions, ) from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_couple_total, - _ssi_spouse_index, + _ssi_couple_budget_total, ) @@ -99,20 +98,14 @@ def formula(person, period, parameters): # 22 CCR 50555.2(a) deducts premiums paid by and purchased for any # person in the family. A couple's budget takes both spouses' # premiums and those of unmarried tax dependents in their marital - # unit, but not an unrelated adult's, whose income also stays out of + # unit and tax unit. It leaves out another couple's dependent and an + # unrelated adult, whose income also stays out of # ssi_marital_unearned_income when a situation omits marital units. - # A situation that sets tax roles for the couple only leaves an - # unrelated adult with neither role, so also require the dependent - # to share a tax unit with a spouse in the couple computation. - dependent_without_spouse = ( - person("is_tax_unit_dependent", period) - & (_ssi_spouse_index(person, period) < 0) - & person.tax_unit.any(couple_computation) + premiums = where( + couple_computation, + _ssi_couple_budget_total(person, period, own_premiums), + own_premiums, ) - family_premiums = _ssi_couple_total( - person, period, own_premiums - ) + person.marital_unit.sum(where(dependent_without_spouse, own_premiums, 0)) - premiums = where(couple_computation, family_premiums, own_premiums) deducts_premiums = p.deducts_health_insurance_premiums[state].astype(bool) # MC 176 (ACWDL 00-57) lines 16-17 subtract the additional disregard # and the premiums from income after the SSI exclusions. diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py index ba5fb0f0b65..c3eb821efdc 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py @@ -101,10 +101,28 @@ def _ssi_resource_couple(person, period): def _ssi_couple_total(person, period, values): """Each person's value plus their co-resident spouse's, if they have one. - This equals the marital-unit sum for units of one or two people, and - adds only the spouse when a unit holds more people, as the default unit - of a situation that omits marital units can. ``period`` is the year of - the spouse test. + This equals the marital-unit sum for a one-person unit and for a + two-person unit whose members the spouse test pairs, and adds only the + spouse when a unit holds more people, as the default unit of a situation + that omits marital units can. ``period`` is the year of the spouse test. """ spouse = _ssi_spouse_index(person, period) return values + where(spouse >= 0, values[np.maximum(spouse, 0)], 0) + + +def _ssi_couple_budget_total(person, period, values): + """A couple's total: both spouses' values plus their dependents'. + + A dependent is an unmarried tax dependent who shares the marital unit of + a couple and the tax unit of the person whose total this is. Another + couple's dependent and an unrelated member of a default marital unit stay + out. ``period`` is the year of the spouse test. + """ + dependent = ( + person("is_tax_unit_dependent", period) + & (_ssi_spouse_index(person, period) < 0) + & person.marital_unit.any(person("ssi_couple_computation_applies", period)) + ) + return _ssi_couple_total(person, period, values) + person.tax_unit.sum( + where(dependent, values, 0) + )