diff --git a/changelog.d/fix-ssi-spousal-general-exclusion.added.md b/changelog.d/fix-ssi-spousal-general-exclusion.added.md new file mode 100644 index 00000000000..cb4319c1cfc --- /dev/null +++ b/changelog.d/fix-ssi-spousal-general-exclusion.added.md @@ -0,0 +1 @@ +Added a monthly variable for SSI income deemed from an ineligible spouse and preserved annual inputs. diff --git a/changelog.d/fix-ssi-spousal-general-exclusion.fixed.md b/changelog.d/fix-ssi-spousal-general-exclusion.fixed.md new file mode 100644 index 00000000000..b9d84d42efb --- /dev/null +++ b/changelog.d/fix-ssi-spousal-general-exclusion.fixed.md @@ -0,0 +1 @@ +Applied SSI's general income exclusion once across a couple's income, including the claimant's in-kind support, and preserved monthly support changes under spousal deeming. diff --git a/policyengine_us/tests/core/test_ssi_monthly_ism_properties.py b/policyengine_us/tests/core/test_ssi_monthly_ism_properties.py index adc208b0f73..263b95b4c82 100644 --- a/policyengine_us/tests/core/test_ssi_monthly_ism_properties.py +++ b/policyengine_us/tests/core/test_ssi_monthly_ism_properties.py @@ -514,7 +514,7 @@ def test_couple_food_pmv_uses_payment_rate_and_splits_countable_income(month): "ssi_earned_income": zero, "ssi_unearned_income": zero, "ssi_unearned_income_deemed_from_ineligible_parent": zero, - "ssi_income_deemed_from_ineligible_spouse": zero, + "ssi_income_deemed_from_ineligible_spouse_monthly": zero, "is_ssi_aged_blind_disabled": np.ones(3, dtype=bool), "is_child": zero.astype(bool), } diff --git a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py new file mode 100644 index 00000000000..a440246ff7d --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py @@ -0,0 +1,353 @@ +"""Check SSI spousal deeming across generated income changes. + +One vectorized Simulation per draw shares the read-only system singleton. +Independent NumPy calculations preserve main's results without ISM and check +the combined income budget, exclusion bound, and individual payment limit. +Leaf ISM inputs keep its value fixed while each cash income rises. Separate +households increase ISM itself, holding the cash incomes and deeming status +fixed. YAML checks the PMV calculation and worked monthly payments. + +These comparisons exercise the Simulation API's monthly and annual input +conversion, deemed-income decomposition, and payment cap. The existing +Rest/core Python group collects this file. +""" + +from types import SimpleNamespace + +import numpy as np +from policyengine_core.periods import period as make_period +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + + +YEAR = 2026 +PERIOD = "2026-01" +INDIVIDUAL_FBR = 994 +COUPLE_FBR = 1_491 +GENERAL_EXCLUSION = 20 +EARNED_EXCLUSION = 65 +TOLERANCE = 0.01 + + +def _countable(earned, unearned): + """Apply 416.1112 and 416.1124 to monthly income independently.""" + unused_general = np.maximum(GENERAL_EXCLUSION - unearned, 0) + return ( + np.maximum(unearned - GENERAL_EXCLUSION, 0) + + np.maximum(earned - EARNED_EXCLUSION - unused_general, 0) / 2 + ) + + +def _situation(rows): + situation = { + plural: {} + for plural in ( + "people", + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ) + } + for index, ( + own_earned, + own_unearned, + spouse_earned, + spouse_unearned, + ism, + ) in enumerate(rows): + claimant, spouse = f"c{index}", f"s{index}" + situation["people"][claimant] = { + "age": {YEAR: 70}, + "employment_income": {YEAR: own_earned * 12}, + "pension_income": {YEAR: own_unearned * 12}, + "ssi_in_kind_support_and_maintenance": { + f"{YEAR}-{month:02}": ism if month == 1 else 0 for month in range(1, 13) + }, + } + situation["people"][spouse] = { + "age": {YEAR: 60}, + "employment_income": {YEAR: spouse_earned * 12}, + "pension_income": {YEAR: spouse_unearned * 12}, + } + for plural in ( + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ): + situation[plural][f"u{index}"] = {"members": [claimant, spouse]} + situation["households"][f"u{index}"]["state_code"] = {YEAR: "TX"} + return situation + + +@settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + rows=st.lists( + st.tuples( + st.integers(0, 2_500), + st.integers(0, 1_200), + st.integers(0, 3_500), + st.integers(0, 900), + st.integers(0, 25_850), + st.integers(1, 100_000), + ), + min_size=3, + max_size=3, + ) +) +def test_spousal_ism_preserves_income_and_exclusion_invariants(rows): + # The final coordinates give ISM and the income increase in cents. + # Fixed rows retain the wage-only defect, spouse unearned income, and + # both sides of the claimant's $20 general exclusion. + cases = [ + *rows, + (0, 0, 2_000, 0, 25_850, 100), + (0, 0, 2_000, 100, 25_850, 100), + (0, 19, 497, 0, 100, 1), + (0, 20, 498, 0, 100, 1), + (0, 21, 496, 0, 100, 1), + ] + expanded = [] + for own_earned, own_unearned, spouse_earned, spouse_unearned, cents, step in cases: + expanded.append((own_earned, own_unearned, spouse_earned, spouse_unearned, 0)) + # Keep deeming active as each income coordinate rises. Changing the + # generated PMV at the deeming threshold requires separate YAML cases. + supported = np.array( + [ + own_earned, + own_unearned, + max(spouse_earned, 498), + spouse_unearned, + cents / 100, + ], + dtype=float, + ) + expanded.append(supported) + for coordinate in range(5): + increased = supported.copy() + increased[coordinate] += step / 100 + expanded.append(increased) + + simulation = Simulation(situation=_situation(expanded), tax_benefit_system=system) + own_earned, own_unearned, spouse_earned, spouse_unearned, ism = np.array( + expanded, dtype=float + ).T + own_countable = _countable(own_earned, own_unearned + ism) + combined_countable = _countable( + own_earned + spouse_earned, own_unearned + spouse_unearned + ism + ) + applies = spouse_earned + spouse_unearned > COUPLE_FBR - INDIVIDUAL_FBR + expected_deemed = np.where( + applies, np.maximum(combined_countable - own_countable, 0), 0 + ) + expected_countable = own_countable + expected_deemed + individual_payment = np.maximum(INDIVIDUAL_FBR - own_countable, 0) + expected_payment = np.where( + applies, + np.maximum(np.minimum(COUPLE_FBR - combined_countable, individual_payment), 0), + individual_payment, + ) + + countable = simulation.calculate("ssi_countable_income", PERIOD)[0::2] + deemed = simulation.calculate( + "ssi_income_deemed_from_ineligible_spouse_monthly", PERIOD + )[0::2] + payment = simulation.calculate("ssi", PERIOD)[0::2] + cap = simulation.calculate("uncapped_ssi_without_spousal_deeming", PERIOD)[0::2] + np.testing.assert_allclose(countable, expected_countable, atol=TOLERANCE) + np.testing.assert_allclose(deemed, expected_deemed, atol=TOLERANCE) + np.testing.assert_allclose(payment, expected_payment, atol=TOLERANCE) + assert np.all(payment <= np.maximum(cap, 0) + TOLERANCE) + + zero_ism_own = _countable(own_earned, own_unearned) + zero_ism_couple = _countable( + own_earned + spouse_earned, own_unearned + spouse_unearned + ) + zero_ism_deemed = np.where( + applies, np.maximum(zero_ism_couple - zero_ism_own, 0), 0 + ) + annual_deemed = simulation.calculate( + "ssi_income_deemed_from_ineligible_spouse", YEAR + )[0::2] + np.testing.assert_allclose( + annual_deemed, expected_deemed + 11 * zero_ism_deemed, atol=TOLERANCE + ) + # Calculating the annual aggregate cannot replace February's own budget + # with one-twelfth of that aggregate. + february_deemed = simulation.calculate( + "ssi_income_deemed_from_ineligible_spouse_monthly", f"{YEAR}-02" + )[0::2] + np.testing.assert_allclose(february_deemed, zero_ism_deemed, atol=TOLERANCE) + + # Freeze main's no-ISM difference formula independently of current + # countable or deemed outputs, including its below-threshold path. + no_ism = np.arange(0, len(expanded), 7) + original_own = _countable(own_earned[no_ism], own_unearned[no_ism]) + original_couple = _countable( + own_earned[no_ism] + spouse_earned[no_ism], + own_unearned[no_ism] + spouse_unearned[no_ism], + ) + original_deemed = np.where( + applies[no_ism], np.maximum(original_couple - original_own, 0), 0 + ) + original_countable = original_own + original_deemed + original_alone = INDIVIDUAL_FBR - original_own + original_payment = np.maximum( + np.where( + applies[no_ism], + np.minimum(COUPLE_FBR - original_countable, original_alone), + original_alone, + ), + 0, + ) + np.testing.assert_allclose(deemed[no_ism], original_deemed, atol=TOLERANCE) + np.testing.assert_allclose( + annual_deemed[no_ism], 12 * original_deemed, atol=TOLERANCE + ) + np.testing.assert_allclose(countable[no_ism], original_countable, atol=TOLERANCE) + np.testing.assert_allclose(payment[no_ism], original_payment, atol=TOLERANCE) + + supported_countable = countable.reshape(len(cases), 7)[:, 1:] + assert np.all(supported_countable[:, 1:] >= supported_countable[:, :1] - TOLERANCE) + + # Remove only the general exclusion from an independent couple budget. + # Retain the $65 earned exclusion and one-half earned-income disregard. + without_general = ( + own_unearned + + spouse_unearned + + ism + + np.maximum(own_earned + spouse_earned - EARNED_EXCLUSION, 0) / 2 + ) + supported = np.ones(len(expanded), dtype=bool) + supported[no_ism] = False + general_reduction = without_general[supported] - countable[supported] + assert np.all(general_reduction >= -TOLERANCE) + assert np.all(general_reduction <= GENERAL_EXCLUSION + TOLERANCE) + + +def test_legacy_annual_override_replaces_cached_monthly_budgets(): + simulation = Simulation( + situation=_situation([(0, 0, 2_000, 0, 258.50)]), + tax_benefit_system=system, + ) + monthly_name = "ssi_income_deemed_from_ineligible_spouse_monthly" + annual_name = "ssi_income_deemed_from_ineligible_spouse" + # January counts $258.50 ISM; February counts none. Annual deeming sums + # $967.50 + 11 * $957.50 = $11,500 without averaging the ISM correction. + np.testing.assert_allclose( + simulation.calculate(monthly_name, PERIOD), [967.50, 0], atol=TOLERANCE + ) + np.testing.assert_allclose( + simulation.calculate(annual_name, YEAR), [11_500, 0], atol=TOLERANCE + ) + + # An explicit legacy $2,160 annual input supplies $180 in every month, + # including months the parent simulation already calculated. + branch = simulation.get_branch("annual_deemed_override") + branch.set_input(annual_name, YEAR, [2_160, 0]) + for month in (PERIOD, f"{YEAR}-02"): + np.testing.assert_allclose( + branch.calculate(monthly_name, month), [180, 0], atol=TOLERANCE + ) + # An explicit zero overrides calculated deeming as an amount, too. + zero_branch = simulation.get_branch("zero_annual_deemed_override") + zero_branch.set_input(annual_name, YEAR, [0, 0]) + np.testing.assert_array_equal(zero_branch.calculate(monthly_name, PERIOD), [0, 0]) + np.testing.assert_allclose( + simulation.calculate(monthly_name, PERIOD), [967.50, 0], atol=TOLERANCE + ) + np.testing.assert_allclose( + simulation.calculate(monthly_name, f"{YEAR}-02"), [957.50, 0], atol=TOLERANCE + ) + + +def test_legacy_annual_override_replaces_cached_ineligible_parent_test(): + # YAML supplies its inputs before any calculation, so it cannot show that + # a branch drops a parent test the simulation calculated earlier. + people = { + "parent": { + "age": {YEAR: 45}, + "is_tax_unit_head": {YEAR: True}, + "own_children_in_household": {YEAR: 1}, + "meets_ssi_disability_criteria": {YEAR: True}, + }, + "spouse": { + "age": {YEAR: 45}, + "is_tax_unit_spouse": {YEAR: True}, + "own_children_in_household": {YEAR: 1}, + "employment_income": {YEAR: 12_000}, + }, + "child": { + "age": {YEAR: 10}, + "is_tax_unit_dependent": {YEAR: True}, + "meets_ssi_disability_criteria": {YEAR: True}, + }, + } + everyone = {"members": list(people)} + situation = { + "people": people, + "tax_units": {"tax_unit": everyone}, + "families": {"family": everyone}, + "spm_units": {"spm_unit": everyone}, + "households": {"household": {**everyone, "state_code": {YEAR: "OH"}}}, + "marital_units": { + "parents": {"members": ["parent", "spouse"]}, + "child": {"members": ["child"]}, + }, + } + simulation = Simulation(situation=situation, tax_benefit_system=system) + flag = "is_ssi_ineligible_parent" + # The spouse's wages deem ($12,000 - $240 - $780) / 2 = $5,490, within the + # couple rate of $1,491 * 12 = $17,892, so the disabled parent passes. + np.testing.assert_array_equal( + simulation.calculate(flag, YEAR), [False, True, False] + ) + # A supplied $18,000 exceeds $17,892 and fails the parent on the branch. + branch = simulation.get_branch("annual_deemed_override_for_parent_test") + branch.set_input("ssi_income_deemed_from_ineligible_spouse", YEAR, [18_000, 0, 0]) + np.testing.assert_array_equal(branch.calculate(flag, YEAR), [True, True, False]) + np.testing.assert_array_equal( + simulation.calculate(flag, YEAR), [False, True, False] + ) + + +def test_annual_input_adapter_honors_dated_legacy_abolition(): + # Exercise the input adapter without constructing another policy system. + # Core checks abolition at the requested month before period conversion. + from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, + ) + + name = "ssi_income_deemed_from_ineligible_spouse" + + class Parameters: + def __call__(self, period): + return SimpleNamespace( + gov=SimpleNamespace(abolitions={name: period.start.month >= 7}) + ) + + holder = SimpleNamespace( + variable=SimpleNamespace(is_neutralized=False, definition_period="year"), + get_known_branch_periods=lambda: [], + _record_store=lambda period, sequence_number: None, + ) + simulation = SimpleNamespace( + tax_benefit_system=SimpleNamespace(parameters=Parameters()), + get_holder=lambda variable: holder, + _user_input_keys=set(), + _get_visible_branch_names=lambda: [], + ) + person = SimpleNamespace(simulation=simulation) + assert annual_input(person, make_period("2026-01"), name) is None + assert annual_input(person, make_period("2026-07"), name) == 0 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml index 9b620bcd8d0..c3525434547 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml @@ -305,3 +305,39 @@ # Couple: (24,204 / 12 - 20 - 65) x 0.5 x 12 = (2,017 - 85) x 6 = 11,592. # Alone: 0. Deemed: 11,592 - 0 = 11,592. ssi_income_deemed_from_ineligible_spouse: [0, 0, 11_592, 0] + +# SSA includes claimant ISM in the couple's unearned income and applies +# one general exclusion (POMS SI 01320.150 B.1.a and SI 01320.400 B.1.e). +# https://secure.ssa.gov/poms.nsf/lnx/0501320150 +# https://secure.ssa.gov/poms.nsf/lnx/0501320400 +- name: Outside shelter support uses the general exclusion once with spouse wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + employment_income: 2_000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # 2026 rates: individual $994, couple $1,491; PMV = $1,491 / 6 + $10. + # Couple: ($258.50 - $20) + ($2,000 - $65) / 2 = $1,206. + # Alone: $258.50 - $20 = $238.50; deemed: $1,206 - $238.50 = $967.50. + # Payment: min($1,491 - $1,206, $994 - $994 / 3) = $285. + ssi_in_kind_support_and_maintenance: [258.50, 0] + ssi_income_deemed_from_ineligible_spouse_monthly: [967.50, 0] + ssi_countable_income: [1_206, 0] + uncapped_ssi_without_spousal_deeming: [662.67, 0] + ssi: [285, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.yaml new file mode 100644 index 00000000000..2257be84a6c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.yaml @@ -0,0 +1,492 @@ +# SSA includes claimant ISM in combined unearned income before applying +# one general exclusion (POMS SI 01320.150 B.1.a and SI 01320.400 B.1.e). +# https://secure.ssa.gov/poms.nsf/lnx/0501320150 +# https://secure.ssa.gov/poms.nsf/lnx/0501320400 + +- name: Claimant cash unearned income shares one exclusion with shelter support. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 100 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 100 + 0 + 258.5; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $1306; alone: $338.5; deemed: $1306 - $338.5 = $967.5. + # SSI: min($1,491 - $1306, $562.6667) = $185. + ssi_income_deemed_from_ineligible_spouse_monthly: [967.5, 0] + ssi_countable_income: [1306, 0] + ssi: [185, 0] + +- name: Spouse unearned income shares one exclusion with claimant shelter support. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + pension_income: 700 * 12 + employment_income: 0 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 700 + 258.5; earned: 0 + 0. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $938.5; alone: $238.5; deemed: $938.5 - $238.5 = $700. + # SSI: min($1,491 - $938.5, $662.6667) = $552.5. + ssi_income_deemed_from_ineligible_spouse_monthly: [700, 0] + ssi_countable_income: [938.5, 0] + ssi: [552.5, 0] + +- name: Mixed spouse income shares one exclusion with claimant shelter support. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + pension_income: 100 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 100 + 258.5; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $1306; alone: $238.5; deemed: $1306 - $238.5 = $1067.5. + # SSI: min($1,491 - $1306, $662.6667) = $185. + ssi_income_deemed_from_ineligible_spouse_monthly: [1067.5, 0] + ssi_countable_income: [1306, 0] + ssi: [185, 0] + +- name: Cash income without shelter support preserves wages-only deeming. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: false + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 0 + 0; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $957.5; alone: $0; deemed: $957.5 - $0 = $957.5. + # SSI: min($1,491 - $957.5, $994.0000) = $533.5. + ssi_income_deemed_from_ineligible_spouse_monthly: [957.5, 0] + ssi_countable_income: [957.5, 0] + ssi: [533.5, 0] + +- name: Cash income without shelter support preserves unearned-only deeming. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: false + spouse: + age: 60 + pension_income: 700 * 12 + employment_income: 0 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 700 + 0; earned: 0 + 0. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $680; alone: $0; deemed: $680 - $0 = $680. + # SSI: min($1,491 - $680, $994.0000) = $811. + ssi_income_deemed_from_ineligible_spouse_monthly: [680, 0] + ssi_countable_income: [680, 0] + ssi: [811, 0] + +- name: Cash income without shelter support preserves mixed-income deeming. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 100 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: false + spouse: + age: 60 + pension_income: 100 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 100 + 100 + 0; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $1147.5; alone: $80; deemed: $1147.5 - $80 = $1067.5. + # SSI: min($1,491 - $1147.5, $914.0000) = $343.5. + ssi_income_deemed_from_ineligible_spouse_monthly: [1067.5, 0] + ssi_countable_income: [1147.5, 0] + ssi: [343.5, 0] + +- name: Shelter support below the general exclusion leaves one dollar for wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + ssi_shelter_support_value: 19 + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 0 + 19; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $967; alone: $0; deemed: $967 - $0 = $967. + # SSI: min($1,491 - $967, $994.0000) = $524. + ssi_income_deemed_from_ineligible_spouse_monthly: [967, 0] + ssi_countable_income: [967, 0] + ssi: [524, 0] + +- name: Shelter support at the general exclusion leaves none for wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + ssi_shelter_support_value: 20 + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 0 + 20; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $967.5; alone: $0; deemed: $967.5 - $0 = $967.5. + # SSI: min($1,491 - $967.5, $994.0000) = $523.5. + ssi_income_deemed_from_ineligible_spouse_monthly: [967.5, 0] + ssi_countable_income: [967.5, 0] + ssi: [523.5, 0] + +- name: Shelter support above the general exclusion adds countable unearned income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + ssi_shelter_support_value: 21 + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 0 + 21; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $968.5; alone: $1; deemed: $968.5 - $1 = $967.5. + # SSI: min($1,491 - $968.5, $993.0000) = $522.5. + ssi_income_deemed_from_ineligible_spouse_monthly: [967.5, 0] + ssi_countable_income: [968.5, 0] + ssi: [522.5, 0] + +- name: Claimant cash below the general exclusion shares the remainder with support. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 19 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 19 + 0 + 258.5; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $1225; alone: $257.5; deemed: $1225 - $257.5 = $967.5. + # SSI: min($1,491 - $1225, $643.6667) = $266. + ssi_income_deemed_from_ineligible_spouse_monthly: [967.5, 0] + ssi_countable_income: [1225, 0] + ssi: [266, 0] + +- name: Claimant cash at the general exclusion leaves none for support. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 20 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 20 + 0 + 258.5; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $1226; alone: $258.5; deemed: $1226 - $258.5 = $967.5. + # SSI: min($1,491 - $1226, $642.6667) = $265. + ssi_income_deemed_from_ineligible_spouse_monthly: [967.5, 0] + ssi_countable_income: [1226, 0] + ssi: [265, 0] + +- name: Claimant cash above the general exclusion leaves none for support. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 21 * 12 + employment_income: 0 * 12 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 21 + 0 + 258.5; earned: 0 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $1227; alone: $259.5; deemed: $1227 - $259.5 = $967.5. + # SSI: min($1,491 - $1227, $641.6667) = $264. + ssi_income_deemed_from_ineligible_spouse_monthly: [967.5, 0] + ssi_countable_income: [1227, 0] + ssi: [264, 0] + +- name: Claimant earnings share the earned exclusion across the couple. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + pension_income: 0 * 12 + employment_income: 100 * 12 + ssi_receives_outside_shelter_support: true + spouse: + age: 60 + pension_income: 0 * 12 + employment_income: 2000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Unearned: 0 + 0 + 258.5; earned: 100 + 2000. + # Subtract $20 from unearned, carry any remainder to earnings, + # then subtract $65 from earnings and count half the remainder. + # Couple: $1256; alone: $256; deemed: $1256 - $256 = $1000. + # SSI: min($1,491 - $1256, $645.1667) = $235. + ssi_income_deemed_from_ineligible_spouse_monthly: [1000, 0] + ssi_countable_income: [1256, 0] + ssi: [235, 0] + +- name: Shelter support in July leaves other months unchanged and annual deeming sums the months. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + ssi_receives_outside_shelter_support: + 2026-01: false + 2026-07: true + 2026-08: false + spouse: + age: 60 + employment_income: 2_000 * 12 + tax_units: + couple: + members: [claimant, spouse] + marital_units: + couple: + members: [claimant, spouse] + households: + home: + members: [claimant, spouse] + state_code: FL + output: + # Without support: ($2,000 - $20 - $65) / 2 = $957.50 deemed; + # SSI = $1,491 - $957.50 = $533.50. + # July: ($258.50 - $20) + ($2,000 - $65) / 2 = $1,206 countable; + # deemed = $1,206 - $238.50 = $967.50; SSI = $285. + # Annual deemed: 11 x $957.50 + $967.50 = $11,500. + # Annual SSI: 11 x $533.50 + $285 = $6,153.50. + ssi_income_deemed_from_ineligible_spouse_monthly: + 2026-01: [957.50, 0] + 2026-07: [967.50, 0] + ssi_income_deemed_from_ineligible_spouse: [11_500, 0] + ssi_countable_income: + 2026-01: [957.50, 0] + 2026-07: [1_206, 0] + ssi: + 2026-01: [533.50, 0] + 2026-07: [285, 0] + 2026: [6_153.50, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.yaml index 633a5341e0b..83bdf6c68d4 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.yaml @@ -1013,3 +1013,95 @@ is_ssi_ineligible_parent: [false, false, false] ssi_unearned_income_deemed_from_ineligible_parent: [0, 0, 0] ssi: [0, 0, 11_928] + +# A disabled parent with no cash income receives shelter support from outside the +# household, and the spouse earns 24,000. While the spouse's income is deemed, SSA +# values that support at 1,491 / 6 + 10 = 258.50 a month and counts it with the +# couple's unearned income (POMS SI 01320.150 B.1.a), so one 20 exclusion applies: +# (258.50 - 20) + (2,000 - 65) / 2 = 1,206, and the deemed amount is 1,206 - 238.50 = +# 967.50 a month, or 11,610 a year. The parent's SSI is (1,491 - 1,206) x 12 = 3,420. +# The ineligible-parent test leaves out in-kind support: (24,000 - 240 - 780) / 2 = +# 11,490 is within 17,892, so the parent stays eligible, and 20 CFR 416.1166(e)(1) +# deems nothing to the disabled child, whose SSI is 994 x 12 = 11,928. +- name: Case 28, disabled parent with shelter support and an earning spouse stays eligible. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + own_children_in_household: 1 + meets_ssi_disability_criteria: true + ssi_receives_outside_shelter_support: true + person2: + age: 45 + is_tax_unit_spouse: true + own_children_in_household: 1 + employment_income_before_lsr: 24_000 + person3: + age: 10 + is_tax_unit_dependent: true + meets_ssi_disability_criteria: true + tax_units: + tax_unit: + members: [person1, person2, person3] + marital_units: + marital_unit1: + members: [person1, person2] + marital_unit2: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: OH + output: + is_ssi_ineligible_parent: [false, true, false] + ssi_in_kind_support_and_maintenance: [3_102, 0, 0] + ssi_income_deemed_from_ineligible_spouse: [11_610, 0, 0] + ssi_unearned_income_deemed_from_ineligible_parent: [0, 0, 0] + ssi: [3_420, 0, 11_928] + +# A supplied annual deemed amount decides the parent's test. The spouse earns 12,000, +# which would deem (12,000 - 240 - 780) / 2 = 5,490, but the supplied 18,000 exceeds +# the couple rate of 1,491 x 12 = 17,892, so both parents are ineligible parents. The +# parents' 5,490 after exclusions falls short of the couple rate that 20 CFR +# 416.1165(d)(3)(i) allocates to them, so nothing is deemed to the child, whose SSI is +# 11,928. The parent's countable income of 18,000 / 12 = 1,500 a month exceeds 1,491, +# so the parent's SSI is 0. +- name: Case 29, supplied annual spousal deemed income decides the parent's income test. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + own_children_in_household: 1 + meets_ssi_disability_criteria: true + ssi_income_deemed_from_ineligible_spouse: 18_000 + person2: + age: 45 + is_tax_unit_spouse: true + own_children_in_household: 1 + employment_income_before_lsr: 12_000 + person3: + age: 10 + is_tax_unit_dependent: true + meets_ssi_disability_criteria: true + tax_units: + tax_unit: + members: [person1, person2, person3] + marital_units: + marital_unit1: + members: [person1, person2] + marital_unit2: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: OH + output: + is_ssi_ineligible_parent: [true, true, false] + ssi_unearned_income_deemed_from_ineligible_parent: [0, 0, 0] + ssi: [0, 0, 11_928] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.yaml index 1fd53696d6e..92f25ec5dd0 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/uncapped_ssi_without_spousal_deeming.yaml @@ -135,17 +135,18 @@ # 2024 Federal benefit rates: $943 individual, $1,415 couple; the deeming # threshold is their difference, $472. The spouse in each case below earns -# $500 a month, so deeming applies, and the model deems -# ($500 - $20 - $65) / 2 = $207.50. +# $500 a month, so deeming applies. While food counts as ISM, the +# claimant uses the general exclusion on ISM and spouse wages contribute +# ($500 - $65) / 2 = $217.50. Without ISM, wages contribute $207.50. # Under 20 CFR 416.420(a) a payment counts food from its second prior income # month, and food stops counting on September 30, 2024 (89 FR 21199). -# SI 01320.400 C.2 computes the individual amount from that budget month, so -# the limit follows each month's PMV status. +# Under SI 01320.400 C.2, SSA uses that budget month for the individual +# computation, including that month's PMV status. # Individual PMV: $943 / 3 + $20 = $334.33; less the $20 general exclusion, # $314.33 counts, which leaves $943 - $314.33 = $628.67. # The model's deeming computation counts the deemed PMV, $1,415 / 6 + $10 = -# $245.83, less $20, plus $207.50: $1,415 - $433.33 = $981.67, so the lesser -# is the individual limit. +# $245.83, less $20, plus $217.50: $1,415 - $443.33 = $971.67. +# The individual computation still gives the lesser payment. # Core stores a plain yearly input in a month case for the year that starts # that month, so we key age, earnings and state to 2024 in the month cases. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ok/dhs/ssp/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/ok/dhs/ssp/integration.yaml index 95731b1700c..9aad62d1ba4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ok/dhs/ssp/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ok/dhs/ssp/integration.yaml @@ -476,13 +476,15 @@ # the ineligible spouse's own 994 / 3 + 20 = 351.33 is excluded by # 317:35-5-42(l)(12). ssi_in_kind_support_and_maintenance: [258.5, 351.33] - # Federal: 1_491 - (258.50 - 20 + 700 - 20) = 572.50 (the deeming - # computation applies the $20 exclusion twice; issue 10243). - ssi: [572.5, 0] - # Combined: 258.50 + 700 - 20 + 572.50 = 1_511, the same as when only - # person1 receives the support; min(1_532 - 1_511, 40) = 21. - ok_ssp_countable_income: [1_511, 1_511] - ok_ssp: [21, 0] + # Federal: SSA adds the claimant's support to the spouse's 700 and applies + # one $20 exclusion (POMS SI 01320.150 B.1.a, SI 01320.400 B.1.e): + # 1_491 - (258.50 + 700 - 20) = 552.50, below the individual-only limit + # of 994 - (351.33 - 20) = 662.67 (20 CFR 416.1163(e)(2)). + ssi: [552.5, 0] + # Combined: 258.50 + 700 - 20 + 552.50 = 1_491, the same as when only + # person1 receives the support; min(1_532 - 1_491, 40) = 40. + ok_ssp_countable_income: [1_491, 1_491] + ok_ssp: [40, 0] - name: Case 17, eligible couple's shared in-kind support and maintenance counts once. period: 2026-01 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 caf109a7574..3c1d3cf0a43 100644 --- a/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py @@ -49,7 +49,7 @@ def ssi_state_supplement( payment = max_(standard - excess, 0) deeming = person("is_ssi_spousal_deeming_applies", period.this_year) - deemed = person("ssi_income_deemed_from_ineligible_spouse", period) + deemed = person("ssi_income_deemed_from_ineligible_spouse_monthly", period) own_income = max_(countable - deemed, 0) ssi = parameters(period).gov.ssa.ssi federal_la = person("ssi_federal_living_arrangement", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py new file mode 100644 index 00000000000..98c59d5b951 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py @@ -0,0 +1,34 @@ +"""Read legacy annual inputs without averaging calculated monthly budgets.""" + +from policyengine_core.data_storage.store_history import next_sequence_number + +from policyengine_us.model_api import has_input_for_period + + +def annual_input(person, period, name): + """Return a supplied annual input, or None to calculate the monthly budget. + + A calculated annual aggregate must never replace a month's own budget. + Recording the input-presence dependency also lets Core invalidate cached + monthly budgets when a branch supplies its first annual override later. + """ + simulation = person.simulation + holder = simulation.get_holder(name) + # Core records dependencies by store order. It has no public method for + # this record, so the adapter calls the holder's private one. + holder._record_store(period.this_year, next_sequence_number()) + + # Monthly readers retain dated abolition switches on the annual name. + if simulation.tax_benefit_system.parameters(period).gov.abolitions[name]: + return 0 + + abolished = simulation.tax_benefit_system.parameters( + period.this_year + ).gov.abolitions[name] + if ( + holder.variable.is_neutralized + or abolished + or has_input_for_period(simulation, name, period.this_year) + ): + return person(name, period.this_year) + return None 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 520cf7efbcd..50df6338ffd 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 @@ -29,10 +29,13 @@ The implementation follows these steps according to the regulations: 4. **Calculate combined countable income** (§416.1163(d)(2)) - Combine the eligible individual's and ineligible spouse's incomes - - Apply all appropriate income exclusions to the combined income + - Count the eligible individual's in-kind support and maintenance (ISM) as unearned income, valued at one-sixth of the couple FBR plus $10 at most ([POMS SI 01320.150 B.1.a](https://secure.ssa.gov/poms.nsf/lnx/0501320150)) + - Apply all appropriate income exclusions to the combined income, including one $20 general exclusion ([SI 01320.400 B.1.e](https://secure.ssa.gov/poms.nsf/lnx/0501320400)) 5. **Calculate deemed income amount** - The deemed amount is the difference between the combined countable income and the individual's countable income + - `ssi_income_deemed_from_ineligible_spouse_monthly` computes it each month, with the individual's ISM in both budgets, and `ssi_income_deemed_from_ineligible_spouse` sums the months + - `is_ssi_ineligible_parent` tests a parent's own income without ISM, so it reads a deemed amount without ISM (`_deemed_income_without_ism.py`) 6. **Limit the benefit to the amount without deeming** (§416.1163(e)(2)) - We compare the couple computation with the individual Federal benefit rate less the individual's own countable income (`uncapped_ssi_without_spousal_deeming`) and apply the lesser amount in `uncapped_ssi` before the zero floor. diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/_deemed_income_without_ism.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/_deemed_income_without_ism.py new file mode 100644 index 00000000000..b304a8ec387 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/_deemed_income_without_ism.py @@ -0,0 +1,46 @@ +"""Annual spousal deemed income for a reader that leaves out in-kind support.""" + +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) + + +def ssi_income_deemed_from_ineligible_spouse_without_ism(person, period, parameters): + """Return a year's income deemed from an ineligible spouse without the + claimant's in-kind support and maintenance (ISM). + + is_ssi_ineligible_parent tests a parent's own income without ISM, since + ISM depends on the living arrangement and the living arrangement depends + on that test. This amount puts the spouse's deemed income on the same + basis. ssi_income_deemed_from_ineligible_spouse adds the claimant's ISM to + both budgets (POMS SI 01320.150 B.1.a), so reading it there would close + that loop. A supplied annual ssi_income_deemed_from_ineligible_spouse, or + its neutralization or abolition, still takes precedence. + """ + supplied = annual_input(person, period, "ssi_income_deemed_from_ineligible_spouse") + if supplied is not None: + return supplied + + deeming_applies = person("is_ssi_spousal_deeming_applies", period) + own_earned = person("ssi_earned_income", period) + own_unearned = person("ssi_unearned_income", period) + spouse_earned = person("ssi_earned_income_deemed_from_ineligible_spouse", period) + spouse_unearned = person( + "ssi_unearned_income_deemed_from_ineligible_spouse", period + ) + # 20 CFR 416.1163(d)(2): combined countable income less the claimant's own. + alone_countable = _apply_ssi_exclusions( + own_earned, own_unearned, parameters, period + ) + couple_countable = _apply_ssi_exclusions( + own_earned + spouse_earned, + own_unearned + spouse_unearned, + parameters, + period, + ) + deemed = deeming_applies * max_(0, couple_countable - alone_countable) + return where(person("is_ssi_aged_blind_disabled", period), deemed, 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py index 9e81894574e..996ca6666ef 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py @@ -1,9 +1,4 @@ -# policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py - from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( - _apply_ssi_exclusions, -) class ssi_income_deemed_from_ineligible_spouse(Variable): @@ -12,47 +7,13 @@ class ssi_income_deemed_from_ineligible_spouse(Variable): label = "SSI income (deemed from ineligible spouse)" definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" - documentation = """ - Spousal deeming: - 1) If leftover spouse income <= (coupleFBR - indivFBR), then 0 is deemed. - 2) Otherwise, spouse's deemed = (couple combined countable) - (individual alone countable). - This yields 816 for 1986 Example 3 and 12000 for the 2025 test. - """ + documentation = ( + "Annual income deemed from an ineligible spouse, summing each month's " + "combined countable income less the claimant's own countable income. " + "Supplied annual amounts retain their annual units." + ) defined_for = "is_ssi_aged_blind_disabled" unit = USD def formula(person, period, parameters): - # 1. Check if spousal deeming applies (avoids duplicating threshold logic) - deeming_applies = person("is_ssi_spousal_deeming_applies", period) - - # 2. If deeming applies, calculate deemed amount using "difference" approach: - # (couple combined countable) - (individual alone countable) - - # Get spouse's leftover income (post-child allocations) - spouse_earned = person( - "ssi_earned_income_deemed_from_ineligible_spouse", period - ) - spouse_unearned = person( - "ssi_unearned_income_deemed_from_ineligible_spouse", period - ) - - # Get individual's own income - individual_earned = person("ssi_earned_income", period) - individual_unearned = person("ssi_unearned_income", period) - - # Calculate individual's countable income (alone) - alone_countable = _apply_ssi_exclusions( - individual_earned, individual_unearned, parameters, period - ) - - # Calculate couple's combined countable income - couple_countable = _apply_ssi_exclusions( - individual_earned + spouse_earned, - individual_unearned + spouse_unearned, - parameters, - period, - ) - - # Deemed amount is the difference (only when deeming applies) - deemed_amount = max_(0, couple_countable - alone_countable) - return deeming_applies * deemed_amount + return add(person, period, ["ssi_income_deemed_from_ineligible_spouse_monthly"]) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.py new file mode 100644 index 00000000000..60cf5f0b7dd --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.py @@ -0,0 +1,63 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions_monthly, +) + + +class ssi_income_deemed_from_ineligible_spouse_monthly(Variable): + value_type = float + entity = Person + label = "Monthly SSI income deemed from an ineligible spouse" + definition_period = MONTH + unit = USD + reference = ( + "https://www.ssa.gov/OP_Home/cfr20/416/416-1163.htm", + "https://secure.ssa.gov/poms.nsf/lnx/0501320150", + "https://secure.ssa.gov/poms.nsf/lnx/0501320400", + ) + documentation = ( + "Monthly combined countable income less the claimant's own countable " + "income when spousal deeming applies. Both budgets include the " + "claimant's in-kind support before applying one general exclusion. " + "Supplied legacy annual deemed income takes precedence." + ) + + def formula(person, period, parameters): + supplied = annual_input( + person, period, "ssi_income_deemed_from_ineligible_spouse" + ) + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + deeming_applies = person("is_ssi_spousal_deeming_applies", period.this_year) + spouse_earned = person( + "ssi_earned_income_deemed_from_ineligible_spouse", period + ) + spouse_unearned = person( + "ssi_unearned_income_deemed_from_ineligible_spouse", period + ) + individual_earned = person("ssi_earned_income", period) + # SSA counts claimant ISM in combined unearned income under + # SI 01320.150 B.1.a. Include it in both budgets so their difference + # adds no second general exclusion when ssi_countable_income counts ISM. + individual_unearned = person("ssi_unearned_income", period) + person( + "ssi_in_kind_support_and_maintenance", period + ) + alone_countable = _apply_ssi_exclusions_monthly( + individual_earned, individual_unearned, parameters, period + ) + couple_countable = _apply_ssi_exclusions_monthly( + individual_earned + spouse_earned, + individual_unearned + spouse_unearned, + parameters, + period, + ) + deemed_amount = max_(0, couple_countable - alone_countable) + return where( + person("is_ssi_aged_blind_disabled", period), + deeming_applies * deemed_amount, + 0, + ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income.py index 264a0894ba3..b97ef9062c7 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income.py @@ -74,7 +74,9 @@ def formula(person, period, parameters): both_eligible = person("ssi_couple_computation_applies", period) # Add spousal deemed income (only relevant when one spouse is ineligible) - spousal_deemed = person("ssi_income_deemed_from_ineligible_spouse", period) + spousal_deemed = person( + "ssi_income_deemed_from_ineligible_spouse_monthly", period + ) # People who are not aged, blind, or disabled have no countable income # of their own; a spouse's income is deemed to the other spouse diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.py index cbcd056ce88..4b172a31db3 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_parent.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.income.deemed.from_ineligible_spouse._deemed_income_without_ism import ( + ssi_income_deemed_from_ineligible_spouse_without_ism, +) from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( _ssi_established_spouse_indices, ) @@ -43,13 +46,16 @@ def formula(person, period, parameters): unearned = person("ssi_unearned_income", period) # A joint claim tests the couple's income. Otherwise 416.1163(d)(2) adds # what remains of an ineligible spouse's income after the allocations - # for ineligible children in 416.1163(b). + # for ineligible children in 416.1163(b). Like the parent's own income, + # the deemed amount leaves out in-kind support and maintenance. countable_income = _apply_ssi_exclusions( where(joint_claim, person.marital_unit.sum(earned), earned), where(joint_claim, person.marital_unit.sum(unearned), unearned), parameters, period, - ) + person("ssi_income_deemed_from_ineligible_spouse", period) + ) + ssi_income_deemed_from_ineligible_spouse_without_ism( + person, period, parameters + ) p = parameters(period).gov.ssa.ssi benefit_rate = where( joint_claim | person("is_ssi_spousal_deeming_applies", period), 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 index 8d54fba0db3..62e324c1b4d 100644 --- 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 @@ -39,7 +39,7 @@ def formula(person, period, parameters): # 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 + "ssi_income_deemed_from_ineligible_spouse_monthly", period ) # SSA applies the regular individual PMV to this individual # computation (POMS SI 01320.150 B.1.b and SI 01320.400 C.2). 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 1c3ebaeaa25..caddf021c70 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 @@ -53,7 +53,9 @@ def formula(person, period, parameters): separated, own_alone, person("ssi_countable_income", period) ) payment_standard = person("ca_capi_payment_standard", period) - deemed_income = person("ssi_income_deemed_from_ineligible_spouse", period) + deemed_income = person( + "ssi_income_deemed_from_ineligible_spouse_monthly", period + ) own_income = max_(countable_income - deemed_income, 0) # Deeming needs an ineligible spouse living in the same household # (20 CFR 416.1160(a)(1)), so a Title XIX facility resident counts diff --git a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py index 217e8fe4deb..3866d6061ea 100644 --- a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py +++ b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py @@ -36,7 +36,9 @@ def formula(person, period, parameters): personal_countable = unearned + countable_earned both_eligible = person("ssi_couple_computation_applies", period) - spousal_deemed = person("ssi_income_deemed_from_ineligible_spouse", period) + spousal_deemed = person( + "ssi_income_deemed_from_ineligible_spouse_monthly", period + ) # NOTE: ssi_countable_income now zeroes only people who are not aged, # blind or disabled. This keeps SSI's eligibility gate, which changes # nothing because KY SSP requires is_ssi_eligible. diff --git a/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_imputed_federal_ssi.py b/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_imputed_federal_ssi.py index dae9a715beb..f3e7d87a3f5 100644 --- a/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_imputed_federal_ssi.py +++ b/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_imputed_federal_ssi.py @@ -56,7 +56,9 @@ def formula(person, period, parameters): in_medical_facility, p.medical_facility, p.individual * vtr_factor ) individual_ssi = individual_rate - countable_income - deemed_income = person("ssi_income_deemed_from_ineligible_spouse", period) + deemed_income = person( + "ssi_income_deemed_from_ineligible_spouse_monthly", period + ) couple_ssi = p.couple * vtr_factor - (countable_income + deemed_income) deeming = ( person("is_ssi_spousal_deeming_applies", period.this_year) diff --git a/policyengine_us/variables/gov/states/tx/hhsc/ssi_state_supplement/tx_ssi_state_supplement_eligible.py b/policyengine_us/variables/gov/states/tx/hhsc/ssi_state_supplement/tx_ssi_state_supplement_eligible.py index dbd74169175..fe1fe9cd905 100644 --- a/policyengine_us/variables/gov/states/tx/hhsc/ssi_state_supplement/tx_ssi_state_supplement_eligible.py +++ b/policyengine_us/variables/gov/states/tx/hhsc/ssi_state_supplement/tx_ssi_state_supplement_eligible.py @@ -40,7 +40,7 @@ def formula(person, period, parameters): p = parameters(period).gov.ssa.ssi own_countable_income = max_( person("ssi_countable_income", period) - - person("ssi_income_deemed_from_ineligible_spouse", period), + - person("ssi_income_deemed_from_ineligible_spouse_monthly", period), 0, ) within_reduced_rate = own_countable_income <= p.amount.medical_facility