From a31a5c67e20672082432c5f99e0d90a03ce4ed20 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 00:58:17 -0500 Subject: [PATCH 01/15] Apply SSI's general exclusion once under spousal deeming Include claimant support in both countable-income budgets and retain monthly amounts without changing supplied annual units. Co-Authored-By: Claude Opus 5.5 --- ...fix-ssi-spousal-general-exclusion.fixed.md | 1 + ..._income_deemed_from_ineligible_spouse.yaml | 36 +++++++++++ .../gov/ssa/ssi/_ssi_state_supplement.py | 2 +- .../ssi/eligibility/income/_annual_input.py | 31 +++++++++ ...si_income_deemed_from_ineligible_spouse.py | 51 ++------------- ...e_deemed_from_ineligible_spouse_monthly.py | 63 +++++++++++++++++++ .../income/ssi_countable_income.py | 4 +- .../uncapped_ssi_without_spousal_deeming.py | 2 +- .../gov/states/ca/cdss/capi/ca_capi_person.py | 4 +- .../ky/dcbs/ssp/ky_ssp_countable_income.py | 4 +- .../dhs/fia/paa/md_paa_imputed_federal_ssi.py | 4 +- .../tx_ssi_state_supplement_eligible.py | 2 +- 12 files changed, 152 insertions(+), 52 deletions(-) create mode 100644 changelog.d/fix-ssi-spousal-general-exclusion.fixed.md create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.py 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/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..67fc60f79e4 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] + +# POMS SI 01320.150 B.1.a includes the claimant's ISM in the couple's +# unearned income; SI 01320.400 B.1.e applies one general exclusion. +# 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/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..30e76d941bf --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py @@ -0,0 +1,31 @@ +"""Read legacy annual inputs without averaging calculated monthly budgets.""" + +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 3.33+ records dependencies by store order. Older supported Core + # versions rely on callers clearing branch caches with get_override_branch. + if hasattr(holder, "_record_store"): + from policyengine_core.data_storage.store_history import next_sequence_number + + holder._record_store(period.this_year, next_sequence_number()) + + 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/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..f4d8e8eaf3f --- /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) + # SI 01320.150 B.1.a counts the claimant's ISM in combined unearned + # income. 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/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 From 301b780ccfd2a48e50f213f37cf739b1bdc002ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:01:12 -0500 Subject: [PATCH 02/15] Check SSI spousal support exclusions and monthly budgets Cover cash-income combinations, exclusion boundaries, annual sums, and legacy annual overrides with YAML and generated API checks. Co-Authored-By: Claude Opus 5.5 --- .../core/test_ssi_spousal_ism_invariants.py | 265 ++++++++++ ...deemed_from_ineligible_spouse_monthly.yaml | 490 ++++++++++++++++++ 2 files changed, 755 insertions(+) create mode 100644 policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.yaml 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..ba2c9cfb6fd --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py @@ -0,0 +1,265 @@ +"""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. +""" + +import numpy as np +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 + ) + 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 + ) 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..2f09808ea80 --- /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,490 @@ +# POMS SI 01320.150 B.1.a and SI 01320.400 B.1.e: include claimant ISM +# in combined unearned income before applying one general exclusion. +# 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-07: 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: + # 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] From 9e784dffb47f2fbeaef573b8b5a8ecef11716269 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:07:21 -0500 Subject: [PATCH 03/15] Preserve dated abolition switches in monthly SSI deeming Honor switches on the annual variable when monthly readers request a later month. Co-Authored-By: Claude Opus 5.5 --- ...deemed_from_ineligible_spouse_monthly.yaml | 35 +++++++++++++++++++ .../ssi/eligibility/income/_annual_input.py | 4 +++ 2 files changed, 39 insertions(+) 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 index 2f09808ea80..2d4dcaca42a 100644 --- 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 @@ -488,3 +488,38 @@ 2026-01: [533.50, 0] 2026-07: [285, 0] 2026: [6_153.50, 0] + +- name: A dated legacy abolition switch still zeroes the monthly deemed amount. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.abolitions.ssi_income_deemed_from_ineligible_spouse: + 2026-01-01: false + 2026-07-01: true + people: + claimant: + age: 70 + 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: + # January: ($2,000 - $20 - $65) / 2 = $957.50 deemed; + # SSI = $1,491 - $957.50 = $533.50. + # July: the supplied abolition switch removes deemed income; the + # individual-only limit holds SSI to $994. + ssi_income_deemed_from_ineligible_spouse_monthly: + 2026-01: [957.50, 0] + 2026-07: [0, 0] + ssi: + 2026-01: [533.50, 0] + 2026-07: [994, 0] 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 index 30e76d941bf..413975d4487 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py @@ -19,6 +19,10 @@ def annual_input(person, period, name): 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] From f157506797cd2e9ba0c21ba37d4650d5269c40ef Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:16:44 -0500 Subject: [PATCH 04/15] Check dated SSI switches in the annual input adapter Test parameter lookup dates without constructing another full policy system. Co-Authored-By: Claude Opus 5.5 --- .../core/test_ssi_spousal_ism_invariants.py | 33 +++++++++++++++++ ...deemed_from_ineligible_spouse_monthly.yaml | 35 ------------------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py index ba2c9cfb6fd..ce5fb14b08e 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py @@ -12,7 +12,10 @@ 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 @@ -263,3 +266,33 @@ def test_legacy_annual_override_replaces_cached_monthly_budgets(): np.testing.assert_allclose( simulation.calculate(monthly_name, f"{YEAR}-02"), [957.50, 0], atol=TOLERANCE ) + + +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: [], + ) + 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_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 index 2d4dcaca42a..2f09808ea80 100644 --- 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 @@ -488,38 +488,3 @@ 2026-01: [533.50, 0] 2026-07: [285, 0] 2026: [6_153.50, 0] - -- name: A dated legacy abolition switch still zeroes the monthly deemed amount. - period: 2026 - absolute_error_margin: 0.01 - input: - gov.abolitions.ssi_income_deemed_from_ineligible_spouse: - 2026-01-01: false - 2026-07-01: true - people: - claimant: - age: 70 - 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: - # January: ($2,000 - $20 - $65) / 2 = $957.50 deemed; - # SSI = $1,491 - $957.50 = $533.50. - # July: the supplied abolition switch removes deemed income; the - # individual-only limit holds SSI to $994. - ssi_income_deemed_from_ineligible_spouse_monthly: - 2026-01: [957.50, 0] - 2026-07: [0, 0] - ssi: - 2026-01: [533.50, 0] - 2026-07: [994, 0] From 8ce09fff659aab75f36909167ac220bdfe335e2e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:19:51 -0500 Subject: [PATCH 05/15] Update SSI limit examples for one combined exclusion Keep the individual-only expected payments and show the corrected couple computation. Co-Authored-By: Claude Opus 5.5 --- .../ssi/uncapped_ssi_without_spousal_deeming.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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. From b899eba904a4d36e401a1ab37cd82e84e926ab99 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:31:23 -0500 Subject: [PATCH 06/15] Limit the SSI support example to July End the support status in August so the annual test counts one month of support. Co-Authored-By: Claude Opus 5.5 --- .../ssi_income_deemed_from_ineligible_spouse_monthly.yaml | 2 ++ 1 file changed, 2 insertions(+) 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 index 2f09808ea80..c928cbbdaf5 100644 --- 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 @@ -456,7 +456,9 @@ 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 From 665162040f3ad425b68090416f3968d835280bc2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:43:39 -0500 Subject: [PATCH 07/15] Preserve explicit zero annual SSI deeming overrides Check that a cached branch honors a supplied zero without changing the parent budget. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py index ce5fb14b08e..f2a6c6f7cb0 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py @@ -260,6 +260,10 @@ def test_legacy_annual_override_replaces_cached_monthly_budgets(): 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 ) From 7302fd7480c93c1257fbbc8defeb1be4796ef28b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 02:00:54 -0500 Subject: [PATCH 08/15] Name SSA in the spousal deeming source comments Co-Authored-By: Claude Opus 5.5 --- .../ssi_income_deemed_from_ineligible_spouse.yaml | 4 ++-- .../ssi_income_deemed_from_ineligible_spouse_monthly.yaml | 4 ++-- .../ssi_income_deemed_from_ineligible_spouse_monthly.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) 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 67fc60f79e4..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 @@ -306,8 +306,8 @@ # Alone: 0. Deemed: 11,592 - 0 = 11,592. ssi_income_deemed_from_ineligible_spouse: [0, 0, 11_592, 0] -# POMS SI 01320.150 B.1.a includes the claimant's ISM in the couple's -# unearned income; SI 01320.400 B.1.e applies one general exclusion. +# 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. 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 index c928cbbdaf5..2257be84a6c 100644 --- 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 @@ -1,5 +1,5 @@ -# POMS SI 01320.150 B.1.a and SI 01320.400 B.1.e: include claimant ISM -# in combined unearned income before applying one general exclusion. +# 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 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 index f4d8e8eaf3f..60cf5f0b7dd 100644 --- 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 @@ -40,9 +40,9 @@ def formula(person, period, parameters): "ssi_unearned_income_deemed_from_ineligible_spouse", period ) individual_earned = person("ssi_earned_income", period) - # SI 01320.150 B.1.a counts the claimant's ISM in combined unearned - # income. Include it in both budgets so their difference adds no - # second general exclusion when ssi_countable_income counts ISM. + # 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 ) From 7a65b16baf2697b4c61d9d3b35725828f89baf82 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 02:19:46 -0500 Subject: [PATCH 09/15] Document the monthly SSI deemed-income variable Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-ssi-spousal-general-exclusion.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/fix-ssi-spousal-general-exclusion.added.md 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. From 9e7c819cae9ca8ed0125c2b194482e47225e2a9e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 13 Nov 2026 03:55:23 -0500 Subject: [PATCH 10/15] Update SSI formula mocks for monthly deemed income Supply the monthly variable to the existing direct-formula checks. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_ssi_monthly_ism_properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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), } From 1572a80af60ce4a3b72adc1901d21381453987a4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 13 Nov 2026 18:20:57 -0500 Subject: [PATCH 11/15] Keep in-kind support out of the SSI ineligible-parent test The ineligible-parent test from main read the annual spousal deemed amount, and that amount now sums monthly budgets that count the claimant's in-kind support. Support depends on the living arrangement, which reads the ineligible-parent test, so every SSI calculation raised a CycleError after the merge. The test now computes the spouse's deemed amount without in-kind support, as it already does the parent's own income, and still takes a supplied, neutralized or abolished annual amount. Two cases cover a parent with shelter support and a parent whose supplied deemed amount decides the test. Co-Authored-By: Claude Opus 5.5 --- .../status/is_ssi_ineligible_parent.yaml | 92 +++++++++++++++++++ .../_deemed_income_without_ism.py | 46 ++++++++++ .../status/is_ssi_ineligible_parent.py | 10 +- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/_deemed_income_without_ism.py 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/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/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), From 3462ebdce370dbd1296954cb4b55da723794b0e4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 13 Nov 2026 18:20:58 -0500 Subject: [PATCH 12/15] Call Core's store record directly in the SSI annual-input adapter Core 3.33.0 and later define the holder's store record, and the package requires Core 3.33.1 or later, so the hasattr fallback never ran. The adapter test's holder double now carries the method. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_ssi_spousal_ism_invariants.py | 1 + .../gov/ssa/ssi/eligibility/income/_annual_input.py | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py index f2a6c6f7cb0..81622784200 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py @@ -290,6 +290,7 @@ def __call__(self, period): 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()), 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 index 413975d4487..98c59d5b951 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_annual_input.py @@ -1,5 +1,7 @@ """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 @@ -12,12 +14,9 @@ def annual_input(person, period, name): """ simulation = person.simulation holder = simulation.get_holder(name) - # Core 3.33+ records dependencies by store order. Older supported Core - # versions rely on callers clearing branch caches with get_override_branch. - if hasattr(holder, "_record_store"): - from policyengine_core.data_storage.store_history import next_sequence_number - - holder._record_store(period.this_year, next_sequence_number()) + # 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]: From 0b1f74e4318f97c171aa6a39a361d1c82bcdd769 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 13 Nov 2026 18:20:58 -0500 Subject: [PATCH 13/15] Document in-kind support in the SSI spousal deeming steps Co-Authored-By: Claude Opus 5.5 --- .../income/deemed/from_ineligible_spouse/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From 833c81aaf67e9c6f08bbce973010609c37800220 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 13 Nov 2026 18:31:49 -0500 Subject: [PATCH 14/15] Test a branch override against a cached SSI ineligible-parent test A branch can supply annual deemed income after the simulation has already tested the parent. The branch then drops that result and tests the parent on the supplied amount. YAML supplies its inputs before any calculation, so a Python test covers this. Co-Authored-By: Claude Fable 5.2 --- .../core/test_ssi_spousal_ism_invariants.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py index 81622784200..a440246ff7d 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py +++ b/policyengine_us/tests/core/test_ssi_spousal_ism_invariants.py @@ -272,6 +272,56 @@ def test_legacy_annual_override_replaces_cached_monthly_budgets(): ) +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 1a83b5ce4dd4c9e01cda9b3137fcc4cb8d30e9cb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 14 Nov 2026 12:56:01 -0500 Subject: [PATCH 15/15] Update the Oklahoma supplement case for one SSI general exclusion SSA adds the claimant's in-kind support to the ineligible spouse's income and applies one $20 exclusion (POMS SI 01320.150 B.1.a and SI 01320.400 B.1.e; 20 CFR 416.1163(d)(2)). In Case 16, SSI falls from $572.50 to $552.50: 1,491 - (258.50 + 700 - 20). Oklahoma's combined countable income falls from $1,511 to $1,491, and the supplement rises from $21 to $40: min(1,532 - 1,491, 40). Co-Authored-By: Claude Fable 5.2 --- .../gov/states/ok/dhs/ssp/integration.yaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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