From 0ccaa0f34788d7e21b5630775b93eb495e2ba8c6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:30:52 -0400 Subject: [PATCH 01/27] Limit SSI spousal-deeming child allocations to the couple's co-resident children 20 CFR 416.1163(b) deducts an allocation for each ineligible child, which 416.1160(d) defines as the claimant's or the spouse's child who lives in the same household and is not eligible for SSI. The allocations were summed over the claimant's tax unit, so grandchildren and other relatives filed there counted, the couple's children filed elsewhere did not, and tax-unit members living in another household did. - New ssi_spousal_deeming_child_allocations sums allocations over co-resident children whose parent ids name the claimant or the spouse. Without ids it keeps the tax-unit grouping (extended to a separately filing spouse), restricted to co-residents and, in households with links, to unlinked parents, as the parent-link contract requires. - is_ssi_ineligible_child applies 416.1856 (under 18, or under 22 and a student; unmarried; not a household head) and treats aged, blind or disabled children without qualifying immigration status as ineligible. - ssi_ineligible_child_allocation makes no allocation for a child receiving public income-maintenance payments (new input) and excludes a student child's wages up to the 416.1112(c)(3) amounts (416.1161(c)). - Earned and unearned deeming now read the same allocation total; the earned side no longer multiplies it by the person's ABD flag. Co-Authored-By: Claude Opus 5.5 --- ...ineligible-child-allocation-scope.fixed.md | 1 + .../eligibility/child/student_age_limit.yaml | 13 + ...t_ssi_spousal_deeming_child_allocations.py | 358 +++++++++++ ...ssi_spousal_deeming_child_allocations.yaml | 591 ++++++++++++++++++ .../ssi_ineligible_child_allocation.yaml | 48 ++ .../status/is_ssi_ineligible_child.yaml | 83 +++ .../deemed/from_ineligible_spouse/README.md | 3 + ...ed_income_deemed_from_ineligible_spouse.py | 4 +- .../ssi_spousal_deeming_child_allocations.py | 75 +++ ...ed_income_deemed_from_ineligible_spouse.py | 4 +- .../deemed/ssi_ineligible_child_allocation.py | 40 +- .../status/is_ssi_ineligible_child.py | 43 +- .../ssi_receives_public_income_maintenance.py | 20 + 13 files changed, 1265 insertions(+), 18 deletions(-) create mode 100644 changelog.d/ssi-ineligible-child-allocation-scope.fixed.md create mode 100644 policyengine_us/parameters/gov/ssa/ssi/eligibility/child/student_age_limit.yaml create mode 100644 policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md new file mode 100644 index 00000000000..e2a7f2bdbf5 --- /dev/null +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -0,0 +1 @@ +Limit the SSI ineligible-child allocation in spousal deeming to children of the claimant or the claimant's spouse who live with the claimant (20 CFR 416.1160(d), 416.1163(b)), using parent ids where supplied. Apply the SSI definition of a child (416.1856), treat children without qualifying immigration status as not eligible for SSI, exclude a student child's wages up to the student exclusion (416.1161(c)), and add an input for children receiving public income-maintenance payments, who get no allocation. diff --git a/policyengine_us/parameters/gov/ssa/ssi/eligibility/child/student_age_limit.yaml b/policyengine_us/parameters/gov/ssa/ssi/eligibility/child/student_age_limit.yaml new file mode 100644 index 00000000000..607f0f7986e --- /dev/null +++ b/policyengine_us/parameters/gov/ssa/ssi/eligibility/child/student_age_limit.yaml @@ -0,0 +1,13 @@ +description: The Social Security Administration treats a student regularly attending school as a child under the Supplemental Security Income program below this age. + +values: + 1974-01-01: 22 +metadata: + label: SSI child student age limit + unit: year + period: year + reference: + - title: 42 U.S.C. § 1382c(c) - Definition of child + href: https://www.law.cornell.edu/uscode/text/42/1382c#c + - title: 20 CFR § 416.1856(a)(2) - Who is considered a child + href: https://www.ecfr.gov/current/title-20/section-416.1856#p-416.1856(a)(2) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py new file mode 100644 index 00000000000..8fcb452d8ca --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py @@ -0,0 +1,358 @@ +"""Invariants of the SSI ineligible-child allocation in spousal deeming. + +20 CFR 416.1163(b) deducts an allocation from an ineligible spouse's income +for each ineligible child: 'your natural child or adopted child, or the +natural or adopted child of your spouse ... who lives in the same household +with you, and is not eligible for SSI benefits' (416.1160(d)). Each +Hypothesis example builds a batch of random households (a married couple, +their children, step-children, grandchildren, unrelated children and other +adults, some living elsewhere, filed in assorted tax units, with parent ids +on everyone, no one or some people) and checks: + +1. Differential: every person's ssi_spousal_deeming_child_allocations + equals an oracle written here from the regulation and the parent-link + contract in household/demographic/person/_parent_links.py, without the + model. The oracle knows each person's true parents, so with ids it is + the regulation itself. Without ids it is the documented proxy: the + co-resident members of the person's or the spouse's tax unit, and in a + household with links only for an unlinked parent. +2. Spouses see the same total: a child of either spouse is a child of 'you + or your spouse' for both. +3. Order: listing each household's people in reverse gives the same totals. +4. Monotonicity: raising every child's unearned income never raises a + total (416.1163(b)(2)). +5. Deemed unearned income is the spouse's unearned income less the total, + floored at zero (416.1163(b)(3)). + +YAML covers the worked cases. This file routes to the Rest/core group. +""" + +import numpy as np +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2026 +# 416.1163(b)(1): the couple rate ($1,491) less the individual rate ($994). +MONTHLY_ALLOCATION = 1_491 - 994 +CHILD_AGE = 18 # 416.1856(a)(1) +STUDENT_AGE = 22 # 416.1856(a)(2) +ABSENT_PARENT_ID = 999_999 +HOUSEHOLDS_PER_EXAMPLE = 30 + +CHILD_KINDS = ("claimant", "spouse", "both", "grandchild", "unrelated") +OTHER = st.fixed_dictionaries( + { + "kind": st.sampled_from(CHILD_KINDS + ("adult",)), + "age": st.integers(0, 23), + "student": st.booleans(), + "monthly_unearned": st.sampled_from((0, 150, 600)), + # About one in four lives in another household. + "away": st.sampled_from((False, False, False, True)), + "tax_unit": st.sampled_from(("couple", "spouse", "own", "adult")), + "has_ids": st.booleans(), + } +) +HOUSEHOLD = st.fixed_dictionaries( + { + "aged_claimant": st.booleans(), + "spouse_unearned": st.sampled_from((0, 300, 600, 1_200)), + "spouse_files_separately": st.booleans(), + "ids": st.sampled_from(("none", "all", "some")), + "report_own_children": st.booleans(), + "others": st.lists(OTHER, min_size=0, max_size=5), + } +) +BATCH = st.lists( + HOUSEHOLD, min_size=HOUSEHOLDS_PER_EXAMPLE, max_size=HOUSEHOLDS_PER_EXAMPLE +) + + +def _people(index, household): + """Return each person's facts, with true parents and tax unit.""" + claimant, spouse = f"h{index}_claimant", f"h{index}_spouse" + people = [ + dict( + name=claimant, + age=70 if household["aged_claimant"] else 45, + disabled=not household["aged_claimant"], + unearned=0, + student=False, + parents=(), + away=False, + tax_unit="couple", + role="head", + ), + dict( + name=spouse, + age=50, + disabled=False, + unearned=household["spouse_unearned"], + student=False, + parents=(), + away=False, + tax_unit="spouse" if household["spouse_files_separately"] else "couple", + role="head" if household["spouse_files_separately"] else "spouse", + ), + ] + # The first other adult can parent grandchildren and head a tax unit. + adult_position = next( + (p for p, o in enumerate(household["others"]) if o["kind"] == "adult"), + None, + ) + adult = None if adult_position is None else f"h{index}_o{adult_position}" + for position, other in enumerate(household["others"]): + name = f"h{index}_o{position}" + kind = other["kind"] + parents = { + "claimant": (claimant,), + "spouse": (spouse, None), + "both": (claimant, spouse), + "grandchild": (adult,) if adult else (None,), + "unrelated": (None,), + "adult": (), + }[kind] + tax_unit = other["tax_unit"] + if tax_unit == "spouse" and not household["spouse_files_separately"]: + tax_unit = "couple" + if tax_unit == "adult" and adult is None: + tax_unit = "own" + if kind == "adult": + tax_unit = "adult" if name == adult else "own" + people.append( + dict( + name=name, + age=other["age"] + (30 if kind == "adult" else 0), + disabled=False, + unearned=other["monthly_unearned"], + student=other["student"], + # None is a parent who lives outside the simulation. + parents=parents, + away=other["away"] and kind != "adult", + tax_unit=tax_unit, + role="head" if tax_unit == "own" or name == adult else "dependent", + has_ids=other["has_ids"], + ) + ) + ids_mode = household["ids"] + for person in people: + person["has_ids"] = bool(person["parents"]) and ( + ids_mode == "all" or (ids_mode == "some" and person.get("has_ids")) + ) + return people + + +def _tax_unit_key(index, person): + if person["tax_unit"] == "own": + return f"{person['name']}_tu" + return f"h{index}_{person['tax_unit']}_tu" + + +def _situation(households, reverse=False, income_rise=0): + situation = { + plural: {} + for plural in ( + "people", + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ) + } + facts = {} + person_ids = {} + for index, household in enumerate(households): + people = _people(index, household) + if reverse: + people = people[::-1] + for number, person in enumerate(people): + person_ids[person["name"]] = 1_000 * (index + 1) + number + 1 + # Each spouse reports their co-resident children when asked. + own_children = {} + for person in people: + if not person["away"]: + for parent in person["parents"]: + own_children[parent] = own_children.get(parent, 0) + 1 + for person in people: + facts[person["name"]] = dict(person, household=index) + child = person["age"] < CHILD_AGE + parent_ids = [ + ( + person_ids.get(parent, ABSENT_PARENT_ID) + if parent + else ABSENT_PARENT_ID + ) + for parent in person["parents"] + ] + parent_ids = (parent_ids + [0, 0])[:2] if person["has_ids"] else [0, 0] + reports = household["report_own_children"] and person["name"].endswith( + ("_claimant", "_spouse") + ) + situation["people"][person["name"]] = { + "age": {YEAR: person["age"]}, + "is_ssi_disabled": {YEAR: person["disabled"]}, + "ssi_unearned_income": { + YEAR: 12 * (person["unearned"] + (income_rise if child else 0)) + }, + "is_full_time_college_student": {YEAR: person["student"]}, + "is_in_k12_school": {YEAR: False}, + "person_id": {YEAR: person_ids[person["name"]]}, + "parent_1_id": {YEAR: parent_ids[0]}, + "parent_2_id": {YEAR: parent_ids[1]}, + "own_children_in_household": { + YEAR: own_children.get(person["name"], 0) if reports else 0 + }, + "is_tax_unit_head": {YEAR: person["role"] == "head"}, + "is_tax_unit_spouse": {YEAR: person["role"] == "spouse"}, + "is_tax_unit_dependent": {YEAR: person["role"] == "dependent"}, + } + units = { + "tax_units": _tax_unit_key(index, person), + "households": ( + f"{person['name']}_away" if person["away"] else f"h{index}_home" + ), + "marital_units": ( + f"h{index}_couple" + if person["name"].endswith(("_claimant", "_spouse")) + else f"{person['name']}_mu" + ), + } + units["families"] = units["households"] + units["spm_units"] = units["households"] + for plural, key in units.items(): + group = situation[plural].setdefault(key, {"members": []}) + group["members"].append(person["name"]) + if plural == "households": + group["state_code"] = {YEAR: "TX"} + return situation, facts + + +def _allocation(person): + """416.1163(b)(1)-(2) for the oracle's people, who have no earnings.""" + child = person["age"] < CHILD_AGE or ( + person["age"] < STUDENT_AGE and person["student"] + ) + if not child or person["disabled"]: + return 0 + return max(0, 12 * (MONTHLY_ALLOCATION - person["unearned"])) + + +def _oracle(facts): + """Each person's total, from true relationships and the id contract.""" + names = list(facts) + spouse_of = {} + for name in names: + if name.endswith("_claimant"): + spouse_of[name] = name.replace("_claimant", "_spouse") + elif name.endswith("_spouse"): + spouse_of[name] = name.replace("_spouse", "_claimant") + + def home(name): + person = facts[name] + return (person["household"], person["away"] and name) + + def tax_unit(name): + return _tax_unit_key(facts[name]["household"], facts[name]) + + def co_resident_named_parents(name): + if not facts[name]["has_ids"]: + return set() + return { + parent + for parent in facts[name]["parents"] + if parent and home(parent) == home(name) + } + + def linked_count(name): + return sum(name in co_resident_named_parents(other) for other in names) + + def household_linked(name): + return any( + facts[other]["has_ids"] for other in names if home(other) == home(name) + ) + + def unlinked_parent(name): + own = facts[name].get("reported_children", 0) + linked = linked_count(name) + return (own > 0 or linked > 0) and (linked == 0 or own > linked) + + totals = {} + for name in names: + selves = {name} | ({spouse_of[name]} if name in spouse_of else set()) + presume = not household_linked(name) or any( + unlinked_parent(member) for member in selves + ) + units = {tax_unit(member) for member in selves} + total = 0 + for other in names: + if other in selves or home(other) != home(name): + continue + if facts[other]["has_ids"]: + counts = bool(co_resident_named_parents(other) & selves) + else: + counts = presume and tax_unit(other) in units + total += counts * _allocation(facts[other]) + totals[name] = total + return totals + + +def _record_reported_children(situation, facts): + for name, person in facts.items(): + person["reported_children"] = situation["people"][name][ + "own_children_in_household" + ][YEAR] + + +def _calculate(situation, variables): + simulation = Simulation(situation=situation) + names = list(situation["people"]) + return { + variable: dict(zip(names, simulation.calculate(variable, YEAR))) + for variable in variables + } + + +@settings(max_examples=3, derandomize=True, deadline=None) +@given(BATCH) +def test_allocations_match_oracle_and_invariants(households): + variables = ( + "ssi_spousal_deeming_child_allocations", + "ssi_unearned_income_deemed_from_ineligible_spouse", + "is_ssi_ineligible_spouse", + ) + situation, facts = _situation(households) + _record_reported_children(situation, facts) + results = _calculate(situation, variables) + totals = results["ssi_spousal_deeming_child_allocations"] + oracle = _oracle(facts) + + # 1. Differential against the oracle. + for name in facts: + assert totals[name] == oracle[name], name + + for index in range(len(households)): + claimant, spouse = f"h{index}_claimant", f"h{index}_spouse" + # 2. Both spouses see the same children. + assert totals[claimant] == totals[spouse] + # 5. Deemed unearned income, 416.1163(b)(3). + assert results["is_ssi_ineligible_spouse"][spouse] + spouse_unearned = 12 * facts[spouse]["unearned"] + assert results["ssi_unearned_income_deemed_from_ineligible_spouse"][ + claimant + ] == max(0, spouse_unearned - totals[claimant]) + + # 3. Order of people. + reversed_situation, _ = _situation(households, reverse=True) + reversed_totals = _calculate( + reversed_situation, ("ssi_spousal_deeming_child_allocations",) + )["ssi_spousal_deeming_child_allocations"] + assert reversed_totals == totals + + # 4. Children's income never raises a total. + richer_situation, _ = _situation(households, income_rise=100) + richer_totals = _calculate( + richer_situation, ("ssi_spousal_deeming_child_allocations",) + )["ssi_spousal_deeming_child_allocations"] + assert all(richer_totals[name] <= totals[name] for name in totals) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml new file mode 100644 index 00000000000..a2ef5c6a0c6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml @@ -0,0 +1,591 @@ +# 20 CFR 416.1163(b): before deeming an ineligible spouse's income, SSI +# deducts an allocation for each ineligible child: 'your natural child or +# adopted child, or the natural or adopted child of your spouse ... who lives +# in the same household with you, and is not eligible for SSI benefits' +# (416.1160(d)). The allocation is the couple rate less the individual rate +# (416.1163(b)(1)): $168 a month in 1986 ($504 - $336) and $497 a month in +# 2026 ($1,491 - $994). Allocations come out of unearned income first, then +# earned income (416.1163(b)(3)). Deeming applies when the spouse's remaining +# income is more than that difference (416.1163(d)). + +- name: 416.1163(g) Example 1, the Todds, with parent ids + period: 1986 + absolute_error_margin: 0 + input: + people: + mr_todd: + age: 70 + person_id: 1 + is_tax_unit_head: true + mrs_todd: + age: 60 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 252 * 12 + mike: + age: 10 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [mr_todd, mrs_todd, mike] + marital_units: + todds: + members: [mr_todd, mrs_todd] + mike_alone: + members: [mike] + households: + household: + members: [mr_todd, mrs_todd, mike] + state_code: TX + output: + is_ssi_ineligible_child: [false, false, true] + ssi_ineligible_child_allocation: [0, 0, 2_016] # $168 * 12 + # Mike is the child of both spouses, so each sees the same allocation. + ssi_spousal_deeming_child_allocations: [2_016, 2_016, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [1_008, 0, 0] # ($252 - $168) * 12 + # '$84 remaining income is not more than $168': nothing is deemed. + is_ssi_spousal_deeming_applies: [false, false, false] + ssi_income_deemed_from_ineligible_spouse: [0, 0, 0] + +- name: 416.1163(g) Example 2, the Joneses, with parent ids + period: 1986 + absolute_error_margin: 0 + input: + people: + mr_jones: + age: 50 + is_ssi_disabled: true + person_id: 1 + is_tax_unit_head: true + mrs_jones: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + ssi_earned_income: 401 * 12 + ssi_unearned_income: 252 * 12 + christine: + age: 8 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [mr_jones, mrs_jones, christine] + marital_units: + joneses: + members: [mr_jones, mrs_jones] + christine_alone: + members: [christine] + households: + household: + members: [mr_jones, mrs_jones, christine] + state_code: TX + output: + ssi_spousal_deeming_child_allocations: [2_016, 2_016, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [1_008, 0, 0] # $84 * 12 + ssi_earned_income_deemed_from_ineligible_spouse: [4_812, 0, 0] # $401 * 12 + # '$84 unearned plus $401 earned is more than $168'. + is_ssi_spousal_deeming_applies: [true, false, false] + # $64 unearned after the $20 exclusion plus $168 earned after $65 and + # half: $232 a month. + ssi_income_deemed_from_ineligible_spouse: [2_784, 0, 0] + ssi: [3_264, 0, 0] # ($504 - $232) * 12 + +- name: Example 1 when Mike receives public income-maintenance payments + # 416.1163(b): 'We do not allocate for ineligible children who are + # receiving public income-maintenance payments.' All of Mrs. Todd's $252 + # remains, which is more than $168, so it is deemed. + period: 1986 + absolute_error_margin: 0 + input: + people: + mr_todd: + age: 70 + person_id: 1 + is_tax_unit_head: true + mrs_todd: + age: 60 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 252 * 12 + mike: + age: 10 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + ssi_receives_public_income_maintenance: true + tax_units: + tax_unit: + members: [mr_todd, mrs_todd, mike] + marital_units: + todds: + members: [mr_todd, mrs_todd] + mike_alone: + members: [mike] + households: + household: + members: [mr_todd, mrs_todd, mike] + state_code: TX + output: + is_ssi_ineligible_child: [false, false, true] + ssi_ineligible_child_allocation: [0, 0, 0] + ssi_spousal_deeming_child_allocations: [0, 0, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [3_024, 0, 0] # $252 * 12 + is_ssi_spousal_deeming_applies: [true, false, false] + ssi_income_deemed_from_ineligible_spouse: [2_784, 0, 0] # ($252 - $20) * 12 + ssi: [3_264, 0, 0] # ($504 - $232) * 12 + +- name: A grandchild in the claimant's tax unit is not the claimant's child + # The grandchild's id names their father, the claimants' son, who files + # alone. The grandparents claim the grandchild, but the grandchild is + # neither spouse's child, so no allocation is made. + period: 2026 + absolute_error_margin: 0 + input: + people: + grandmother: + age: 70 + person_id: 1 + is_tax_unit_head: true + grandfather: + age: 62 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + father: + age: 35 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_head: true + grandchild: + age: 8 + person_id: 4 + parent_1_id: 3 + is_tax_unit_dependent: true + tax_units: + grandparents: + members: [grandmother, grandfather, grandchild] + father_alone: + members: [father] + marital_units: + grandparents: + members: [grandmother, grandfather] + father_alone: + members: [father] + grandchild_alone: + members: [grandchild] + households: + household: + members: [grandmother, grandfather, father, grandchild] + state_code: TX + output: + is_ssi_ineligible_child: [false, false, false, true] + # The father's own total holds his child's allocation; he has no spouse, + # so nothing is deemed with it. + ssi_spousal_deeming_child_allocations: [0, 0, 5_964, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [7_200, 0, 0, 0] + # $600 is more than $497. + is_ssi_spousal_deeming_applies: [true, false, false, false] + ssi_income_deemed_from_ineligible_spouse: [6_960, 0, 0, 0] # ($600 - $20) * 12 + ssi: [10_932, 0, 0, 0] # ($1,491 - $580) * 12 + +- name: Without parent ids the tax-unit proxy still allocates for a grandchild + # The same household with no parent ids. The parent-link contract keeps + # the tax-unit grouping when no member has ids, and a grandchild there + # cannot be told apart from a child. Supplying ids, as above, avoids this. + period: 2026 + absolute_error_margin: 0 + input: + people: + grandmother: + age: 70 + is_tax_unit_head: true + grandfather: + age: 62 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + father: + age: 35 + is_tax_unit_head: true + grandchild: + age: 8 + is_tax_unit_dependent: true + tax_units: + grandparents: + members: [grandmother, grandfather, grandchild] + father_alone: + members: [father] + marital_units: + grandparents: + members: [grandmother, grandfather] + father_alone: + members: [father] + grandchild_alone: + members: [grandchild] + households: + household: + members: [grandmother, grandfather, father, grandchild] + state_code: TX + output: + ssi_spousal_deeming_child_allocations: [5_964, 5_964, 0, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [1_236, 0, 0, 0] # ($600 - $497) * 12 + is_ssi_spousal_deeming_applies: [false, false, false, false] + ssi: [11_928, 0, 0, 0] # $994 * 12 + +- name: The spouse's child from another relationship, claimed by their other parent + # The stepson lives with his father and his father's wife, the claimant. + # His mother lives elsewhere and claims him. He is 'the natural or adopted + # child of your spouse ... who lives in the same household with you'. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_ssi_disabled: true + person_id: 1 + is_tax_unit_head: true + husband: + age: 42 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + stepson: + age: 12 + person_id: 3 + parent_1_id: 2 + parent_2_id: 4 + is_tax_unit_dependent: true + mother: + age: 38 + person_id: 4 + is_tax_unit_head: true + tax_units: + couple: + members: [claimant, husband] + mother: + members: [mother, stepson] + marital_units: + couple: + members: [claimant, husband] + stepson_alone: + members: [stepson] + mother_alone: + members: [mother] + households: + home: + members: [claimant, husband, stepson] + state_code: TX + elsewhere: + members: [mother] + state_code: TX + output: + is_ssi_ineligible_child: [false, false, true, false] + # The mother does not live with her son, so her total is zero. + ssi_spousal_deeming_child_allocations: [5_964, 5_964, 0, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [1_236, 0, 0, 0] # ($600 - $497) * 12 + # $103 is not more than $497. + is_ssi_spousal_deeming_applies: [false, false, false, false] + ssi_income_deemed_from_ineligible_spouse: [0, 0, 0, 0] + ssi: [11_928, 0, 0, 0] # $994 * 12 + +- name: The claimants' own child, claimed in another tax unit, still counts + # Their daughter lives with them but her grandmother claims her. Tax + # filing does not change whose child she is or where she lives. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 45 + is_ssi_disabled: true + person_id: 1 + parent_1_id: 4 + is_tax_unit_head: true + husband: + age: 47 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + daughter: + age: 15 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + grandmother: + age: 64 + person_id: 4 + is_tax_unit_head: true + tax_units: + couple: + members: [claimant, husband] + grandmother: + members: [grandmother, daughter] + marital_units: + couple: + members: [claimant, husband] + daughter_alone: + members: [daughter] + grandmother_alone: + members: [grandmother] + households: + household: + members: [claimant, husband, daughter, grandmother] + state_code: TX + output: + ssi_spousal_deeming_child_allocations: [5_964, 5_964, 0, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [1_236, 0, 0, 0] + is_ssi_spousal_deeming_applies: [false, false, false, false] + ssi_income_deemed_from_ineligible_spouse: [0, 0, 0, 0] + ssi: [11_928, 0, 0, 0] + +- name: A 19-year-old student who files her own return counts, and her wages do not reduce it + # 416.1856: a student under 22 regularly attending school is a child. + # 416.1161(c) excludes a student child's earned income up to the + # 416.1112(c)(3) amounts ($2,410 a month in 2026), so her $300 a month of + # wages leaves the full allocation. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 50 + is_ssi_disabled: true + person_id: 1 + is_tax_unit_head: true + husband: + age: 52 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + daughter: + age: 19 + is_full_time_college_student: true + employment_income: 300 * 12 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_head: true + tax_units: + couple: + members: [claimant, husband] + daughter: + members: [daughter] + marital_units: + couple: + members: [claimant, husband] + daughter_alone: + members: [daughter] + households: + household: + members: [claimant, husband, daughter] + state_code: TX + output: + is_ssi_ineligible_child: [false, false, true] + ssi_ineligible_child_allocation: [0, 0, 5_964] + ssi_spousal_deeming_child_allocations: [5_964, 5_964, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [1_236, 0, 0] + is_ssi_spousal_deeming_applies: [false, false, false] + ssi: [11_928, 0, 0] + +- name: A child in the tax unit who lives in another household does not count + # No member has parent ids, so the tax-unit proxy applies, but only to + # children who live with the claimant: 'who lives in the same household + # with you'. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 70 + is_tax_unit_head: true + wife: + age: 60 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + son: + age: 16 + is_tax_unit_dependent: true + aunt: + age: 40 + is_tax_unit_head: true + tax_units: + family: + members: [claimant, wife, son] + aunt: + members: [aunt] + marital_units: + couple: + members: [claimant, wife] + son_alone: + members: [son] + aunt_alone: + members: [aunt] + households: + home: + members: [claimant, wife] + state_code: TX + away: + members: [son, aunt] + state_code: TX + output: + is_ssi_ineligible_child: [false, false, true, false] + ssi_spousal_deeming_child_allocations: [0, 0, 0, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [7_200, 0, 0, 0] + is_ssi_spousal_deeming_applies: [true, false, false, false] + ssi_income_deemed_from_ineligible_spouse: [6_960, 0, 0, 0] # ($600 - $20) * 12 + ssi: [10_932, 0, 0, 0] # ($1,491 - $580) * 12 + +- name: With parent links, a child without ids is presumed only for an unlinked parent + # The first child's ids name both spouses. The second has none. Neither + # spouse has a child that no id names (own_children_in_household is not + # supplied), so the parent-link contract does not presume the second + # child is theirs. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 70 + person_id: 1 + is_tax_unit_head: true + husband: + age: 62 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + linked_child: + age: 10 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + child_without_ids: + age: 8 + person_id: 4 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [claimant, husband, linked_child, child_without_ids] + marital_units: + couple: + members: [claimant, husband] + linked_child_alone: + members: [linked_child] + child_without_ids_alone: + members: [child_without_ids] + households: + household: + members: [claimant, husband, linked_child, child_without_ids] + state_code: TX + output: + ssi_spousal_deeming_child_allocations: [5_964, 5_964, 0, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [1_236, 0, 0, 0] + +- name: With parent links, a child without ids counts for an unlinked parent + # The claimant reports two children in the household but ids name only + # one, so the claimant is an unlinked parent and the second child, in the + # claimant's tax unit, is presumed to be theirs. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 70 + person_id: 1 + own_children_in_household: 2 + is_tax_unit_head: true + husband: + age: 62 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + linked_child: + age: 10 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + child_without_ids: + age: 8 + person_id: 4 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [claimant, husband, linked_child, child_without_ids] + marital_units: + couple: + members: [claimant, husband] + linked_child_alone: + members: [linked_child] + child_without_ids_alone: + members: [child_without_ids] + households: + household: + members: [claimant, husband, linked_child, child_without_ids] + state_code: TX + output: + ssi_spousal_deeming_child_allocations: [11_928, 11_928, 0, 0] + # The $11,928 of allocations exceed the husband's $7,200. + ssi_unearned_income_deemed_from_ineligible_spouse: [0, 0, 0, 0] + is_ssi_spousal_deeming_applies: [false, false, false, false] + +- name: Allocations beyond unearned income come out of earned income + # 416.1163(b)(3). Two children: $994 a month of allocations against $200 + # of unearned income leaves $794 to take from $1,500 of earnings: $706. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_ssi_disabled: true + person_id: 1 + is_tax_unit_head: true + husband: + age: 41 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 200 * 12 + ssi_earned_income: 1_500 * 12 + first_child: + age: 9 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + second_child: + age: 6 + person_id: 4 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [claimant, husband, first_child, second_child] + marital_units: + couple: + members: [claimant, husband] + first_child_alone: + members: [first_child] + second_child_alone: + members: [second_child] + households: + household: + members: [claimant, husband, first_child, second_child] + state_code: TX + output: + ssi_spousal_deeming_child_allocations: [11_928, 11_928, 0, 0] + ssi_unearned_income_deemed_from_ineligible_spouse: [0, 0, 0, 0] + ssi_earned_income_deemed_from_ineligible_spouse: [8_472, 0, 0, 0] # $706 * 12 + # $706 is more than $497. + is_ssi_spousal_deeming_applies: [true, false, false, false] + # $706 less the $20 general and $65 earned exclusions, halved: $310.50. + ssi_income_deemed_from_ineligible_spouse: [3_726, 0, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml index cf2a42e0de8..fd861b557a8 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml @@ -1,6 +1,54 @@ + - name: Child with no income, full allocation period: 2022 input: age: 14 output: ssi_ineligible_child_allocation: 5_040 # (1,261 - 841) * 12 + +- name: No allocation for a child receiving public income-maintenance payments + # 416.1163(b): 'We do not allocate for ineligible children who are + # receiving public income-maintenance payments (see § 416.1142(a)).' + period: 2026 + input: + age: 14 + ssi_receives_public_income_maintenance: true + output: + ssi_ineligible_child_allocation: 0 + +- name: The child's unearned income reduces the allocation + # 416.1163(b)(2). ($1,491 - $994 - $200) * 12. + period: 2026 + input: + age: 10 + ssi_unearned_income: 200 * 12 + output: + ssi_ineligible_child_allocation: 3_564 + +- name: A student child's wages up to the student exclusion do not reduce it + # 416.1161(c) excludes a student child's earned income up to the + # 416.1112(c)(3) amounts: $2,410 a month and $9,730 a year in 2026. + period: 2026 + input: + age: 16 + employment_income: 500 * 12 + output: + ssi_ineligible_child_allocation: 5_964 # $497 * 12 + +- name: A student child's wages above the annual student exclusion reduce it + # $12,000 of wages less the $9,730 annual cap leaves $2,270. + period: 2026 + input: + age: 16 + employment_income: 1_000 * 12 + output: + ssi_ineligible_child_allocation: 3_694 # 5,964 - 2,270 + +- name: A child who is not a student has no wage exclusion + period: 2026 + input: + age: 17 + is_in_k12_school: false + employment_income: 300 * 12 + output: + ssi_ineligible_child_allocation: 2_364 # 5,964 - 3,600 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml new file mode 100644 index 00000000000..a8b52fb5784 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml @@ -0,0 +1,83 @@ +# 416.1160(d): an ineligible child is a child, 'as the term child is defined +# in § 416.1101', who 'is not eligible for SSI benefits'. 416.1856: under 18, +# or under 22 and a student regularly attending school; not married; not the +# head of a household. + +- name: An unmarried 17-year-old is a child + period: 2026 + input: + age: 17 + output: + is_ssi_ineligible_child: true + +- name: An 18-year-old who is not in school is not a child + period: 2026 + input: + age: 18 + output: + is_ssi_ineligible_child: false + +- name: A 21-year-old full-time college student is a child + period: 2026 + input: + age: 21 + is_full_time_college_student: true + output: + is_ssi_ineligible_child: true + +- name: A 22-year-old full-time college student is not a child + period: 2026 + input: + age: 22 + is_full_time_college_student: true + output: + is_ssi_ineligible_child: false + +- name: A 16-year-old head of a household is not a child + period: 2026 + input: + age: 16 + is_household_head: true + output: + is_ssi_ineligible_child: false + +- name: A married 17-year-old is not a child + # Tax roles are supplied: when they are omitted the model makes the minor + # spouse a dependent, and the SSI spouse test cannot then see the marriage. + period: 2026 + input: + people: + wife: + age: 17 + is_tax_unit_spouse: true + husband: + age: 20 + is_tax_unit_head: true + tax_units: + tax_unit: + members: [wife, husband] + marital_units: + couple: + members: [wife, husband] + households: + household: + members: [wife, husband] + output: + is_ssi_ineligible_child: [false, false] + +- name: A disabled child who is a citizen is treated as eligible for SSI + period: 2026 + input: + age: 10 + is_ssi_disabled: true + output: + is_ssi_ineligible_child: false + +- name: A disabled child without qualifying immigration status is not eligible for SSI + period: 2026 + input: + age: 10 + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + output: + is_ssi_ineligible_child: true diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md index 2458846818d..0922e600c16 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 @@ -19,6 +19,9 @@ The implementation follows these steps according to the regulations: - Deduct allocations for ineligible children from the ineligible spouse's income - Deductions are made from unearned income first, then earned income + - An ineligible child is the claimant's or the spouse's child who lives in the claimant's household and is not eligible for SSI (§416.1160(d)), and who is a child under §416.1856: under 18, or under 22 and a student; not married; not the head of a household + - `ssi_spousal_deeming_child_allocations` sums the allocations. Parent ids (`parent_1_id`, `parent_2_id`) decide whose child someone is. Without ids, the co-resident members of the claimant's or the spouse's tax unit stand in, as the parent-link contract in `household/demographic/person/_parent_links.py` allows; that proxy cannot tell a grandchild from a child + - No allocation is made for a child receiving public income-maintenance payments (`ssi_receives_public_income_maintenance`) 3. **Compare remaining income to FBR differential** (§416.1163(d)) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py index 2be85271634..08f6676035e 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py @@ -26,9 +26,7 @@ def formula(person, period, parameters): ) # Next, (b) determine and subtract allocations for ineligible children. - child_allocations = add( - person.tax_unit, period, ["ssi_ineligible_child_allocation"] - ) * person("is_ssi_aged_blind_disabled", period) + child_allocations = person("ssi_spousal_deeming_child_allocations", period) # Child allocations are deducted from unearned income first - ensure we only deduct # the remaining allocations from earned income. remaining_child_allocations = max_( diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py new file mode 100644 index 00000000000..223c687f60e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py @@ -0,0 +1,75 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_established_spouse_indices, +) +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, + has_parent_ids, + household_has_parent_ids, + household_member_indices, + unlinked_parent, +) + + +class ssi_spousal_deeming_child_allocations(Variable): + value_type = float + entity = Person + label = "SSI ineligible child allocations from an ineligible spouse's income" + documentation = ( + "The sum of the ineligible child allocations deducted from the " + "income of this person's ineligible spouse before it is deemed: one " + "for each ineligible child of this person or of their spouse who " + "lives in their household. A child whose parent ids are set counts " + "when an id names this person or their co-resident spouse. A child " + "without ids counts when they belong to the tax unit of this person " + "or of their spouse; in a household with parent links, only when " + "this person or their spouse is a parent some of whose children no " + "id names. Without ids, a grandchild or other relative in that tax " + "unit cannot be told apart from a child, so supply parent ids." + ) + unit = USD + definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", + "https://www.ecfr.gov/current/title-20/section-416.1160#p-416.1160(d)", + ) + + def formula(person, period, parameters): + allocation = person("ssi_ineligible_child_allocation", period) + # 416.1160(d): 'your natural child or adopted child, or the natural + # or adopted child of your spouse ... who lives in the same household + # with you'. Parent ids resolve among household members, so a child + # who lives elsewhere names no co-resident parent. + first, second = co_resident_parent_indices(person, period) + spouse = _ssi_established_spouse_indices(person, period, first, second) + has_spouse = spouse >= 0 + safe_spouse = np.maximum(spouse, 0) + own_index = np.arange(person.count) + # Without ids, keep the tax-unit grouping, extended to a spouse who + # files separately. In a household with links, the parent-link + # contract lets this proxy assign a child only to unlinked parents. + tax_unit = person.tax_unit.reference_entity.members_entity_id + spouse_tax_unit = np.where(has_spouse, tax_unit[safe_spouse], -1) + unlinked = unlinked_parent(person, period) + may_presume = ~household_has_parent_ids(person, period) | ( + unlinked | (has_spouse & unlinked[safe_spouse]) + ) + linked_child = has_parent_ids(person, period) + total = np.zeros(person.count) + for child in household_member_indices(person): + safe = np.maximum(child, 0) + named = (first[safe] == own_index) | (second[safe] == own_index) + named_spouse = has_spouse & ( + (first[safe] == spouse) | (second[safe] == spouse) + ) + presumed = may_presume & ( + (tax_unit[safe] == tax_unit) | (tax_unit[safe] == spouse_tax_unit) + ) + counts = ( + (child >= 0) + & (child != own_index) + & (child != spouse) + & where(linked_child[safe], named | named_spouse, presumed) + ) + total += where(counts, allocation[safe], 0) + return total diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py index 93257349be8..c4b72ee2684 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py @@ -23,7 +23,5 @@ def formula(person, period, parameters): ) # Next, (b) determine and subtract allocations for ineligible children. - child_allocations = add( - person.tax_unit, period, ["ssi_ineligible_child_allocation"] - ) + child_allocations = person("ssi_spousal_deeming_child_allocations", period) return max_(0, ineligible_spousal_unearned_income - child_allocations) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index 36aeedef7e9..dd0a396521e 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -6,16 +6,38 @@ class ssi_ineligible_child_allocation(Variable): entity = Person label = "SSI ineligible child allocation" unit = USD - documentation = "The amount of income that SSI deems ought to be spent on this child, and therefore is not deemed to SSI claimants." + documentation = ( + "The allocation SSI deducts for this child from an ineligible " + "spouse's or parent's income before deeming it. Which claimants it " + "applies to is decided where the allocations are summed." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", + "https://www.ecfr.gov/current/title-20/section-416.1161#p-416.1161(c)", + "https://www.ecfr.gov/current/title-20/section-416.1165#p-416.1165(b)", + ) def formula(person, period, parameters): - income = add(person, period, ["ssi_earned_income", "ssi_unearned_income"]) - ssi = parameters(period).gov.ssa.ssi.amount - allocation = ( - person("is_ssi_ineligible_child", period) - * (ssi.couple - ssi.individual) - * MONTHS_IN_YEAR + p = parameters(period).gov.ssa.ssi + # 416.1163(b): 'We do not allocate for ineligible children who are + # receiving public income-maintenance payments.' + allocated = person("is_ssi_ineligible_child", period) & ~person( + "ssi_receives_public_income_maintenance", period ) - return max_(0, allocation - income) + # 416.1163(b)(1): the couple rate less the individual rate. + allocation = (p.amount.couple - p.amount.individual) * MONTHS_IN_YEAR + # 416.1163(b)(2) reduces it by the child's income as described in + # 416.1161(c), which excludes a student's earned income up to the + # amounts in 416.1112(c)(3). + student = p.income.exclusions.blind_or_disabled_working_student + earned = person("ssi_earned_income", period) + is_student = person("is_full_time_student", period) & ( + person("age", period) < student.age_limit + ) + monthly_exclusion = min_(student.amount, earned / MONTHS_IN_YEAR) + student_exclusion = is_student * min_( + monthly_exclusion * MONTHS_IN_YEAR, student.cap + ) + income = earned - student_exclusion + person("ssi_unearned_income", period) + return allocated * max_(0, allocation - income) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py index d178b8b63d8..1500c40813b 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py @@ -1,13 +1,50 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class is_ssi_ineligible_child(Variable): value_type = bool entity = Person label = "Is an SSI-ineligible child" + documentation = ( + "A child under the SSI definition who is not eligible for SSI. " + "Whose child the person is, and whether they live with the " + "claimant, are tested where the allocation is summed." + ) definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1160#p-416.1160(d)", + "https://www.ecfr.gov/current/title-20/section-416.1856", + "https://www.law.cornell.edu/uscode/text/42/1382c#c", + ) def formula(person, period, parameters): - abd = person("is_ssi_aged_blind_disabled", period) - child = person("is_child", period) - return ~abd & child + p = parameters(period).gov.ssa.ssi.eligibility.child + # 416.1160(d) takes 'child' from 416.1101, which points to 416.1856: + # under 18, or under 22 and a student regularly attending school; + # not married; and not the head of a household. Full-time students + # stand in for students regularly attending school, as in the + # blind or disabled working student exclusion. + student = person("is_full_time_student", period) & ( + person("age", period) < p.student_age_limit + ) + # Marriage uses the spouse test of SSI resource deeming, so the + # default marital unit of a situation without marital units does not + # marry a child to the parent who claims them. + child = ( + (person("is_child", period) | student) + & (_ssi_spouse_index(person, period) < 0) + & ~person("is_household_head", period) + ) + # 'Is not eligible for SSI benefits': aged, blind or disabled people + # who meet the citizenship or qualified-noncitizen condition are + # treated as eligible. The income and resource tests are not read, + # because they depend on deemed income and so on this allocation. + immigration_status = person("immigration_status", period) + is_citizen = immigration_status == immigration_status.possible_values.CITIZEN + eligible = person("is_ssi_aged_blind_disabled", period) & ( + is_citizen | person("is_ssi_qualified_noncitizen", period) + ) + return child & ~eligible diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py new file mode 100644 index 00000000000..03e6f0c4abe --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class ssi_receives_public_income_maintenance(Variable): + value_type = bool + entity = Person + label = "Receives public income-maintenance payments for SSI purposes" + documentation = ( + "Whether this person receives one of the public income-maintenance " + "payments listed in 20 CFR 416.1142(a), such as TANF or need-based " + "State or local assistance. SSI makes no ineligible-child " + "allocation for a child who receives them (416.1163(b)). This input " + "is not derived from modeled program benefits." + ) + definition_period = YEAR + default_value = False + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1142#p-416.1142(a)", + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", + ) From 892fccaaafa099640973f000968b4b6b1ddf07c4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:40:24 -0400 Subject: [PATCH 02/27] Cite POMS for spouse-to-spouse child allocations and add the SI 01320.400 Jack case POMS SI 01310.115 A.3 and SI 01310.140 A limit spouse-to-spouse allocations to children of either member of the couple; SI 01320.141 adds SNAP to the public income-maintenance payments from 30 Sept 2024 and SI 01320.410 keeps the allocation for a TANF family-cap child; SI 01320.185 applies the student earned income exclusion to any student child. Co-Authored-By: Claude Opus 5.5 --- ...ssi_spousal_deeming_child_allocations.yaml | 46 +++++++++++++++++++ .../ssi_spousal_deeming_child_allocations.py | 14 ++++-- .../deemed/ssi_ineligible_child_allocation.py | 4 +- .../ssi_receives_public_income_maintenance.py | 13 ++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml index a2ef5c6a0c6..000c880d26f 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.yaml @@ -589,3 +589,49 @@ is_ssi_spousal_deeming_applies: [true, false, false, false] # $706 less the $20 general and $65 earned exclusions, halved: $310.50. ssi_income_deemed_from_ineligible_spouse: [3_726, 0, 0, 0] + +- name: POMS SI 01320.400 D.2, Jack, August 2024 + # $472 allocation ($1,415 - $943). The spouse's $662 of unearned income + # less $472 leaves $190; with $715 of earnings that is more than $472. + # Countable: $190 - $20 = $170 unearned, ($715 - $65) / 2 = $325 earned, + # $495 in all. The same income is used in every month of the year. + period: 2024 + absolute_error_margin: 0 + input: + people: + jack: + age: 50 + is_ssi_disabled: true + person_id: 1 + is_tax_unit_head: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + ssi_earned_income: 715 * 12 + ssi_unearned_income: 662 * 12 + child: + age: 9 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [jack, spouse, child] + marital_units: + couple: + members: [jack, spouse] + child_alone: + members: [child] + households: + household: + members: [jack, spouse, child] + state_code: TX + output: + ssi_spousal_deeming_child_allocations: [5_664, 5_664, 0] # $472 * 12 + ssi_unearned_income_deemed_from_ineligible_spouse: [2_280, 0, 0] # $190 * 12 + ssi_earned_income_deemed_from_ineligible_spouse: [8_580, 0, 0] # $715 * 12 + is_ssi_spousal_deeming_applies: [true, false, false] + ssi_income_deemed_from_ineligible_spouse: [5_940, 0, 0] # $495 * 12 + ssi: [11_040, 0, 0] # ($1,415 - $495) * 12 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py index 223c687f60e..8b052a723ed 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py @@ -25,21 +25,29 @@ class ssi_spousal_deeming_child_allocations(Variable): "or of their spouse; in a household with parent links, only when " "this person or their spouse is a parent some of whose children no " "id names. Without ids, a grandchild or other relative in that tax " - "unit cannot be told apart from a child, so supply parent ids." + "unit cannot be told apart from a child, so supply parent ids. Enter " + "a child who is only temporarily absent, such as a child away at " + "school who is still under parental control, in the household they " + "remain a member of (416.1167, POMS SI 01310.165)." ) unit = USD definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", "https://www.ecfr.gov/current/title-20/section-416.1160#p-416.1160(d)", + "https://secure.ssa.gov/poms.nsf/lnx/0501310115", + "https://secure.ssa.gov/poms.nsf/lnx/0501310140", ) def formula(person, period, parameters): allocation = person("ssi_ineligible_child_allocation", period) # 416.1160(d): 'your natural child or adopted child, or the natural # or adopted child of your spouse ... who lives in the same household - # with you'. Parent ids resolve among household members, so a child - # who lives elsewhere names no co-resident parent. + # with you'. Its other clause, the child 'of your parent or of your + # parent's spouse', serves parent-to-child deeming: POMS SI 01310.115 + # A.3 and SI 01310.140 A give spouse-to-spouse deeming the children of + # either member of the couple. Parent ids resolve among household + # members, so a child who lives elsewhere names no co-resident parent. first, second = co_resident_parent_indices(person, period) spouse = _ssi_established_spouse_indices(person, period, first, second) has_spouse = spouse >= 0 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index dd0a396521e..a8c00a548a8 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -16,6 +16,7 @@ class ssi_ineligible_child_allocation(Variable): "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", "https://www.ecfr.gov/current/title-20/section-416.1161#p-416.1161(c)", "https://www.ecfr.gov/current/title-20/section-416.1165#p-416.1165(b)", + "https://secure.ssa.gov/poms.nsf/lnx/0501320185", ) def formula(person, period, parameters): @@ -29,7 +30,8 @@ def formula(person, period, parameters): allocation = (p.amount.couple - p.amount.individual) * MONTHS_IN_YEAR # 416.1163(b)(2) reduces it by the child's income as described in # 416.1161(c), which excludes a student's earned income up to the - # amounts in 416.1112(c)(3). + # amounts in 416.1112(c)(3), whether or not the child is blind or + # disabled (POMS SI 01320.185). No $20 or $65 exclusion applies. student = p.income.exclusions.blind_or_disabled_working_student earned = person("ssi_earned_income", period) is_student = person("is_full_time_student", period) & ( diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py index 03e6f0c4abe..d5bb891478a 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/ssi_receives_public_income_maintenance.py @@ -7,14 +7,19 @@ class ssi_receives_public_income_maintenance(Variable): label = "Receives public income-maintenance payments for SSI purposes" documentation = ( "Whether this person receives one of the public income-maintenance " - "payments listed in 20 CFR 416.1142(a), such as TANF or need-based " - "State or local assistance. SSI makes no ineligible-child " - "allocation for a child who receives them (416.1163(b)). This input " - "is not derived from modeled program benefits." + "payments listed in 20 CFR 416.1142(a), such as TANF, need-based " + "State or local assistance, or, from September 30, 2024, SNAP. SSI " + "makes no ineligible-child allocation for a child who receives them " + "(416.1163(b)). A child in a TANF grant held down by a family cap " + "is not treated as receiving TANF (POMS SI 01320.410). This input " + "is not derived from modeled benefits: SNAP counts SSI as income, " + "so deriving it from modeled SNAP would make SSI depend on itself." ) definition_period = YEAR default_value = False reference = ( "https://www.ecfr.gov/current/title-20/section-416.1142#p-416.1142(a)", "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", + "https://secure.ssa.gov/poms.nsf/lnx/0501320141", + "https://secure.ssa.gov/poms.nsf/lnx/0501320410", ) From fe68e0ec364487120ceb0e580c94b797692f99aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:10:19 -0400 Subject: [PATCH 03/27] Floor each month's ineligible-child allocation and run the student exclusion monthly 416.1163(b)(2) reduces each month's allocation by that month's income, and the 416.1112(c)(3) student exclusion runs month by month until its yearly limit is used up (POMS SI 00820.510). The annual computation let income left over in months after the yearly limit ran out reduce the allocation for earlier months. Add a month-by-month differential test. Co-Authored-By: Claude Opus 5.5 --- ...t_ssi_spousal_deeming_child_allocations.py | 81 +++++++++++++++++++ .../ssi_ineligible_child_allocation.yaml | 21 ++++- .../deemed/ssi_ineligible_child_allocation.py | 25 +++--- 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py index 8fcb452d8ca..02c24c1e8ab 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py @@ -24,6 +24,12 @@ 5. Deemed unearned income is the spouse's unearned income less the total, floored at zero (416.1163(b)(3)). +A second test checks each child's own allocation against a month-by-month +oracle of 416.1163(b)(1)-(2) and 416.1161(c): the student earned income +exclusion runs month by month until its yearly limit is used up, and each +month's allocation is floored at zero. It also checks the bounds and that +more income never raises an allocation. + YAML covers the worked cases. This file routes to the Rest/core group. """ @@ -40,6 +46,9 @@ STUDENT_AGE = 22 # 416.1856(a)(2) ABSENT_PARENT_ID = 999_999 HOUSEHOLDS_PER_EXAMPLE = 30 +# 416.1112(c)(3) student earned income exclusion in 2026 (POMS SI 00820.510). +STUDENT_MONTHLY_EXCLUSION = 2_410 +STUDENT_YEARLY_EXCLUSION = 9_730 CHILD_KINDS = ("claimant", "spouse", "both", "grandchild", "unrelated") OTHER = st.fixed_dictionaries( @@ -356,3 +365,75 @@ def test_allocations_match_oracle_and_invariants(households): richer_situation, ("ssi_spousal_deeming_child_allocations",) )["ssi_spousal_deeming_child_allocations"] assert all(richer_totals[name] <= totals[name] for name in totals) + + +CHILD = st.fixed_dictionaries( + { + "age": st.integers(0, 23), + "in_school": st.booleans(), + "in_college": st.booleans(), + "monthly_earned": st.sampled_from((0, 300, 497, 800, 1_000, 2_410, 2_600)), + "monthly_unearned": st.sampled_from((0, 100, 497, 600)), + "public_income_maintenance": st.sampled_from((False, False, False, True)), + } +) + + +def _child_situation(children, rise=0): + people = {} + for index, child in enumerate(children): + people[f"c{index}"] = { + "age": {YEAR: child["age"]}, + "is_ssi_disabled": {YEAR: False}, + "is_in_k12_school": {YEAR: child["in_school"]}, + "is_full_time_college_student": {YEAR: child["in_college"]}, + "ssi_earned_income": {YEAR: 12 * (child["monthly_earned"] + rise)}, + "ssi_unearned_income": {YEAR: 12 * (child["monthly_unearned"] + rise)}, + "ssi_receives_public_income_maintenance": { + YEAR: child["public_income_maintenance"] + }, + } + situation = {"people": people} + for plural in ( + "tax_units", + "households", + "marital_units", + "families", + "spm_units", + ): + situation[plural] = {f"u{name}": {"members": [name]} for name in people} + for household in situation["households"].values(): + household["state_code"] = {YEAR: "TX"} + return situation + + +def _monthly_allocation_oracle(child): + """Sum over months of 416.1163(b)(2) with the 416.1161(c) exclusion.""" + student = child["in_school"] or child["in_college"] + is_child = child["age"] < CHILD_AGE or (child["age"] < STUDENT_AGE and student) + if not is_child or child["public_income_maintenance"]: + return 0 + left = STUDENT_YEARLY_EXCLUSION if student and child["age"] < STUDENT_AGE else 0 + total = 0 + for _ in range(12): + exclusion = min(STUDENT_MONTHLY_EXCLUSION, child["monthly_earned"], left) + left -= exclusion + income = child["monthly_earned"] - exclusion + child["monthly_unearned"] + total += max(0, MONTHLY_ALLOCATION - income) + return total + + +@settings(max_examples=3, derandomize=True, deadline=None) +@given(st.lists(CHILD, min_size=60, max_size=60)) +def test_child_allocation_matches_monthly_oracle(children): + allocation = Simulation(situation=_child_situation(children)).calculate( + "ssi_ineligible_child_allocation", YEAR + ) + expected = [_monthly_allocation_oracle(child) for child in children] + np.testing.assert_allclose(allocation, expected, atol=1e-6) + assert np.all(allocation >= 0) + assert np.all(allocation <= 12 * MONTHLY_ALLOCATION) + richer = Simulation(situation=_child_situation(children, rise=100)).calculate( + "ssi_ineligible_child_allocation", YEAR + ) + assert np.all(richer <= allocation + 1e-6) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml index fd861b557a8..082260575b1 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml @@ -35,14 +35,29 @@ output: ssi_ineligible_child_allocation: 5_964 # $497 * 12 -- name: A student child's wages above the annual student exclusion reduce it - # $12,000 of wages less the $9,730 annual cap leaves $2,270. +- name: Once the student exclusion's yearly limit is used up, wages reduce it + # $1,000 a month is excluded in full for nine months ($9,000), leaving + # $730 of the $9,730 yearly limit. In month 10, $270 counts: $497 - $270 = + # $227. In months 11 and 12, $1,000 counts and the allocation is zero; + # the excess does not reduce other months (416.1163(b)(2), monthly). period: 2026 input: age: 16 employment_income: 1_000 * 12 output: - ssi_ineligible_child_allocation: 3_694 # 5,964 - 2,270 + ssi_ineligible_child_allocation: 4_700 # 9 * 497 + 227 + +- name: Student wages above the monthly exclusion reduce each month separately + # $2,600 a month: $2,410 is excluded in months 1-4 ($9,640), leaving $190 + # counted and a $307 allocation each month. In month 5 only the last $90 + # of the yearly limit is excluded, so $2,510 counts and the allocation is + # zero, as in months 6-12. + period: 2026 + input: + age: 17 + employment_income: 2_600 * 12 + output: + ssi_ineligible_child_allocation: 1_228 # 4 * 307 - name: A child who is not a student has no wage exclusion period: 2026 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index a8c00a548a8..9fd21c79b2d 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -26,20 +26,27 @@ def formula(person, period, parameters): allocated = person("is_ssi_ineligible_child", period) & ~person( "ssi_receives_public_income_maintenance", period ) - # 416.1163(b)(1): the couple rate less the individual rate. - allocation = (p.amount.couple - p.amount.individual) * MONTHS_IN_YEAR + # 416.1163(b)(1): the couple rate less the individual rate, a month. + monthly_allocation = p.amount.couple - p.amount.individual # 416.1163(b)(2) reduces it by the child's income as described in # 416.1161(c), which excludes a student's earned income up to the # amounts in 416.1112(c)(3), whether or not the child is blind or # disabled (POMS SI 01320.185). No $20 or $65 exclusion applies. student = p.income.exclusions.blind_or_disabled_working_student - earned = person("ssi_earned_income", period) is_student = person("is_full_time_student", period) & ( person("age", period) < student.age_limit ) - monthly_exclusion = min_(student.amount, earned / MONTHS_IN_YEAR) - student_exclusion = is_student * min_( - monthly_exclusion * MONTHS_IN_YEAR, student.cap - ) - income = earned - student_exclusion + person("ssi_unearned_income", period) - return allocated * max_(0, allocation - income) + # Annual income is spread evenly over the year. The student + # exclusion applies month by month until its yearly limit is used + # up (POMS SI 00820.510), and each month's allocation is floored at + # zero, so income left over in one month never reduces another's. + monthly_earned = person("ssi_earned_income", period) / MONTHS_IN_YEAR + monthly_unearned = person("ssi_unearned_income", period) / MONTHS_IN_YEAR + exclusion_left = where(is_student, student.cap, 0) + total = np.zeros(person.count) + for _ in range(MONTHS_IN_YEAR): + exclusion = min_(min_(student.amount, monthly_earned), exclusion_left) + exclusion_left = exclusion_left - exclusion + income = monthly_earned - exclusion + monthly_unearned + total += max_(0, monthly_allocation - income) + return allocated * total From 9d14d23db34212abc5ce16cfe3a05c800e642df1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:20:14 -0400 Subject: [PATCH 04/27] Treat a child's net loss as no income in the ineligible-child allocation 416.1163(b)(1)-(2) caps the allocation at the couple rate less the individual rate, reduced by the child's income. On main a child with a $20,000 business loss got a $25,964 allocation, and negative unearned income raised it or offset wages. Floor earned and unearned income at zero separately. Co-Authored-By: Claude Opus 5.5 --- ...t_ssi_spousal_deeming_child_allocations.py | 18 ++++++++++----- .../ssi_ineligible_child_allocation.yaml | 22 +++++++++++++++++++ .../deemed/ssi_ineligible_child_allocation.py | 8 +++++-- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py b/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py index 02c24c1e8ab..b06c1644832 100644 --- a/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py +++ b/policyengine_us/tests/core/test_ssi_spousal_deeming_child_allocations.py @@ -26,8 +26,8 @@ A second test checks each child's own allocation against a month-by-month oracle of 416.1163(b)(1)-(2) and 416.1161(c): the student earned income -exclusion runs month by month until its yearly limit is used up, and each -month's allocation is floored at zero. It also checks the bounds and that +exclusion runs month by month until its yearly limit is used up, each +month's allocation is floored at zero, and a loss counts as no income. It also checks the bounds and that more income never raises an allocation. YAML covers the worked cases. This file routes to the Rest/core group. @@ -372,8 +372,10 @@ def test_allocations_match_oracle_and_invariants(households): "age": st.integers(0, 23), "in_school": st.booleans(), "in_college": st.booleans(), - "monthly_earned": st.sampled_from((0, 300, 497, 800, 1_000, 2_410, 2_600)), - "monthly_unearned": st.sampled_from((0, 100, 497, 600)), + "monthly_earned": st.sampled_from( + (-1_500, 0, 300, 497, 800, 1_000, 2_410, 2_600) + ), + "monthly_unearned": st.sampled_from((-250, 0, 100, 497, 600)), "public_income_maintenance": st.sampled_from((False, False, False, True)), } ) @@ -414,11 +416,15 @@ def _monthly_allocation_oracle(child): if not is_child or child["public_income_maintenance"]: return 0 left = STUDENT_YEARLY_EXCLUSION if student and child["age"] < STUDENT_AGE else 0 + # A loss is no income: it neither raises the allocation nor offsets other + # income. + earned = max(0, child["monthly_earned"]) + unearned = max(0, child["monthly_unearned"]) total = 0 for _ in range(12): - exclusion = min(STUDENT_MONTHLY_EXCLUSION, child["monthly_earned"], left) + exclusion = min(STUDENT_MONTHLY_EXCLUSION, earned, left) left -= exclusion - income = child["monthly_earned"] - exclusion + child["monthly_unearned"] + income = earned - exclusion + unearned total += max(0, MONTHLY_ALLOCATION - income) return total diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml index 082260575b1..10ed5eaaafe 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml @@ -67,3 +67,25 @@ employment_income: 300 * 12 output: ssi_ineligible_child_allocation: 2_364 # 5,964 - 3,600 + +- name: A child's net business loss does not raise the allocation + # 416.1163(b)(1)-(2): the allocation is the couple rate less the + # individual rate, reduced by the child's income. A loss is no income. + period: 2026 + input: + age: 17 + is_in_k12_school: false + ssi_earned_income: -20_000 + output: + ssi_ineligible_child_allocation: 5_964 # $497 * 12 + +- name: Negative unearned income neither raises the allocation nor offsets wages + # $300 a month of wages reduce it; the -$250 a month does not. + period: 2026 + input: + age: 17 + is_in_k12_school: false + ssi_earned_income: 300 * 12 + ssi_unearned_income: -3_000 + output: + ssi_ineligible_child_allocation: 2_364 # 5,964 - 3,600 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index 9fd21c79b2d..68c6f7f8204 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -40,8 +40,12 @@ def formula(person, period, parameters): # exclusion applies month by month until its yearly limit is used # up (POMS SI 00820.510), and each month's allocation is floored at # zero, so income left over in one month never reduces another's. - monthly_earned = person("ssi_earned_income", period) / MONTHS_IN_YEAR - monthly_unearned = person("ssi_unearned_income", period) / MONTHS_IN_YEAR + # The child's income only reduces the allocation: a net loss is no + # income, so it never raises the allocation above (b)(1). + monthly_earned = max_(0, person("ssi_earned_income", period)) / MONTHS_IN_YEAR + monthly_unearned = ( + max_(0, person("ssi_unearned_income", period)) / MONTHS_IN_YEAR + ) exclusion_left = where(is_student, student.cap, 0) total = np.zeros(person.count) for _ in range(MONTHS_IN_YEAR): From 30fc588ccdb924ab9eb08d7deff9e7bdc35cc476 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 01:49:15 -0400 Subject: [PATCH 05/27] Respect legal marriage and historical student limits in SSI deeming --- .../child/deeming_student_age_limit.yaml | 12 ++ .../status/is_ssi_ineligible_child.yaml | 113 ++++++++++++++++++ .../resources/deemed/_ssi_spouses.py | 60 +++++++++- .../status/is_ssi_ineligible_child.py | 41 ++++--- 4 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 policyengine_us/parameters/gov/ssa/ssi/eligibility/child/deeming_student_age_limit.yaml diff --git a/policyengine_us/parameters/gov/ssa/ssi/eligibility/child/deeming_student_age_limit.yaml b/policyengine_us/parameters/gov/ssa/ssi/eligibility/child/deeming_student_age_limit.yaml new file mode 100644 index 00000000000..2a300f97669 --- /dev/null +++ b/policyengine_us/parameters/gov/ssa/ssi/eligibility/child/deeming_student_age_limit.yaml @@ -0,0 +1,12 @@ +description: The Social Security Administration treats a student regularly attending school as an ineligible child for deeming below this age. + +values: + 1974-01-01: 21 + 2008-06-16: 22 +metadata: + label: SSI ineligible child deeming student age limit + unit: year + period: year + reference: + - title: POMS SI 01310.115 A.3 - Ineligible child for deeming purposes + href: https://secure.ssa.gov/poms.nsf/lnx/0501310115 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml index a8b52fb5784..1df8f6f9f79 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml @@ -81,3 +81,116 @@ immigration_status: UNDOCUMENTED output: is_ssi_ineligible_child: true + +- name: Review r2 a married student remains married while her husband lives elsewhere + # 20 CFR 416.1856(b) and 416.1806(a)(1): legal marriage does not require + # co-residence, although spousal deeming does. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + person_id: 1 + is_tax_unit_head: true + spouse: + age: 60 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + daughter: + age: 20 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_spouse: true + is_full_time_college_student: true + husband: + age: 22 + person_id: 4 + is_tax_unit_head: true + tax_units: + parents: + members: [claimant, spouse] + daughter_couple: + members: [daughter, husband] + marital_units: + parents: + members: [claimant, spouse] + daughter_unit: + members: [daughter] + husband_unit: + members: [husband] + households: + home: + members: [claimant, spouse, daughter] + state_code: TX + away: + members: [husband] + state_code: TX + output: + people: + daughter: + is_ssi_ineligible_child: false + ssi_ineligible_child_allocation: 0 + claimant: + ssi: 10_932 + +- name: Review r2 a 21-year-old student was not an ineligible child before June 2008 + # POMS SI 01310.115 A.3: before June 16, 2008 the deeming age limit was 21. + # The general SSI child definition keeps its separate student age limit. + period: 1986 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + person_id: 1 + is_tax_unit_head: true + spouse: + age: 60 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 252 * 12 + student: + age: 21 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + is_full_time_college_student: true + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + student: + is_ssi_ineligible_child: false + ssi_ineligible_child_allocation: 0 + claimant: + ssi: 3_264 + +- name: Review r2 the deeming student age limit is under 21 in May 2008 + period: 2008-05 + input: + age: 21 + is_full_time_college_student: true + output: + is_ssi_ineligible_child: false + +- name: Review r2 the deeming student age limit is under 22 in July 2008 + period: 2008-07 + input: + age: 21 + is_full_time_college_student: true + output: + is_ssi_ineligible_child: true diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py index fbd5f09ede5..b35fcdda241 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py @@ -1,6 +1,9 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( + _parent_indices, co_resident_parent_indices, + group_member_indices, + tax_unit_parent_indices, ) @@ -8,8 +11,8 @@ def _ssi_established_spouse_indices(person, period, first, second): """Rows of each person's co-resident spouse, or -1. SSI resource deeming uses this one test for a claimant's spouse - (416.1202(a)), a parent's spouse (416.1202(b)) and whether a child is - married (416.1856). A marital unit holds an unmarried person or a married, + (416.1202(a)) and a parent's spouse (416.1202(b)). A marital unit holds + an unmarried person or a married, cohabiting couple, so the members of a two-person marital unit who live in the same household are spouses. So are the head and spouse of one tax unit who share a marital unit, which covers situations that omit marital @@ -67,3 +70,56 @@ def _ssi_spouse_index(person, period): """The row of each person's co-resident spouse, or -1.""" first, second = co_resident_parent_indices(person, period) return _ssi_established_spouse_indices(person, period, first, second) + + +def _ssi_is_married(person, period): + """Marriage classification without a co-residence requirement. + + Legal spouses can live apart (416.1806(a)(1)). A two-person marital + unit establishes marriage, as does a tax unit's head/spouse pair, even + when they have separate marital units. Apply the spouse lookup's + parent-link and default-marital-unit safeguards to both sources; + ambiguous candidates do not establish marriage. + """ + period = period.this_year + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + dependent = person("is_tax_unit_dependent", period) + joint = head_or_spouse & person.tax_unit("tax_unit_married", period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + couple = person.marital_unit.nb_persons() == 2 + possible_default = person.marital_unit.count == 1 + own_index = np.arange(person.count) + married = np.zeros(person.count, dtype=bool) + + for group, marital_couple in ( + (person.marital_unit, True), + (person.tax_unit, False), + ): + first, second = ( + _parent_indices(person, period, group) + if marital_couple + else tax_unit_parent_indices(person, period) + ) + candidates = np.zeros(person.count, dtype=int) + for member in group_member_indices(group): + safe = np.maximum(member, 0) + same_tax_unit = tax_unit[safe] == tax_unit + names_applicant = (first[safe] == own_index) | (second[safe] == own_index) + named_by_applicant = (first == member) | (second == member) + claims = possible_default & same_tax_unit & (dependent != dependent[safe]) + established = ( + couple + if marital_couple + else joint & head_or_spouse[safe] & same_tax_unit + ) + shown = ( + (member >= 0) + & (member != own_index) + & established + & ~claims + & ~names_applicant + & ~named_by_applicant + ) + candidates += shown + married |= candidates == 1 + return married diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py index 1500c40813b..ffd3d36668c 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( - _ssi_spouse_index, + _ssi_is_married, ) @@ -9,42 +9,45 @@ class is_ssi_ineligible_child(Variable): entity = Person label = "Is an SSI-ineligible child" documentation = ( - "A child under the SSI definition who is not eligible for SSI. " + "A child under the SSI ineligible-child deeming definition who is not " + "eligible for SSI. " "Whose child the person is, and whether they live with the " "claimant, are tested where the allocation is summed." ) - definition_period = YEAR + definition_period = MONTH reference = ( "https://www.ecfr.gov/current/title-20/section-416.1160#p-416.1160(d)", "https://www.ecfr.gov/current/title-20/section-416.1856", "https://www.law.cornell.edu/uscode/text/42/1382c#c", + "https://secure.ssa.gov/poms.nsf/lnx/0501310115", ) def formula(person, period, parameters): p = parameters(period).gov.ssa.ssi.eligibility.child - # 416.1160(d) takes 'child' from 416.1101, which points to 416.1856: - # under 18, or under 22 and a student regularly attending school; - # not married; and not the head of a household. Full-time students - # stand in for students regularly attending school, as in the - # blind or disabled working student exclusion. - student = person("is_full_time_student", period) & ( - person("age", period) < p.student_age_limit + # SI 01310.115 A.3: the ineligible-student deeming limit was under + # 21 before June 16, 2008 and under 22 afterward. The general SSI + # child definition remains distinct. Monthly status follows the + # parameters at the start of the month; no mid-month proration is + # modeled. Full-time attendance remains the model's proxy for + # regularly attending school. + annual_period = period.this_year + student = person("is_full_time_student", annual_period) & ( + person("age", annual_period) < p.deeming_student_age_limit ) - # Marriage uses the spouse test of SSI resource deeming, so the - # default marital unit of a situation without marital units does not - # marry a child to the parent who claims them. + # Marriage is distinct from the co-resident spouse lookup used for + # deeming: legal spouses can live apart. child = ( - (person("is_child", period) | student) - & (_ssi_spouse_index(person, period) < 0) - & ~person("is_household_head", period) + (person("is_child", annual_period) | student) + & ~_ssi_is_married(person, annual_period) + & ~person("is_household_head", annual_period) ) # 'Is not eligible for SSI benefits': aged, blind or disabled people # who meet the citizenship or qualified-noncitizen condition are # treated as eligible. The income and resource tests are not read, # because they depend on deemed income and so on this allocation. - immigration_status = person("immigration_status", period) + immigration_status = person("immigration_status", annual_period) is_citizen = immigration_status == immigration_status.possible_values.CITIZEN - eligible = person("is_ssi_aged_blind_disabled", period) & ( - is_citizen | person("is_ssi_qualified_noncitizen", period) + eligible = person("is_ssi_aged_blind_disabled", annual_period) & ( + is_citizen | person("is_ssi_qualified_noncitizen", annual_period) ) return child & ~eligible From 411fc28b11faaf57c53f7483924d4274fac0b4c3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 01:50:00 -0400 Subject: [PATCH 06/27] Scope parental child allocations and align SSI and Medicaid recipients --- ..._income_deemed_from_ineligible_parent.yaml | 154 ++++++++++++++++++ ...ed_income_deemed_from_ineligible_parent.py | 32 ++-- .../income/_apply_ssi_exclusions.py | 14 +- .../_ssi_parental_child_allocation_scope.py | 98 +++++++++++ ...ed_income_deemed_from_ineligible_parent.py | 25 ++- 5 files changed, 299 insertions(+), 24 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_unearned_income_deemed_from_ineligible_parent.yaml create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_unearned_income_deemed_from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_unearned_income_deemed_from_ineligible_parent.yaml new file mode 100644 index 00000000000..6ac59e6060f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_unearned_income_deemed_from_ineligible_parent.yaml @@ -0,0 +1,154 @@ +- name: Review r2 excludes a permanently absent adult student's allocation from parental deeming + # 20 CFR 416.1165(b): allocations cover ineligible children in the household. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + ssi_unearned_income: 24_000 + claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + student: + age: 20 + person_id: 3 + parent_1_id: 1 + is_tax_unit_dependent: true + is_full_time_college_student: true + tax_units: + family: + members: [parent, claimant, student] + marital_units: + parent_unit: + members: [parent] + claimant_unit: + members: [claimant] + student_unit: + members: [student] + households: + home: + members: [parent, claimant] + state_code: TX + away: + members: [student] + state_code: TX + output: + people: + claimant: + # 24,000 - 240 general exclusion - 11,928 parent allocation. + ssi_unearned_income_deemed_from_ineligible_parent: 11_832 + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11_832 + # 11,928 - (11,832 - 240 own general exclusion). + ssi: 336 + student: + ssi_ineligible_child_allocation: 5_964 + +- name: Review r2 excludes an immigration-ineligible sibling from the SSI parental recipient divisor + # 20 CFR 416.1165(e)(2): divide the remainder among eligible children only. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + ssi_unearned_income: 24_000 + claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + sibling: + age: 12 + person_id: 3 + parent_1_id: 1 + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + tax_units: + family: + members: [parent, claimant, sibling] + marital_units: + parent_unit: + members: [parent] + claimant_unit: + members: [claimant] + sibling_unit: + members: [sibling] + households: + home: + members: [parent, claimant, sibling] + state_code: TX + output: + people: + claimant: + # 24,000 - 5,964 sibling allocation - 240 - 11,928 = 5,868. + ssi_unearned_income_deemed_from_ineligible_parent: 5_868 + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5_868 + ssi: 6_300 + sibling: + is_ssi_ineligible_child: true + ssi_ineligible_child_allocation: 5_964 + ssi_unearned_income_deemed_from_ineligible_parent: 0 + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 0 + +- name: Review r2 Medicaid uses California's broader immigration eligibility independently of SSI + # California covers undocumented minors. Both are Medicaid recipients, so + # neither receives an ineligible-child allocation in Medicaid's own pool. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + ssi_unearned_income: 24_000 + claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + sibling: + age: 12 + person_id: 3 + parent_1_id: 1 + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + tax_units: + family: + members: [parent, claimant, sibling] + marital_units: + parent_unit: + members: [parent] + claimant_unit: + members: [claimant] + sibling_unit: + members: [sibling] + households: + home: + members: [parent, claimant, sibling] + state_code: CA + output: + people: + claimant: + ssi_unearned_income_deemed_from_ineligible_parent: 5_868 + # (24,000 - 230 * 12 - 11,928 parent allocation) / 2. + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 4_656 + sibling: + is_medicaid_immigration_status_eligible: true + ssi_unearned_income_deemed_from_ineligible_parent: 0 + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 4_656 diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py index 2ecd23b455a..afe28f1e001 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py @@ -5,6 +5,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_parent._ssi_parental_child_allocation_scope import ( + ssi_parental_child_allocation_total, +) class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent( @@ -14,12 +17,19 @@ class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligibl entity = Person label = "Medicaid optional senior or disabled unearned income deemed from ineligible parent" unit = USD - definition_period = YEAR - reference = "https://www.law.cornell.edu/cfr/text/20/416.1165" + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/20/416.1165", + "https://www.law.cornell.edu/uscode/text/42/1396b#v", + ) def formula(person, period, parameters): - eligible_child = person("is_ssi_aged_blind_disabled", period) & person( - "is_child", period + # Medicaid's own immigration rules include eligible groups who do + # not qualify for SSI, including state-covered noncitizen children. + eligible_child = ( + person("is_ssi_aged_blind_disabled", period) + & person("is_child", period) + & person("is_medicaid_immigration_status_eligible", period) ) ineligible_parent = person("is_ssi_ineligible_parent", period) tax_unit = person.tax_unit @@ -39,8 +49,8 @@ def formula(person, period, parameters): person("ssi_unearned_income", period) * ineligible_parent ) - child_allocations = eligible_child * add( - tax_unit, period, ["ssi_ineligible_child_allocation"] + child_allocations = eligible_child * ssi_parental_child_allocation_total( + person, period, eligible_child ) parental_allocations = eligible_child * add( tax_unit, period, ["ssi_ineligible_parent_allocation"] @@ -70,15 +80,17 @@ def formula(person, period, parameters): parameters, period, general_exclusion=general_disregard, + monthly=True, ), _apply_medicaid_optional_senior_or_disabled_exclusions( - parental_earned_income, - parental_unearned_income, + parental_earned_income * MONTHS_IN_YEAR, + parental_unearned_income * MONTHS_IN_YEAR, state, general_disregard, parameters, period, - ), + ) + / MONTHS_IN_YEAR, ) net_parental_deemed_income = max_( 0, net_parental_deemed_income - parental_allocations @@ -88,4 +100,4 @@ def formula(person, period, parameters): income = np.zeros_like(count_eligible_children, dtype=float) mask = count_eligible_children > 0 income[mask] = net_parental_deemed_income[mask] / count_eligible_children[mask] - return income + return where(eligible_child, income, 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_apply_ssi_exclusions.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_apply_ssi_exclusions.py index 0b5de7a0f16..e5cc1c7ab68 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_apply_ssi_exclusions.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_apply_ssi_exclusions.py @@ -7,6 +7,7 @@ def _apply_ssi_exclusions( parameters: ParameterNode, period: Period, general_exclusion: ArrayLike = None, + monthly: bool = False, ) -> ArrayLike: """ Applies standard SSI income exclusions to convert total earned/unearned income @@ -15,12 +16,12 @@ def _apply_ssi_exclusions( 2) Subtract $65 'earned' exclusion from any remaining earned income. 3) Exclude 50% of the remainder of earned income. - All amounts are stored as annual but exclusions are monthly amounts, so we convert - from annual -> monthly, apply monthly exclusions, then go back to annual. + By default inputs and outputs are annual. Monthly callers pass monthly=True + so exclusions apply to that month before annual aggregation. """ - # Convert from annual to monthly - earned_monthly = earned_income / MONTHS_IN_YEAR - unearned_monthly = unearned_income / MONTHS_IN_YEAR + factor = 1 if monthly else MONTHS_IN_YEAR + earned_monthly = earned_income / factor + unearned_monthly = unearned_income / factor p = parameters(period).gov.ssa.ssi.income.exclusions general_exclusion = p.general if general_exclusion is None else general_exclusion @@ -39,5 +40,4 @@ def _apply_ssi_exclusions( # So the fraction that remains is (1 - earned_share). countable_earned = earned_after_flat * (1.0 - p.earned_share) - # Convert back to annual - return (countable_unearned + countable_earned) * MONTHS_IN_YEAR + return (countable_unearned + countable_earned) * factor diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py new file mode 100644 index 00000000000..69d2d97cc4e --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py @@ -0,0 +1,98 @@ +"""Relationship scope for allocations deducted before parental income deeming.""" + +import numpy as np + +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_established_spouse_indices, +) +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, + group_member_indices, + has_parent_ids, + household_has_parent_ids, + household_member_indices, + unlinked_parent, +) + + +def _allocation_parent_rows(person, period, first, second): + """Resolve the co-resident parents and their spouses for allocation scope. + + Parent ids take precedence. Without ids, only unlinked parents enter + the tax-unit proxy, together with their co-resident spouses, as in the + existing SSI resource-deeming relationship contract. + """ + linked = has_parent_ids(person, period) + spouse = _ssi_established_spouse_indices(person, period, first, second) + first_spouse = np.where(first >= 0, spouse[np.maximum(first, 0)], -1) + second_spouse = np.where(second >= 0, spouse[np.maximum(second, 0)], -1) + named = np.column_stack((first, second, first_spouse, second_spouse)) + rows = [np.where(linked[:, None], named, -1)] + + unlinked = unlinked_parent(person, period) + parent_or_spouse = unlinked | ((spouse >= 0) & unlinked[np.maximum(spouse, 0)]) + households = person.household.reference_entity.members_entity_id + tax_units = person.tax_unit.reference_entity.members_entity_id + for member in group_member_indices(person.tax_unit): + safe = np.maximum(member, 0) + selected = ( + ~linked + & (member >= 0) + & (households[safe] == households) + & parent_or_spouse[safe] + ) + rows.append(np.where(selected, member, -1)[:, None]) + # A parent's spouse can file in a different tax unit. + partner = np.where(selected, spouse[safe], -1) + outside = (partner >= 0) & (tax_units[np.maximum(partner, 0)] != tax_units) + rows.append(np.where(outside, partner, -1)[:, None]) + return np.column_stack(rows) + + +def ssi_parental_child_allocation_total(person, period, recipients): + """Allocations for relevant children who live with this recipient. + + 20 CFR 416.1165(b) limits allocations to ineligible children in the + household. Parent ids identify a child of this person or their parents + and parents' spouses; the unlinked-parent tax-unit proxy applies only + where the parent-link contract permits it. A recipient never receives + an allocation from the same pool of income they share. + """ + first, second = co_resident_parent_indices(person, period) + parents = _allocation_parent_rows(person, period, first, second) + own_index = np.arange(person.count) + relatives = np.column_stack((own_index, parents)) + present = relatives >= 0 + safe_relatives = np.maximum(relatives, 0) + tax_unit = person.tax_unit.reference_entity.members_entity_id + relative_tax_units = np.where(present, tax_unit[safe_relatives], -1) + unlinked = unlinked_parent(person, period) + may_presume = ~household_has_parent_ids(person, period) | np.any( + present & unlinked[safe_relatives], axis=1 + ) + linked = has_parent_ids(person, period) + allocation = person("ssi_ineligible_child_allocation", period) + total = np.zeros(person.count) + for child in household_member_indices(person): + safe = np.maximum(child, 0) + named = np.any( + present + & ( + (relatives == first[safe][:, None]) + | (relatives == second[safe][:, None]) + ), + axis=1, + ) + presumed = may_presume & np.any( + present & (relative_tax_units == tax_unit[safe][:, None]), axis=1 + ) + is_parent = np.any(parents == child[:, None], axis=1) + counts = ( + (child >= 0) + & (child != own_index) + & ~is_parent + & ~recipients[safe] + & np.where(linked[safe], named, presumed) + ) + total += np.where(counts, allocation[safe], 0) + return total diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent.py index a6d6252e24b..81bc74fd301 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_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_parent._ssi_parental_child_allocation_scope import ( + ssi_parental_child_allocation_total, +) class ssi_unearned_income_deemed_from_ineligible_parent(Variable): @@ -9,12 +12,19 @@ class ssi_unearned_income_deemed_from_ineligible_parent(Variable): entity = Person label = "SSI unearned income (deemed from ineligible parent)" unit = USD - definition_period = YEAR + definition_period = MONTH reference = "https://www.law.cornell.edu/cfr/text/20/416.1165" def formula(person, period, parameters): - eligible_child = person("is_ssi_aged_blind_disabled", period) & person( - "is_child", period + immigration_status = person("immigration_status", period) + is_citizen = immigration_status == immigration_status.possible_values.CITIZEN + # 416.1165(e)(2) divides only among SSI-eligible children. Income + # and resource eligibility depend on this calculation, so use the + # status conditions shared with the ineligible-child classification. + eligible_child = ( + person("is_ssi_aged_blind_disabled", period) + & person("is_child", period) + & (is_citizen | person("is_ssi_qualified_noncitizen", period)) ) ineligible_parent = person("is_ssi_ineligible_parent", period) tax_unit = person.tax_unit @@ -34,8 +44,8 @@ def formula(person, period, parameters): person("ssi_unearned_income", period) * ineligible_parent ) - child_allocations = eligible_child * add( - tax_unit, period, ["ssi_ineligible_child_allocation"] + child_allocations = eligible_child * ssi_parental_child_allocation_total( + person, period, eligible_child ) parental_allocations = eligible_child * add( tax_unit, period, ["ssi_ineligible_parent_allocation"] @@ -56,6 +66,7 @@ def formula(person, period, parameters): parental_unearned_income, parameters, period, + monthly=True, ) net_parental_deemed_income = max_( @@ -66,7 +77,7 @@ def formula(person, period, parameters): # avoid array divide-by-zero warnings by not using where() function # see the following GitHub issue for more details: # https://github.com/PolicyEngine/policyengine-us/issues/2494 - income = np.zeros_like(count_eligible_children) + income = np.zeros_like(count_eligible_children, dtype=float) mask = count_eligible_children > 0 income[mask] = net_parental_deemed_income[mask] / count_eligible_children[mask] - return income + return where(eligible_child, income, 0) From e90834c2be742d36a57a34c7006a393558afb92a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 01:51:56 -0400 Subject: [PATCH 07/27] Retain monthly SSI allocations through deeming thresholds and benefit caps --- ...ineligible-child-allocation-scope.fixed.md | 2 +- ..._income_deemed_from_ineligible_spouse.yaml | 98 +++++++++++++++++++ .../gov/ssa/ssi/_ssi_state_supplement.py | 2 +- .../deemed/from_ineligible_spouse/README.md | 8 +- .../is_ssi_spousal_deeming_applies.py | 4 +- ...ed_income_deemed_from_ineligible_spouse.py | 2 +- ...si_income_deemed_from_ineligible_spouse.py | 7 +- .../ssi_spousal_deeming_child_allocations.py | 2 +- ...ed_income_deemed_from_ineligible_spouse.py | 2 +- .../deemed/ssi_ineligible_child_allocation.py | 34 +++---- .../ssi_in_kind_support_and_maintenance.py | 2 +- .../eligibility/income/ism/ssi_pmv_amount.py | 4 +- .../income/ssi_countable_income.py | 3 +- ...resources_deemed_from_ineligible_parent.py | 6 +- .../gov/ssa/ssi/ssi_amount_if_eligible.py | 2 +- .../variables/gov/ssa/ssi/ssi_if_takes_up.py | 2 +- 16 files changed, 139 insertions(+), 41 deletions(-) diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index e2a7f2bdbf5..a50c9b3a462 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit the SSI ineligible-child allocation in spousal deeming to children of the claimant or the claimant's spouse who live with the claimant (20 CFR 416.1160(d), 416.1163(b)), using parent ids where supplied. Apply the SSI definition of a child (416.1856), treat children without qualifying immigration status as not eligible for SSI, exclude a student child's wages up to the student exclusion (416.1161(c)), and add an input for children receiving public income-maintenance payments, who get no allocation. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. 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 0b05128cf11..543418815f8 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 @@ -224,3 +224,101 @@ members: [person1, person2] output: ssi_income_deemed_from_ineligible_spouse: [0, 0] + +- name: Review r2 applies monthly spousal thresholds after a student's annual exclusion expires + # 20 CFR 416.1163(d), (e)(2): budget each month's allocations and cap + # before aggregation. Ten months at $994 and two at $911 = $11,762. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + employment_income: 1_000 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + student: + ssi_ineligible_child_allocation: 4_700 + claimant: + ssi_unearned_income_deemed_from_ineligible_spouse: 2_500 + ssi_income_deemed_from_ineligible_spouse: 1_160 + ssi: + "2026": 11_762 + "2026-01": 994 + "2026-10": 994 + "2026-11": 911 + +- name: Review r2 applies monthly spousal thresholds for wages above the student monthly exclusion + # Four months at $994 and eight at $911 = $11,264. The $307 child + # allocation in each of the first four months keeps deeming below $497. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + employment_income: 2_600 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + student: + ssi_ineligible_child_allocation: 1_228 + claimant: + ssi_unearned_income_deemed_from_ineligible_spouse: 5_972 + ssi_income_deemed_from_ineligible_spouse: 4_640 + ssi: + "2026": 11_264 + "2026-01": 994 + "2026-04": 994 + "2026-05": 911 diff --git a/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py b/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py index 5e0c84da3f3..b19683d9d32 100644 --- a/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py @@ -25,7 +25,7 @@ def ssi_state_supplement( excess = max_(countable - federal_amount, 0) payment = max_(standard - excess, 0) - deeming = person("is_ssi_spousal_deeming_applies", period.this_year) + deeming = person("is_ssi_spousal_deeming_applies", period) deemed = person("ssi_income_deemed_from_ineligible_spouse", period) own_income = max_(countable - deemed, 0) ssi = parameters(period).gov.ssa.ssi 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 0922e600c16..b3736c4f18d 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 @@ -19,14 +19,16 @@ The implementation follows these steps according to the regulations: - Deduct allocations for ineligible children from the ineligible spouse's income - Deductions are made from unearned income first, then earned income - - An ineligible child is the claimant's or the spouse's child who lives in the claimant's household and is not eligible for SSI (§416.1160(d)), and who is a child under §416.1856: under 18, or under 22 and a student; not married; not the head of a household + - An ineligible child is the claimant's or the spouse's child who lives in the claimant's household and is not eligible for SSI (§416.1160(d)), and who is a child under §416.1856: under 18, or a student under the deeming age limit (under 21 before June 16, 2008, under 22 afterward); unmarried even when a legal spouse lives elsewhere; not the head of a household - `ssi_spousal_deeming_child_allocations` sums the allocations. Parent ids (`parent_1_id`, `parent_2_id`) decide whose child someone is. Without ids, the co-resident members of the claimant's or the spouse's tax unit stand in, as the parent-link contract in `household/demographic/person/_parent_links.py` allows; that proxy cannot tell a grandchild from a child - No allocation is made for a child receiving public income-maintenance payments (`ssi_receives_public_income_maintenance`) + - Student earnings use the monthly student exclusion until its annual cap is exhausted. Each month retains its allocation through spouse-income deductions, the deeming threshold, income exclusions, floors and benefit caps; annual outputs sum the completed monthly calculations. Annual income inputs are spread evenly over the year + - Parent deeming and its Medicaid counterpart also restrict allocations to relevant co-resident children. SSI recipient division uses SSI immigration status; Medicaid uses its own immigration eligibility and excludes its recipients from the allocation pool 3. **Compare remaining income to FBR differential** (§416.1163(d)) - Compare the remaining income to the difference between couple and individual FBR - - If less than the differential, no income is deemed + - If no more than the differential, no income is deemed - If more than the differential, continue with deeming calculations 4. **Calculate combined countable income** (§416.1163(d)(2)) @@ -61,7 +63,7 @@ These tests can be found in `ssi_income_deemed_from_ineligible_spouse.yaml` and ### Integration Tests -Integration tests verify the full SSI benefit calculation pipeline and can be found in the `integration.yaml` file. These tests check: +Integration tests verify the full SSI benefit calculation pipeline and can be found in the `integration_tests.yaml` file. These tests check: - The entire SSI benefit calculation for households with ineligible spouses - Interactions between spousal deeming and other components of the SSI program diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py index 9c7348fb608..392ebd7e746 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py @@ -5,7 +5,7 @@ class is_ssi_spousal_deeming_applies(Variable): value_type = bool entity = Person label = "SSI spousal deeming applies" - definition_period = YEAR + definition_period = MONTH reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" defined_for = "is_ssi_eligible_individual" documentation = """ @@ -34,7 +34,7 @@ def formula(person, period, parameters): # Compare to FBR differential (couple rate - individual rate) p = parameters(period).gov.ssa.ssi.amount - diff = (p.couple - p.individual) * MONTHS_IN_YEAR + diff = p.couple - p.individual # Deeming applies when spouse's deemed income exceeds the differential # Note: regulation says "not more than" means ≤, so we use > for deeming diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py index 08f6676035e..0166e6db812 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py @@ -6,7 +6,7 @@ class ssi_earned_income_deemed_from_ineligible_spouse(Variable): entity = Person label = "SSI earned income (deemed from ineligible spouse)" unit = USD - definition_period = YEAR + definition_period = MONTH reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" def formula(person, period, parameters): 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 8a885f8d66f..563927d2c7c 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 @@ -10,13 +10,13 @@ class ssi_income_deemed_from_ineligible_spouse(Variable): value_type = float entity = Person label = "SSI income (deemed from ineligible spouse)" - definition_period = YEAR + definition_period = MONTH 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. + Each month is calculated before annual amounts are summed. """ defined_for = "is_ssi_eligible_individual" unit = USD @@ -42,7 +42,7 @@ def formula(person, period, parameters): # Calculate individual's countable income (alone) alone_countable = _apply_ssi_exclusions( - individual_earned, individual_unearned, parameters, period + individual_earned, individual_unearned, parameters, period, monthly=True ) # Calculate couple's combined countable income @@ -51,6 +51,7 @@ def formula(person, period, parameters): individual_unearned + spouse_unearned, parameters, period, + monthly=True, ) # Deemed amount is the difference (only when deeming applies) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py index 8b052a723ed..adb6629818c 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py @@ -31,7 +31,7 @@ class ssi_spousal_deeming_child_allocations(Variable): "remain a member of (416.1167, POMS SI 01310.165)." ) unit = USD - definition_period = YEAR + definition_period = MONTH reference = ( "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", "https://www.ecfr.gov/current/title-20/section-416.1160#p-416.1160(d)", diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py index c4b72ee2684..97c0390aada 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py @@ -9,7 +9,7 @@ class ssi_unearned_income_deemed_from_ineligible_spouse(Variable): "This is ignored if total income is under the SSI individual allowance." ) unit = USD - definition_period = YEAR + definition_period = MONTH reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" def formula(person, period, parameters): diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index 68c6f7f8204..fff90a67bf7 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -11,7 +11,7 @@ class ssi_ineligible_child_allocation(Variable): "spouse's or parent's income before deeming it. Which claimants it " "applies to is decided where the allocations are summed." ) - definition_period = YEAR + definition_period = MONTH reference = ( "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", "https://www.ecfr.gov/current/title-20/section-416.1161#p-416.1161(c)", @@ -36,21 +36,17 @@ def formula(person, period, parameters): is_student = person("is_full_time_student", period) & ( person("age", period) < student.age_limit ) - # Annual income is spread evenly over the year. The student - # exclusion applies month by month until its yearly limit is used - # up (POMS SI 00820.510), and each month's allocation is floored at - # zero, so income left over in one month never reduces another's. - # The child's income only reduces the allocation: a net loss is no - # income, so it never raises the allocation above (b)(1). - monthly_earned = max_(0, person("ssi_earned_income", period)) / MONTHS_IN_YEAR - monthly_unearned = ( - max_(0, person("ssi_unearned_income", period)) / MONTHS_IN_YEAR - ) - exclusion_left = where(is_student, student.cap, 0) - total = np.zeros(person.count) - for _ in range(MONTHS_IN_YEAR): - exclusion = min_(min_(student.amount, monthly_earned), exclusion_left) - exclusion_left = exclusion_left - exclusion - income = monthly_earned - exclusion + monthly_unearned - total += max_(0, monthly_allocation - income) - return allocated * total + # Annual income is spread evenly over the year. Keep each month's + # allocation through downstream deductions, exclusions and benefit + # caps; annual reads sum the completed monthly budgets. + earned = max_(0, person("ssi_earned_income", period)) + unearned = max_(0, person("ssi_unearned_income", period)) + # The student exclusion is used consecutively from January until + # its annual cap is exhausted (POMS SI 00820.510). Annual income + # inputs imply the same monthly earnings in the preceding months. + monthly_exclusion = min_(student.amount, earned) + used = monthly_exclusion * (period.start.month - 1) + exclusion_left = max_(0, student.cap - used) + exclusion = where(is_student, min_(monthly_exclusion, exclusion_left), 0) + income = earned - exclusion + unearned + return allocated * max_(0, monthly_allocation - income) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py index 993b20fe33b..d3156de0f31 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py @@ -6,7 +6,7 @@ class ssi_in_kind_support_and_maintenance(Variable): entity = Person label = "SSI in-kind support and maintenance" unit = USD - definition_period = YEAR + definition_period = MONTH reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1130", "https://www.law.cornell.edu/cfr/text/20/416.1140", diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py index 18c70e2a9be..406b036324d 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py @@ -6,7 +6,7 @@ class ssi_pmv_amount(Variable): entity = Person label = "SSI presumed maximum value amount" unit = USD - definition_period = YEAR + definition_period = MONTH reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1140", "https://secure.ssa.gov/poms.nsf/lnx/0500835300", @@ -35,4 +35,4 @@ def formula(person, period, parameters): applicable_fbr * p.income.ism.pmv_fbr_fraction + p.income.exclusions.general ) pmv_monthly = where(deeming_applies, pmv_monthly / 2, pmv_monthly) - return pmv_monthly * MONTHS_IN_YEAR + return pmv_monthly 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 1d268ec5b6c..0b454074f63 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 @@ -9,7 +9,7 @@ class ssi_countable_income(Variable): entity = Person label = "SSI countable income" unit = USD - definition_period = YEAR + definition_period = MONTH reference = "https://www.law.cornell.edu/uscode/text/42/1382a#b" documentation = """ Calculates total countable income for SSI: @@ -61,6 +61,7 @@ def formula(person, period, parameters): total_unearned, parameters, period, + monthly=True, ) # Check if couple computation applies (both eligible AND not diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py index 2c0a1f658dd..375e65927f5 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py @@ -133,9 +133,9 @@ def formula(person, period, parameters): # the payment is ineligible 'for any reason': they are still deemed a # share, but do not count in the division. Neither variable reads the # resource test. - income_eligible = person( - "ssi_countable_income", period.this_year - ) / MONTHS_IN_YEAR < person("ssi_amount_if_eligible", period) + income_eligible = person("ssi_countable_income", period) < person( + "ssi_amount_if_eligible", period + ) deemed = np.zeros(person.count) # SI 01330.200 B: 'equally divide' among eligible children; when a # child is ineligible 'divide ... among the remaining eligible children'. diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py index bfe5cdf394a..7319fb96ce2 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py @@ -26,7 +26,7 @@ def formula(person, period, parameters): # SSI-eligible. ssi_claim_is_joint remains true for state SSPs. couple_computation = person("ssi_couple_computation_applies", period) - deeming_applies = person("is_ssi_spousal_deeming_applies", period.this_year) + deeming_applies = person("is_ssi_spousal_deeming_applies", period) individual_or_deeming_amount = where( deeming_applies, diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py index 7e6a85efd5c..f9c96c70368 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py @@ -28,7 +28,7 @@ def formula(person, period, parameters): # - Deeming applies (uses couple FBR) # - After exclusions, countable may be low # - Benefit could exceed individual FBR without this cap - deeming_applies = person("is_ssi_spousal_deeming_applies", period.this_year) + deeming_applies = person("is_ssi_spousal_deeming_applies", period) p = parameters(period).gov.ssa.ssi.amount capped_benefit = min_(benefit, p.individual) From 9e7a6b73552a8628aaacc6fd6c9050b267bddd8f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 01:57:19 -0400 Subject: [PATCH 08/27] Preserve monthly deeming in Medicaid and MSP income consumers --- ..._income_deemed_from_ineligible_spouse.yaml | 98 +++++++++++++++++++ .../income/msp_countable_income.yaml | 96 ++++++++++++++++++ .../chhs/wdp/eligibility/ca_wdp_eligible.yaml | 33 +++++-- .../ca_wdp_ssi_ssp_income_eligible.yaml | 48 +++++++++ ...ed_income_deemed_from_ineligible_spouse.py | 36 ++++--- ...nal_senior_or_disabled_countable_income.py | 19 ++-- .../income/msp_countable_income.py | 29 +++--- .../_ca_wdp_annual_spousal_deeming.py | 26 +++++ .../wdp/eligibility/ca_wdp_income_eligible.py | 7 +- .../ca_wdp_ssi_ssp_income_eligible.py | 9 +- 10 files changed, 352 insertions(+), 49 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.yaml create mode 100644 policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/_ca_wdp_annual_spousal_deeming.py diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml index 1724495a64d..b775b361082 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml @@ -120,3 +120,101 @@ # (17,004 - 20 * 12 - 65 * 12) / 2 = 7,992 deemed and countable. medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [7_992, 0, 0] medicaid_optional_senior_or_disabled_countable_income: [7_992, 0, 0] + +- name: Optional Medicaid budgets monthly allocations for a student earning $1_000 per month + # 20 CFR 416.1163(d): deem only in months above the FBR differential. + # The spouse has $580 countable income in each month with no allocation. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + is_medicare_eligible: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + is_medicare_eligible: false + ssi_unearned_income: 600 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 1_000 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + claimant: + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: + "2026": 1_160 + "2026-01": 0 + "2026-10": 0 + "2026-11": 580 + medicaid_optional_senior_or_disabled_countable_income: 1_160 + +- name: Optional Medicaid budgets monthly allocations for a student earning $2_600 per month + # 20 CFR 416.1163(d): deem only in months above the FBR differential. + # The spouse has $580 countable income in each month with no allocation. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + is_medicare_eligible: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + is_medicare_eligible: false + ssi_unearned_income: 600 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 2_600 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + claimant: + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: + "2026": 4_640 + "2026-01": 0 + "2026-04": 0 + "2026-05": 580 + medicaid_optional_senior_or_disabled_countable_income: 4_640 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml index 1c823197a3b..4e0130bd7db 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml @@ -113,3 +113,99 @@ # (12,000 - 2,400 + 24,000 - 3,600) / 12 = 2,500 monthly. # Apply the couple's exclusions once: (2,500 - 20 - 65) / 2 = 1,207.50. msp_countable_income: [1_207.50, 1_207.50] + +- name: MSP budgets monthly allocations for a student earning $1_000 per month + # 20 CFR 416.1163(d): deem only in months above the FBR differential. + # The spouse has $580 countable income in each month with no allocation. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + is_medicare_eligible: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + is_medicare_eligible: false + ssi_unearned_income: 600 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 1_000 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + claimant: + msp_countable_income: + "2026": 1_160 + "2026-01": 0 + "2026-10": 0 + "2026-11": 580 + +- name: MSP budgets monthly allocations for a student earning $2_600 per month + # 20 CFR 416.1163(d): deem only in months above the FBR differential. + # The spouse has $580 countable income in each month with no allocation. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + is_medicare_eligible: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + is_medicare_eligible: false + ssi_unearned_income: 600 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 2_600 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + claimant: + msp_countable_income: + "2026": 4_640 + "2026-01": 0 + "2026-04": 0 + "2026-05": 580 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml index 357e7530ca7..01a5201b0c8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml @@ -159,11 +159,30 @@ - name: Case 10, SSI spousal deeming triggers the married SSI SSP standard. period: 2026 input: - state_code: CA - is_disabled: true - employment_income: 1_000 - pension_income: 20_000 - ssi_countable_resources: 10_000 - is_ssi_spousal_deeming_applies: true + people: + recipient: + age: 45 + is_disabled: true + employment_income: 1_000 + pension_income: 20_000 + ssi_countable_resources: 10_000 + is_tax_unit_head: true + spouse: + age: 45 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + tax_units: + couple: + members: [recipient, spouse] + marital_units: + couple: + members: [recipient, spouse] + households: + home: + members: [recipient, spouse] + state_code: CA output: - ca_wdp_ssi_ssp_income_eligible: true + people: + recipient: + is_ssi_spousal_deeming_applies: true + ca_wdp_ssi_ssp_income_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.yaml new file mode 100644 index 00000000000..184ae663be6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.yaml @@ -0,0 +1,48 @@ +- name: WDP annual screen preserves its annual approximation when December deeming differs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + ssi_unearned_income: 16_000 + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + ssi_unearned_income: 600 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + is_full_time_student: true + employment_income: 1_000 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: CA + output: + people: + claimant: + # The annual stock value reflects December, when the student's + # exclusion is exhausted and SSI deeming applies. WDP's existing + # annual approximation instead compares the $2,500 annual spouse + # remainder with the annual differential, $5,964. + is_ssi_spousal_deeming_applies: true + # $16,000 - $240 = $15,760 exceeds the single SSI/SSP standard, + # $1,233.94 * 12 = $14,807.28. December's flag must not select + # the couple standard for this whole-year approximation. + ca_wdp_ssi_ssp_income_eligible: false diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py index a692202dac3..16f30628c47 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py @@ -10,7 +10,7 @@ class medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse( value_type = float entity = Person label = "Medicaid optional senior or disabled income deemed from ineligible spouse" - definition_period = YEAR + definition_period = MONTH reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1163", "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-015-00/0805-015-05/", @@ -54,21 +54,27 @@ def formula(person, period, parameters): has_ineligible_spouse = marital_unit.sum(ineligible_spouse) > 0 deeming_applies = deeming_applies | (is_mo & has_ineligible_spouse) - alone_countable = _apply_medicaid_optional_senior_or_disabled_exclusions( - individual_earned, - individual_unearned, - state, - p.individual[state], - parameters, - period, + alone_countable = ( + _apply_medicaid_optional_senior_or_disabled_exclusions( + individual_earned * MONTHS_IN_YEAR, + individual_unearned * MONTHS_IN_YEAR, + state, + p.individual[state], + parameters, + period, + ) + / MONTHS_IN_YEAR ) - couple_countable = _apply_medicaid_optional_senior_or_disabled_exclusions( - individual_earned + spouse_earned, - individual_unearned + spouse_unearned, - state, - p.couple[state], - parameters, - period, + couple_countable = ( + _apply_medicaid_optional_senior_or_disabled_exclusions( + (individual_earned + spouse_earned) * MONTHS_IN_YEAR, + (individual_unearned + spouse_unearned) * MONTHS_IN_YEAR, + state, + p.couple[state], + parameters, + period, + ) + / MONTHS_IN_YEAR ) deemed_amount = max_(0, couple_countable - alone_countable) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py index 044371e7419..dc2199a60dc 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py @@ -9,7 +9,7 @@ class medicaid_optional_senior_or_disabled_countable_income(Variable): entity = Person label = "Countable income for Medicaid's optional senior or disabled pathway" unit = USD - definition_period = YEAR + definition_period = MONTH reference = ( "https://www.law.cornell.edu/uscode/text/42/1396a#m", "https://www.kff.org/medicaid/medicaid-financial-eligibility-for-seniors-and-people-with-disabilities-findings-from-a-50-state-survey/", @@ -49,13 +49,16 @@ def formula(person, period, parameters): p.couple[state], p.individual[state], ) - personal_countable = _apply_medicaid_optional_senior_or_disabled_exclusions( - earned_income, - total_unearned_income, - state, - income_disregard, - parameters, - period, + personal_countable = ( + _apply_medicaid_optional_senior_or_disabled_exclusions( + earned_income * MONTHS_IN_YEAR, + total_unearned_income * MONTHS_IN_YEAR, + state, + income_disregard, + parameters, + period, + ) + / MONTHS_IN_YEAR ) spousal_deemed = person( diff --git a/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py b/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py index 73a0ce26fb3..0ad174cb537 100644 --- a/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py @@ -32,32 +32,32 @@ class msp_countable_income(Variable): """ def formula(person, period, parameters): - year = period.this_year - earned_income = person("ssi_earned_income", year) - unearned_income = person("ssi_unearned_income", year) - is_medicare_eligible = person("is_medicare_eligible", year) + earned_income = person("ssi_earned_income", period) + unearned_income = person("ssi_unearned_income", period) + is_medicare_eligible = person("is_medicare_eligible", period) both_medicare_eligible = person.marital_unit.sum(is_medicare_eligible) == 2 blind_or_disabled_working_student_exclusion = person( - "ssi_blind_or_disabled_working_student_exclusion", year + "ssi_blind_or_disabled_working_student_exclusion", period ) personal_earned_income = max_( earned_income - blind_or_disabled_working_student_exclusion, 0, ) - deeming_applies = person("is_ssi_spousal_deeming_applies", year) + deeming_applies = person("is_ssi_spousal_deeming_applies", period) spouse_earned_income = person( - "ssi_earned_income_deemed_from_ineligible_spouse", year + "ssi_earned_income_deemed_from_ineligible_spouse", period ) spouse_unearned_income = person( - "ssi_unearned_income_deemed_from_ineligible_spouse", year + "ssi_unearned_income_deemed_from_ineligible_spouse", period ) personal_countable_income = _apply_ssi_exclusions( personal_earned_income, unearned_income, parameters, - year, + period, + monthly=True, ) couple_countable = _apply_ssi_exclusions( @@ -68,21 +68,20 @@ def formula(person, period, parameters): ), person.marital_unit.sum(unearned_income), parameters, - year, + period, + monthly=True, ) deemed_countable = _apply_ssi_exclusions( personal_earned_income + spouse_earned_income, unearned_income + spouse_unearned_income, parameters, - year, + period, + monthly=True, ) single_countable = where( deeming_applies, deemed_countable, personal_countable_income ) - annual_countable = where( - both_medicare_eligible, couple_countable, single_countable - ) - return annual_countable / MONTHS_IN_YEAR + return where(both_medicare_eligible, couple_countable, single_countable) diff --git a/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/_ca_wdp_annual_spousal_deeming.py b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/_ca_wdp_annual_spousal_deeming.py new file mode 100644 index 00000000000..e786377aaad --- /dev/null +++ b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/_ca_wdp_annual_spousal_deeming.py @@ -0,0 +1,26 @@ +"""Retain the California WDP screens' existing annual income approximation.""" + +from policyengine_us.model_api import * + + +def ca_wdp_annual_spousal_deeming_applies(person, period, parameters): + """Apply the SSI differential to the screens' annual income budget. + + These first-pass WDP screens use annual income. The SSI deeming flag + itself is monthly, and an annual read of that stock gives December's + result. Compare annual aggregate spouse income with the annualized FBR + differential here to preserve the screens' existing approximation; + federal SSI continues to apply the threshold separately each month. + """ + spouse_income = add( + person, + period, + [ + "ssi_earned_income_deemed_from_ineligible_spouse", + "ssi_unearned_income_deemed_from_ineligible_spouse", + ], + ) + p = parameters(period).gov.ssa.ssi.amount + return person("is_ssi_eligible_individual", period) & ( + spouse_income > (p.couple - p.individual) * MONTHS_IN_YEAR + ) diff --git a/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py index bbf6698e347..75d9f038670 100644 --- a/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py +++ b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_income_eligible.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.ca.chhs.wdp.eligibility._ca_wdp_annual_spousal_deeming import ( + ca_wdp_annual_spousal_deeming_applies, +) class ca_wdp_income_eligible(Variable): @@ -20,7 +23,9 @@ def formula(person, period, parameters): p = parameters(period).gov.states.ca.chhs.wdp.eligibility.income personal_income = person("ca_wdp_countable_income", period) spouse_income = person.marital_unit.sum(personal_income) - personal_income - spousal_deeming_applies = person("is_ssi_spousal_deeming_applies", period) + spousal_deeming_applies = ca_wdp_annual_spousal_deeming_applies( + person, period, parameters + ) countable_income = personal_income + where( spousal_deeming_applies, spouse_income, diff --git a/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.py b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.py index d7fb59b6bf2..7ea1892ebe1 100644 --- a/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.py +++ b/policyengine_us/variables/gov/states/ca/chhs/wdp/eligibility/ca_wdp_ssi_ssp_income_eligible.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.states.ca.chhs.wdp.eligibility._ca_wdp_annual_spousal_deeming import ( + ca_wdp_annual_spousal_deeming_applies, +) class ca_wdp_ssi_ssp_income_eligible(Variable): @@ -36,9 +39,9 @@ def formula(person, period, parameters): p = parameters( period.first_month ).gov.states.ca.cdss.state_supplement.payment_standard - uses_couple_standard = person("ssi_claim_is_joint", period) | person( - "is_ssi_spousal_deeming_applies", period - ) + uses_couple_standard = person( + "ssi_claim_is_joint", period + ) | ca_wdp_annual_spousal_deeming_applies(person, period, parameters) monthly_standard = where( uses_couple_standard, p.aged_or_disabled.amount.married, From 16418a7d0ff646f405f48189201e3c5766281a6d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 01:57:45 -0400 Subject: [PATCH 09/27] Date historical student boundary fixtures at their annual input period --- .../status/is_ssi_ineligible_child.yaml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml index 1df8f6f9f79..b6876e8691f 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/status/is_ssi_ineligible_child.yaml @@ -182,15 +182,21 @@ - name: Review r2 the deeming student age limit is under 21 in May 2008 period: 2008-05 input: - age: 21 - is_full_time_college_student: true + # Demographic inputs are annual; do not date them after January 1. + age: + 2008: 21 + is_full_time_college_student: + 2008: true output: is_ssi_ineligible_child: false - name: Review r2 the deeming student age limit is under 22 in July 2008 period: 2008-07 input: - age: 21 - is_full_time_college_student: true + # Demographic inputs are annual; do not date them after January 1. + age: + 2008: 21 + is_full_time_college_student: + 2008: true output: is_ssi_ineligible_child: true From a41437dcf4e8be47f8a0b0d5f0781da508458de9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 02:01:42 -0400 Subject: [PATCH 10/27] Cover monthly floors across both spouse income pools --- .../income/deemed/from_ineligible_spouse.yaml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml index 64125417f99..630937d66bb 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml @@ -93,3 +93,51 @@ is_ssi_spousal_deeming_applies: [true, false, false] ssi_income_deemed_from_ineligible_spouse: [7_992, 0, 0] ssi_countable_income: [7_992, 0, 0] + +- name: Monthly child allocations floor each spouse income pool before annual aggregation + # With $1,000 monthly student wages, the allocations are $497 in + # January-September, $227 in October, then zero. The spouse has $100 + # unearned and $300 earned a month: both pools are exhausted in the + # first nine months; October leaves $173 earned; November-December + # leave $100 unearned and $300 earned each. An annual floor would + # incorrectly leave only $100 earned and no unearned income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + person_id: 1 + is_tax_unit_head: true + is_ssi_disabled: true + spouse: + age: 48 + person_id: 2 + is_tax_unit_spouse: true + ssi_earned_income: 300 * 12 + ssi_unearned_income: 100 * 12 + student: + age: 16 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_tax_unit_dependent: true + employment_income: 1_000 * 12 + tax_units: + family: + members: [claimant, spouse, student] + marital_units: + parents: + members: [claimant, spouse] + student_unit: + members: [student] + households: + home: + members: [claimant, spouse, student] + state_code: TX + output: + people: + claimant: + ssi_earned_income_deemed_from_ineligible_spouse: 773 + ssi_unearned_income_deemed_from_ineligible_spouse: 200 + ssi: 11_928 From e5e449e60097552a7b8b0b7d7d831c684c326468 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 02:03:52 -0400 Subject: [PATCH 11/27] Keep pretax premium regressions distinct from student exclusions --- ..._disabled_unearned_income_deemed_from_ineligible_parent.yaml | 2 ++ ...senior_or_disabled_income_deemed_from_ineligible_spouse.yaml | 2 ++ .../ssi/eligibility/income/deemed/from_ineligible_parent.yaml | 2 ++ .../ssi/eligibility/income/deemed/from_ineligible_spouse.yaml | 2 ++ 4 files changed, 8 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml index fe17f4085ea..2f553b5e908 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml @@ -29,6 +29,8 @@ is_ssi_disabled: true sibling: age: 14 + # Isolate pretax premiums from the student earned-income exclusion. + is_in_k12_school: false employment_income: 2_400 pre_tax_health_insurance_premiums: 1_200 parent: diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml index b775b361082..53b3a565e17 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml @@ -100,6 +100,8 @@ pre_tax_health_insurance_premiums: 2_400 child: age: 14 + # Isolate pretax premiums from the student earned-income exclusion. + is_in_k12_school: false employment_income: 2_400 pre_tax_health_insurance_premiums: 1_200 tax_units: diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml index 96f3e7376a9..351201e2e96 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml @@ -85,6 +85,8 @@ is_ssi_disabled: true sibling: age: 14 + # Isolate pretax premiums from the student earned-income exclusion. + is_in_k12_school: false employment_income: 2_400 pre_tax_health_insurance_premiums: 1_200 parent: diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml index 630937d66bb..44ab08fc324 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml @@ -73,6 +73,8 @@ pre_tax_health_insurance_premiums: 2_400 child: age: 14 + # Isolate pretax premiums from the student earned-income exclusion. + is_in_k12_school: false employment_income: 2_400 pre_tax_health_insurance_premiums: 1_200 tax_units: From 8256e5103e3a2a6b37e6f550e455b239706d3da3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 02:08:32 -0400 Subject: [PATCH 12/27] Derive both spouse and student wages in monthly floor regression --- .../ssi/eligibility/income/deemed/from_ineligible_spouse.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml index 44ab08fc324..20f1e3a5253 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse.yaml @@ -116,7 +116,7 @@ age: 48 person_id: 2 is_tax_unit_spouse: true - ssi_earned_income: 300 * 12 + employment_income: 300 * 12 ssi_unearned_income: 100 * 12 student: age: 16 From a4021effed4c25e8e5fee853aa04095a8061bedf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 02:09:09 -0400 Subject: [PATCH 13/27] Preserve recipient pension income in the California married-screen fixture --- .../gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml index 01a5201b0c8..6af7de9fc88 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml @@ -170,7 +170,7 @@ spouse: age: 45 is_tax_unit_spouse: true - ssi_unearned_income: 600 * 12 + pension_income: 600 * 12 tax_units: couple: members: [recipient, spouse] From 58e361325e4b28fb5928c49dfc213d20f7d1891c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 02:10:57 -0400 Subject: [PATCH 14/27] Read annual student age in the monthly SSI allocation --- .../income/deemed/ssi_ineligible_child_allocation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index fff90a67bf7..c7085c3f6a9 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -34,7 +34,7 @@ def formula(person, period, parameters): # disabled (POMS SI 01320.185). No $20 or $65 exclusion applies. student = p.income.exclusions.blind_or_disabled_working_student is_student = person("is_full_time_student", period) & ( - person("age", period) < student.age_limit + person("age", period.this_year) < student.age_limit ) # Annual income is spread evenly over the year. Keep each month's # allocation through downstream deductions, exclusions and benefit From 204532dad75f84002e7e1f07958407e34e4f89ba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 02:23:10 -0400 Subject: [PATCH 15/27] Specify SSI disability status in the derived California deeming case --- .../gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml index 6af7de9fc88..e039fc02ef4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/chhs/wdp/eligibility/ca_wdp_eligible.yaml @@ -163,6 +163,7 @@ recipient: age: 45 is_disabled: true + meets_ssi_disability_criteria: true employment_income: 1_000 pension_income: 20_000 ssi_countable_resources: 10_000 From 4e42c600ed8e5fbfc6648da6c1db9993a07db7fd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 03:06:10 -0400 Subject: [PATCH 16/27] Preserve Missouri's under-18 parental allocation scope --- ...ineligible-child-allocation-scope.fixed.md | 2 +- ..._income_deemed_from_ineligible_parent.yaml | 46 +++++++++++++++++++ ...ed_income_deemed_from_ineligible_parent.py | 12 ++++- .../_ssi_parental_child_allocation_scope.py | 10 ++-- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index a50c9b3a462..9db9d34a32a 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve Missouri's under-18 allocation limit, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml index 2f553b5e908..6dec1f9f0c1 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml @@ -131,3 +131,49 @@ # the parents' living allowance (1,491 x 12 = 17,892, the couple SSI # federal benefit rate) -> 19,098, split equally -> 9,549 each. medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: [9_549, 9_549, 0, 0] + +- name: Missouri does not allocate to a co-resident adult student. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + is_parent: true + ssi_unearned_income: 24_000 + claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + student: + age: 20 + person_id: 3 + parent_1_id: 1 + is_tax_unit_dependent: true + is_full_time_student: true + tax_units: + family: + members: [parent, claimant, student] + marital_units: + parent_unit: + members: [parent] + claimant_unit: + members: [claimant] + student_unit: + members: [student] + households: + home: + members: [parent, claimant, student] + state_code: MO + output: + # DSS 0805.020.15 step 1 restricts allocations to children under 18: + # https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-020-00/0805-020-15/ + # SSI still allocates for this student, but Missouri must not deduct it. + ssi_ineligible_child_allocation: [0, 0, 5_964] + # ($2,000 - $20 - $994) * 12 = $11,832, not $5,868. + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: [0, 11_832, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py index afe28f1e001..4ad1faacd27 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py @@ -21,6 +21,7 @@ class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligibl reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1165", "https://www.law.cornell.edu/uscode/text/42/1396b#v", + "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-020-00/0805-020-15/", ) def formula(person, period, parameters): @@ -49,8 +50,16 @@ def formula(person, period, parameters): person("ssi_unearned_income", period) * ineligible_parent ) + state = person.household("state_code_str", period) + # DSS 0805.020.15 step 1 allows Missouri allocations only for + # children under 18 who are not applying for MA. In particular, + # SSI's adult-student allocation must not enter Missouri's budget. + allocation_child = where(state == "MO", person("is_child", period), True) + allocations = ( + person("ssi_ineligible_child_allocation", period) * allocation_child + ) child_allocations = eligible_child * ssi_parental_child_allocation_total( - person, period, eligible_child + person, period, eligible_child, allocations ) parental_allocations = eligible_child * add( tax_unit, period, ["ssi_ineligible_parent_allocation"] @@ -63,7 +72,6 @@ def formula(person, period, parameters): 0, parental_earned_income - remaining_child_allocations ) - state = person.household("state_code_str", period) general_disregard = parameters( period ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.disregard.individual[ diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py index 69d2d97cc4e..27a3eb1412c 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py @@ -49,7 +49,7 @@ def _allocation_parent_rows(person, period, first, second): return np.column_stack(rows) -def ssi_parental_child_allocation_total(person, period, recipients): +def ssi_parental_child_allocation_total(person, period, recipients, allocations=None): """Allocations for relevant children who live with this recipient. 20 CFR 416.1165(b) limits allocations to ineligible children in the @@ -57,6 +57,9 @@ def ssi_parental_child_allocation_total(person, period, recipients): and parents' spouses; the unlinked-parent tax-unit proxy applies only where the parent-link contract permits it. A recipient never receives an allocation from the same pool of income they share. + + Medicaid callers can supply allocations after applying their state's + child definition, while retaining the same relationship scope. """ first, second = co_resident_parent_indices(person, period) parents = _allocation_parent_rows(person, period, first, second) @@ -71,7 +74,8 @@ def ssi_parental_child_allocation_total(person, period, recipients): present & unlinked[safe_relatives], axis=1 ) linked = has_parent_ids(person, period) - allocation = person("ssi_ineligible_child_allocation", period) + if allocations is None: + allocations = person("ssi_ineligible_child_allocation", period) total = np.zeros(person.count) for child in household_member_indices(person): safe = np.maximum(child, 0) @@ -94,5 +98,5 @@ def ssi_parental_child_allocation_total(person, period, recipients): & ~recipients[safe] & np.where(linked[safe], named, presumed) ) - total += np.where(counts, allocation[safe], 0) + total += np.where(counts, allocations[safe], 0) return total From cb5e4290112e4aa6b226e05cd6eeae028425dbfc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 03:31:50 -0400 Subject: [PATCH 17/27] Apply sourced state limits to Medicaid child allocation scope --- ...ineligible-child-allocation-scope.fixed.md | 2 +- .../deeming/ineligible_child_age_limit.yaml | 70 + ..._income_deemed_from_ineligible_parent.yaml | 1140 +++++++++++++++++ ...ed_income_deemed_from_ineligible_parent.py | 27 +- 4 files changed, 1234 insertions(+), 5 deletions(-) create mode 100644 policyengine_us/parameters/gov/hhs/medicaid/eligibility/categories/senior_or_disabled/income/deeming/ineligible_child_age_limit.yaml diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index 9db9d34a32a..ab4caa4eb12 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve Missouri's under-18 allocation limit, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. diff --git a/policyengine_us/parameters/gov/hhs/medicaid/eligibility/categories/senior_or_disabled/income/deeming/ineligible_child_age_limit.yaml b/policyengine_us/parameters/gov/hhs/medicaid/eligibility/categories/senior_or_disabled/income/deeming/ineligible_child_age_limit.yaml new file mode 100644 index 00000000000..038f0873f20 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicaid/eligibility/categories/senior_or_disabled/income/deeming/ineligible_child_age_limit.yaml @@ -0,0 +1,70 @@ +description: States limit parental Medicaid income allocations to ineligible children below these ages. These overrides constrain the shared SSI allocation definition; states absent from this parameter retain the existing shared allocation behavior. +# The dates mark the shared Medicaid income model's 2015 coverage baseline, +# not an assertion that the states changed their allocation rules in 2015. +# Sources were checked for the current rules in October 2026. +metadata: + unit: year + period: year + label: Medicaid parental allocation ineligible child age limit + breakdown: + - state_code + reference: + - title: California Code of Regulations, title 22, section 50030 - Child + href: https://www.cdss.ca.gov/shd/res/pdf/50101.pdf + - title: Florida ESS Manual 2240.0606.03 - Dependent in Deeming + href: https://ffic.myflfamilies.com/manual/2200.pdf#page=40 + - title: Florida ESS Manual 2640.0212.02 - Determine if Parent has Income to Deem + href: https://ffic.myflfamilies.com/manual/2600.pdf#page=61 + - title: Idaho IDAPA 16.03.05.451.03 - Ineligible Child + href: https://adminrules.idaho.gov/rules/current/16/160305.pdf#page=37 + - title: Louisiana Medicaid Eligibility Manual I-1400 - Ineligible Child + href: https://ldh.la.gov/assets/medicaid/MedicaidEligibilityPolicy/I-1400.pdf#page=4 + - title: Michigan BEM 540 - Allocation to Non-SSI-Related Children + href: https://mdhhs-pres-prod.michigan.gov/olmweb/ex/BP/Public/BEM/540.pdf#page=2 + - title: Missouri DSS 0805.020.15 - Deeming Parental Income, step 1 + href: https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-020-00/0805-020-15/ + - title: Montana Combined Medicaid Manual 603-1 - Ineligible Child Allocation + href: https://prod-dphhs.mt.gov/assets/hcsd/mamanual/CMA603.1.pdf + - title: North Carolina MA-2260 IV.A - Financial Eligibility Requirements + href: https://policies.ncdhhs.gov/wp-content/uploads/MA-2260-Financial-Elig-Requirements-PLA.pdf#page=4 + - title: New Jersey Medicaid Only Manual 10:71-5.5 - Deeming of Income + href: https://nj.gov/humanservices/notices/documents/rules-and-regulations/NJAC%2010_71%20%20MEDICAID%20ONLY.PDF#page=247 + - title: New York Medicaid Reference Guide - Allocation + href: https://www.health.ny.gov/health_care/medicaid/reference/mrg/income.pdf + - title: Ohio Administrative Code 5160:1-3-03.3(B)(2) and (8) - Medicaid deeming of income + href: https://codes.ohio.gov/ohio-administrative-code/rule-5160:1-3-03.3 + - title: Texas MEPD G-2312 - Parent-to-Child Deeming, step 2 + href: https://fhb.hhs.texas.gov/handbooks/medicaid-elderly-people-disabilities-handbook/g-2300-special-income-noninstitutional-budgets + - title: Virginia Medicaid Manual M0530.002 B.1-B.2 - Nonblind Nondisabled Child + href: https://www.dmas.virginia.gov/media/mghpujpo/m05-1-1-26.pdf#page=57 + - title: Wisconsin Medicaid Eligibility Handbook 15.1.2 - Special Financial Tests + href: https://www.emhandbooks.wisconsin.gov/meh-ebd/policy_files/15/15.1.htm + +CA: + 2015-01-01: 21 +FL: + 2015-01-01: 21 +ID: + 2015-01-01: 21 +LA: + 2015-01-01: 21 +MI: + 2015-01-01: 18 +MO: + 2015-01-01: 18 +MT: + 2015-01-01: 21 +NC: + 2015-01-01: 21 +NJ: + 2015-01-01: 18 +NY: + 2015-01-01: 18 +OH: + 2015-01-01: 18 +TX: + 2015-01-01: 21 +VA: + 2015-01-01: 21 +WI: + 2015-01-01: 19 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml index 6dec1f9f0c1..e184339a11a 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml @@ -177,3 +177,1143 @@ ssi_ineligible_child_allocation: [0, 0, 5_964] # ($2,000 - $20 - $994) * 12 = $11,832, not $5,868. medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: [0, 11_832, 0] + +# Current state manual definitions constrain SSI's newly supported adult-student +# allocations before the common deduction sequence. Each household has one +# parent with $2,000 monthly unearned income and a disabled citizen claimant. +# At the state's age ceiling there is no Medicaid child allocation: +# ($2,000 - $20 - $994) * 12 = $11,832. California retains its existing +# $230 general disregard, giving ($2,000 - $230 - $994) * 12 = $9,312. +# SSI still gives each unmarried full-time student a $497 monthly allocation. +- name: Review r3 state age ceilings exclude adult students from Medicaid parental allocations. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + ca_parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + ca_claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + ca_student: + age: 21 + person_id: 3 + parent_1_id: 1 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + fl_parent: + age: 40 + person_id: 7 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + fl_claimant: + age: 10 + person_id: 8 + parent_1_id: 7 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + fl_student: + age: 21 + person_id: 9 + parent_1_id: 7 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + id_parent: + age: 40 + person_id: 10 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + id_claimant: + age: 10 + person_id: 11 + parent_1_id: 10 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + id_student: + age: 21 + person_id: 12 + parent_1_id: 10 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + la_parent: + age: 40 + person_id: 13 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + la_claimant: + age: 10 + person_id: 14 + parent_1_id: 13 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + la_student: + age: 21 + person_id: 15 + parent_1_id: 13 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + mi_parent: + age: 40 + person_id: 16 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + mi_claimant: + age: 10 + person_id: 17 + parent_1_id: 16 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + mi_student: + age: 18 + person_id: 18 + parent_1_id: 16 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + mo_parent: + age: 40 + person_id: 19 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + mo_claimant: + age: 10 + person_id: 20 + parent_1_id: 19 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + mo_student: + age: 18 + person_id: 21 + parent_1_id: 19 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + mt_parent: + age: 40 + person_id: 22 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + mt_claimant: + age: 10 + person_id: 23 + parent_1_id: 22 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + mt_student: + age: 21 + person_id: 24 + parent_1_id: 22 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + nc_parent: + age: 40 + person_id: 25 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + nc_claimant: + age: 10 + person_id: 26 + parent_1_id: 25 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + nc_student: + age: 21 + person_id: 27 + parent_1_id: 25 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + nj_parent: + age: 40 + person_id: 28 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + nj_claimant: + age: 10 + person_id: 29 + parent_1_id: 28 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + nj_student: + age: 18 + person_id: 30 + parent_1_id: 28 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + ny_parent: + age: 40 + person_id: 31 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + ny_claimant: + age: 10 + person_id: 32 + parent_1_id: 31 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + ny_student: + age: 18 + person_id: 33 + parent_1_id: 31 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + oh_parent: + age: 40 + person_id: 34 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + oh_claimant: + age: 10 + person_id: 35 + parent_1_id: 34 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + oh_student: + age: 18 + person_id: 36 + parent_1_id: 34 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + va_parent: + age: 40 + person_id: 40 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + va_claimant: + age: 10 + person_id: 41 + parent_1_id: 40 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + va_student: + age: 21 + person_id: 42 + parent_1_id: 40 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + wi_parent: + age: 40 + person_id: 43 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + wi_claimant: + age: 10 + person_id: 44 + parent_1_id: 43 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + wi_student: + age: 19 + person_id: 45 + parent_1_id: 43 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + tx_parent: + age: 40 + person_id: 46 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + tx_claimant: + age: 10 + person_id: 47 + parent_1_id: 46 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + tx_student: + age: 21 + person_id: 48 + parent_1_id: 46 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + tax_units: + ca_family: + members: [ca_parent, ca_claimant, ca_student] + fl_family: + members: [fl_parent, fl_claimant, fl_student] + id_family: + members: [id_parent, id_claimant, id_student] + la_family: + members: [la_parent, la_claimant, la_student] + mi_family: + members: [mi_parent, mi_claimant, mi_student] + mo_family: + members: [mo_parent, mo_claimant, mo_student] + mt_family: + members: [mt_parent, mt_claimant, mt_student] + nc_family: + members: [nc_parent, nc_claimant, nc_student] + nj_family: + members: [nj_parent, nj_claimant, nj_student] + ny_family: + members: [ny_parent, ny_claimant, ny_student] + oh_family: + members: [oh_parent, oh_claimant, oh_student] + va_family: + members: [va_parent, va_claimant, va_student] + wi_family: + members: [wi_parent, wi_claimant, wi_student] + tx_family: + members: [tx_parent, tx_claimant, tx_student] + marital_units: + ca_parent_unit: + members: [ca_parent] + ca_claimant_unit: + members: [ca_claimant] + ca_student_unit: + members: [ca_student] + fl_parent_unit: + members: [fl_parent] + fl_claimant_unit: + members: [fl_claimant] + fl_student_unit: + members: [fl_student] + id_parent_unit: + members: [id_parent] + id_claimant_unit: + members: [id_claimant] + id_student_unit: + members: [id_student] + la_parent_unit: + members: [la_parent] + la_claimant_unit: + members: [la_claimant] + la_student_unit: + members: [la_student] + mi_parent_unit: + members: [mi_parent] + mi_claimant_unit: + members: [mi_claimant] + mi_student_unit: + members: [mi_student] + mo_parent_unit: + members: [mo_parent] + mo_claimant_unit: + members: [mo_claimant] + mo_student_unit: + members: [mo_student] + mt_parent_unit: + members: [mt_parent] + mt_claimant_unit: + members: [mt_claimant] + mt_student_unit: + members: [mt_student] + nc_parent_unit: + members: [nc_parent] + nc_claimant_unit: + members: [nc_claimant] + nc_student_unit: + members: [nc_student] + nj_parent_unit: + members: [nj_parent] + nj_claimant_unit: + members: [nj_claimant] + nj_student_unit: + members: [nj_student] + ny_parent_unit: + members: [ny_parent] + ny_claimant_unit: + members: [ny_claimant] + ny_student_unit: + members: [ny_student] + oh_parent_unit: + members: [oh_parent] + oh_claimant_unit: + members: [oh_claimant] + oh_student_unit: + members: [oh_student] + va_parent_unit: + members: [va_parent] + va_claimant_unit: + members: [va_claimant] + va_student_unit: + members: [va_student] + wi_parent_unit: + members: [wi_parent] + wi_claimant_unit: + members: [wi_claimant] + wi_student_unit: + members: [wi_student] + tx_parent_unit: + members: [tx_parent] + tx_claimant_unit: + members: [tx_claimant] + tx_student_unit: + members: [tx_student] + households: + ca_home: + members: [ca_parent, ca_claimant, ca_student] + state_code: CA + fl_home: + members: [fl_parent, fl_claimant, fl_student] + state_code: FL + id_home: + members: [id_parent, id_claimant, id_student] + state_code: ID + la_home: + members: [la_parent, la_claimant, la_student] + state_code: LA + mi_home: + members: [mi_parent, mi_claimant, mi_student] + state_code: MI + mo_home: + members: [mo_parent, mo_claimant, mo_student] + state_code: MO + mt_home: + members: [mt_parent, mt_claimant, mt_student] + state_code: MT + nc_home: + members: [nc_parent, nc_claimant, nc_student] + state_code: NC + nj_home: + members: [nj_parent, nj_claimant, nj_student] + state_code: NJ + ny_home: + members: [ny_parent, ny_claimant, ny_student] + state_code: NY + oh_home: + members: [oh_parent, oh_claimant, oh_student] + state_code: OH + va_home: + members: [va_parent, va_claimant, va_student] + state_code: VA + wi_home: + members: [wi_parent, wi_claimant, wi_student] + state_code: WI + tx_home: + members: [tx_parent, tx_claimant, tx_student] + state_code: TX + output: + people: + ca_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 9312 + ca_student: + ssi_ineligible_child_allocation: 5964 + fl_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + fl_student: + ssi_ineligible_child_allocation: 5964 + id_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + id_student: + ssi_ineligible_child_allocation: 5964 + la_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + la_student: + ssi_ineligible_child_allocation: 5964 + mi_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + mi_student: + ssi_ineligible_child_allocation: 5964 + mo_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + mo_student: + ssi_ineligible_child_allocation: 5964 + mt_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + mt_student: + ssi_ineligible_child_allocation: 5964 + nc_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + nc_student: + ssi_ineligible_child_allocation: 5964 + nj_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + nj_student: + ssi_ineligible_child_allocation: 5964 + ny_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + ny_student: + ssi_ineligible_child_allocation: 5964 + oh_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + oh_student: + ssi_ineligible_child_allocation: 5964 + va_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + va_student: + ssi_ineligible_child_allocation: 5964 + wi_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + wi_student: + ssi_ineligible_child_allocation: 5964 + tx_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + tx_student: + ssi_ineligible_child_allocation: 5964 + +# Below-ceiling controls retain $497 monthly allocations: $11,832 - $5,964 +# = $5,868, or $9,312 - $5,964 = $3,348 in California. Wisconsin includes +# dependent 18-year-olds; Texas retains allocations for students below 21. +# The disabled siblings are immigration-ineligible for both programs, so +# their Medicaid allocation must be excluded by the state's nonblind and +# nondisabled definition, independently of the Medicaid recipient mask. +- name: Review r3 state allocation definitions preserve eligible students and exclude disabled children. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mo_below_parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + mo_below_claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + mo_below_student: + age: 17 + person_id: 3 + parent_1_id: 1 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + la_below_parent: + age: 40 + person_id: 4 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + la_below_claimant: + age: 10 + person_id: 5 + parent_1_id: 4 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + la_below_student: + age: 20 + person_id: 6 + parent_1_id: 4 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + ca_below_parent: + age: 40 + person_id: 7 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + ca_below_claimant: + age: 10 + person_id: 8 + parent_1_id: 7 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + ca_below_student: + age: 20 + person_id: 9 + parent_1_id: 7 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + wi_dependent_parent: + age: 40 + person_id: 10 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + wi_dependent_claimant: + age: 10 + person_id: 11 + parent_1_id: 10 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + wi_dependent_student: + age: 18 + person_id: 12 + parent_1_id: 10 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + wi_not_dependent_parent: + age: 40 + person_id: 13 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + wi_not_dependent_claimant: + age: 10 + person_id: 14 + parent_1_id: 13 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + wi_not_dependent_student: + age: 18 + person_id: 15 + parent_1_id: 13 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_full_time_student: true + is_household_head: false + tx_below_parent: + age: 40 + person_id: 16 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + ga_ssi_scope_parent: + age: 40 + person_id: 1001 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + tx_below_claimant: + age: 10 + person_id: 17 + parent_1_id: 16 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + ga_ssi_scope_claimant: + age: 10 + person_id: 1002 + parent_1_id: 1001 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + tx_below_student: + age: 20 + person_id: 18 + parent_1_id: 16 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + ga_ssi_scope_student: + age: 21 + person_id: 1003 + parent_1_id: 1001 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + la_disabled_parent: + age: 40 + person_id: 19 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + la_disabled_claimant: + age: 10 + person_id: 20 + parent_1_id: 19 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + la_disabled_student: + age: 17 + person_id: 21 + parent_1_id: 19 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + is_medicaid_immigration_status_eligible: false + ny_disabled_parent: + age: 40 + person_id: 22 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + ny_disabled_claimant: + age: 10 + person_id: 23 + parent_1_id: 22 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + ny_disabled_student: + age: 17 + person_id: 24 + parent_1_id: 22 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + is_medicaid_immigration_status_eligible: false + va_disabled_parent: + age: 40 + person_id: 25 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + va_disabled_claimant: + age: 10 + person_id: 26 + parent_1_id: 25 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + va_disabled_student: + age: 17 + person_id: 27 + parent_1_id: 25 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + is_medicaid_immigration_status_eligible: false + wi_disabled_parent: + age: 40 + person_id: 28 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + wi_disabled_claimant: + age: 10 + person_id: 29 + parent_1_id: 28 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + wi_disabled_student: + age: 17 + person_id: 30 + parent_1_id: 28 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + is_medicaid_immigration_status_eligible: false + fl_disabled_parent: + age: 40 + person_id: 31 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + fl_disabled_claimant: + age: 10 + person_id: 32 + parent_1_id: 31 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + # This variable is supplied for siblings below; supply claimants too. + is_medicaid_immigration_status_eligible: true + fl_disabled_student: + age: 17 + person_id: 33 + parent_1_id: 31 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + is_ssi_disabled: true + immigration_status: UNDOCUMENTED + is_medicaid_immigration_status_eligible: false + tax_units: + mo_below_family: + members: [mo_below_parent, mo_below_claimant, mo_below_student] + la_below_family: + members: [la_below_parent, la_below_claimant, la_below_student] + ca_below_family: + members: [ca_below_parent, ca_below_claimant, ca_below_student] + wi_dependent_family: + members: [wi_dependent_parent, wi_dependent_claimant, wi_dependent_student] + wi_not_dependent_family: + members: [wi_not_dependent_parent, wi_not_dependent_claimant, wi_not_dependent_student] + tx_below_family: + members: [tx_below_parent, tx_below_claimant, tx_below_student] + ga_ssi_scope_family: + members: [ga_ssi_scope_parent, ga_ssi_scope_claimant, ga_ssi_scope_student] + la_disabled_family: + members: [la_disabled_parent, la_disabled_claimant, la_disabled_student] + ny_disabled_family: + members: [ny_disabled_parent, ny_disabled_claimant, ny_disabled_student] + va_disabled_family: + members: [va_disabled_parent, va_disabled_claimant, va_disabled_student] + wi_disabled_family: + members: [wi_disabled_parent, wi_disabled_claimant, wi_disabled_student] + fl_disabled_family: + members: [fl_disabled_parent, fl_disabled_claimant, fl_disabled_student] + marital_units: + mo_below_parent_unit: + members: [mo_below_parent] + mo_below_claimant_unit: + members: [mo_below_claimant] + mo_below_student_unit: + members: [mo_below_student] + la_below_parent_unit: + members: [la_below_parent] + la_below_claimant_unit: + members: [la_below_claimant] + la_below_student_unit: + members: [la_below_student] + ca_below_parent_unit: + members: [ca_below_parent] + ca_below_claimant_unit: + members: [ca_below_claimant] + ca_below_student_unit: + members: [ca_below_student] + wi_dependent_parent_unit: + members: [wi_dependent_parent] + wi_dependent_claimant_unit: + members: [wi_dependent_claimant] + wi_dependent_student_unit: + members: [wi_dependent_student] + wi_not_dependent_parent_unit: + members: [wi_not_dependent_parent] + wi_not_dependent_claimant_unit: + members: [wi_not_dependent_claimant] + wi_not_dependent_student_unit: + members: [wi_not_dependent_student] + tx_below_parent_unit: + members: [tx_below_parent] + ga_ssi_scope_parent_unit: + members: [ga_ssi_scope_parent] + tx_below_claimant_unit: + members: [tx_below_claimant] + ga_ssi_scope_claimant_unit: + members: [ga_ssi_scope_claimant] + tx_below_student_unit: + members: [tx_below_student] + ga_ssi_scope_student_unit: + members: [ga_ssi_scope_student] + la_disabled_parent_unit: + members: [la_disabled_parent] + la_disabled_claimant_unit: + members: [la_disabled_claimant] + la_disabled_student_unit: + members: [la_disabled_student] + ny_disabled_parent_unit: + members: [ny_disabled_parent] + ny_disabled_claimant_unit: + members: [ny_disabled_claimant] + ny_disabled_student_unit: + members: [ny_disabled_student] + va_disabled_parent_unit: + members: [va_disabled_parent] + va_disabled_claimant_unit: + members: [va_disabled_claimant] + va_disabled_student_unit: + members: [va_disabled_student] + wi_disabled_parent_unit: + members: [wi_disabled_parent] + wi_disabled_claimant_unit: + members: [wi_disabled_claimant] + wi_disabled_student_unit: + members: [wi_disabled_student] + fl_disabled_parent_unit: + members: [fl_disabled_parent] + fl_disabled_claimant_unit: + members: [fl_disabled_claimant] + fl_disabled_student_unit: + members: [fl_disabled_student] + households: + mo_below_home: + members: [mo_below_parent, mo_below_claimant, mo_below_student] + state_code: MO + la_below_home: + members: [la_below_parent, la_below_claimant, la_below_student] + state_code: LA + ca_below_home: + members: [ca_below_parent, ca_below_claimant, ca_below_student] + state_code: CA + wi_dependent_home: + members: [wi_dependent_parent, wi_dependent_claimant, wi_dependent_student] + state_code: WI + wi_not_dependent_home: + members: [wi_not_dependent_parent, wi_not_dependent_claimant, wi_not_dependent_student] + state_code: WI + tx_below_home: + members: [tx_below_parent, tx_below_claimant, tx_below_student] + state_code: TX + ga_ssi_scope_home: + members: [ga_ssi_scope_parent, ga_ssi_scope_claimant, ga_ssi_scope_student] + state_code: GA + la_disabled_home: + members: [la_disabled_parent, la_disabled_claimant, la_disabled_student] + state_code: LA + ny_disabled_home: + members: [ny_disabled_parent, ny_disabled_claimant, ny_disabled_student] + state_code: NY + va_disabled_home: + members: [va_disabled_parent, va_disabled_claimant, va_disabled_student] + state_code: VA + wi_disabled_home: + members: [wi_disabled_parent, wi_disabled_claimant, wi_disabled_student] + state_code: WI + fl_disabled_home: + members: [fl_disabled_parent, fl_disabled_claimant, fl_disabled_student] + state_code: FL + output: + people: + mo_below_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 + mo_below_student: + ssi_ineligible_child_allocation: 5964 + la_below_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 + la_below_student: + ssi_ineligible_child_allocation: 5964 + ca_below_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 3348 + ca_below_student: + ssi_ineligible_child_allocation: 5964 + wi_dependent_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 + wi_dependent_student: + ssi_ineligible_child_allocation: 5964 + wi_not_dependent_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + wi_not_dependent_student: + ssi_ineligible_child_allocation: 5964 + tx_below_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 + ga_ssi_scope_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 + tx_below_student: + ssi_ineligible_child_allocation: 5964 + ga_ssi_scope_student: + ssi_ineligible_child_allocation: 5964 + la_disabled_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + la_disabled_student: + ssi_ineligible_child_allocation: 5964 + ny_disabled_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + ny_disabled_student: + ssi_ineligible_child_allocation: 5964 + va_disabled_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + va_disabled_student: + ssi_ineligible_child_allocation: 5964 + wi_disabled_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + wi_disabled_student: + ssi_ineligible_child_allocation: 5964 + fl_disabled_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + fl_disabled_student: + ssi_ineligible_child_allocation: 5964 diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py index 4ad1faacd27..8f9ddb0fe3f 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py @@ -51,10 +51,29 @@ def formula(person, period, parameters): ) state = person.household("state_code_str", period) - # DSS 0805.020.15 step 1 allows Missouri allocations only for - # children under 18 who are not applying for MA. In particular, - # SSI's adult-student allocation must not enter Missouri's budget. - allocation_child = where(state == "MO", person("is_child", period), True) + # State allocation definitions can be narrower than SSI's adult + # student definition. Apply each sourced ceiling before deducting + # the shared allocation amount; other states retain their existing + # shared allocation behavior. + age = person("age", period.this_year) + age_limits = parameters( + period + ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.deeming.ineligible_child_age_limit + allocation_child = np.ones(person.count, dtype=bool) + for state_code in age_limits._children: + allocation_child &= (state != state_code) | (age < age_limits[state_code]) + # These states define allocation children as nonblind and + # nondisabled, independently of Medicaid immigration eligibility. + allocation_child &= ~( + np.isin(state, ("FL", "LA", "NY", "VA", "WI")) + & person("is_ssi_aged_blind_disabled", period) + ) + # Wisconsin includes dependent 18-year-olds, rather than all + # adult students below 19. Dependency uses the existing tax-unit + # role proxy, as elsewhere in the model. + allocation_child &= ( + (state != "WI") | (age < 18) | person("is_tax_unit_dependent", period) + ) allocations = ( person("ssi_ineligible_child_allocation", period) * allocation_child ) From e9794af1c0fa25ee8ed1bcd1e315d581f8921754 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 03:47:14 -0400 Subject: [PATCH 18/27] Retain Medicaid allocation scope for states without overrides --- ...isabled_unearned_income_deemed_from_ineligible_parent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py index 8f9ddb0fe3f..b53d54ee020 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py @@ -61,7 +61,11 @@ def formula(person, period, parameters): ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.deeming.ineligible_child_age_limit allocation_child = np.ones(person.count, dtype=bool) for state_code in age_limits._children: - allocation_child &= (state != state_code) | (age < age_limits[state_code]) + # State breakdown expansion fills omitted states with zero. + # Only a positive, sourced ceiling overrides the shared scope. + age_limit = age_limits[state_code] + if age_limit > 0: + allocation_child &= (state != state_code) | (age < age_limit) # These states define allocation children as nonblind and # nondisabled, independently of Medicaid immigration eligibility. allocation_child &= ~( From 9968f387224e43cec90e17d6546cfb57ee352ea5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 04:39:49 -0400 Subject: [PATCH 19/27] Fix Wisconsin dependent 18-year-old Medicaid allocations --- ...ineligible-child-allocation-scope.fixed.md | 2 +- ..._income_deemed_from_ineligible_parent.yaml | 212 +++++++++++++++++- ...ed_income_deemed_from_ineligible_parent.py | 16 +- 3 files changed, 220 insertions(+), 10 deletions(-) diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index ab4caa4eb12..74f8e1c041f 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml index e184339a11a..16dffde8850 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml @@ -772,7 +772,9 @@ # Below-ceiling controls retain $497 monthly allocations: $11,832 - $5,964 # = $5,868, or $9,312 - $5,964 = $3,348 in California. Wisconsin includes -# dependent 18-year-olds; Texas retains allocations for students below 21. +# full-time secondary students at 18 regardless of tax role; Texas includes students below 21. +# The Wisconsin seniors each earn $9,000, excluded under MEH 15.5.8. +# Secondary enrollment proxies expected completion before 19 (MEH 1.1.3.3). # The disabled siblings are immigration-ineligible for both programs, so # their Medicaid allocation must be excluded by the state's nonblind and # nondisabled definition, independently of the Medicaid recipient mask. @@ -892,6 +894,8 @@ is_tax_unit_spouse: false is_tax_unit_dependent: true is_full_time_student: true + is_in_secondary_school: true + ssi_earned_income: 9000 is_household_head: false wi_not_dependent_parent: age: 40 @@ -916,10 +920,12 @@ age: 18 person_id: 15 parent_1_id: 13 - is_tax_unit_head: false + is_tax_unit_head: true is_tax_unit_spouse: false is_tax_unit_dependent: false is_full_time_student: true + is_in_secondary_school: true + ssi_earned_income: 9000 is_household_head: false tx_below_parent: age: 40 @@ -1142,7 +1148,9 @@ wi_dependent_family: members: [wi_dependent_parent, wi_dependent_claimant, wi_dependent_student] wi_not_dependent_family: - members: [wi_not_dependent_parent, wi_not_dependent_claimant, wi_not_dependent_student] + members: [wi_not_dependent_parent, wi_not_dependent_claimant] + wi_not_dependent_student_unit: + members: [wi_not_dependent_student] tx_below_family: members: [tx_below_parent, tx_below_claimant, tx_below_student] ga_ssi_scope_family: @@ -1286,7 +1294,7 @@ wi_dependent_student: ssi_ineligible_child_allocation: 5964 wi_not_dependent_claimant: - medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 wi_not_dependent_student: ssi_ineligible_child_allocation: 5964 tx_below_claimant: @@ -1317,3 +1325,199 @@ medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 fl_disabled_student: ssi_ineligible_child_allocation: 5964 + +# MEH 1.1.3.3 and 15.1.2: at 18 the education condition, not tax +# dependency, governs allocations. College alone does not qualify. Minors +# need no school attendance. Secondary includes equivalent-level training. +- name: Wisconsin allocation children require secondary attendance at 18 regardless of tax role. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + college_dependent_parent: + age: 40 + person_id: 100 + is_parent: true + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + ssi_unearned_income: 24000 + college_dependent_claimant: + age: 10 + person_id: 101 + parent_1_id: 100 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + is_medicaid_immigration_status_eligible: true + college_dependent_student: + age: 18 + person_id: 102 + parent_1_id: 100 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + is_full_time_student: true + is_in_secondary_school: false + is_full_time_college_student: true + ssi_earned_income: 9000 + college_independent_parent: + age: 40 + person_id: 103 + is_parent: true + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + ssi_unearned_income: 24000 + college_independent_claimant: + age: 10 + person_id: 104 + parent_1_id: 103 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + is_medicaid_immigration_status_eligible: true + college_independent_student: + age: 18 + person_id: 105 + parent_1_id: 103 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_household_head: false + is_full_time_student: true + is_in_secondary_school: false + is_full_time_college_student: true + ssi_earned_income: 9000 + part_time_secondary_parent: + age: 40 + person_id: 106 + is_parent: true + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + ssi_unearned_income: 24000 + part_time_secondary_claimant: + age: 10 + person_id: 107 + parent_1_id: 106 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + is_medicaid_immigration_status_eligible: true + part_time_secondary_student: + age: 18 + person_id: 108 + parent_1_id: 106 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + is_full_time_student: false + is_in_secondary_school: true + is_full_time_college_student: false + ssi_earned_income: 0 + minor_nonstudent_parent: + age: 40 + person_id: 109 + is_parent: true + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + ssi_unearned_income: 24000 + minor_nonstudent_claimant: + age: 10 + person_id: 110 + parent_1_id: 109 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + is_medicaid_immigration_status_eligible: true + minor_nonstudent_student: + age: 17 + person_id: 111 + parent_1_id: 109 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_household_head: false + is_full_time_student: false + is_in_secondary_school: false + is_full_time_college_student: false + ssi_earned_income: 0 + tax_units: + college_dependent_family: + members: [college_dependent_parent, college_dependent_claimant, college_dependent_student] + college_independent_family: + members: [college_independent_parent, college_independent_claimant] + college_independent_student_unit: + members: [college_independent_student] + part_time_secondary_family: + members: [part_time_secondary_parent, part_time_secondary_claimant, part_time_secondary_student] + minor_nonstudent_family: + members: [minor_nonstudent_parent, minor_nonstudent_claimant, minor_nonstudent_student] + marital_units: + college_dependent_parent_marital_unit: + members: [college_dependent_parent] + college_dependent_claimant_marital_unit: + members: [college_dependent_claimant] + college_dependent_student_marital_unit: + members: [college_dependent_student] + college_independent_parent_marital_unit: + members: [college_independent_parent] + college_independent_claimant_marital_unit: + members: [college_independent_claimant] + college_independent_student_marital_unit: + members: [college_independent_student] + part_time_secondary_parent_marital_unit: + members: [part_time_secondary_parent] + part_time_secondary_claimant_marital_unit: + members: [part_time_secondary_claimant] + part_time_secondary_student_marital_unit: + members: [part_time_secondary_student] + minor_nonstudent_parent_marital_unit: + members: [minor_nonstudent_parent] + minor_nonstudent_claimant_marital_unit: + members: [minor_nonstudent_claimant] + minor_nonstudent_student_marital_unit: + members: [minor_nonstudent_student] + households: + college_dependent_home: + members: [college_dependent_parent, college_dependent_claimant, college_dependent_student] + state_code: WI + college_independent_home: + members: [college_independent_parent, college_independent_claimant, college_independent_student] + state_code: WI + part_time_secondary_home: + members: [part_time_secondary_parent, part_time_secondary_claimant, part_time_secondary_student] + state_code: WI + minor_nonstudent_home: + members: [minor_nonstudent_parent, minor_nonstudent_claimant, minor_nonstudent_student] + state_code: WI + output: + people: + college_dependent_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + college_dependent_student: + ssi_ineligible_child_allocation: 5964 + college_independent_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + college_independent_student: + ssi_ineligible_child_allocation: 5964 + part_time_secondary_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 11832 + part_time_secondary_student: + ssi_ineligible_child_allocation: 0 + minor_nonstudent_claimant: + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 + minor_nonstudent_student: + ssi_ineligible_child_allocation: 5964 diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py index b53d54ee020..06ce91dd356 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py @@ -22,6 +22,8 @@ class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligibl "https://www.law.cornell.edu/cfr/text/20/416.1165", "https://www.law.cornell.edu/uscode/text/42/1396b#v", "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-020-00/0805-020-15/", + "https://www.emhandbooks.wisconsin.gov/meh-ebd/policy_files/1/1.1.htm#1.1.3.3", + "https://www.emhandbooks.wisconsin.gov/meh-ebd/policy_files/15/15.1.htm#15.1.2", ) def formula(person, period, parameters): @@ -72,12 +74,16 @@ def formula(person, period, parameters): np.isin(state, ("FL", "LA", "NY", "VA", "WI")) & person("is_ssi_aged_blind_disabled", period) ) - # Wisconsin includes dependent 18-year-olds, rather than all - # adult students below 19. Dependency uses the existing tax-unit - # role proxy, as elsewhere in the model. - allocation_child &= ( - (state != "WI") | (age < 18) | person("is_tax_unit_dependent", period) + # MEH 1.1.3.3 and 15.1.2 define dependent 18-year-olds through + # full-time secondary school or equivalent-level training, not + # tax dependency. Enrollment proxies expected completion before + # 19: the model has no completion-date or remaining-credit input. + dependent_18_year_old = ( + (age == 18) + & person("is_in_secondary_school", period.this_year) + & person("is_full_time_student", period.this_year) ) + allocation_child &= (state != "WI") | (age < 18) | dependent_18_year_old allocations = ( person("ssi_ineligible_child_allocation", period) * allocation_child ) From 47f4a1a770260db71a0473e76ffd5c6e20c2b87a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 05:08:16 -0400 Subject: [PATCH 20/27] Preserve K-12 evidence for Wisconsin allocation children --- ...ineligible-child-allocation-scope.fixed.md | 2 +- ..._income_deemed_from_ineligible_parent.yaml | 109 ++++++++++++++++++ ...ed_income_deemed_from_ineligible_parent.py | 7 +- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index 74f8e1c041f..aece4f44163 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml index 16dffde8850..fa1ff80e88f 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.yaml @@ -1521,3 +1521,112 @@ medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: 5868 minor_nonstudent_student: ssi_ineligible_child_allocation: 5964 + +# Original review r4 witnesses: K-12 evidence without is_in_secondary_school. +- name: WI legal dependent 18-year-old high-school senior, tax dependent True + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + student: + age: 18 + person_id: 3 + parent_1_id: 1 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_full_time_student: true + is_household_head: false + is_in_k12_school: true + is_full_time_college_student: false + ssi_earned_income: 9000 + tax_units: + family: + members: [parent, claimant, student] + marital_units: + parent_unit: + members: [parent] + claimant_unit: + members: [claimant] + student_unit: + members: [student] + households: + home: + members: [parent, claimant, student] + state_code: WI + output: + ssi_ineligible_child_allocation: [0, 0, 5964] + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: [0, 5868, 0] + ssi_unearned_income_deemed_from_ineligible_parent: [0, 5868, 0] + +- name: WI legal dependent 18-year-old high-school senior, tax dependent False + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_parent: true + ssi_unearned_income: 24000 + claimant: + age: 10 + person_id: 2 + parent_1_id: 1 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_ssi_disabled: true + immigration_status: CITIZEN + student: + age: 18 + person_id: 3 + parent_1_id: 1 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_full_time_student: true + is_household_head: false + is_in_k12_school: true + is_full_time_college_student: false + ssi_earned_income: 9000 + tax_units: + family: + members: [parent, claimant] + student_family: + members: [student] + marital_units: + parent_unit: + members: [parent] + claimant_unit: + members: [claimant] + student_unit: + members: [student] + households: + home: + members: [parent, claimant, student] + state_code: WI + output: + ssi_ineligible_child_allocation: [0, 0, 5964] + medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent: [0, 5868, 0] + ssi_unearned_income_deemed_from_ineligible_parent: [0, 5868, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py index 06ce91dd356..a3b349b7084 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py @@ -78,9 +78,14 @@ def formula(person, period, parameters): # full-time secondary school or equivalent-level training, not # tax dependency. Enrollment proxies expected completion before # 19: the model has no completion-date or remaining-credit input. + # At age 18, explicit K-12 enrollment is also secondary-school + # evidence; the age gate excludes younger pupils from this extension. dependent_18_year_old = ( (age == 18) - & person("is_in_secondary_school", period.this_year) + & ( + person("is_in_secondary_school", period.this_year) + | person("is_in_k12_school", period.this_year) + ) & person("is_full_time_student", period.this_year) ) allocation_child &= (state != "WI") | (age < 18) | dependent_18_year_old From 9d5777d3ccb5de922f5c47824d046321c5408ece Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 08:38:46 -0400 Subject: [PATCH 21/27] Preserve annual SSI inputs while calculating monthly budgets --- ...ineligible-child-allocation-scope.fixed.md | 2 +- .../core/test_ssi_annual_income_inputs.py | 142 ++++++++++++++++ ...ed_income_deemed_from_ineligible_parent.py | 130 +-------------- ...e_deemed_from_ineligible_parent_monthly.py | 156 ++++++++++++++++++ ...ed_income_deemed_from_ineligible_spouse.py | 68 +------- ...e_deemed_from_ineligible_spouse_monthly.py | 95 +++++++++++ ...nal_senior_or_disabled_countable_income.py | 65 +------- ...or_or_disabled_countable_income_monthly.py | 89 ++++++++++ .../income/msp_countable_income.py | 6 +- .../gov/ssa/ssi/_ssi_state_supplement.py | 6 +- .../ssi/eligibility/income/_annual_input.py | 31 ++++ .../_ssi_parental_child_allocation_scope.py | 2 +- ...ed_income_deemed_from_ineligible_parent.py | 75 +-------- ...e_deemed_from_ineligible_parent_monthly.py | 92 +++++++++++ .../deemed/from_ineligible_spouse/README.md | 2 + .../is_ssi_spousal_deeming_applies.py | 21 +-- .../is_ssi_spousal_deeming_applies_monthly.py | 49 ++++++ ...ed_income_deemed_from_ineligible_spouse.py | 27 +-- ...e_deemed_from_ineligible_spouse_monthly.py | 44 +++++ ...si_income_deemed_from_ineligible_spouse.py | 44 +---- ...e_deemed_from_ineligible_spouse_monthly.py | 68 ++++++++ .../ssi_spousal_deeming_child_allocations.py | 2 +- ...ed_income_deemed_from_ineligible_spouse.py | 16 +- ...e_deemed_from_ineligible_spouse_monthly.py | 36 ++++ .../deemed/ssi_ineligible_child_allocation.py | 34 +--- ...ssi_ineligible_child_allocation_monthly.py | 59 +++++++ .../ssi_in_kind_support_and_maintenance.py | 18 +- ...in_kind_support_and_maintenance_monthly.py | 36 ++++ .../eligibility/income/ism/ssi_pmv_amount.py | 26 +-- .../income/ism/ssi_pmv_amount_monthly.py | 45 +++++ .../income/ssi_countable_income.py | 67 +------- .../income/ssi_countable_income_monthly.py | 97 +++++++++++ ...resources_deemed_from_ineligible_parent.py | 2 +- .../gov/ssa/ssi/ssi_amount_if_eligible.py | 2 +- .../variables/gov/ssa/ssi/ssi_if_takes_up.py | 2 +- .../variables/gov/ssa/ssi/uncapped_ssi.py | 2 +- .../state_supplement/ca_state_supplement.py | 2 +- .../state_supplement/co_state_supplement.py | 2 +- .../dss/ssp/income/ct_ssp_countable_income.py | 4 +- .../income/ct_ssp_earned_income_disregard.py | 2 +- .../ct/dss/ssp/income/ct_ssp_gross_income.py | 4 +- .../variables/gov/states/fl/dcf/oss/fl_oss.py | 2 +- .../gov/states/fl/dcf/oss/fl_oss_eligible.py | 2 +- .../gov/states/id/dhw/aabd/id_aabd.py | 2 +- .../gov/states/in/fssa/ssp/in_ssp_rcap.py | 2 +- .../gov/states/in/fssa/ssp/in_ssp_sapn.py | 2 +- .../ky/dcbs/ssp/ky_ssp_countable_income.py | 10 +- .../variables/gov/states/la/hhs/oss/la_oss.py | 2 +- .../eligibility/mn_msa_net_income_eligible.py | 10 +- .../mn/dhs/msa/income/mn_msa_gross_income.py | 6 +- .../gov/states/mn/dhs/msa/mn_msa_person.py | 10 +- .../gov/states/nc/ncdhhs/sa/nc_sa_eligible.py | 2 +- .../sc_ssi_state_supplement.py | 2 +- .../sc_ssi_state_supplement_eligible.py | 2 +- .../sc_ssi_state_supplement_pna.py | 2 +- 55 files changed, 1134 insertions(+), 594 deletions(-) create mode 100644 policyengine_us/tests/core/test_ssi_annual_income_inputs.py create mode 100644 policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent_monthly.py create mode 100644 policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly.py create mode 100644 policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income_monthly.py 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_parent/ssi_unearned_income_deemed_from_ineligible_parent_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount_monthly.py create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income_monthly.py diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index aece4f44163..3e707debc33 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. diff --git a/policyengine_us/tests/core/test_ssi_annual_income_inputs.py b/policyengine_us/tests/core/test_ssi_annual_income_inputs.py new file mode 100644 index 00000000000..f6fecee9a0a --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_annual_income_inputs.py @@ -0,0 +1,142 @@ +"""SSI monthly budgets must preserve existing annual input overrides. + +Policy examples and monthly allocation caps are covered in YAML. These tests +exercise Core's period conversion, supplied-input detection and branch caches, +which require the Python API. One small Core system uses the production +variable classes and the imported country's read-only parameters; it never +constructs or copies another country model. This belongs to Rest/core. +""" + +import numpy as np +import pytest +from policyengine_core.simulations import Simulation +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +from policyengine_us.entities import MaritalUnit, Person +from policyengine_us.system import system as country_system +from policyengine_us.tools.period_branch import get_override_branch + +YEAR = 2026 +MONTH = "2026-01" +ANNUAL_CURRENCY_VARIABLES = ( + "ssi_countable_income", + "ssi_unearned_income_deemed_from_ineligible_parent", + "ssi_earned_income_deemed_from_ineligible_spouse", + "ssi_unearned_income_deemed_from_ineligible_spouse", + "ssi_income_deemed_from_ineligible_spouse", + "ssi_ineligible_child_allocation", + "ssi_in_kind_support_and_maintenance", + "ssi_pmv_amount", + "medicaid_optional_senior_or_disabled_countable_income", + "medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent", + "medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse", +) + +ANNUAL_INPUT_VARIABLES = (*ANNUAL_CURRENCY_VARIABLES, "is_ssi_spousal_deeming_applies") + + +@pytest.fixture(scope="module") +def small_system(): + result = TaxBenefitSystem(entities=[Person, MaritalUnit]) + names = ( + "is_ssi_eligible_individual", + "is_ssi_ineligible_spouse", + "ssi_earned_income", + "ssi_unearned_income", + "ssi_spousal_deeming_child_allocations", + *ANNUAL_INPUT_VARIABLES, + *(f"{name}_monthly" for name in ANNUAL_INPUT_VARIABLES), + ) + result.add_variables(*(type(country_system.variables[name]) for name in names)) + result.parameters = country_system.parameters + return result + + +@pytest.mark.parametrize("input_period", [YEAR, MONTH]) +@pytest.mark.parametrize("name", ANNUAL_INPUT_VARIABLES) +def test_annual_input_overrides_reach_monthly_budgets(small_system, name, input_period): + amount = 1_200 if name in ANNUAL_CURRENCY_VARIABLES else True + monthly = 100 if name in ANNUAL_CURRENCY_VARIABLES else True + changed = 2_400 if name in ANNUAL_CURRENCY_VARIABLES else False + changed_monthly = 200 if name in ANNUAL_CURRENCY_VARIABLES else False + simulation = Simulation( + tax_benefit_system=small_system, + situation={ + "people": { + "claimant": { + name: {input_period: amount}, + # Supplied inputs bypass the formula's eligibility gate. + "is_ssi_eligible_individual": {YEAR: False}, + } + }, + "marital_units": {"unit": {"members": ["claimant"]}}, + }, + ) + assert small_system.variables[name].definition_period == "year" + np.testing.assert_array_equal(simulation.calculate(name, YEAR), [amount]) + np.testing.assert_array_equal(simulation.calculate(name, MONTH), [monthly]) + np.testing.assert_array_equal( + simulation.calculate(f"{name}_monthly", MONTH), [monthly] + ) + + branch = get_override_branch( + simulation, "annual_override", YEAR, {name: np.array([changed])} + ) + np.testing.assert_array_equal( + branch.calculate(f"{name}_monthly", MONTH), [changed_monthly] + ) + np.testing.assert_array_equal( + simulation.calculate(f"{name}_monthly", MONTH), [monthly] + ) + + +def test_computed_annual_cache_does_not_replace_monthly_budgets(small_system): + name = "ssi_earned_income_deemed_from_ineligible_spouse" + allocations = { + f"2026-{month:02d}": 0 if month <= 6 else 200 for month in range(1, 13) + } + simulation = Simulation( + tax_benefit_system=small_system, + situation={ + "people": { + "claimant": { + "is_ssi_ineligible_spouse": {YEAR: False}, + "ssi_earned_income": {YEAR: 0}, + "ssi_unearned_income": {YEAR: 0}, + "ssi_spousal_deeming_child_allocations": allocations, + }, + "spouse": { + "is_ssi_ineligible_spouse": {YEAR: True}, + "ssi_earned_income": {YEAR: 12_000}, + "ssi_unearned_income": {YEAR: 0}, + "ssi_spousal_deeming_child_allocations": { + month: 0 for month in allocations + }, + }, + }, + "marital_units": {"couple": {"members": ["claimant", "spouse"]}}, + }, + ) + # Six months of $1,000 and six of $800 are not $900 in every month. + np.testing.assert_array_equal(simulation.calculate(name, YEAR), [10_800, 0]) + np.testing.assert_array_equal( + simulation.calculate(f"{name}_monthly", MONTH), [1_000, 0] + ) + np.testing.assert_array_equal( + simulation.calculate(f"{name}_monthly", "2026-07"), [800, 0] + ) + + if hasattr(simulation, "_get_store_history"): + # Core 3.33+ invalidates inherited calculations on direct set_input. + branch = simulation.get_branch("first_annual_override") + branch.set_input(name, YEAR, [24_000, 0]) + else: + branch = get_override_branch( + simulation, "first_annual_override", YEAR, {name: np.array([24_000, 0])} + ) + np.testing.assert_array_equal( + branch.calculate(f"{name}_monthly", MONTH), [2_000, 0] + ) + np.testing.assert_array_equal( + simulation.calculate(f"{name}_monthly", MONTH), [1_000, 0] + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py index a3b349b7084..7ce567ee0c9 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent.py @@ -1,13 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( - _apply_medicaid_optional_senior_or_disabled_exclusions, -) -from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( - _apply_ssi_exclusions, -) -from policyengine_us.variables.gov.ssa.ssi.eligibility.income.deemed.from_ineligible_parent._ssi_parental_child_allocation_scope import ( - ssi_parental_child_allocation_total, -) class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent( @@ -17,7 +8,7 @@ class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligibl entity = Person label = "Medicaid optional senior or disabled unearned income deemed from ineligible parent" unit = USD - definition_period = MONTH + definition_period = YEAR reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1165", "https://www.law.cornell.edu/uscode/text/42/1396b#v", @@ -27,119 +18,8 @@ class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligibl ) def formula(person, period, parameters): - # Medicaid's own immigration rules include eligible groups who do - # not qualify for SSI, including state-covered noncitizen children. - eligible_child = ( - person("is_ssi_aged_blind_disabled", period) - & person("is_child", period) - & person("is_medicaid_immigration_status_eligible", period) + # Aggregate monthly budgets while retaining the annual input contract. + return person( + "medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent_monthly", + period, ) - ineligible_parent = person("is_ssi_ineligible_parent", period) - tax_unit = person.tax_unit - - prereduction_parental_earned_income = eligible_child * tax_unit.sum( - person("ssi_earned_income", period) * ineligible_parent - ) - blind_disabled_working_student_income = person( - "ssi_blind_or_disabled_working_student_exclusion", period - ) - parental_earned_income = max_( - prereduction_parental_earned_income - blind_disabled_working_student_income, - 0, - ) - - parental_unearned_income = eligible_child * tax_unit.sum( - person("ssi_unearned_income", period) * ineligible_parent - ) - - state = person.household("state_code_str", period) - # State allocation definitions can be narrower than SSI's adult - # student definition. Apply each sourced ceiling before deducting - # the shared allocation amount; other states retain their existing - # shared allocation behavior. - age = person("age", period.this_year) - age_limits = parameters( - period - ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.deeming.ineligible_child_age_limit - allocation_child = np.ones(person.count, dtype=bool) - for state_code in age_limits._children: - # State breakdown expansion fills omitted states with zero. - # Only a positive, sourced ceiling overrides the shared scope. - age_limit = age_limits[state_code] - if age_limit > 0: - allocation_child &= (state != state_code) | (age < age_limit) - # These states define allocation children as nonblind and - # nondisabled, independently of Medicaid immigration eligibility. - allocation_child &= ~( - np.isin(state, ("FL", "LA", "NY", "VA", "WI")) - & person("is_ssi_aged_blind_disabled", period) - ) - # MEH 1.1.3.3 and 15.1.2 define dependent 18-year-olds through - # full-time secondary school or equivalent-level training, not - # tax dependency. Enrollment proxies expected completion before - # 19: the model has no completion-date or remaining-credit input. - # At age 18, explicit K-12 enrollment is also secondary-school - # evidence; the age gate excludes younger pupils from this extension. - dependent_18_year_old = ( - (age == 18) - & ( - person("is_in_secondary_school", period.this_year) - | person("is_in_k12_school", period.this_year) - ) - & person("is_full_time_student", period.this_year) - ) - allocation_child &= (state != "WI") | (age < 18) | dependent_18_year_old - allocations = ( - person("ssi_ineligible_child_allocation", period) * allocation_child - ) - child_allocations = eligible_child * ssi_parental_child_allocation_total( - person, period, eligible_child, allocations - ) - parental_allocations = eligible_child * add( - tax_unit, period, ["ssi_ineligible_parent_allocation"] - ) - - parental_unearned_income -= child_allocations - remaining_child_allocations = max_(0, -parental_unearned_income) - parental_unearned_income = max_(0, parental_unearned_income) - parental_earned_income = max_( - 0, parental_earned_income - remaining_child_allocations - ) - - general_disregard = parameters( - period - ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.disregard.individual[ - state - ] - # Missouri deems parental income in the SSI order: the $20 - # exclusion comes off before the $65 and the half (DSS Manual - # § 0805.020.15 steps 4-6), unlike its applicant budget. - net_parental_deemed_income = where( - state == "MO", - _apply_ssi_exclusions( - parental_earned_income, - parental_unearned_income, - parameters, - period, - general_exclusion=general_disregard, - monthly=True, - ), - _apply_medicaid_optional_senior_or_disabled_exclusions( - parental_earned_income * MONTHS_IN_YEAR, - parental_unearned_income * MONTHS_IN_YEAR, - state, - general_disregard, - parameters, - period, - ) - / MONTHS_IN_YEAR, - ) - net_parental_deemed_income = max_( - 0, net_parental_deemed_income - parental_allocations - ) - - count_eligible_children = tax_unit.sum(eligible_child) - income = np.zeros_like(count_eligible_children, dtype=float) - mask = count_eligible_children > 0 - income[mask] = net_parental_deemed_income[mask] / count_eligible_children[mask] - return where(eligible_child, income, 0) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent_monthly.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent_monthly.py new file mode 100644 index 00000000000..33b0e025d80 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_parent/medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent_monthly.py @@ -0,0 +1,156 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( + _apply_medicaid_optional_senior_or_disabled_exclusions, +) +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) +from policyengine_us.variables.gov.ssa.ssi.eligibility.income.deemed.from_ineligible_parent._ssi_parental_child_allocation_scope import ( + ssi_parental_child_allocation_total, +) + + +class medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent_monthly( + Variable +): + value_type = float + entity = Person + label = "Medicaid optional senior or disabled unearned income deemed from ineligible parent" + unit = USD + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/20/416.1165", + "https://www.law.cornell.edu/uscode/text/42/1396b#v", + "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-020-00/0805-020-15/", + "https://www.emhandbooks.wisconsin.gov/meh-ebd/policy_files/1/1.1.htm#1.1.3.3", + "https://www.emhandbooks.wisconsin.gov/meh-ebd/policy_files/15/15.1.htm#15.1.2", + ) + + def formula(person, period, parameters): + supplied = annual_input( + person, + period, + "medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent", + ) + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + # Medicaid's own immigration rules include eligible groups who do + # not qualify for SSI, including state-covered noncitizen children. + eligible_child = ( + person("is_ssi_aged_blind_disabled", period) + & person("is_child", period) + & person("is_medicaid_immigration_status_eligible", period) + ) + ineligible_parent = person("is_ssi_ineligible_parent", period) + tax_unit = person.tax_unit + + prereduction_parental_earned_income = eligible_child * tax_unit.sum( + person("ssi_earned_income", period) * ineligible_parent + ) + blind_disabled_working_student_income = person( + "ssi_blind_or_disabled_working_student_exclusion", period + ) + parental_earned_income = max_( + prereduction_parental_earned_income - blind_disabled_working_student_income, + 0, + ) + + parental_unearned_income = eligible_child * tax_unit.sum( + person("ssi_unearned_income", period) * ineligible_parent + ) + + state = person.household("state_code_str", period) + # State allocation definitions can be narrower than SSI's adult + # student definition. Apply each sourced ceiling before deducting + # the shared allocation amount; other states retain their existing + # shared allocation behavior. + age = person("age", period.this_year) + age_limits = parameters( + period + ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.deeming.ineligible_child_age_limit + allocation_child = np.ones(person.count, dtype=bool) + for state_code in age_limits._children: + # State breakdown expansion fills omitted states with zero. + # Only a positive, sourced ceiling overrides the shared scope. + age_limit = age_limits[state_code] + if age_limit > 0: + allocation_child &= (state != state_code) | (age < age_limit) + # These states define allocation children as nonblind and + # nondisabled, independently of Medicaid immigration eligibility. + allocation_child &= ~( + np.isin(state, ("FL", "LA", "NY", "VA", "WI")) + & person("is_ssi_aged_blind_disabled", period) + ) + # MEH 1.1.3.3 and 15.1.2 define dependent 18-year-olds through + # full-time secondary school or equivalent-level training, not + # tax dependency. Enrollment proxies expected completion before + # 19: the model has no completion-date or remaining-credit input. + # At age 18, explicit K-12 enrollment is also secondary-school + # evidence; the age gate excludes younger pupils from this extension. + dependent_18_year_old = ( + (age == 18) + & ( + person("is_in_secondary_school", period.this_year) + | person("is_in_k12_school", period.this_year) + ) + & person("is_full_time_student", period.this_year) + ) + allocation_child &= (state != "WI") | (age < 18) | dependent_18_year_old + allocations = ( + person("ssi_ineligible_child_allocation_monthly", period) * allocation_child + ) + child_allocations = eligible_child * ssi_parental_child_allocation_total( + person, period, eligible_child, allocations + ) + parental_allocations = eligible_child * add( + tax_unit, period, ["ssi_ineligible_parent_allocation"] + ) + + parental_unearned_income -= child_allocations + remaining_child_allocations = max_(0, -parental_unearned_income) + parental_unearned_income = max_(0, parental_unearned_income) + parental_earned_income = max_( + 0, parental_earned_income - remaining_child_allocations + ) + + general_disregard = parameters( + period + ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.disregard.individual[ + state + ] + # Missouri deems parental income in the SSI order: the $20 + # exclusion comes off before the $65 and the half (DSS Manual + # § 0805.020.15 steps 4-6), unlike its applicant budget. + net_parental_deemed_income = where( + state == "MO", + _apply_ssi_exclusions( + parental_earned_income, + parental_unearned_income, + parameters, + period, + general_exclusion=general_disregard, + monthly=True, + ), + _apply_medicaid_optional_senior_or_disabled_exclusions( + parental_earned_income * MONTHS_IN_YEAR, + parental_unearned_income * MONTHS_IN_YEAR, + state, + general_disregard, + parameters, + period, + ) + / MONTHS_IN_YEAR, + ) + net_parental_deemed_income = max_( + 0, net_parental_deemed_income - parental_allocations + ) + + count_eligible_children = tax_unit.sum(eligible_child) + income = np.zeros_like(count_eligible_children, dtype=float) + mask = count_eligible_children > 0 + income[mask] = net_parental_deemed_income[mask] / count_eligible_children[mask] + return where(eligible_child, income, 0) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py index 16f30628c47..47cf98bfcb8 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py @@ -1,7 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( - _apply_medicaid_optional_senior_or_disabled_exclusions, -) class medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse( @@ -10,7 +7,7 @@ class medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse( value_type = float entity = Person label = "Medicaid optional senior or disabled income deemed from ineligible spouse" - definition_period = MONTH + definition_period = YEAR reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1163", "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-015-00/0805-015-05/", @@ -19,63 +16,8 @@ class medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse( unit = USD def formula(person, period, parameters): - deeming_applies = person("is_ssi_spousal_deeming_applies", period) - spouse_earned = person( - "ssi_earned_income_deemed_from_ineligible_spouse", period + # Aggregate monthly budgets while retaining the annual input contract. + return person( + "medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly", + period, ) - spouse_unearned = person( - "ssi_unearned_income_deemed_from_ineligible_spouse", period - ) - individual_earned = person("ssi_earned_income", period) - individual_unearned = person("ssi_unearned_income", period) - - p = parameters( - period - ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.disregard - state = person.household("state_code_str", period) - - # Missouri MHABD considers the gross income of a married couple - # living together (DSS Manual § 0805.015.05) rather than the SSI - # deeming methodology: the spouse's income counts without the - # FBR-differential threshold or ineligible-child allocations. - is_mo = state == "MO" - marital_unit = person.marital_unit - ineligible_spouse = person("is_ssi_ineligible_spouse", period) - spouse_gross_earned = ( - marital_unit.sum(ineligible_spouse * individual_earned) - - ineligible_spouse * individual_earned - ) - spouse_gross_unearned = ( - marital_unit.sum(ineligible_spouse * individual_unearned) - - ineligible_spouse * individual_unearned - ) - spouse_earned = where(is_mo, spouse_gross_earned, spouse_earned) - spouse_unearned = where(is_mo, spouse_gross_unearned, spouse_unearned) - has_ineligible_spouse = marital_unit.sum(ineligible_spouse) > 0 - deeming_applies = deeming_applies | (is_mo & has_ineligible_spouse) - - alone_countable = ( - _apply_medicaid_optional_senior_or_disabled_exclusions( - individual_earned * MONTHS_IN_YEAR, - individual_unearned * MONTHS_IN_YEAR, - state, - p.individual[state], - parameters, - period, - ) - / MONTHS_IN_YEAR - ) - couple_countable = ( - _apply_medicaid_optional_senior_or_disabled_exclusions( - (individual_earned + spouse_earned) * MONTHS_IN_YEAR, - (individual_unearned + spouse_unearned) * MONTHS_IN_YEAR, - state, - p.couple[state], - parameters, - period, - ) - / MONTHS_IN_YEAR - ) - - deemed_amount = max_(0, couple_countable - alone_countable) - return deeming_applies * deemed_amount diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly.py new file mode 100644 index 00000000000..69c6caabf3e --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly.py @@ -0,0 +1,95 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( + _apply_medicaid_optional_senior_or_disabled_exclusions, +) + + +class medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly( + Variable +): + value_type = float + entity = Person + label = "Medicaid optional senior or disabled income deemed from ineligible spouse" + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/20/416.1163", + "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-015-00/0805-015-05/", + ) + unit = USD + + def formula(person, period, parameters): + supplied = annual_input( + person, + period, + "medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse", + ) + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + deeming_applies = person("is_ssi_spousal_deeming_applies_monthly", period) + spouse_earned = person( + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", period + ) + spouse_unearned = person( + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", period + ) + individual_earned = person("ssi_earned_income", period) + individual_unearned = person("ssi_unearned_income", period) + + p = parameters( + period + ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.disregard + state = person.household("state_code_str", period) + + # Missouri MHABD considers the gross income of a married couple + # living together (DSS Manual § 0805.015.05) rather than the SSI + # deeming methodology: the spouse's income counts without the + # FBR-differential threshold or ineligible-child allocations. + is_mo = state == "MO" + marital_unit = person.marital_unit + ineligible_spouse = person("is_ssi_ineligible_spouse", period) + spouse_gross_earned = ( + marital_unit.sum(ineligible_spouse * individual_earned) + - ineligible_spouse * individual_earned + ) + spouse_gross_unearned = ( + marital_unit.sum(ineligible_spouse * individual_unearned) + - ineligible_spouse * individual_unearned + ) + spouse_earned = where(is_mo, spouse_gross_earned, spouse_earned) + spouse_unearned = where(is_mo, spouse_gross_unearned, spouse_unearned) + has_ineligible_spouse = marital_unit.sum(ineligible_spouse) > 0 + deeming_applies = deeming_applies | (is_mo & has_ineligible_spouse) + + alone_countable = ( + _apply_medicaid_optional_senior_or_disabled_exclusions( + individual_earned * MONTHS_IN_YEAR, + individual_unearned * MONTHS_IN_YEAR, + state, + p.individual[state], + parameters, + period, + ) + / MONTHS_IN_YEAR + ) + couple_countable = ( + _apply_medicaid_optional_senior_or_disabled_exclusions( + (individual_earned + spouse_earned) * MONTHS_IN_YEAR, + (individual_unearned + spouse_unearned) * MONTHS_IN_YEAR, + state, + p.couple[state], + parameters, + period, + ) + / MONTHS_IN_YEAR + ) + + deemed_amount = max_(0, couple_countable - alone_countable) + return where( + person("is_ssi_eligible_individual", period), + deeming_applies * deemed_amount, + 0, + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py index dc2199a60dc..1fec8756b1e 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income.py @@ -1,7 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( - _apply_medicaid_optional_senior_or_disabled_exclusions, -) class medicaid_optional_senior_or_disabled_countable_income(Variable): @@ -9,7 +6,7 @@ class medicaid_optional_senior_or_disabled_countable_income(Variable): entity = Person label = "Countable income for Medicaid's optional senior or disabled pathway" unit = USD - definition_period = MONTH + definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/42/1396a#m", "https://www.kff.org/medicaid/medicaid-financial-eligibility-for-seniors-and-people-with-disabilities-findings-from-a-50-state-survey/", @@ -20,61 +17,7 @@ class medicaid_optional_senior_or_disabled_countable_income(Variable): ) def formula(person, period, parameters): - pre_reduction_earned_income = person("ssi_marital_earned_income", period) - blind_disabled_working_student_income = person( - "ssi_blind_or_disabled_working_student_exclusion", period - ) - earned_income = max_( - pre_reduction_earned_income - blind_disabled_working_student_income, - 0, - ) - - total_unearned_income = add( - person, - period, - [ - "ssi_marital_unearned_income", - "medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent", - "ssi_in_kind_support_and_maintenance", - ], - ) - - couple_computation = person("ssi_couple_computation_applies", period) - state = person.household("state_code_str", period) - p = parameters( - period - ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.disregard - income_disregard = where( - couple_computation, - p.couple[state], - p.individual[state], - ) - personal_countable = ( - _apply_medicaid_optional_senior_or_disabled_exclusions( - earned_income * MONTHS_IN_YEAR, - total_unearned_income * MONTHS_IN_YEAR, - state, - income_disregard, - parameters, - period, - ) - / MONTHS_IN_YEAR - ) - - spousal_deemed = person( - "medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse", - period, - ) - is_eligible_individual = person("is_ssi_eligible_individual", period) - is_eligible_spouse = person("is_ssi_eligible_spouse", period) - is_eligible = is_eligible_individual | is_eligible_spouse - - return where( - ~is_eligible, - 0, - where( - couple_computation, - personal_countable / 2, - personal_countable + spousal_deemed, - ), + # Aggregate monthly budgets while retaining the annual input contract. + return person( + "medicaid_optional_senior_or_disabled_countable_income_monthly", period ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income_monthly.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income_monthly.py new file mode 100644 index 00000000000..ec38da4e52f --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_optional_senior_or_disabled_countable_income_monthly.py @@ -0,0 +1,89 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.medicaid.income._apply_medicaid_optional_senior_or_disabled_exclusions import ( + _apply_medicaid_optional_senior_or_disabled_exclusions, +) + + +class medicaid_optional_senior_or_disabled_countable_income_monthly(Variable): + value_type = float + entity = Person + label = "Countable income for Medicaid's optional senior or disabled pathway" + unit = USD + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1396a#m", + "https://www.kff.org/medicaid/medicaid-financial-eligibility-for-seniors-and-people-with-disabilities-findings-from-a-50-state-survey/", + # Missouri MHABD budgeting order and rounding + "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-015-00/", + "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-015-00/0805-015-25/", + "https://dssmanuals.mo.gov/mo-healthnet-for-the-aged-blind-and-disabled/0805-000-00/0805-015-00/0805-015-40/", + ) + + def formula(person, period, parameters): + supplied = annual_input( + person, period, "medicaid_optional_senior_or_disabled_countable_income" + ) + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + pre_reduction_earned_income = person("ssi_marital_earned_income", period) + blind_disabled_working_student_income = person( + "ssi_blind_or_disabled_working_student_exclusion", period + ) + earned_income = max_( + pre_reduction_earned_income - blind_disabled_working_student_income, + 0, + ) + + total_unearned_income = add( + person, + period, + [ + "ssi_marital_unearned_income", + "medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent_monthly", + "ssi_in_kind_support_and_maintenance_monthly", + ], + ) + + couple_computation = person("ssi_couple_computation_applies", period) + state = person.household("state_code_str", period) + p = parameters( + period + ).gov.hhs.medicaid.eligibility.categories.senior_or_disabled.income.disregard + income_disregard = where( + couple_computation, + p.couple[state], + p.individual[state], + ) + personal_countable = ( + _apply_medicaid_optional_senior_or_disabled_exclusions( + earned_income * MONTHS_IN_YEAR, + total_unearned_income * MONTHS_IN_YEAR, + state, + income_disregard, + parameters, + period, + ) + / MONTHS_IN_YEAR + ) + + spousal_deemed = person( + "medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly", + period, + ) + is_eligible_individual = person("is_ssi_eligible_individual", period) + is_eligible_spouse = person("is_ssi_eligible_spouse", period) + is_eligible = is_eligible_individual | is_eligible_spouse + + return where( + ~is_eligible, + 0, + where( + couple_computation, + personal_countable / 2, + personal_countable + spousal_deemed, + ), + ) diff --git a/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py b/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py index 0ad174cb537..beb87578a6f 100644 --- a/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py @@ -44,13 +44,13 @@ def formula(person, period, parameters): earned_income - blind_or_disabled_working_student_exclusion, 0, ) - deeming_applies = person("is_ssi_spousal_deeming_applies", period) + deeming_applies = person("is_ssi_spousal_deeming_applies_monthly", period) spouse_earned_income = person( - "ssi_earned_income_deemed_from_ineligible_spouse", period + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", period ) spouse_unearned_income = person( - "ssi_unearned_income_deemed_from_ineligible_spouse", period + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", period ) personal_countable_income = _apply_ssi_exclusions( personal_earned_income, 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 b19683d9d32..a8d05f9be6e 100644 --- a/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/ssa/ssi/_ssi_state_supplement.py @@ -20,13 +20,13 @@ def ssi_state_supplement( joint = person("ssi_couple_computation_applies", period) couple_total = person.marital_unit.sum(couple_rate / 2) standard = where(joint, couple_total / 2, individual_rate) - countable = person("ssi_countable_income", period) + countable = person("ssi_countable_income_monthly", period) federal_amount = person("ssi_amount_if_eligible", period) excess = max_(countable - federal_amount, 0) payment = max_(standard - excess, 0) - deeming = person("is_ssi_spousal_deeming_applies", period) - deemed = person("ssi_income_deemed_from_ineligible_spouse", period) + deeming = person("is_ssi_spousal_deeming_applies_monthly", 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_parent/_ssi_parental_child_allocation_scope.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py index 27a3eb1412c..52a368fcb88 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/_ssi_parental_child_allocation_scope.py @@ -75,7 +75,7 @@ def ssi_parental_child_allocation_total(person, period, recipients, allocations= ) linked = has_parent_ids(person, period) if allocations is None: - allocations = person("ssi_ineligible_child_allocation", period) + allocations = person("ssi_ineligible_child_allocation_monthly", period) total = np.zeros(person.count) for child in household_member_indices(person): safe = np.maximum(child, 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent.py index 81bc74fd301..add18cfbebc 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent.py @@ -1,10 +1,4 @@ from policyengine_us.model_api import * -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_parent._ssi_parental_child_allocation_scope import ( - ssi_parental_child_allocation_total, -) class ssi_unearned_income_deemed_from_ineligible_parent(Variable): @@ -12,72 +6,11 @@ class ssi_unearned_income_deemed_from_ineligible_parent(Variable): entity = Person label = "SSI unearned income (deemed from ineligible parent)" unit = USD - definition_period = MONTH + definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/20/416.1165" def formula(person, period, parameters): - immigration_status = person("immigration_status", period) - is_citizen = immigration_status == immigration_status.possible_values.CITIZEN - # 416.1165(e)(2) divides only among SSI-eligible children. Income - # and resource eligibility depend on this calculation, so use the - # status conditions shared with the ineligible-child classification. - eligible_child = ( - person("is_ssi_aged_blind_disabled", period) - & person("is_child", period) - & (is_citizen | person("is_ssi_qualified_noncitizen", period)) + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person( + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", period ) - ineligible_parent = person("is_ssi_ineligible_parent", period) - tax_unit = person.tax_unit - - prereduction_parental_earned_income = eligible_child * tax_unit.sum( - person("ssi_earned_income", period) * ineligible_parent - ) - blind_disabled_working_student_income = person( - "ssi_blind_or_disabled_working_student_exclusion", period - ) - parental_earned_income = max_( - prereduction_parental_earned_income - blind_disabled_working_student_income, - 0, - ) - - parental_unearned_income = eligible_child * tax_unit.sum( - person("ssi_unearned_income", period) * ineligible_parent - ) - - child_allocations = eligible_child * ssi_parental_child_allocation_total( - person, period, eligible_child - ) - parental_allocations = eligible_child * add( - tax_unit, period, ["ssi_ineligible_parent_allocation"] - ) - - # To understand this ordering of operations, see Example 4 of - # https://www.law.cornell.edu/cfr/text/20/416.1165#h. - - parental_unearned_income -= child_allocations - remaining_child_allocations = max_(0, -parental_unearned_income) - parental_unearned_income = max_(0, parental_unearned_income) - parental_earned_income = max_( - 0, parental_earned_income - remaining_child_allocations - ) - - net_parental_deemed_income = _apply_ssi_exclusions( - parental_earned_income, - parental_unearned_income, - parameters, - period, - monthly=True, - ) - - net_parental_deemed_income = max_( - 0, net_parental_deemed_income - parental_allocations - ) - - count_eligible_children = tax_unit.sum(eligible_child) - # avoid array divide-by-zero warnings by not using where() function - # see the following GitHub issue for more details: - # https://github.com/PolicyEngine/policyengine-us/issues/2494 - income = np.zeros_like(count_eligible_children, dtype=float) - mask = count_eligible_children > 0 - income[mask] = net_parental_deemed_income[mask] / count_eligible_children[mask] - return where(eligible_child, income, 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent_monthly.py new file mode 100644 index 00000000000..9dfbd7c04cd --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent/ssi_unearned_income_deemed_from_ineligible_parent_monthly.py @@ -0,0 +1,92 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * +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_parent._ssi_parental_child_allocation_scope import ( + ssi_parental_child_allocation_total, +) + + +class ssi_unearned_income_deemed_from_ineligible_parent_monthly(Variable): + value_type = float + entity = Person + label = "SSI unearned income (deemed from ineligible parent)" + unit = USD + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/20/416.1165" + + def formula(person, period, parameters): + supplied = annual_input( + person, period, "ssi_unearned_income_deemed_from_ineligible_parent" + ) + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + immigration_status = person("immigration_status", period) + is_citizen = immigration_status == immigration_status.possible_values.CITIZEN + # 416.1165(e)(2) divides only among SSI-eligible children. Income + # and resource eligibility depend on this calculation, so use the + # status conditions shared with the ineligible-child classification. + eligible_child = ( + person("is_ssi_aged_blind_disabled", period) + & person("is_child", period) + & (is_citizen | person("is_ssi_qualified_noncitizen", period)) + ) + ineligible_parent = person("is_ssi_ineligible_parent", period) + tax_unit = person.tax_unit + + prereduction_parental_earned_income = eligible_child * tax_unit.sum( + person("ssi_earned_income", period) * ineligible_parent + ) + blind_disabled_working_student_income = person( + "ssi_blind_or_disabled_working_student_exclusion", period + ) + parental_earned_income = max_( + prereduction_parental_earned_income - blind_disabled_working_student_income, + 0, + ) + + parental_unearned_income = eligible_child * tax_unit.sum( + person("ssi_unearned_income", period) * ineligible_parent + ) + + child_allocations = eligible_child * ssi_parental_child_allocation_total( + person, period, eligible_child + ) + parental_allocations = eligible_child * add( + tax_unit, period, ["ssi_ineligible_parent_allocation"] + ) + + # To understand this ordering of operations, see Example 4 of + # https://www.law.cornell.edu/cfr/text/20/416.1165#h. + + parental_unearned_income -= child_allocations + remaining_child_allocations = max_(0, -parental_unearned_income) + parental_unearned_income = max_(0, parental_unearned_income) + parental_earned_income = max_( + 0, parental_earned_income - remaining_child_allocations + ) + + net_parental_deemed_income = _apply_ssi_exclusions( + parental_earned_income, + parental_unearned_income, + parameters, + period, + monthly=True, + ) + + net_parental_deemed_income = max_( + 0, net_parental_deemed_income - parental_allocations + ) + + count_eligible_children = tax_unit.sum(eligible_child) + # avoid array divide-by-zero warnings by not using where() function + # see the following GitHub issue for more details: + # https://github.com/PolicyEngine/policyengine-us/issues/2494 + income = np.zeros_like(count_eligible_children, dtype=float) + mask = count_eligible_children > 0 + income[mask] = net_parental_deemed_income[mask] / count_eligible_children[mask] + return where(eligible_child, income, 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/README.md index b3736c4f18d..c28fda00f1f 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 @@ -23,6 +23,8 @@ The implementation follows these steps according to the regulations: - `ssi_spousal_deeming_child_allocations` sums the allocations. Parent ids (`parent_1_id`, `parent_2_id`) decide whose child someone is. Without ids, the co-resident members of the claimant's or the spouse's tax unit stand in, as the parent-link contract in `household/demographic/person/_parent_links.py` allows; that proxy cannot tell a grandchild from a child - No allocation is made for a child receiving public income-maintenance payments (`ssi_receives_public_income_maintenance`) - Student earnings use the monthly student exclusion until its annual cap is exhausted. Each month retains its allocation through spouse-income deductions, the deeming threshold, income exclusions, floors and benefit caps; annual outputs sum the completed monthly calculations. Annual income inputs are spread evenly over the year + - Existing income and allocation variables retain their annual input and output basis, including when supplied in a monthly situation. Their `*_monthly` counterparts carry each month's budget through the calculation chain and honor supplied annual inputs. Annual eligibility screens read the annual variables directly, avoiding rounding from dividing supplied annual income and summing it again + - `is_ssi_spousal_deeming_applies` also retains its annual input basis; its calculated annual output remains the December status. Benefit calculations use `is_ssi_spousal_deeming_applies_monthly` for the current month's threshold - Parent deeming and its Medicaid counterpart also restrict allocations to relevant co-resident children. SSI recipient division uses SSI immigration status; Medicaid uses its own immigration eligibility and excludes its recipients from the allocation pool 3. **Compare remaining income to FBR differential** (§416.1163(d)) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py index 392ebd7e746..6e2e484a4ae 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies.py @@ -5,7 +5,7 @@ class is_ssi_spousal_deeming_applies(Variable): value_type = bool entity = Person label = "SSI spousal deeming applies" - definition_period = MONTH + definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" defined_for = "is_ssi_eligible_individual" documentation = """ @@ -22,20 +22,5 @@ class is_ssi_spousal_deeming_applies(Variable): """ def formula(person, period, parameters): - # Get spouse's deemed income (after child allocations) - spouse_deemed_income = add( - person, - period, - [ - "ssi_earned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_spouse", - ], - ) - - # Compare to FBR differential (couple rate - individual rate) - p = parameters(period).gov.ssa.ssi.amount - diff = p.couple - p.individual - - # Deeming applies when spouse's deemed income exceeds the differential - # Note: regulation says "not more than" means ≤, so we use > for deeming - return spouse_deemed_income > diff + # Preserve the annual status input and the monthly flag's year-end output. + return person("is_ssi_spousal_deeming_applies_monthly", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies_monthly.py new file mode 100644 index 00000000000..f6482c10d30 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/is_ssi_spousal_deeming_applies_monthly.py @@ -0,0 +1,49 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * + + +class is_ssi_spousal_deeming_applies_monthly(Variable): + value_type = bool + entity = Person + label = "SSI spousal deeming applies" + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" + documentation = """ + Returns True when spousal deeming applies according to 20 CFR §416.1163. + + Spousal deeming applies when: + 1. Person is an eligible individual (not part of an eligible couple) + 2. Spouse is ineligible (not aged/blind/disabled) + 3. Spouse's leftover income (after child allocations) exceeds the difference + between couple FBR and individual FBR + + When deeming applies, the benefit calculation uses the couple FBR instead + of the individual FBR, recognizing that two people have higher expenses. + """ + + def formula(person, period, parameters): + supplied = annual_input(person, period, "is_ssi_spousal_deeming_applies") + if supplied is not None: + return supplied + + # Get spouse's deemed income (after child allocations) + spouse_deemed_income = add( + person, + period, + [ + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", + ], + ) + + # Compare to FBR differential (couple rate - individual rate) + p = parameters(period).gov.ssa.ssi.amount + diff = p.couple - p.individual + + # Deeming applies when spouse's deemed income exceeds the differential + # Note: regulation says "not more than" means ≤, so we use > for deeming + return person("is_ssi_eligible_individual", period) & ( + spouse_deemed_income > diff + ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py index 0166e6db812..7375d4eabcd 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py @@ -6,30 +6,9 @@ class ssi_earned_income_deemed_from_ineligible_spouse(Variable): entity = Person label = "SSI earned income (deemed from ineligible spouse)" unit = USD - definition_period = MONTH + definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" def formula(person, period, parameters): - # First, determine unearned income from the ineligible spouse. - # This is (a) in the law. - ineligible_spouse = person("is_ssi_ineligible_spouse", period) - unearned_income = person("ssi_unearned_income", period) - marital_unit = person.marital_unit - ineligible_spousal_unearned_income = ( - marital_unit.sum(ineligible_spouse * unearned_income) - - ineligible_spouse * unearned_income - ) - earned_income = person("ssi_earned_income", period) - ineligible_spousal_earned_income = ( - marital_unit.sum(ineligible_spouse * earned_income) - - ineligible_spouse * earned_income - ) - - # Next, (b) determine and subtract allocations for ineligible children. - child_allocations = person("ssi_spousal_deeming_child_allocations", period) - # Child allocations are deducted from unearned income first - ensure we only deduct - # the remaining allocations from earned income. - remaining_child_allocations = max_( - 0, child_allocations - ineligible_spousal_unearned_income - ) - return max_(0, ineligible_spousal_earned_income - remaining_child_allocations) + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person("ssi_earned_income_deemed_from_ineligible_spouse_monthly", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse_monthly.py new file mode 100644 index 00000000000..77a6b047ed0 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse_monthly.py @@ -0,0 +1,44 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * + + +class ssi_earned_income_deemed_from_ineligible_spouse_monthly(Variable): + value_type = float + entity = Person + label = "SSI earned income (deemed from ineligible spouse)" + unit = USD + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" + + def formula(person, period, parameters): + supplied = annual_input( + person, period, "ssi_earned_income_deemed_from_ineligible_spouse" + ) + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + # First, determine unearned income from the ineligible spouse. + # This is (a) in the law. + ineligible_spouse = person("is_ssi_ineligible_spouse", period) + unearned_income = person("ssi_unearned_income", period) + marital_unit = person.marital_unit + ineligible_spousal_unearned_income = ( + marital_unit.sum(ineligible_spouse * unearned_income) + - ineligible_spouse * unearned_income + ) + earned_income = person("ssi_earned_income", period) + ineligible_spousal_earned_income = ( + marital_unit.sum(ineligible_spouse * earned_income) + - ineligible_spouse * earned_income + ) + + # Next, (b) determine and subtract allocations for ineligible children. + child_allocations = person("ssi_spousal_deeming_child_allocations", period) + # Child allocations are deducted from unearned income first - ensure we only deduct + # the remaining allocations from earned income. + remaining_child_allocations = max_( + 0, child_allocations - ineligible_spousal_unearned_income + ) + return max_(0, ineligible_spousal_earned_income - remaining_child_allocations) 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 563927d2c7c..f7b4dd1e733 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,16 +1,11 @@ -# 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): value_type = float entity = Person label = "SSI income (deemed from ineligible spouse)" - definition_period = MONTH + definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" documentation = """ Spousal deeming: @@ -22,38 +17,5 @@ class ssi_income_deemed_from_ineligible_spouse(Variable): 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, monthly=True - ) - - # Calculate couple's combined countable income - couple_countable = _apply_ssi_exclusions( - individual_earned + spouse_earned, - individual_unearned + spouse_unearned, - parameters, - period, - monthly=True, - ) - - # Deemed amount is the difference (only when deeming applies) - deemed_amount = max_(0, couple_countable - alone_countable) - return deeming_applies * deemed_amount + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person("ssi_income_deemed_from_ineligible_spouse_monthly", period) 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..83456df096d --- /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,68 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +# 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_monthly(Variable): + value_type = float + entity = Person + label = "SSI income (deemed from ineligible spouse)" + definition_period = MONTH + 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). + Each month is calculated before annual amounts are summed. + """ + unit = USD + + 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 + + # 1. Check if spousal deeming applies (avoids duplicating threshold logic) + deeming_applies = person("is_ssi_spousal_deeming_applies_monthly", 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_monthly", period + ) + spouse_unearned = person( + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", 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, monthly=True + ) + + # Calculate couple's combined countable income + couple_countable = _apply_ssi_exclusions( + individual_earned + spouse_earned, + individual_unearned + spouse_unearned, + parameters, + period, + monthly=True, + ) + + # Deemed amount is the difference (only when deeming applies) + deemed_amount = max_(0, couple_countable - alone_countable) + result = deeming_applies * deemed_amount + return where(person("is_ssi_eligible_individual", period), result, 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py index adb6629818c..8cdf6d16d4d 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_spousal_deeming_child_allocations.py @@ -40,7 +40,7 @@ class ssi_spousal_deeming_child_allocations(Variable): ) def formula(person, period, parameters): - allocation = person("ssi_ineligible_child_allocation", period) + allocation = person("ssi_ineligible_child_allocation_monthly", period) # 416.1160(d): 'your natural child or adopted child, or the natural # or adopted child of your spouse ... who lives in the same household # with you'. Its other clause, the child 'of your parent or of your diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py index 97c0390aada..8134c2ed6cc 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse.py @@ -9,19 +9,11 @@ class ssi_unearned_income_deemed_from_ineligible_spouse(Variable): "This is ignored if total income is under the SSI individual allowance." ) unit = USD - definition_period = MONTH + definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" def formula(person, period, parameters): - # First, determine unearned income from the ineligible spouse. - # This is (a) in the law. - ineligible_spouse = person("is_ssi_ineligible_spouse", period) - unearned_income = person("ssi_unearned_income", period) - ineligible_spousal_unearned_income = ( - person.marital_unit.sum(ineligible_spouse * unearned_income) - - ineligible_spouse * unearned_income + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person( + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", period ) - - # Next, (b) determine and subtract allocations for ineligible children. - child_allocations = person("ssi_spousal_deeming_child_allocations", period) - return max_(0, ineligible_spousal_unearned_income - child_allocations) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse_monthly.py new file mode 100644 index 00000000000..87006c24179 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_unearned_income_deemed_from_ineligible_spouse_monthly.py @@ -0,0 +1,36 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * + + +class ssi_unearned_income_deemed_from_ineligible_spouse_monthly(Variable): + value_type = float + entity = Person + label = "SSI unearned income (deemed from ineligible spouse)" + documentation = ( + "This is ignored if total income is under the SSI individual allowance." + ) + unit = USD + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" + + def formula(person, period, parameters): + supplied = annual_input( + person, period, "ssi_unearned_income_deemed_from_ineligible_spouse" + ) + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + # First, determine unearned income from the ineligible spouse. + # This is (a) in the law. + ineligible_spouse = person("is_ssi_ineligible_spouse", period) + unearned_income = person("ssi_unearned_income", period) + ineligible_spousal_unearned_income = ( + person.marital_unit.sum(ineligible_spouse * unearned_income) + - ineligible_spouse * unearned_income + ) + + # Next, (b) determine and subtract allocations for ineligible children. + child_allocations = person("ssi_spousal_deeming_child_allocations", period) + return max_(0, ineligible_spousal_unearned_income - child_allocations) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index c7085c3f6a9..7e3b6104210 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -11,7 +11,7 @@ class ssi_ineligible_child_allocation(Variable): "spouse's or parent's income before deeming it. Which claimants it " "applies to is decided where the allocations are summed." ) - definition_period = MONTH + definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", "https://www.ecfr.gov/current/title-20/section-416.1161#p-416.1161(c)", @@ -20,33 +20,5 @@ class ssi_ineligible_child_allocation(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.ssa.ssi - # 416.1163(b): 'We do not allocate for ineligible children who are - # receiving public income-maintenance payments.' - allocated = person("is_ssi_ineligible_child", period) & ~person( - "ssi_receives_public_income_maintenance", period - ) - # 416.1163(b)(1): the couple rate less the individual rate, a month. - monthly_allocation = p.amount.couple - p.amount.individual - # 416.1163(b)(2) reduces it by the child's income as described in - # 416.1161(c), which excludes a student's earned income up to the - # amounts in 416.1112(c)(3), whether or not the child is blind or - # disabled (POMS SI 01320.185). No $20 or $65 exclusion applies. - student = p.income.exclusions.blind_or_disabled_working_student - is_student = person("is_full_time_student", period) & ( - person("age", period.this_year) < student.age_limit - ) - # Annual income is spread evenly over the year. Keep each month's - # allocation through downstream deductions, exclusions and benefit - # caps; annual reads sum the completed monthly budgets. - earned = max_(0, person("ssi_earned_income", period)) - unearned = max_(0, person("ssi_unearned_income", period)) - # The student exclusion is used consecutively from January until - # its annual cap is exhausted (POMS SI 00820.510). Annual income - # inputs imply the same monthly earnings in the preceding months. - monthly_exclusion = min_(student.amount, earned) - used = monthly_exclusion * (period.start.month - 1) - exclusion_left = max_(0, student.cap - used) - exclusion = where(is_student, min_(monthly_exclusion, exclusion_left), 0) - income = earned - exclusion + unearned - return allocated * max_(0, monthly_allocation - income) + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person("ssi_ineligible_child_allocation_monthly", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation_monthly.py new file mode 100644 index 00000000000..79b218e219d --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation_monthly.py @@ -0,0 +1,59 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * + + +class ssi_ineligible_child_allocation_monthly(Variable): + value_type = float + entity = Person + label = "SSI ineligible child allocation" + unit = USD + documentation = ( + "The allocation SSI deducts for this child from an ineligible " + "spouse's or parent's income before deeming it. Which claimants it " + "applies to is decided where the allocations are summed." + ) + definition_period = MONTH + reference = ( + "https://www.ecfr.gov/current/title-20/section-416.1163#p-416.1163(b)", + "https://www.ecfr.gov/current/title-20/section-416.1161#p-416.1161(c)", + "https://www.ecfr.gov/current/title-20/section-416.1165#p-416.1165(b)", + "https://secure.ssa.gov/poms.nsf/lnx/0501320185", + ) + + def formula(person, period, parameters): + supplied = annual_input(person, period, "ssi_ineligible_child_allocation") + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + p = parameters(period).gov.ssa.ssi + # 416.1163(b): 'We do not allocate for ineligible children who are + # receiving public income-maintenance payments.' + allocated = person("is_ssi_ineligible_child", period) & ~person( + "ssi_receives_public_income_maintenance", period + ) + # 416.1163(b)(1): the couple rate less the individual rate, a month. + monthly_allocation = p.amount.couple - p.amount.individual + # 416.1163(b)(2) reduces it by the child's income as described in + # 416.1161(c), which excludes a student's earned income up to the + # amounts in 416.1112(c)(3), whether or not the child is blind or + # disabled (POMS SI 01320.185). No $20 or $65 exclusion applies. + student = p.income.exclusions.blind_or_disabled_working_student + is_student = person("is_full_time_student", period) & ( + person("age", period.this_year) < student.age_limit + ) + # Annual income is spread evenly over the year. Keep each month's + # allocation through downstream deductions, exclusions and benefit + # caps; annual reads sum the completed monthly budgets. + earned = max_(0, person("ssi_earned_income", period)) + unearned = max_(0, person("ssi_unearned_income", period)) + # The student exclusion is used consecutively from January until + # its annual cap is exhausted (POMS SI 00820.510). Annual income + # inputs imply the same monthly earnings in the preceding months. + monthly_exclusion = min_(student.amount, earned) + used = monthly_exclusion * (period.start.month - 1) + exclusion_left = max_(0, student.cap - used) + exclusion = where(is_student, min_(monthly_exclusion, exclusion_left), 0) + income = earned - exclusion + unearned + return allocated * max_(0, monthly_allocation - income) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py index d3156de0f31..6d7e1efd0d5 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance.py @@ -6,24 +6,12 @@ class ssi_in_kind_support_and_maintenance(Variable): entity = Person label = "SSI in-kind support and maintenance" unit = USD - definition_period = MONTH + definition_period = YEAR reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1130", "https://www.law.cornell.edu/cfr/text/20/416.1140", ) def formula(person, period, parameters): - # ISM is counted as unearned income for own-household recipients - # who receive shelter (or food, pre-10/2024) support. - # The PMV rule caps ISM at the presumed maximum value. - # If the recipient provides an actual shelter value (rebuttal), - # ISM is the lesser of the actual value and the PMV. - # Another person's household: VTR FBR adjustment, not here. - # Child in parental household: deeming replaces ISM. - # Medical treatment facility: ISM not countable ($30 rate). - pmv_applies = person("ssi_pmv_applies", period) - pmv_amount = person("ssi_pmv_amount", period) - actual_value = person("ssi_shelter_support_value", period) - has_actual = actual_value > 0 - ism_value = where(has_actual, min_(pmv_amount, actual_value), pmv_amount) - return pmv_applies * ism_value + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person("ssi_in_kind_support_and_maintenance_monthly", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance_monthly.py new file mode 100644 index 00000000000..2bd3e99aa22 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_in_kind_support_and_maintenance_monthly.py @@ -0,0 +1,36 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * + + +class ssi_in_kind_support_and_maintenance_monthly(Variable): + value_type = float + entity = Person + label = "SSI in-kind support and maintenance" + unit = USD + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/20/416.1130", + "https://www.law.cornell.edu/cfr/text/20/416.1140", + ) + + def formula(person, period, parameters): + supplied = annual_input(person, period, "ssi_in_kind_support_and_maintenance") + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + # ISM is counted as unearned income for own-household recipients + # who receive shelter (or food, pre-10/2024) support. + # The PMV rule caps ISM at the presumed maximum value. + # If the recipient provides an actual shelter value (rebuttal), + # ISM is the lesser of the actual value and the PMV. + # Another person's household: VTR FBR adjustment, not here. + # Child in parental household: deeming replaces ISM. + # Medical treatment facility: ISM not countable ($30 rate). + pmv_applies = person("ssi_pmv_applies", period) + pmv_amount = person("ssi_pmv_amount_monthly", period) + actual_value = person("ssi_shelter_support_value", period) + has_actual = actual_value > 0 + ism_value = where(has_actual, min_(pmv_amount, actual_value), pmv_amount) + return pmv_applies * ism_value diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py index 406b036324d..1064779b8e4 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount.py @@ -6,7 +6,7 @@ class ssi_pmv_amount(Variable): entity = Person label = "SSI presumed maximum value amount" unit = USD - definition_period = MONTH + definition_period = YEAR reference = ( "https://www.law.cornell.edu/cfr/text/20/416.1140", "https://secure.ssa.gov/poms.nsf/lnx/0500835300", @@ -14,25 +14,5 @@ class ssi_pmv_amount(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.ssa.ssi - couple_computation = person("ssi_couple_computation_applies", period) - deeming_applies = person("is_ssi_spousal_deeming_applies", period) - - # Per POMS SI 00835.300 and SI 00835.901: - # Individual / joint couple: PMV = 1/3 × FBR + $20 - # Joint couple PMV is unit-level; the /2 in ssi_countable_income - # splits it per-person. - # - # Per POMS SI 01320.150: when spousal deeming applies, the PMV - # is halved to 1/6 × couple FBR + $10, because the deeming path - # in ssi_countable_income does NOT do a /2 split. - applicable_fbr = where( - couple_computation | deeming_applies, - p.amount.couple, - p.amount.individual, - ) - pmv_monthly = ( - applicable_fbr * p.income.ism.pmv_fbr_fraction + p.income.exclusions.general - ) - pmv_monthly = where(deeming_applies, pmv_monthly / 2, pmv_monthly) - return pmv_monthly + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person("ssi_pmv_amount_monthly", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount_monthly.py new file mode 100644 index 00000000000..e7082a954ae --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ism/ssi_pmv_amount_monthly.py @@ -0,0 +1,45 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * + + +class ssi_pmv_amount_monthly(Variable): + value_type = float + entity = Person + label = "SSI presumed maximum value amount" + unit = USD + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/20/416.1140", + "https://secure.ssa.gov/poms.nsf/lnx/0500835300", + "https://secure.ssa.gov/poms.nsf/lnx/0501320150", + ) + + def formula(person, period, parameters): + supplied = annual_input(person, period, "ssi_pmv_amount") + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + p = parameters(period).gov.ssa.ssi + couple_computation = person("ssi_couple_computation_applies", period) + deeming_applies = person("is_ssi_spousal_deeming_applies_monthly", period) + + # Per POMS SI 00835.300 and SI 00835.901: + # Individual / joint couple: PMV = 1/3 × FBR + $20 + # Joint couple PMV is unit-level; the /2 in ssi_countable_income + # splits it per-person. + # + # Per POMS SI 01320.150: when spousal deeming applies, the PMV + # is halved to 1/6 × couple FBR + $10, because the deeming path + # in ssi_countable_income does NOT do a /2 split. + applicable_fbr = where( + couple_computation | deeming_applies, + p.amount.couple, + p.amount.individual, + ) + pmv_monthly = ( + applicable_fbr * p.income.ism.pmv_fbr_fraction + p.income.exclusions.general + ) + pmv_monthly = where(deeming_applies, pmv_monthly / 2, pmv_monthly) + return pmv_monthly 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 0b454074f63..c1910b94df8 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 @@ -1,7 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( - _apply_ssi_exclusions, -) class ssi_countable_income(Variable): @@ -9,7 +6,7 @@ class ssi_countable_income(Variable): entity = Person label = "SSI countable income" unit = USD - definition_period = MONTH + definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/42/1382a#b" documentation = """ Calculates total countable income for SSI: @@ -26,63 +23,5 @@ class ssi_countable_income(Variable): """ def formula(person, period, parameters): - # The individual's earned income, after the blind/disabled student exclusion - pre_reduction_earned_income = person("ssi_marital_earned_income", period) - blind_disabled_working_student_income = person( - "ssi_blind_or_disabled_working_student_exclusion", period - ) - earned_income = max_( - pre_reduction_earned_income - blind_disabled_working_student_income, - 0, - ) - - # The individual's unearned + any unearned deemed from a parent - # When both spouses are SSI eligible, ssi_marital_unearned_income and ssi_marital_earned_income - # will already have attributed the combined earned/unearned income to each person in the - # marital unit, so we don't need additional head/spouse checks here. - # This occurs via the both_eligible check in those variables. - unearned_income = person("ssi_marital_unearned_income", period) - parent_deemed = person( - "ssi_unearned_income_deemed_from_ineligible_parent", period - ) - - # ISM (PMV for own-household recipients receiving shelter support) is - # counted as unearned income per 20 CFR § 416.1140. It enters - # the unearned pool BEFORE exclusions so the $20 general exclusion - # naturally applies. POMS SI 00835.901 confirms that the net - # countable ISM is 1/3 FBR (PMV minus $20 general exclusion). - ism = person("ssi_in_kind_support_and_maintenance", period) - - total_unearned = unearned_income + parent_deemed + ism - - # Apply standard SSI exclusions to the individual's own income - personal_countable = _apply_ssi_exclusions( - earned_income, - total_unearned, - parameters, - period, - monthly=True, - ) - - # Check if couple computation applies (both eligible AND not - # separated by a medical facility per 20 CFR 416.414(b)(3)) - 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) - - # Check if person is eligible (either as individual OR as eligible spouse) - is_eligible_individual = person("is_ssi_eligible_individual", period) - is_eligible_spouse = person("is_ssi_eligible_spouse", period) - is_eligible = is_eligible_individual | is_eligible_spouse - - # Calculate final countable income - return where( - ~is_eligible, - 0, - where( - both_eligible, - personal_countable / 2, - personal_countable + spousal_deemed, - ), - ) + # Sum completed monthly budgets; supplied amounts retain their annual basis. + return person("ssi_countable_income_monthly", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income_monthly.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income_monthly.py new file mode 100644 index 00000000000..6bc694307c1 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_countable_income_monthly.py @@ -0,0 +1,97 @@ +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._annual_input import ( + annual_input, +) +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) + + +class ssi_countable_income_monthly(Variable): + value_type = float + entity = Person + label = "SSI countable income" + unit = USD + definition_period = MONTH + reference = "https://www.law.cornell.edu/uscode/text/42/1382a#b" + documentation = """ + Calculates total countable income for SSI: + - Earned (after ignoring blind/disabled student exclusion) + - Unearned + - Parental deemed if child + - Spousal deemed if married to an ineligible spouse + - In-kind support and maintenance (ISM) via the PMV rule for Status A + - Applies standard SSI exclusions ($20 general, $65 earned, 50% earned). + + ISM is added to unearned income BEFORE exclusions per 20 CFR § 416.1140. + The PMV formula includes $20 so that after the general exclusion applies, + the net countable ISM equals 1/3 FBR (matching POMS SI 00835.901 values). + """ + + def formula(person, period, parameters): + supplied = annual_input(person, period, "ssi_countable_income") + if supplied is not None: + return supplied / MONTHS_IN_YEAR + + # The individual's earned income, after the blind/disabled student exclusion + pre_reduction_earned_income = person("ssi_marital_earned_income", period) + blind_disabled_working_student_income = person( + "ssi_blind_or_disabled_working_student_exclusion", period + ) + earned_income = max_( + pre_reduction_earned_income - blind_disabled_working_student_income, + 0, + ) + + # The individual's unearned + any unearned deemed from a parent + # When both spouses are SSI eligible, ssi_marital_unearned_income and ssi_marital_earned_income + # will already have attributed the combined earned/unearned income to each person in the + # marital unit, so we don't need additional head/spouse checks here. + # This occurs via the both_eligible check in those variables. + unearned_income = person("ssi_marital_unearned_income", period) + parent_deemed = person( + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", period + ) + + # ISM (PMV for own-household recipients receiving shelter support) is + # counted as unearned income per 20 CFR § 416.1140. It enters + # the unearned pool BEFORE exclusions so the $20 general exclusion + # naturally applies. POMS SI 00835.901 confirms that the net + # countable ISM is 1/3 FBR (PMV minus $20 general exclusion). + ism = person("ssi_in_kind_support_and_maintenance_monthly", period) + + total_unearned = unearned_income + parent_deemed + ism + + # Apply standard SSI exclusions to the individual's own income + personal_countable = _apply_ssi_exclusions( + earned_income, + total_unearned, + parameters, + period, + monthly=True, + ) + + # Check if couple computation applies (both eligible AND not + # separated by a medical facility per 20 CFR 416.414(b)(3)) + 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_monthly", period + ) + + # Check if person is eligible (either as individual OR as eligible spouse) + is_eligible_individual = person("is_ssi_eligible_individual", period) + is_eligible_spouse = person("is_ssi_eligible_spouse", period) + is_eligible = is_eligible_individual | is_eligible_spouse + + # Calculate final countable income + return where( + ~is_eligible, + 0, + where( + both_eligible, + personal_countable / 2, + personal_countable + spousal_deemed, + ), + ) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py index 375e65927f5..a1c99d3c6ae 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/ssi_resources_deemed_from_ineligible_parent.py @@ -133,7 +133,7 @@ def formula(person, period, parameters): # the payment is ineligible 'for any reason': they are still deemed a # share, but do not count in the division. Neither variable reads the # resource test. - income_eligible = person("ssi_countable_income", period) < person( + income_eligible = person("ssi_countable_income_monthly", period) < person( "ssi_amount_if_eligible", period ) deemed = np.zeros(person.count) diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py index 7319fb96ce2..8b20920e981 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_amount_if_eligible.py @@ -26,7 +26,7 @@ def formula(person, period, parameters): # SSI-eligible. ssi_claim_is_joint remains true for state SSPs. couple_computation = person("ssi_couple_computation_applies", period) - deeming_applies = person("is_ssi_spousal_deeming_applies", period) + deeming_applies = person("is_ssi_spousal_deeming_applies_monthly", period) individual_or_deeming_amount = where( deeming_applies, diff --git a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py index f9c96c70368..fc6202c5f31 100644 --- a/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py +++ b/policyengine_us/variables/gov/ssa/ssi/ssi_if_takes_up.py @@ -28,7 +28,7 @@ def formula(person, period, parameters): # - Deeming applies (uses couple FBR) # - After exclusions, countable may be low # - Benefit could exceed individual FBR without this cap - deeming_applies = person("is_ssi_spousal_deeming_applies", period) + deeming_applies = person("is_ssi_spousal_deeming_applies_monthly", period) p = parameters(period).gov.ssa.ssi.amount capped_benefit = min_(benefit, p.individual) diff --git a/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py b/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py index 04b751a49b7..52fbea2ce8d 100644 --- a/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py +++ b/policyengine_us/variables/gov/ssa/ssi/uncapped_ssi.py @@ -12,5 +12,5 @@ class uncapped_ssi(Variable): def formula(person, period, parameters): amount = person("ssi_amount_if_eligible", period) - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) return amount - countable_income diff --git a/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py b/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py index cb5e88fe1df..c83bb0e5af3 100644 --- a/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py +++ b/policyengine_us/variables/gov/states/ca/cdss/state_supplement/ca_state_supplement.py @@ -13,5 +13,5 @@ class ca_state_supplement(Variable): def formula(spm_unit, period, parameters): payment_standard = spm_unit("ca_state_supplement_payment_standard", period) ssi = add(spm_unit, period, ["ssi"]) - countable_income = add(spm_unit, period, ["ssi_countable_income"]) + countable_income = add(spm_unit, period, ["ssi_countable_income_monthly"]) return max_(0, payment_standard - ssi - countable_income) diff --git a/policyengine_us/variables/gov/states/co/ssa/state_supplement/co_state_supplement.py b/policyengine_us/variables/gov/states/co/ssa/state_supplement/co_state_supplement.py index da55e8d65e7..30a1170993f 100644 --- a/policyengine_us/variables/gov/states/co/ssa/state_supplement/co_state_supplement.py +++ b/policyengine_us/variables/gov/states/co/ssa/state_supplement/co_state_supplement.py @@ -9,7 +9,7 @@ class co_state_supplement(Variable): defined_for = "co_state_supplement_eligible" def formula(person, period, parameters): - income = person("ssi_countable_income", period) + income = person("ssi_countable_income_monthly", period) ssi = person("ssi", period) total_countable_income = ssi + income p = parameters(period).gov.states.co.ssa.state_supplement diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py index 44c69bb62e6..3ba941bc3af 100644 --- a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py @@ -23,7 +23,7 @@ def formula(person, period, parameters): [ "ssi_unearned_income", "ssi", - "ssi_unearned_income_deemed_from_ineligible_spouse", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", ], ) unearned_disregard = person("ct_ssp_unearned_income_disregard", period) @@ -34,7 +34,7 @@ def formula(person, period, parameters): period, [ "ssi_earned_income", - "ssi_earned_income_deemed_from_ineligible_spouse", + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", ], ) earned_disregard = person("ct_ssp_earned_income_disregard", period) diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py index ca004a4ce77..cde939e3af9 100644 --- a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py @@ -20,7 +20,7 @@ def formula(person, period, parameters): period, [ "ssi_earned_income", - "ssi_earned_income_deemed_from_ineligible_spouse", + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", ], ) is_blind = person("is_blind", period.this_year) diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py index e8e2a0cb64a..037827d747b 100644 --- a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py @@ -23,7 +23,7 @@ def formula(person, period, parameters): "ssi_earned_income", "ssi_unearned_income", "ssi", - "ssi_earned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_spouse", + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", ], ) diff --git a/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss.py b/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss.py index 08d2f86d495..799f263fc5b 100644 --- a/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss.py +++ b/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss.py @@ -53,7 +53,7 @@ def formula(person, period, parameters): provider_rate = person("fl_oss_provider_rate", period) # total_needs = recognized cost of care + personal needs allowance. total_needs = provider_rate + p.pna - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) max_oss = person("fl_oss_max_oss", period) # Deficit (2640.0131.04 Step 2), floored at $0 and capped by the # Appendix A-12 Maximum OSS Payment. diff --git a/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_eligible.py b/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_eligible.py index b535187e546..3b7b60286e7 100644 --- a/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_eligible.py +++ b/policyengine_us/variables/gov/states/fl/dcf/oss/fl_oss_eligible.py @@ -66,7 +66,7 @@ def formula(person, period, parameters): receives_ssi = (person("ssi", period) > 0) | person("receives_ssi", period) # FAC 65A-2.033(2): income within OSS income standard (Group 2). income_standard = person("fl_oss_income_standard", period) - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) income_within_standard = countable_income <= income_standard return ( categorically_eligible diff --git a/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py b/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py index 822990e39b4..3f4effd5219 100644 --- a/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py +++ b/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py @@ -32,6 +32,6 @@ def formula(person, period, parameters): # AABD cash = basic allowance - countable income, capped at max # (Section 514). Idaho mirrors SSI disregards (Sections 540-546). - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) financial_need = max_(0, per_person_allowance - countable_income) return min_(financial_need, per_person_max) diff --git a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_rcap.py b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_rcap.py index 72773a67fdc..7cd4535f360 100644 --- a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_rcap.py +++ b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_rcap.py @@ -29,5 +29,5 @@ def formula(person, period, parameters): average_days_per_month = 365 / MONTHS_IN_YEAR state_standard = per_diem * average_days_per_month ssi_monthly = person("ssi", period) - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) return max_(state_standard - ssi_monthly - countable_income, 0) diff --git a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py index a70a664f6df..7bcb5d3a24d 100644 --- a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py +++ b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py @@ -24,7 +24,7 @@ def formula(person, period, parameters): both_eligible = person.marital_unit.sum(eligible) == 2 is_couple = joint_claim & both_eligible ssi_monthly = person("ssi", period) - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) max_individual = p.sapn.amount.individual max_couple = p.sapn.amount.couple pna = p.personal_needs_allowance 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 ed143bb9dfd..d5229b2f855 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 @@ -28,15 +28,19 @@ def formula(person, period, parameters): ) unearned = ( person("ssi_marital_unearned_income", period) - + person("ssi_unearned_income_deemed_from_ineligible_parent", period) - + person("ssi_in_kind_support_and_maintenance", period) + + person( + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", period + ) + + person("ssi_in_kind_support_and_maintenance_monthly", period) ) p = parameters(period).gov.ssa.ssi.income.exclusions countable_earned = max_(earned - p.earned, 0) * (1 - p.earned_share) 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 + ) is_eligible = person("is_ssi_eligible_individual", period.this_year) | person( "is_ssi_eligible_spouse", period.this_year ) diff --git a/policyengine_us/variables/gov/states/la/hhs/oss/la_oss.py b/policyengine_us/variables/gov/states/la/hhs/oss/la_oss.py index e7f9a8d8992..f82ea496eae 100644 --- a/policyengine_us/variables/gov/states/la/hhs/oss/la_oss.py +++ b/policyengine_us/variables/gov/states/la/hhs/oss/la_oss.py @@ -23,6 +23,6 @@ def formula(person, period, parameters): # $0.50/$0.49 round-up rule are not modeled at the moment. p = parameters(period).gov.states.la.hhs.oss ssi = person("ssi", period) - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) raw = p.personal_care_needs_allowance - ssi - countable_income return min_(max_(raw, 0), p.maximum_payment) diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py index bd8a807be09..e20094802e5 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py @@ -47,8 +47,8 @@ def formula(person, period, parameters): period, [ "ssi_unearned_income", - "ssi_unearned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_parent", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", ], ) @@ -71,7 +71,7 @@ def formula(person, period, parameters): period.this_year, [ "ssi_earned_income", - "ssi_earned_income_deemed_from_ineligible_spouse", + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", ], ) annual_unearned = add( @@ -79,8 +79,8 @@ def formula(person, period, parameters): period.this_year, [ "ssi_unearned_income", - "ssi_unearned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_parent", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", ], ) couple_earned = person.marital_unit.sum(annual_earned) diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py b/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py index a6438e3c287..f06c60f2ce7 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py @@ -23,9 +23,9 @@ def formula(person, period, parameters): [ "ssi_earned_income", "ssi_unearned_income", - "ssi_earned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_parent", + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", ], ) # MSA gross income is measured before deductions and disregards. diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py b/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py index 4019169214c..5e2d46f8d51 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py @@ -54,8 +54,8 @@ def formula(person, period, parameters): period, [ "ssi_unearned_income", - "ssi_unearned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_parent", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", ], ) disregard = where(is_medicaid_facility, 0, p.general) @@ -85,7 +85,7 @@ def formula(person, period, parameters): period.this_year, [ "ssi_earned_income", - "ssi_earned_income_deemed_from_ineligible_spouse", + "ssi_earned_income_deemed_from_ineligible_spouse_monthly", ], ) annual_unearned = add( @@ -93,8 +93,8 @@ def formula(person, period, parameters): period.this_year, [ "ssi_unearned_income", - "ssi_unearned_income_deemed_from_ineligible_spouse", - "ssi_unearned_income_deemed_from_ineligible_parent", + "ssi_unearned_income_deemed_from_ineligible_spouse_monthly", + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", ], ) couple_earned = person.marital_unit.sum(annual_earned) diff --git a/policyengine_us/variables/gov/states/nc/ncdhhs/sa/nc_sa_eligible.py b/policyengine_us/variables/gov/states/nc/ncdhhs/sa/nc_sa_eligible.py index 30db42526e9..d7e56816df5 100644 --- a/policyengine_us/variables/gov/states/nc/ncdhhs/sa/nc_sa_eligible.py +++ b/policyengine_us/variables/gov/states/nc/ncdhhs/sa/nc_sa_eligible.py @@ -34,7 +34,7 @@ def formula(person, period, parameters): # countable income, including deemed spousal income, so an applicant # denied SSI for spousal income qualifies (SA-5400 V). over_income_for_ssi = person("ssi_amount_if_eligible", period) <= person( - "ssi_countable_income", period + "ssi_countable_income_monthly", period ) # An SSI recipient automatically meets the categorical, financial and # resource requirements (SA-3100 II.B; SA-3200 I.A). An applicant over diff --git a/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement.py b/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement.py index ef835f77dc1..3c3dc5c3485 100644 --- a/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement.py +++ b/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement.py @@ -17,6 +17,6 @@ def formula(person, period, parameters): # Per S.C. Code Regs. 126-910: OSS = max(0, NIL - countable income) p = parameters(period).gov.states.sc.scdhhs.ssi_state_supplement nil = p.net_income_limit - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) ssi = person("ssi", period) return max_(0, nil - countable_income - ssi) diff --git a/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_eligible.py b/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_eligible.py index 56c9168bdd3..1afeb5dd21d 100644 --- a/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_eligible.py +++ b/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_eligible.py @@ -18,7 +18,7 @@ def formula(person, period, parameters): in_facility = person("is_in_residential_care_facility", period) p = parameters(period).gov.states.sc.scdhhs.ssi_state_supplement nil = p.net_income_limit - countable_income = person("ssi_countable_income", period) + countable_income = person("ssi_countable_income_monthly", period) ssi = person("ssi", period) income_eligible = (countable_income + ssi) < nil return is_aged_blind_disabled & in_facility & income_eligible diff --git a/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_pna.py b/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_pna.py index 84716e45cd3..556403e1fcd 100644 --- a/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_pna.py +++ b/policyengine_us/variables/gov/states/sc/scdhhs/ssi_state_supplement/sc_ssi_state_supplement_pna.py @@ -19,5 +19,5 @@ def formula(person, period, parameters): ).gov.states.sc.scdhhs.ssi_state_supplement.personal_needs_allowance # Per S.C. Code Regs. 126-910(G): SSI-only PNA if no other # countable income; higher PNA if other income sources exist - ssi_only = person("ssi_countable_income", period) == 0 + ssi_only = person("ssi_countable_income_monthly", period) == 0 return where(ssi_only, p.ssi_only, p.other_income) From 9dabd073bf99f0c2989def4678b6ec894baca3be Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 09:25:22 -0400 Subject: [PATCH 22/27] Keep monthly cap witnesses on explicit monthly budgets --- ..._or_disabled_income_deemed_from_ineligible_spouse.yaml | 8 ++++---- .../income/deemed/from_ineligible_spouse/README.md | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml index 53b3a565e17..f68aa900459 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.yaml @@ -165,8 +165,8 @@ output: people: claimant: - medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: - "2026": 1_160 + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: 1_160 + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly: "2026-01": 0 "2026-10": 0 "2026-11": 580 @@ -214,8 +214,8 @@ output: people: claimant: - medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: - "2026": 4_640 + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: 4_640 + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse_monthly: "2026-01": 0 "2026-04": 0 "2026-05": 580 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 c28fda00f1f..b3ea6dd2ebc 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 @@ -73,3 +73,5 @@ Integration tests verify the full SSI benefit calculation pipeline and can be fo - Households with various income combinations Integration tests are particularly important for SSI spousal deeming as they validate the correct application of the regulation's multi-step process in a full benefit calculation context. + +Monthly policy assertions and consumers must use the `*_monthly` variables. The existing annual public variables aggregate those monthly budgets; Core divides an annual variable by 12 when it is requested for a month, so that request is an annual average rather than the month’s budget. From 8573f6ada38a3ac51e59fed709ad780a0fdeff3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 13:26:03 -0400 Subject: [PATCH 23/27] Use approved 2027 COLA for North Carolina supplement rates --- ...ineligible-child-allocation-scope.fixed.md | 2 +- .../gov/states/nc/ncdhhs/sa/rate/basic.yaml | 5 ++++ .../states/nc/ncdhhs/sa/rate/enhanced.yaml | 5 ++++ .../gov/states/nc/ncdhhs/sa/nc_sa.yaml | 19 ++++++------- .../gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml | 28 +++++++++++++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index aba4d98f4b0..5f9d960ba5f 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers on those monthly interfaces. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers on those monthly interfaces. Use the federally approved 2027 COLA for North Carolina Special Assistance rates after the Social Security parameter update. diff --git a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml index ab22c2f1430..2a2910b5934 100644 --- a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml +++ b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml @@ -1,6 +1,9 @@ description: North Carolina provides this amount as the maximum monthly basic rate under the State-County Special Assistance program. values: 2026-01-01: 1_397 + # G.S. 108A-42.1(a): use the federally approved 3.6% COLA, + # rather than the unrounded CPI-W index ratio, for 2027. + 2027-01-01: 1_447 metadata: unit: currency-USD @@ -20,3 +23,5 @@ metadata: href: https://policies.ncdhhs.gov/wp-content/uploads/Special-Assistance-Manual-Rev-June-2026.pdf#page=204 - title: North Carolina Change Notice EFS-SA-CN-01-2026 I href: https://policies.ncdhhs.gov/wp-content/uploads/EFS-SA-CN-01-2026.pdf#page=1 + - title: Social Security Administration 2027 COLA Fact Sheet + href: https://www.ssa.gov/news/en/cola/factsheets/2027.html diff --git a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml index 6bd404365dc..0a3dae02a13 100644 --- a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml +++ b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml @@ -1,6 +1,9 @@ description: North Carolina provides this amount as the maximum monthly enhanced rate for special care units and in-home recipients with Alzheimer's disease or dementia under the State-County Special Assistance program. values: 2026-01-01: 1_792 + # G.S. 108A-42.1(b): use the federally approved 3.6% COLA, + # rather than the unrounded CPI-W index ratio, for 2027. + 2027-01-01: 1_857 metadata: unit: currency-USD @@ -20,3 +23,5 @@ metadata: href: https://policies.ncdhhs.gov/wp-content/uploads/Special-Assistance-Manual-Rev-June-2026.pdf#page=205 - title: North Carolina Change Notice EFS-SA-CN-01-2026 I href: https://policies.ncdhhs.gov/wp-content/uploads/EFS-SA-CN-01-2026.pdf#page=1 + - title: Social Security Administration 2027 COLA Fact Sheet + href: https://www.ssa.gov/news/en/cola/factsheets/2027.html diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml index d7a1f2a8e82..f786ebc6bab 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml @@ -484,9 +484,10 @@ nc_sa_eligible: false nc_sa: 0 -# G.S. 108A-42.1 adjusts the rates each January 1 by the Social Security -# cost-of-living adjustment. The 2027 rate and FBR are projections. -# https://www.ncleg.gov/EnactedLegislation/Statutes/PDF/BySection/Chapter_108A/GS_108A-42.1.pdf +# G.S. 108A-42.1(a) applies the federally approved Social Security COLA. +# The official 2027 COLA is 3.6%; its individual SSI standard is $1,029. +# https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_108A/GS_108A-42.1.html +# https://www.ssa.gov/news/en/cola/factsheets/2027.html - name: Case 37, basic facility rate follows the cost-of-living adjustment in 2027. period: 2027-01 absolute_error_margin: 0.01 @@ -495,13 +496,11 @@ age: 65 nc_sa_care_setting: ADULT_CARE_HOME output: - # 1,397 uprated by the projected Social Security index and rounded to the - # dollar. - nc_sa_rate: 1_436 - # Projected FBR. - ssi: 1_021.83 - # 1,436 + 70 - 1,021.83 = 484.17 -> 484. - nc_sa: 484 + # 1,397 * 1.036 = 1,447.292, rounded to the whole-dollar rate. + nc_sa_rate: 1_447 + ssi: 1_029 + # 1,447 + 70 - 1,029 = 488. + nc_sa: 488 # An applicant "ineligible for SSI solely due to being over income for SSI" # qualifies without applying for SSI (SA-3100 I, II.A.6): "If the a/b diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml index ef5b484b3b9..07f69cc4126 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml @@ -76,3 +76,31 @@ nc_sa_facility_rate: 1_000 output: nc_sa_rate: 1_397 + +# G.S. 108A-42.1(a)-(b) applies the federally approved Social Security COLA, +# which is 3.6% in 2027, to the published 2026 rates $1,397 and $1,792. +# These are derived rates, pending a 2027 state rate notice. +# https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_108A/GS_108A-42.1.html +# https://www.ssa.gov/news/en/cola/factsheets/2027.html +# https://policies.ncdhhs.gov/wp-content/uploads/EFS-SA-CN-01-2026.pdf#page=1 +- name: Case 8, 2027 basic rate uses the federally approved 3.6 percent COLA. + period: 2027-01 + absolute_error_margin: 0.01 + input: + state_code: NC + age: 65 + nc_sa_care_setting: ADULT_CARE_HOME + output: + # round(1,397 * 1.036) = 1,447; the raw CPI ratio would give 1,448. + nc_sa_rate: 1_447 + +- name: Case 9, 2027 enhanced rate uses the federally approved 3.6 percent COLA. + period: 2027-01 + absolute_error_margin: 0.01 + input: + state_code: NC + age: 65 + nc_sa_care_setting: SPECIAL_CARE_UNIT + output: + # round(1,792 * 1.036) = 1,857. + nc_sa_rate: 1_857 From 9852fb964a2422c5df2b873e6ab394572376605a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:43:15 -0400 Subject: [PATCH 24/27] Remove the North Carolina 2027 rate update from this PR PolicyEngine/policyengine-us#10288 sets North Carolina's 2027 Special Assistance rates to the approved COLA, and #10279 already updated main's Case 37. This PR is about SSI child allocations, so the North Carolina change belongs there. Co-Authored-By: Claude Opus 5.5 --- ...ineligible-child-allocation-scope.fixed.md | 2 +- .../gov/states/nc/ncdhhs/sa/rate/basic.yaml | 5 ---- .../states/nc/ncdhhs/sa/rate/enhanced.yaml | 5 ---- .../gov/states/nc/ncdhhs/sa/nc_sa.yaml | 19 +++++++------ .../gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml | 28 ------------------- 5 files changed, 11 insertions(+), 48 deletions(-) diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index 5f9d960ba5f..aba4d98f4b0 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers on those monthly interfaces. Use the federally approved 2027 COLA for North Carolina Special Assistance rates after the Social Security parameter update. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers on those monthly interfaces. diff --git a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml index 2a2910b5934..ab22c2f1430 100644 --- a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml +++ b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/basic.yaml @@ -1,9 +1,6 @@ description: North Carolina provides this amount as the maximum monthly basic rate under the State-County Special Assistance program. values: 2026-01-01: 1_397 - # G.S. 108A-42.1(a): use the federally approved 3.6% COLA, - # rather than the unrounded CPI-W index ratio, for 2027. - 2027-01-01: 1_447 metadata: unit: currency-USD @@ -23,5 +20,3 @@ metadata: href: https://policies.ncdhhs.gov/wp-content/uploads/Special-Assistance-Manual-Rev-June-2026.pdf#page=204 - title: North Carolina Change Notice EFS-SA-CN-01-2026 I href: https://policies.ncdhhs.gov/wp-content/uploads/EFS-SA-CN-01-2026.pdf#page=1 - - title: Social Security Administration 2027 COLA Fact Sheet - href: https://www.ssa.gov/news/en/cola/factsheets/2027.html diff --git a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml index 0a3dae02a13..6bd404365dc 100644 --- a/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml +++ b/policyengine_us/parameters/gov/states/nc/ncdhhs/sa/rate/enhanced.yaml @@ -1,9 +1,6 @@ description: North Carolina provides this amount as the maximum monthly enhanced rate for special care units and in-home recipients with Alzheimer's disease or dementia under the State-County Special Assistance program. values: 2026-01-01: 1_792 - # G.S. 108A-42.1(b): use the federally approved 3.6% COLA, - # rather than the unrounded CPI-W index ratio, for 2027. - 2027-01-01: 1_857 metadata: unit: currency-USD @@ -23,5 +20,3 @@ metadata: href: https://policies.ncdhhs.gov/wp-content/uploads/Special-Assistance-Manual-Rev-June-2026.pdf#page=205 - title: North Carolina Change Notice EFS-SA-CN-01-2026 I href: https://policies.ncdhhs.gov/wp-content/uploads/EFS-SA-CN-01-2026.pdf#page=1 - - title: Social Security Administration 2027 COLA Fact Sheet - href: https://www.ssa.gov/news/en/cola/factsheets/2027.html diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml index f786ebc6bab..d7a1f2a8e82 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa.yaml @@ -484,10 +484,9 @@ nc_sa_eligible: false nc_sa: 0 -# G.S. 108A-42.1(a) applies the federally approved Social Security COLA. -# The official 2027 COLA is 3.6%; its individual SSI standard is $1,029. -# https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_108A/GS_108A-42.1.html -# https://www.ssa.gov/news/en/cola/factsheets/2027.html +# G.S. 108A-42.1 adjusts the rates each January 1 by the Social Security +# cost-of-living adjustment. The 2027 rate and FBR are projections. +# https://www.ncleg.gov/EnactedLegislation/Statutes/PDF/BySection/Chapter_108A/GS_108A-42.1.pdf - name: Case 37, basic facility rate follows the cost-of-living adjustment in 2027. period: 2027-01 absolute_error_margin: 0.01 @@ -496,11 +495,13 @@ age: 65 nc_sa_care_setting: ADULT_CARE_HOME output: - # 1,397 * 1.036 = 1,447.292, rounded to the whole-dollar rate. - nc_sa_rate: 1_447 - ssi: 1_029 - # 1,447 + 70 - 1,029 = 488. - nc_sa: 488 + # 1,397 uprated by the projected Social Security index and rounded to the + # dollar. + nc_sa_rate: 1_436 + # Projected FBR. + ssi: 1_021.83 + # 1,436 + 70 - 1,021.83 = 484.17 -> 484. + nc_sa: 484 # An applicant "ineligible for SSI solely due to being over income for SSI" # qualifies without applying for SSI (SA-3100 I, II.A.6): "If the a/b diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml index 07f69cc4126..ef5b484b3b9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/ncdhhs/sa/nc_sa_rate.yaml @@ -76,31 +76,3 @@ nc_sa_facility_rate: 1_000 output: nc_sa_rate: 1_397 - -# G.S. 108A-42.1(a)-(b) applies the federally approved Social Security COLA, -# which is 3.6% in 2027, to the published 2026 rates $1,397 and $1,792. -# These are derived rates, pending a 2027 state rate notice. -# https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_108A/GS_108A-42.1.html -# https://www.ssa.gov/news/en/cola/factsheets/2027.html -# https://policies.ncdhhs.gov/wp-content/uploads/EFS-SA-CN-01-2026.pdf#page=1 -- name: Case 8, 2027 basic rate uses the federally approved 3.6 percent COLA. - period: 2027-01 - absolute_error_margin: 0.01 - input: - state_code: NC - age: 65 - nc_sa_care_setting: ADULT_CARE_HOME - output: - # round(1,397 * 1.036) = 1,447; the raw CPI ratio would give 1,448. - nc_sa_rate: 1_447 - -- name: Case 9, 2027 enhanced rate uses the federally approved 3.6 percent COLA. - period: 2027-01 - absolute_error_margin: 0.01 - input: - state_code: NC - age: 65 - nc_sa_care_setting: SPECIAL_CARE_UNIT - output: - # round(1,792 * 1.036) = 1,857. - nc_sa_rate: 1_857 From 7d8c624138450bf2af350ce97dc03a4b8307aae3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 22:59:14 -0400 Subject: [PATCH 25/27] Keep new state SSI supplement consumers on monthly budgets --- ...ineligible-child-allocation-scope.fixed.md | 2 +- ...o_oap_in_kind_support_and_maintenance.yaml | 19 +++ .../gov/states/ny/otda/ssp/ny_ssp.yaml | 119 +++++++++++++ .../gov/states/ok/dhs/ssp/integration.yaml | 157 ++++++++++++++++++ .../baseline/gov/states/va/dars/ag/va_ag.yaml | 27 +++ .../resources/deemed/_ssi_spouses.py | 7 +- .../co_oap_in_kind_support_and_maintenance.py | 2 +- .../gov/states/ny/otda/ssp/ny_ssp.py | 6 +- .../gov/states/ny/otda/ssp/ny_ssp_is_minor.py | 5 +- .../gov/states/ok/dhs/ssp/_ok_ssp_income.py | 4 +- .../dhs/ssp/ok_ssp_spouse_child_allocation.py | 7 +- .../va/dars/ag/va_ag_countable_income.py | 4 +- 12 files changed, 347 insertions(+), 12 deletions(-) diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index aba4d98f4b0..0059239866e 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers on those monthly interfaces. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers, New York SSP, Virginia AG, Oklahoma SSP, and Colorado OAP on those monthly interfaces. diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.yaml index 1f1d9f3fd88..b0b413be60c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.yaml @@ -21,3 +21,22 @@ ssi_couple_computation_applies: true output: co_oap_in_kind_support_and_maintenance: 150 + +- name: Matching SSI ISM follows each month's amount rather than the annual average. + period: 2026 + absolute_error_margin: 0.01 + input: + ssi: 0 + receives_ssi: true + ssi_in_kind_support_and_maintenance_monthly: + 2026-01: 300 + 2026-02: 120 + ssi_couple_computation_applies: true + output: + # Other months have no ISM, so the annual total is 300 + 120 = 420. + ssi_in_kind_support_and_maintenance: 420 + # The recipient's share is January's 300 / 2 = 150 and February's + # 120 / 2 = 60. Averaging the year would give 420 / 12 / 2 = 17.50. + co_oap_in_kind_support_and_maintenance: + 2026-01: 150 + 2026-02: 60 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/otda/ssp/ny_ssp.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/otda/ssp/ny_ssp.yaml index 0559c19f184..8ad24555b88 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/otda/ssp/ny_ssp.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/otda/ssp/ny_ssp.yaml @@ -739,3 +739,122 @@ # counts as living alone. The resident gets the family care rate. ny_ssp_living_arrangement: [LIVING_ALONE, FAMILY_CARE] ny_ssp: [87, 228.48] + +- name: Case 49, student exclusions change spousal deeming and SSP during the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + meets_ssi_disability_criteria: true + spouse: + age: 43 + pension_income: 14_400 + student1: + age: 19 + is_full_time_student: true + employment_income: 24_000 + student2: + age: 20 + is_full_time_student: true + employment_income: 24_000 + marital_units: + couple: + members: [claimant, spouse] + student1: + members: [student1] + student2: + members: [student2] + tax_units: + family: + members: [claimant, spouse, student1, student2] + households: + household: + members: [claimant, spouse, student1, student2] + state_code: NY + output: + # Each student earns 2_000/month. January-April exclude all earnings: + # 1_200 spouse pension - 2 * 497 allocations = 206 <= 497, no deeming. + # In May, 9_730 - 4 * 2_000 = 1_730 of each student's exclusion remains. + # Allocation = 497 - (2_000 - 1_730) = 227 each; countable deemed income + # = 1_200 - 2 * 227 - 20 = 726. June-December allocations are zero, + # so countable income = 1_200 - 20 = 1_180 and SSI = 1_491 - 1_180 = 311. + is_ssi_spousal_deeming_applies_monthly: + 2026-01: [false, false, false, false] + 2026-05: [true, false, false, false] + 2026-06: [true, false, false, false] + ssi_countable_income_monthly: + 2026-01: [0, 0, 0, 0] + 2026-05: [726, 0, 0, 0] + 2026-06: [1_180, 0, 0, 0] + ssi: + 2026-01: [994, 0, 0, 0] + 2026-05: [765, 0, 0, 0] + 2026-06: [311, 0, 0, 0] + # Living with others rate 23. Through May countable income is below 994; + # June-December max(23 - (1_180 - 994), 0) = 0. Annual = 5 * 23 = 115. + # The annual-average countable income (726 + 7 * 1_180) / 12 = 748.83 + # would incorrectly preserve all 12 payments, totaling 276. + ny_ssp: + 2026-01: [23, 0, 0, 0] + 2026-05: [23, 0, 0, 0] + 2026-06: [0, 0, 0, 0] + 2026-12: [0, 0, 0, 0] + 2026: [115, 0, 0, 0] + +- name: Case 50, January spousal deeming uses January status rather than December. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: NY + age: 65 + ny_ssp_living_arrangement: LIVING_WITH_OTHERS + is_ssi_spousal_deeming_applies_monthly: + 2026-01: true + 2026-12: false + ssi_countable_income_monthly: + 2026-01: 1_180 + ssi_income_deemed_from_ineligible_spouse_monthly: + 2026-01: 1_180 + output: + # January's SSI couple budget is 1_491 - 1_180 = 311. New York compares + # countable income with the individual FBR: max(23 - (1_180 - 994), 0). + # December's false status would retain the shared helper's 23 payment. + ssi: 311 + ny_ssp: 0 + +- name: Case 51, personal needs allowance uses the facility resident's monthly income. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: NY + age: 70 + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + ssi_countable_income_monthly: + 2026-01: 0 + 2026-02: 0 + 2026-03: 0 + 2026-04: 0 + 2026-05: 0 + 2026-06: 0 + 2026-07: 10 + 2026-08: 10 + 2026-09: 10 + 2026-10: 10 + 2026-11: 10 + 2026-12: 10 + output: + # Federal facility rate 30 less countable income 0/10 gives SSI 30/20. + # New York pays 25 only in the six zero-income months: 6 * 25 = 150. + # Annual countable income is 60, so its average of 5 would wrongly + # suppress the allowance even in January. + ssi: + 2026-01: 30 + 2026-07: 20 + 2026: 300 + ny_ssp: + 2026-01: 25 + 2026-07: 0 + 2026: 150 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..9af7058eb02 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 @@ -418,6 +418,7 @@ employment_income: 41_600 person3: age: 16 + is_in_k12_school: false employment_income: 3_600 person4: age: 9 @@ -571,3 +572,159 @@ # 757.50 + 783.50 = 1_541, not below the 1_532 ineligible spouse standard. ok_ssp_countable_income: [1_541, 1_541] ok_ssp: [0, 0] + +- name: Case 20, a teen student's excluded earnings preserve the federal child allocation. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + meets_ssi_disability_criteria: true + person2: + age: 43 + employment_income: 41_600 + person3: + age: 16 + is_in_k12_school: true + employment_income: 3_600 + person4: + age: 9 + marital_units: + couple: + members: [person1, person2] + teen: + members: [person3] + child: + members: [person4] + families: + family: + members: [person1, person2, person3, person4] + households: + hh: + members: [person1, person2, person3, person4] + state_code: OK + output: + # Oklahoma counts the teen's earnings: (498 - 300) + 498 = 696. + ok_ssp_spouse_child_allocation: [696, 696, 696, 696] + # Federal SSI excludes the student's 300 monthly earnings, so allocations + # are 2 * 497 = 994. Deemed earnings: 3_466.67 - 994 = 2_472.67 > 497. + # (2_472.67 - 85) / 2 = 1_193.83; 1_491 - 1_193.83 = 297.17 SSI. + ssi: [297.17, 0, 0, 0] + ok_ssp_eligible: [true, false, false, false] + # Claimant: 297.17 SSI. Spouse: (3_467 - 85) / 2 = 1_691. + # Teen: (300 - 85) / 2 = 107.5. + ok_ssp_own_countable_income: [297.17, 1_691, 107.5, 0] + # Combined: (3_467 - 696 - 85) / 2 + 297.17 = 1_640.17 > 1_532. + ok_ssp_countable_income: [1_640.17, 1_640.17, 107.5, 0] + ok_ssp: [0, 0, 0, 0] + +- name: Case 21, a disabled child's parental deeming uses January's student allocation. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + employment_income: 31_200 + child: + age: 9 + person_id: 2 + parent_1_id: 1 + meets_ssi_disability_criteria: true + student: + age: 19 + person_id: 3 + parent_1_id: 1 + is_full_time_college_student: true + employment_income: 24_000 + marital_units: + parent_unit: + members: [parent] + child_unit: + members: [child] + student_unit: + members: [student] + families: + family: + members: [parent, child, student] + households: + hh: + members: [parent, child, student] + state_code: OK + output: + # The student's 2_000 monthly earnings use up the 9_730 annual exclusion + # in May. Sibling allocations are 497 Jan-Apr, 227 May, and 0 Jun-Dec. + # January parental deeming: (2_600 - 497 - 85) / 2 - 994 = 15. + # May is 150; Jun-Dec is 263.5. Annual average deeming would be + # (4 * 15 + 150 + 7 * 263.5) / 12 = 171.21, which does not apply here. + ssi_unearned_income_deemed_from_ineligible_parent_monthly: [0, 15, 0] + # January's 15 is below the child's 20 general exclusion, so full SSI. + ssi: [0, 994, 0] + # Parent: (2_600 - 85) / 2 = 1_257.5. Child: max(15 - 20, 0) + 994. + # Student: (2_000 - 85) / 2 = 957.5. + ok_ssp_own_countable_income: [1_257.5, 994, 957.5] + # min(1_034 - 994, 40) = 40. The annual average would instead produce + # 171.21 - 20 + 994 = 1_145.21 and incorrectly bar the child's SSP. + ok_ssp: [0, 40, 0] + +- name: Case 22, June in-kind support follows that month's spousal deeming. + period: 2026-06 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + person_id: 1 + meets_ssi_disability_criteria: true + ssi_receives_outside_shelter_support: true + spouse: + age: 43 + person_id: 2 + pension_income: 14_400 + student1: + age: 19 + person_id: 3 + parent_1_id: 1 + parent_2_id: 2 + is_full_time_college_student: true + employment_income: 24_000 + student2: + age: 20 + person_id: 4 + parent_1_id: 1 + parent_2_id: 2 + is_full_time_college_student: true + employment_income: 24_000 + marital_units: + couple: + members: [claimant, spouse] + student1_unit: + members: [student1] + student2_unit: + members: [student2] + families: + family: + members: [claimant, spouse, student1, student2] + households: + hh: + members: [claimant, spouse, student1, student2] + state_code: OK + output: + # Student exclusions run out in May, so no federal child allocation + # remains in June. Spouse's 1_200 pension is above the 497 threshold. + # ISM Jan-Apr is 994 / 3 + 20 = 351.33; May-Dec is + # 1_491 / 6 + 10 = 258.5. The annual average, 289.44, is not June's ISM. + ssi_in_kind_support_and_maintenance_monthly: [258.5, 0, 0, 0] + # Federal SSI: 1_491 - ((258.5 - 20) + (1_200 - 20)) = 72.5. + ssi: [72.5, 0, 0, 0] + # Oklahoma's allocation is 0 because each student's gross 2_000 > 498. + ok_ssp_spouse_child_allocation: [0, 0, 0, 0] + # Claimant: 258.5 - 20 + 72.5 = 311. Spouse: 1_200 - 20 = 1_180. + # Each student: (2_000 - 85) / 2 = 957.5. + ok_ssp_own_countable_income: [311, 1_180, 957.5, 957.5] + # Combined: 258.5 + 1_200 - 20 + 72.5 = 1_511, leaving 1_532 - 1_511 = 21. + # Using annual-average ISM would give 1_541.94 and incorrectly pay 0. + ok_ssp_countable_income: [1_511, 1_511, 957.5, 957.5] + ok_ssp: [21, 0, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml b/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml index 0f21c77ff63..8a9df91b7a4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/va/dars/ag/va_ag.yaml @@ -404,3 +404,30 @@ va_ag: 2026-01: 558 2026-02: 1_223 + +- name: Case 31, SSI recipients use each month's verified countable income. + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: VA + age: 65 + va_ag_certified: true + ssi_countable_income_monthly: + 2026-01: 100 + 2026-02: 300 + output: + # January SSI: 994 - 100 = 894; February SSI: 994 - 300 = 694. + ssi: + 2026-01: 894 + 2026-02: 694 + # Other months have no income, so the annual total is 100 + 300 = 400. + ssi_countable_income: 400 + # AG counts January's 100 + 894 and February's 300 + 694, both 994. + # The annual average, 400 / 12 = 33.33, would give 927.33 and 727.33. + va_ag_countable_income: + 2026-01: 994 + 2026-02: 994 + # Each full-month grant is 2,130 + 87 - 994 = 1,223. + va_ag: + 2026-01: 1_223 + 2026-02: 1_223 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py index b35fcdda241..cb5f30895e3 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/resources/deemed/_ssi_spouses.py @@ -67,7 +67,12 @@ def _ssi_established_spouse_indices(person, period, first, second): def _ssi_spouse_index(person, period): - """The row of each person's co-resident spouse, or -1.""" + """The row of each person's co-resident spouse, or -1. + + New York's SSP minor test also uses this co-resident spouse proxy. + SSI's 416.1856 marriage classification uses _ssi_is_married instead, + including legal spouses living apart. + """ first, second = co_resident_parent_indices(person, period) return _ssi_established_spouse_indices(person, period, first, second) diff --git a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.py b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.py index fd0e5adaf85..ebe7625512f 100644 --- a/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.py +++ b/policyengine_us/variables/gov/states/co/ssa/oap/co_oap_in_kind_support_and_maintenance.py @@ -21,7 +21,7 @@ def formula(person, period, parameters): # couple, which SSI splits equally between the spouses. ISM through # SSA's one-third reduction, and Colorado's own ISM for a client not # on SSI (3.520.79(C)), are not modeled. - ism = person("ssi_in_kind_support_and_maintenance", period) + ism = person("ssi_in_kind_support_and_maintenance_monthly", period) couple_computation = person("ssi_couple_computation_applies", period) ssi = ssi_paid(person("ssi", period)) receives_ssi = (ssi > 0) | person("receives_ssi", period) diff --git a/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp.py b/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp.py index c69e3ac2acf..4cf4ab020eb 100644 --- a/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp.py +++ b/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp.py @@ -91,7 +91,7 @@ def formula(person, period, parameters): # October 1, 2014, and the model follows the regulation: countable # income includes the deemed income (398-2.1(g)), measured against the # individual's own FBR. - deeming = person("is_ssi_spousal_deeming_applies", period.this_year) + deeming = person("is_ssi_spousal_deeming_applies_monthly", period) federal_la = person("ssi_federal_living_arrangement", period) # 20 CFR 416.1131(a): one-third of the FBR counts as income in another # person's household. @@ -100,7 +100,7 @@ def formula(person, period, parameters): 1 - fbr.one_third_reduction_rate, 1, ) - countable = person("ssi_countable_income", period) + countable = person("ssi_countable_income_monthly", period) regulatory = max_(individual - max_(countable - own_fbr, 0), 0) eligible = person("is_ssi_eligible", period) & person( "takes_up_ssi_if_eligible", period.this_year @@ -122,7 +122,7 @@ def formula(person, period, parameters): # hospitals and pay $25 in residential health care facilities. The # model follows the statute and regulation. personal_needs_eligible = (person("ssi", period) > 0) & ( - person("ssi_countable_income", period) == 0 + person("ssi_countable_income_monthly", period) == 0 ) personal_needs = select( [ diff --git a/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_is_minor.py b/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_is_minor.py index 55e7265f1b6..53db36d19d7 100644 --- a/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_is_minor.py +++ b/policyengine_us/variables/gov/states/ny/otda/ssp/ny_ssp_is_minor.py @@ -28,8 +28,9 @@ def formula(person, period, parameters): p = parameters(period).gov.states.ny.otda.ssp age = person("age", period) student = person("is_full_time_student", period) - # Marriage uses the SSI spouse test, so a child of a married couple - # is not treated as married. + # New York uses the co-resident SSI spouse lookup as its marriage + # proxy, which does not pair a parent with their child. SSI's separate + # 416.1856 marriage classification uses _ssi_is_married. married = _ssi_spouse_index(person, period) >= 0 head = person("is_household_head", period) under_age = (age < p.minor_age) | ((age < p.student_minor_age) & student) diff --git a/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py b/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py index b6b072a38e3..522cee87bf6 100644 --- a/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py +++ b/policyengine_us/variables/gov/states/ok/dhs/ssp/_ok_ssp_income.py @@ -35,7 +35,7 @@ def ok_ssp_income(person, period, parameters, *, combined): # It also uses SSI's earned income, which excludes the parents' pre-tax # health premiums that 317:35-5-42(b)(9)(A) counts as wages. unearned = unearned + person( - "ssi_unearned_income_deemed_from_ineligible_parent", period + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", period ) earned = max_( earned @@ -59,7 +59,7 @@ def ok_ssp_income(person, period, parameters, *, combined): # disabled spouse who is not eligible (issue 10243), so the half equals the # deeming value (1/6 couple FBR + $10) even when 317:35-5-42(m)(4) deems # nothing and the individual value would apply. - ism = person("ssi_in_kind_support_and_maintenance", period) + ism = person("ssi_in_kind_support_and_maintenance_monthly", period) ism = where(joint, ism / 2, ism) if combined: eligible = person("ok_ssp_eligible", period) diff --git a/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp_spouse_child_allocation.py b/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp_spouse_child_allocation.py index b274e4ea099..669fed5da8d 100644 --- a/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp_spouse_child_allocation.py +++ b/policyengine_us/variables/gov/states/ok/dhs/ssp/ok_ssp_spouse_child_allocation.py @@ -8,7 +8,12 @@ class ok_ssp_spouse_child_allocation(Variable): definition_period = MONTH unit = USD reference = "https://www.law.cornell.edu/regulations/oklahoma/OAC-340-15-1-5" - documentation = "The total allocation for the family's children who are not aged, blind or disabled, each reduced by that child's own income. Blended families and children in a TANF case are not modeled separately." + documentation = ( + "The total allocation for the family's unmarried SSI-ineligible children " + "who are not household heads, including full-time students under age 22. " + "Each allocation is reduced by the child's own income. Blended families " + "and children in a TANF case are not modeled separately." + ) def formula(person, period, parameters): p = parameters(period).gov.states.ok.dhs.ssp diff --git a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py index 314011cd7d3..3a5aff6dfd1 100644 --- a/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py +++ b/policyengine_us/variables/gov/states/va/dars/ag/va_ag_countable_income.py @@ -53,7 +53,9 @@ def formula(person, period, parameters): # although Chapter B section 6 makes the assistance unit the applicant # only. countable = where( - ssi > 0, person("ssi_countable_income", period) + ssi, non_ssi_countable + ssi > 0, + person("ssi_countable_income_monthly", period) + ssi, + non_ssi_countable, ) exclusions = max_(person("va_ag_allowable_income_exclusions", period), 0) return max_(countable - exclusions, 0) From ce0054c70d955bccd92d63c1d834c3b420266efa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 23:21:29 -0400 Subject: [PATCH 26/27] Make Oklahoma monthly regression fixtures explicit --- .../gov/states/ok/dhs/ssp/integration.yaml | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 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 9af7058eb02..184eb2cdb4e 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 @@ -616,7 +616,9 @@ # Teen: (300 - 85) / 2 = 107.5. ok_ssp_own_countable_income: [297.17, 1_691, 107.5, 0] # Combined: (3_467 - 696 - 85) / 2 + 297.17 = 1_640.17 > 1_532. - ok_ssp_countable_income: [1_640.17, 1_640.17, 107.5, 0] + # The combined-income helper also subtracts the family's 696 allocation + # from the ineligible teen's 300 earnings, leaving 0 for that unit. + ok_ssp_countable_income: [1_640.17, 1_640.17, 0, 0] ok_ssp: [0, 0, 0, 0] - name: Case 21, a disabled child's parental deeming uses January's student allocation. @@ -670,22 +672,26 @@ ok_ssp: [0, 40, 0] - name: Case 22, June in-kind support follows that month's spousal deeming. - period: 2026-06 + # Annual inputs cover calendar 2026; outputs below select June. + period: 2026 absolute_error_margin: 0.01 input: people: claimant: age: 45 person_id: 1 + is_tax_unit_head: true meets_ssi_disability_criteria: true ssi_receives_outside_shelter_support: true spouse: age: 43 person_id: 2 + is_tax_unit_spouse: true pension_income: 14_400 student1: age: 19 person_id: 3 + is_tax_unit_dependent: true parent_1_id: 1 parent_2_id: 2 is_full_time_college_student: true @@ -693,6 +699,7 @@ student2: age: 20 person_id: 4 + is_tax_unit_dependent: true parent_1_id: 1 parent_2_id: 2 is_full_time_college_student: true @@ -704,6 +711,9 @@ members: [student1] student2_unit: members: [student2] + tax_units: + family_tax_unit: + members: [claimant, spouse, student1, student2] families: family: members: [claimant, spouse, student1, student2] @@ -714,17 +724,26 @@ output: # Student exclusions run out in May, so no federal child allocation # remains in June. Spouse's 1_200 pension is above the 497 threshold. + is_ssi_spousal_deeming_applies_monthly: + 2026-01: [false, false, false, false] + 2026-06: [true, false, false, false] # ISM Jan-Apr is 994 / 3 + 20 = 351.33; May-Dec is # 1_491 / 6 + 10 = 258.5. The annual average, 289.44, is not June's ISM. - ssi_in_kind_support_and_maintenance_monthly: [258.5, 0, 0, 0] + ssi_in_kind_support_and_maintenance_monthly: + 2026-06: [258.5, 0, 0, 0] # Federal SSI: 1_491 - ((258.5 - 20) + (1_200 - 20)) = 72.5. - ssi: [72.5, 0, 0, 0] + ssi: + 2026-06: [72.5, 0, 0, 0] # Oklahoma's allocation is 0 because each student's gross 2_000 > 498. - ok_ssp_spouse_child_allocation: [0, 0, 0, 0] + ok_ssp_spouse_child_allocation: + 2026-06: [0, 0, 0, 0] # Claimant: 258.5 - 20 + 72.5 = 311. Spouse: 1_200 - 20 = 1_180. # Each student: (2_000 - 85) / 2 = 957.5. - ok_ssp_own_countable_income: [311, 1_180, 957.5, 957.5] + ok_ssp_own_countable_income: + 2026-06: [311, 1_180, 957.5, 957.5] # Combined: 258.5 + 1_200 - 20 + 72.5 = 1_511, leaving 1_532 - 1_511 = 21. # Using annual-average ISM would give 1_541.94 and incorrectly pay 0. - ok_ssp_countable_income: [1_511, 1_511, 957.5, 957.5] - ok_ssp: [21, 0, 0, 0] + ok_ssp_countable_income: + 2026-06: [1_511, 1_511, 957.5, 957.5] + ok_ssp: + 2026-06: [21, 0, 0, 0] From 6559b7c3358e935951799543890a6bbbef30d8f4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 00:33:59 -0400 Subject: [PATCH 27/27] Use monthly parental deeming in new New Hampshire SSP reader --- ...ineligible-child-allocation-scope.fixed.md | 2 +- .../nh/dhhs/ssp/nh_ssp_countable_income.yaml | 61 +++++++++++++++++++ .../nh/dhhs/ssp/nh_ssp_countable_income.py | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml diff --git a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md index 0059239866e..e4bae6e01ed 100644 --- a/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md +++ b/changelog.d/ssi-ineligible-child-allocation-scope.fixed.md @@ -1 +1 @@ -Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers, New York SSP, Virginia AG, Oklahoma SSP, and Colorado OAP on those monthly interfaces. +Limit SSI spousal-deeming child allocations to the couple's co-resident children. Scope parental allocations to co-resident children, align SSI and Medicaid recipient immigration rules, preserve state Medicaid allocation age ceilings and child definitions, recognize marriage across households and historical student age limits, and retain monthly allocations through deeming thresholds, exclusions and benefit caps. Use full-time secondary-school attendance, including explicit K-12 enrollment at age 18, rather than tax dependency for Wisconsin's dependent 18-year-old allocation condition, with enrollment proxying expected completion before 19. Preserve annual SSI and Medicaid income inputs while applying allocations, exclusions, floors, and caps monthly. Keep Colorado and Texas couple-budget consumers, New York SSP, Virginia AG, Oklahoma SSP, Colorado OAP, and New Hampshire SSP on those monthly interfaces. diff --git a/policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml new file mode 100644 index 00000000000..add648ddbac --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml @@ -0,0 +1,61 @@ +- name: A blind child's parental deeming uses January's student allocation. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + person_id: 1 + is_tax_unit_head: true + employment_income: 31_200 + child: + age: 9 + person_id: 2 + parent_1_id: 1 + is_tax_unit_dependent: true + is_blind: true + student: + age: 19 + person_id: 3 + parent_1_id: 1 + is_tax_unit_dependent: true + is_full_time_college_student: true + employment_income: 24_000 + marital_units: + parent_unit: + members: [parent] + child_unit: + members: [child] + student_unit: + members: [student] + tax_units: + family_tax_unit: + members: [parent, child, student] + families: + family: + members: [parent, child, student] + households: + home: + members: [parent, child, student] + state_code: NH + output: + people: + child: + # Student earnings are 24,000 / 12 = 2,000 per month. The 9,730 + # annual student exclusion is exhausted in May: sibling allocations + # are 497 Jan-Apr, 497 - (2,000 - 1,730) = 227 May, and 0 Jun-Dec. + # January parental deeming: (2,600 - 497 - 85) / 2 - 994 = 15. + ssi_unearned_income_deemed_from_ineligible_parent_monthly: 15 + # May deeming is (2,600 - 227 - 85) / 2 - 994 = 150; Jun-Dec is + # (2,600 - 85) / 2 - 994 = 263.50. Annual deeming is therefore + # 4 * 15 + 150 + 7 * 263.50 = 2,054.50; its monthly average is 171.21. + ssi_unearned_income_deemed_from_ineligible_parent: + 2026: 2_054.5 + # January's 15 is below the federal 20 general exclusion: full SSI. + ssi: 994 + # NH counts January's 15 + 994 SSI - 13 standard disregard = 996. + nh_ssp_countable_income: 996 + # One-person standard: 994 + 27 - 13 = 1,008; grant: 1,008 - 996 = 12. + # Annual-average deeming would give 171.21 + 994 - 13 = 1,152.21 + # countable income and incorrectly pay no state grant. + nh_ssp: 12 diff --git a/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py b/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py index 3df789f2c0c..3fd721a4b82 100644 --- a/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py +++ b/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py @@ -61,7 +61,7 @@ def formula(person, period, parameters): # NOTE: that term uses SSI's earned income, so the parents' pre-tax # health premiums are excluded there; 652.03(f)(2) names no such exclusion. unearned = unearned + person( - "ssi_unearned_income_deemed_from_ineligible_parent", period + "ssi_unearned_income_deemed_from_ineligible_parent_monthly", period ) unearned = unearned + spouse_value(unearned, spouse) other = max_(person("nh_ssp_other_income_deductions", period), 0)