From 06f0b3fd6b65d8a6481cdc2cd1bd718b5d6637d6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 7 Sep 2026 16:59:23 -0400 Subject: [PATCH 01/24] fix: derive Medicaid MAGI households from parent ids Co-Authored-By: Claude Fable 5.1 --- changelog.d/medicaid-magi-parent-ids.fixed.md | 1 + .../tests/core/test_parent_links.py | 150 +++++ .../gov/hhs/medicaid/income/parent_ids.yaml | 567 ++++++++++++++++++ .../demographic/person/parent_ids.yaml | 117 ++++ .../medicaid_claimed_by_parent_in_tax_unit.py | 17 +- .../income/medicaid_household_income.py | 15 +- .../income/medicaid_household_size.py | 15 +- ...dent_exception_living_with_both_parents.py | 25 +- .../demographic/person/_parent_links.py | 76 +++ .../household/demographic/person/is_father.py | 5 +- .../household/demographic/person/is_mother.py | 7 +- .../household/demographic/person/is_parent.py | 15 +- .../demographic/person/parent_1_id.py | 10 + .../demographic/person/parent_2_id.py | 10 + 14 files changed, 1016 insertions(+), 14 deletions(-) create mode 100644 changelog.d/medicaid-magi-parent-ids.fixed.md create mode 100644 policyengine_us/tests/core/test_parent_links.py create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml create mode 100644 policyengine_us/variables/household/demographic/person/_parent_links.py create mode 100644 policyengine_us/variables/household/demographic/person/parent_1_id.py create mode 100644 policyengine_us/variables/household/demographic/person/parent_2_id.py diff --git a/changelog.d/medicaid-magi-parent-ids.fixed.md b/changelog.d/medicaid-magi-parent-ids.fixed.md new file mode 100644 index 00000000000..360030ea696 --- /dev/null +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -0,0 +1 @@ +Use optional co-resident parent identifiers for Medicaid MAGI household composition and parent status, preserving existing household outputs when all parent identifiers are zero. diff --git a/policyengine_us/tests/core/test_parent_links.py b/policyengine_us/tests/core/test_parent_links.py new file mode 100644 index 00000000000..cc0fe316252 --- /dev/null +++ b/policyengine_us/tests/core/test_parent_links.py @@ -0,0 +1,150 @@ +from copy import deepcopy + +import numpy as np + +from policyengine_us import Simulation + + +def test_parent_links_are_scoped_to_interleaved_households(): + # Person IDs repeat across households, and linked/unlinked rows alternate. + # Composition inputs isolate the non-filer branch from income exclusions. + names = [ + "linked_parent", + "legacy_parent", + "linked_child", + "legacy_child", + "unrelated_child", + "legacy_sibling", + ] + ages = [35, 45, 10, 8, 16, 13] + incomes = [10_000.125, 20_000.375, 100.25, 300.125, 200.5, 400.25] + people = { + name: { + "age": {"2026": age}, + "person_id": {"2026": person_id}, + "own_children_in_household": {"2026": own_children}, + "medicaid_uses_non_filer_rules": {"2026": True}, + "medicaid_household_income_member": {"2026": income}, + "medicaid_magi_person": {"2026": income}, + "medicaid_person_is_required_to_file": {"2026": True}, + } + for name, age, income, person_id, own_children in zip( + names, ages, incomes, [11, 11, 12, 12, 13, 13], [0, 2, 0, 0, 0, 0] + ) + } + people["linked_child"]["parent_1_id"] = {"2026": 11} + groups = { + "linked": ["linked_parent", "linked_child", "unrelated_child"], + "legacy": ["legacy_parent", "legacy_child", "legacy_sibling"], + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": { + name: {"members": members} for name, members in groups.items() + }, + "spm_units": { + name: {"members": members} for name, members in groups.items() + }, + "families": { + name: {"members": members} for name, members in groups.items() + }, + "marital_units": {name: {"members": [name]} for name in names}, + "households": { + name: {"members": members, "state_code": {"2026": "OH"}} + for name, members in groups.items() + }, + } + ) + + np.testing.assert_array_equal( + simulation.calculate("is_parent", 2026), + [True, True, False, False, False, False], + ) + np.testing.assert_array_equal( + simulation.calculate("medicaid_household_size", 2026), + [3, 3, 2, 3, 3, 3], + ) + # Exact binary fractions expose cross-household joins without tolerances. + # The unrelated child has no links and retains the family-sum fallback. + np.testing.assert_array_equal( + simulation.calculate("medicaid_household_income", 2026), + [10_300.875, 20_700.75, 10_100.375, 20_700.75, 10_300.875, 20_700.75], + ) + + +def test_explicit_zero_parent_ids_exactly_match_omitted_inputs(): + situation = { + "people": { + "mother": { + "age": {"2026": 35}, + "is_female": {"2026": True}, + "own_children_in_household": {"2026": 1}, + "employment_income": {"2026": 12_345.67}, + }, + "father": { + "age": {"2026": 36}, + "is_female": {"2026": False}, + "own_children_in_household": {"2026": 1}, + "employment_income": {"2026": 23_456.78}, + }, + "child": { + "age": {"2026": 10}, + "employment_income": {"2026": 600.11}, + }, + }, + "tax_units": { + "mother": { + "members": ["mother", "child"], + "tax_unit_is_filer": {"2026": True}, + }, + "father": { + "members": ["father"], + "tax_unit_is_filer": {"2026": True}, + }, + }, + "spm_units": {"family": {"members": ["mother", "father", "child"]}}, + "families": {"family": {"members": ["mother", "father", "child"]}}, + "marital_units": { + name: {"members": [name]} for name in ["mother", "father", "child"] + }, + "households": { + "household": { + "members": ["mother", "father", "child"], + "state_code": {"2026": "OH"}, + } + }, + } + explicit_zeros = deepcopy(situation) + for inputs in explicit_zeros["people"].values(): + inputs["parent_1_id"] = {"2026": 0} + inputs["parent_2_id"] = {"2026": 0} + + omitted = Simulation(situation=situation) + zeros = Simulation(situation=explicit_zeros) + for variable in [ + "is_parent", + "is_mother", + "is_father", + "medicaid_claimed_by_parent_in_tax_unit", + "medicaid_tax_dependent_exception_living_with_both_parents", + "medicaid_uses_non_filer_rules", + "medicaid_household_size", + "medicaid_household_income", + "is_medicaid_eligible", + ]: + np.testing.assert_array_equal( + zeros.calculate(variable, 2026), + omitted.calculate(variable, 2026), + err_msg=variable, + ) + + np.testing.assert_array_equal( + zeros.calculate("is_parent", 2026), [True, True, False] + ) + np.testing.assert_array_equal( + zeros.calculate("medicaid_uses_non_filer_rules", 2026), [False, False, True] + ) + np.testing.assert_array_equal( + zeros.calculate("medicaid_household_size", 2026), [2, 1, 3] + ) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml new file mode 100644 index 00000000000..db164be2fe3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml @@ -0,0 +1,567 @@ +# Regression for [policyengine-us#9404](https://github.com/PolicyEngine/policyengine-us/issues/9404). +- name: Three generations use each actual parent tax household in Ohio. + period: 2026 + input: + people: + grandparent: + person_id: 101 + age: 56 + employment_income: 94_504 + own_children_in_household: 4 + parent_a: + person_id: 102 + age: 30 + employment_income: 23_334 + own_children_in_household: 4 + parent_1_id: 101 + parent_b: + person_id: 103 + age: 27 + employment_income: 24_010 + own_children_in_household: 1 + parent_1_id: 101 + teen_a: + person_id: 104 + age: 17 + parent_1_id: 101 + teen_b: + person_id: 105 + age: 16 + parent_1_id: 101 + child_a: + person_id: 106 + age: 10 + parent_1_id: 102 + child_b: + person_id: 107 + age: 8 + parent_1_id: 102 + child_c: + person_id: 108 + age: 6 + parent_1_id: 102 + child_d: + person_id: 109 + age: 4 + parent_1_id: 102 + child_e: + person_id: 110 + age: 3 + parent_1_id: 103 + tax_units: + grandparent_unit: + members: [grandparent, teen_a, teen_b] + parent_a_unit: + members: [parent_a, child_a, child_b, child_c, child_d] + parent_b_unit: + members: [parent_b, child_e] + families: + family: + members: [grandparent, parent_a, parent_b, teen_a, teen_b, child_a, child_b, child_c, child_d, child_e] + marital_units: + grandparent: + members: [grandparent] + parent_a: + members: [parent_a] + parent_b: + members: [parent_b] + teen_a: + members: [teen_a] + teen_b: + members: [teen_b] + child_a: + members: [child_a] + child_b: + members: [child_b] + child_c: + members: [child_c] + child_d: + members: [child_d] + child_e: + members: [child_e] + households: + household: + members: [grandparent, parent_a, parent_b, teen_a, teen_b, child_a, child_b, child_c, child_d, child_e] + state_code: OH + output: + medicaid_household_size: [3, 5, 2, 3, 3, 5, 5, 5, 5, 2] + medicaid_household_income: [94_504, 23_334, 24_010, 94_504, 94_504, 23_334, 23_334, 23_334, 23_334, 24_010] + medicaid_uses_non_filer_rules: [false, false, false, false, false, false, false, false, false, false] + is_medicaid_eligible: [false, true, true, false, false, true, true, true, true, true] + +- name: Three generations with zero parent ids retain the legacy family proxy. + period: 2026 + input: + people: + grandparent: + person_id: 101 + age: 56 + employment_income: 94_504 + own_children_in_household: 4 + parent_1_id: 0 + parent_2_id: 0 + parent_a: + person_id: 102 + age: 30 + employment_income: 23_334 + own_children_in_household: 4 + parent_1_id: 0 + parent_2_id: 0 + parent_b: + person_id: 103 + age: 27 + employment_income: 24_010 + own_children_in_household: 1 + parent_1_id: 0 + parent_2_id: 0 + teen_a: + person_id: 104 + age: 17 + parent_1_id: 0 + parent_2_id: 0 + teen_b: + person_id: 105 + age: 16 + parent_1_id: 0 + parent_2_id: 0 + child_a: + person_id: 106 + age: 10 + parent_1_id: 0 + parent_2_id: 0 + child_b: + person_id: 107 + age: 8 + parent_1_id: 0 + parent_2_id: 0 + child_c: + person_id: 108 + age: 6 + parent_1_id: 0 + parent_2_id: 0 + child_d: + person_id: 109 + age: 4 + parent_1_id: 0 + parent_2_id: 0 + child_e: + person_id: 110 + age: 3 + parent_1_id: 0 + parent_2_id: 0 + tax_units: + grandparent_unit: + members: [grandparent, teen_a, teen_b] + parent_a_unit: + members: [parent_a, child_a, child_b, child_c, child_d] + parent_b_unit: + members: [parent_b, child_e] + families: + family: + members: [grandparent, parent_a, parent_b, teen_a, teen_b, child_a, child_b, child_c, child_d, child_e] + marital_units: + grandparent: + members: [grandparent] + parent_a: + members: [parent_a] + parent_b: + members: [parent_b] + teen_a: + members: [teen_a] + teen_b: + members: [teen_b] + child_a: + members: [child_a] + child_b: + members: [child_b] + child_c: + members: [child_c] + child_d: + members: [child_d] + child_e: + members: [child_e] + households: + household: + members: [grandparent, parent_a, parent_b, teen_a, teen_b, child_a, child_b, child_c, child_d, child_e] + state_code: OH + output: + medicaid_household_size: [3, 5, 2, 10, 10, 10, 10, 10, 10, 10] + medicaid_household_income: [94_504, 23_334, 24_010, 141_848, 141_848, 141_848, 141_848, 141_848, 141_848, 141_848] + medicaid_uses_non_filer_rules: [false, false, false, true, true, true, true, true, true, true] + is_medicaid_eligible: [false, true, true, false, false, false, false, false, false, false] + +- name: Unmarried separate filers include both parents and half siblings but exclude cousins. + period: 2026 + input: + people: + parent_a: + person_id: 11 + age: 35 + employment_income: 20_000 + parent_b: + person_id: 12 + age: 33 + employment_income: 15_000 + aunt: + person_id: 13 + age: 30 + employment_income: 80_000 + child: + person_id: 14 + age: 10 + parent_1_id: 11 + parent_2_id: 12 + sibling: + person_id: 15 + age: 12 + employment_income: 1_000 + parent_1_id: 12 + parent_2_id: 11 + half_sibling: + person_id: 16 + age: 17 + employment_income: 25_000 + parent_2_id: 11 + cousin: + person_id: 17 + age: 16 + employment_income: 30_000 + parent_1_id: 13 + tax_units: + parent_a_unit: + members: [parent_a, child, sibling, half_sibling] + tax_unit_is_filer: true + parent_b_unit: + members: [parent_b] + tax_unit_is_filer: true + aunt_unit: + members: [aunt, cousin] + tax_unit_is_filer: true + families: + family: + members: [parent_a, parent_b, aunt, child, sibling, half_sibling, cousin] + marital_units: + parent_a: + members: [parent_a] + parent_b: + members: [parent_b] + aunt: + members: [aunt] + child: + members: [child] + sibling: + members: [sibling] + half_sibling: + members: [half_sibling] + cousin: + members: [cousin] + households: + household: + members: [parent_a, parent_b, aunt, child, sibling, half_sibling, cousin] + state_code: OH + output: + medicaid_claimed_by_parent_in_tax_unit: [false, false, false, true, true, true, true] + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, false, true, true, false, false] + medicaid_uses_non_filer_rules: [false, false, false, true, true, false, false] + medicaid_household_size: [4, 1, 2, 5, 5, 4, 2] + medicaid_household_income: [45_000, 15_000, 110_000, 60_000, 60_000, 45_000, 110_000] + +- name: Parents who are the head and spouse of one joint return do not trigger the exception. + period: 2026 + input: + people: + parent_a: + person_id: 21 + age: 35 + employment_income: 20_000 + parent_b: + person_id: 22 + age: 33 + employment_income: 15_000 + child: + person_id: 23 + age: 10 + parent_1_id: 22 + parent_2_id: 21 + tax_units: + joint: + members: [parent_a, parent_b, child] + tax_unit_is_filer: true + filing_status: JOINT + families: + family: + members: [parent_a, parent_b, child] + marital_units: + parents: + members: [parent_a, parent_b] + child: + members: [child] + households: + household: + members: [parent_a, parent_b, child] + state_code: OH + output: + medicaid_claimed_by_parent_in_tax_unit: [false, false, true] + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, false] + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [3, 3, 3] + medicaid_household_income: [35_000, 35_000, 35_000] + +- name: Dangling and duplicate ids cannot create a second co-resident parent. + period: 2026 + input: + people: + parent: + person_id: 31 + age: 35 + employment_income: 20_000 + one_resolving_parent: + person_id: 32 + age: 10 + parent_1_id: 31 + parent_2_id: 999 + duplicate_parent: + person_id: 33 + age: 8 + parent_1_id: 31 + parent_2_id: 31 + no_resolving_parent: + person_id: 34 + age: 6 + parent_1_id: 991 + parent_2_id: 992 + tax_units: + unit: + members: [parent, one_resolving_parent, duplicate_parent, no_resolving_parent] + tax_unit_is_filer: true + families: + family: + members: [parent, one_resolving_parent, duplicate_parent, no_resolving_parent] + marital_units: + parent: + members: [parent] + one_resolving_parent: + members: [one_resolving_parent] + duplicate_parent: + members: [duplicate_parent] + no_resolving_parent: + members: [no_resolving_parent] + households: + household: + members: [parent, one_resolving_parent, duplicate_parent, no_resolving_parent] + state_code: OH + output: + medicaid_claimed_by_parent_in_tax_unit: [false, true, true, false] + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, false, false] + medicaid_uses_non_filer_rules: [false, false, false, true] + medicaid_household_size: [4, 4, 4, 1] + medicaid_household_income: [20_000, 20_000, 20_000, 0] + +- name: An identified parent in another household does not trigger the both-parent exception. + period: 2026 + input: + people: + parent: + person_id: 41 + age: 35 + employment_income: 20_000 + outside_parent: + person_id: 42 + age: 33 + employment_income: 15_000 + child: + person_id: 43 + age: 10 + parent_1_id: 41 + parent_2_id: 42 + other_adult: + person_id: 44 + age: 50 + employment_income: 80_000 + own_children_in_household: 1 + tax_units: + parent_unit: + members: [parent, child] + tax_unit_is_filer: true + outside_unit: + members: [outside_parent] + tax_unit_is_filer: true + other_unit: + members: [other_adult] + tax_unit_is_filer: true + families: + home: + members: [parent, child, other_adult] + outside: + members: [outside_parent] + marital_units: + parent: + members: [parent] + outside_parent: + members: [outside_parent] + child: + members: [child] + other_adult: + members: [other_adult] + households: + home: + members: [parent, child, other_adult] + state_code: OH + outside: + members: [outside_parent] + state_code: OH + output: + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, false, false] + medicaid_uses_non_filer_rules: [false, false, false, false] + medicaid_household_size: [2, 1, 2, 1] + medicaid_household_income: [20_000, 15_000, 20_000, 80_000] + +- name: A child claimed by an aunt uses the identified parent even across family groups. + period: 2026 + input: + people: + aunt: + person_id: 51 + age: 40 + employment_income: 80_000 + own_children_in_household: 1 + parent: + person_id: 52 + age: 30 + employment_income: 20_000 + child: + person_id: 53 + age: 10 + parent_2_id: 52 + tax_units: + aunt_unit: + members: [aunt, child] + tax_unit_is_filer: true + parent_unit: + members: [parent] + tax_unit_is_filer: true + families: + aunt_family: + members: [aunt, child] + parent_family: + members: [parent] + marital_units: + aunt: + members: [aunt] + parent: + members: [parent] + child: + members: [child] + households: + household: + members: [aunt, parent, child] + state_code: OH + output: + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, false, true] + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [2, 1, 2] + medicaid_household_income: [80_000, 20_000, 20_000] + +- name: A parent who is the spouse on the claiming return is recognized. + period: 2026 + input: + people: + parent_a: + person_id: 21 + age: 35 + employment_income: 20_000 + parent_b: + person_id: 22 + age: 33 + employment_income: 15_000 + child: + person_id: 23 + age: 10 + parent_1_id: 22 + parent_2_id: 0 + tax_units: + joint: + members: [parent_a, parent_b, child] + tax_unit_is_filer: true + filing_status: JOINT + families: + family: + members: [parent_a, parent_b, child] + marital_units: + parents: + members: [parent_a, parent_b] + child: + members: [child] + households: + household: + members: [parent_a, parent_b, child] + state_code: OH + output: + medicaid_claimed_by_parent_in_tax_unit: [false, false, true] + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, false] + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [3, 3, 3] + medicaid_household_income: [35_000, 35_000, 35_000] + +- name: A non-filer child excludes an identified parent who lives in another household. + period: 2026 + input: + people: + aunt: + person_id: 51 + age: 40 + employment_income: 80_000 + own_children_in_household: 1 + parent: + person_id: 52 + age: 30 + employment_income: 20_000 + child: + person_id: 53 + age: 10 + parent_2_id: 52 + parent_1_id: 54 + outside_parent: + person_id: 54 + age: 33 + employment_income: 15_000 + tax_units: + aunt_unit: + members: [aunt, child] + tax_unit_is_filer: true + parent_unit: + members: [parent] + tax_unit_is_filer: true + outside_unit: + members: [outside_parent] + tax_unit_is_filer: true + families: + aunt_family: + members: [aunt, child] + parent_family: + members: [parent] + outside_family: + members: [outside_parent] + marital_units: + aunt: + members: [aunt] + parent: + members: [parent] + child: + members: [child] + outside_parent: + members: [outside_parent] + households: + household: + members: [aunt, parent, child] + state_code: OH + outside_household: + members: [outside_parent] + state_code: OH + output: + medicaid_claimed_by_parent_in_tax_unit: [false, false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, false, true, false] + medicaid_uses_non_filer_rules: [false, false, true, false] + medicaid_household_size: [2, 1, 2, 1] + medicaid_household_income: [80_000, 20_000, 20_000, 15_000] diff --git a/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml b/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml new file mode 100644 index 00000000000..50f88c821dd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml @@ -0,0 +1,117 @@ +- name: Parent ids override disagreeing child counts while breastfeeding still identifies mothers. + period: 2026 + input: + people: + mother: + person_id: 61 + is_female: true + own_children_in_household: 0 + father: + person_id: 62 + is_female: false + own_children_in_household: 0 + unrelated_adult: + person_id: 63 + is_female: true + own_children_in_household: 3 + child: + person_id: 64 + parent_1_id: 61 + parent_2_id: 62 + breastfeeding_adult: + person_id: 65 + is_female: true + is_breastfeeding: true + households: + household: + members: [mother, father, unrelated_adult, child, breastfeeding_adult] + output: + is_parent: [true, true, false, false, false] + is_mother: [true, false, false, false, true] + is_father: [false, true, false, false, false] + +- name: Zero parent ids preserve the child-count inference and breastfeeding branch. + period: 2026 + input: + people: + mother: + person_id: 71 + is_female: true + own_children_in_household: 1 + parent_1_id: 0 + parent_2_id: 0 + father: + person_id: 72 + is_female: false + own_children_in_household: 1 + parent_1_id: 0 + parent_2_id: 0 + breastfeeding_adult: + person_id: 73 + is_breastfeeding: true + parent_1_id: 0 + parent_2_id: 0 + households: + household: + members: [mother, father, breastfeeding_adult] + output: + is_parent: [true, true, false] + is_mother: [true, false, true] + is_father: [false, true, false] + +- name: Parent inference uses household-local pointers and retains zero-id household fallback. + period: 2026 + input: + people: + outside_parent: + person_id: 81 + own_children_in_household: 0 + legacy_parent: + person_id: 82 + own_children_in_household: 1 + is_female: true + child: + person_id: 83 + parent_1_id: 81 + unrelated_adult: + person_id: 84 + own_children_in_household: 2 + households: + outside: + members: [outside_parent, legacy_parent] + home: + members: [child, unrelated_adult] + output: + is_parent: [false, true, false, false] + is_mother: [false, true, false, false] + is_father: [false, false, false, false] + +- name: Unknown zero parent ids do not resolve to the default zero person id. + period: 2026 + input: + people: + zero_id_person: + person_id: 0 + own_children_in_household: 2 + child: + person_id: 91 + parent_1_id: 999 + parent_2_id: 0 + households: + household: + members: [zero_id_person, child] + output: + is_parent: [false, false] + is_mother: [false, false] + is_father: [false, false] + +- name: A malformed self-reference does not make someone their own parent. + period: 2026 + input: + person_id: 92 + parent_1_id: 92 + own_children_in_household: 1 + output: + is_parent: false + is_mother: false + is_father: false diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py index adafb804eb9..17df02fb207 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, +) class medicaid_claimed_by_parent_in_tax_unit(Variable): @@ -16,7 +19,19 @@ def formula(person, period, parameters): has_parent_filer_in_tax_unit = person.tax_unit.any( person("is_parent", period) & person("is_tax_unit_head_or_spouse", period) ) - return person("is_tax_unit_dependent", period) & ( + inferred_parent = ( person("is_qualifying_child_dependent", period) | has_parent_filer_in_tax_unit ) + first, second = co_resident_parent_indices(person, period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + linked_parent = ( + (first >= 0) & head_or_spouse[first] & (tax_unit[first] == tax_unit) + ) | ((second >= 0) & head_or_spouse[second] & (tax_unit[second] == tax_unit)) + has_parent_ids = (person("parent_1_id", period) != 0) | ( + person("parent_2_id", period) != 0 + ) + return person("is_tax_unit_dependent", period) & where( + has_parent_ids, linked_parent, inferred_parent + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 0dbd9d927eb..51a15b11e00 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -3,6 +3,9 @@ medicaid_claiming_tax_unit_value, medicaid_external_claimed_sum, ) +from policyengine_us.variables.household.demographic.person._parent_links import ( + parent_and_child_sibling_sum, +) class medicaid_household_income(Variable): @@ -48,9 +51,19 @@ def formula(person, period, parameters): family_parent_income = person.family.sum( person("is_parent", period) * member_income ) + has_parent_ids = (person("parent_1_id", period) != 0) | ( + person("parent_2_id", period) != 0 + ) + child_family_income = parent_and_child_sibling_sum( + person, period, child_age_eligible, member_income + ) non_filer_household_income = where( child_age_eligible, - spouse_income + family_parent_income + family_child_income, + where( + has_parent_ids, + spouse_income + child_family_income, + spouse_income + family_parent_income + family_child_income, + ), member_income + spouse_income + family_child_income, ) tax_household_income = ( diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py index de55df9bd80..5f6ddde104c 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py @@ -3,6 +3,9 @@ medicaid_claiming_tax_unit_value, medicaid_external_claimed_sum, ) +from policyengine_us.variables.household.demographic.person._parent_links import ( + parent_and_child_sibling_sum, +) class medicaid_household_size(Variable): @@ -37,9 +40,19 @@ def formula(person, period, parameters): int ) * cohabitating_separate.astype(int) spouse_count = same_unit_spouse_count + separate_spouse_count + has_parent_ids = (person("parent_1_id", period) != 0) | ( + person("parent_2_id", period) != 0 + ) + child_family_count = parent_and_child_sibling_sum( + person, period, child_age_eligible, np.ones(person.count, dtype=int) + ) non_filer_household_size = where( child_age_eligible, - spouse_count + family_parent_count + family_child_count, + where( + has_parent_ids, + spouse_count + child_family_count, + spouse_count + family_parent_count + family_child_count, + ), 1 + spouse_count + family_child_count, ) tax_household_size = person.tax_unit("tax_unit_size", period) + ( diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py index 4cb593a2097..23878fc62fe 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, +) class medicaid_tax_dependent_exception_living_with_both_parents(Variable): @@ -9,10 +12,28 @@ class medicaid_tax_dependent_exception_living_with_both_parents(Variable): reference = "https://www.law.cornell.edu/cfr/text/42/435.603#f_2_ii" def formula(person, period, parameters): + first, second = co_resident_parent_indices(person, period) + two_parents = (first >= 0) & (second >= 0) & (first != second) + head = person("is_tax_unit_head", period) + spouse = person("is_tax_unit_spouse", period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + filing_status = person.tax_unit("filing_status", period) + joint = filing_status == filing_status.possible_values.JOINT + parents_file_jointly = ( + (tax_unit[first] == tax_unit[second]) + & ((head[first] & spouse[second]) | (spouse[first] & head[second])) + & joint[first] + & person.tax_unit("tax_unit_is_filer", period)[first] + ) + has_parent_ids = (person("parent_1_id", period) != 0) | ( + person("parent_2_id", period) != 0 + ) + legacy_proxy = (person.family.sum(person("is_parent", period)) > 1) & ( + person.tax_unit.sum(person("is_parent", period)) == 1 + ) return ( person("is_tax_unit_dependent", period) & person("medicaid_non_filer_child_age_eligible", period) & person("medicaid_claimed_by_parent_in_tax_unit", period) - & (person.family.sum(person("is_parent", period)) > 1) - & (person.tax_unit.sum(person("is_parent", period)) == 1) + & where(has_parent_ids, two_parents & ~parents_file_jointly, legacy_proxy) ) diff --git a/policyengine_us/variables/household/demographic/person/_parent_links.py b/policyengine_us/variables/household/demographic/person/_parent_links.py new file mode 100644 index 00000000000..d66b4d46323 --- /dev/null +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -0,0 +1,76 @@ +"""Household-scoped parent links, with linear temporary memory use.""" + +import numpy as np + + +def household_member_indices(person): + """Yield each co-resident's row index, projected to every household member. + + Iterate over household positions, not pairs of people in the population. + Membership comes from the entity structure, not optional household ID inputs. + A missing position is -1 and must be masked before using its values. + """ + indices = np.arange(person.count) + positions = person.household.reference_entity.members_position + for position in range(int(np.max(positions, initial=-1)) + 1): + yield person.household.value_nth_person(position, indices, default=-1) + + +def co_resident_parent_indices(person, period): + """Resolve the two parent slots to person rows; unknown links remain -1.""" + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + first = np.full(person.count, -1, dtype=int) + second = np.full(person.count, -1, dtype=int) + if not np.any((parent_1 != 0) | (parent_2 != 0)): + return first, second + + person_id = person("person_id", period) + own_index = np.arange(person.count) + for member in household_member_indices(person): + valid = (member >= 0) & (member != own_index) + member_id = person_id[member] + first = np.where( + valid & (parent_1 != 0) & (parent_1 == member_id), member, first + ) + second = np.where( + valid & (parent_2 != 0) & (parent_2 == member_id), member, second + ) + return first, second + + +def parent_and_child_sibling_sum(person, period, child_age_eligible, values): + """Sum applicant, co-resident parents, and child-age siblings once each. + + Used for the child non-filer household under 42 CFR 435.603(f)(3)(iv). + Siblings share at least one nonzero parent ID, including half siblings. + A shared absent parent can identify siblings without adding that parent. + The caller selects its existing family sum when both parent IDs are zero. + """ + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + result = np.zeros(person.count, dtype=np.asarray(values).dtype) + if not np.any((parent_1 != 0) | (parent_2 != 0)): + return result + + person_id = person("person_id", period) + own_index = np.arange(person.count) + for member in household_member_indices(person): + member_id = person_id[member] + is_parent = ((parent_1 != 0) & (parent_1 == member_id)) | ( + (parent_2 != 0) & (parent_2 == member_id) + ) + shares_parent = ( + (parent_1 != 0) + & ((parent_1 == parent_1[member]) | (parent_1 == parent_2[member])) + ) | ( + (parent_2 != 0) + & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) + ) + included = (member >= 0) & ( + (member == own_index) + | is_parent + | (child_age_eligible[member] & shares_parent) + ) + result += np.where(included, values[member], 0) + return result diff --git a/policyengine_us/variables/household/demographic/person/is_father.py b/policyengine_us/variables/household/demographic/person/is_father.py index 75caa06d1aa..908900ecc4b 100644 --- a/policyengine_us/variables/household/demographic/person/is_father.py +++ b/policyengine_us/variables/household/demographic/person/is_father.py @@ -8,9 +8,8 @@ class is_father(Variable): definition_period = YEAR def formula(person, period, parameters): - # In the absence of relationship identifiers, check if the person is - # male and has some children in their own household (provided in the - # CPS). + # is_parent uses co-resident parent links when available and otherwise + # the child count. female = person("is_female", period) has_children = person("is_parent", period) return ~female & has_children diff --git a/policyengine_us/variables/household/demographic/person/is_mother.py b/policyengine_us/variables/household/demographic/person/is_mother.py index 1394af0eaa8..9ffca441122 100644 --- a/policyengine_us/variables/household/demographic/person/is_mother.py +++ b/policyengine_us/variables/household/demographic/person/is_mother.py @@ -8,11 +8,8 @@ class is_mother(Variable): definition_period = YEAR def formula(person, period, parameters): - # In the absence of relationship identifiers, check one of two - # conditions: - # 1. The person is female and has some children in their own household - # (provided in the CPS). - # 2. Breastfeeding (user-input). + # is_parent uses co-resident parent links when available and otherwise + # the child count. Breastfeeding independently identifies a mother. female = person("is_female", period) has_children = person("is_parent", period) breastfeeding = person("is_breastfeeding", period) diff --git a/policyengine_us/variables/household/demographic/person/is_parent.py b/policyengine_us/variables/household/demographic/person/is_parent.py index fcb5d85dcf8..f64a0fb2225 100644 --- a/policyengine_us/variables/household/demographic/person/is_parent.py +++ b/policyengine_us/variables/household/demographic/person/is_parent.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, +) class is_parent(Variable): @@ -8,4 +11,14 @@ class is_parent(Variable): definition_period = YEAR def formula(person, period, parameters): - return person("own_children_in_household", period) > 0 + has_parent_ids = (person("parent_1_id", period) != 0) | ( + person("parent_2_id", period) != 0 + ) + first, second = co_resident_parent_indices(person, period) + parents = np.concatenate((first, second)) + linked_parent = np.bincount(parents[parents >= 0], minlength=person.count) > 0 + return where( + person.household.any(has_parent_ids), + linked_parent, + person("own_children_in_household", period) > 0, + ) diff --git a/policyengine_us/variables/household/demographic/person/parent_1_id.py b/policyengine_us/variables/household/demographic/person/parent_1_id.py new file mode 100644 index 00000000000..7b3f6e64148 --- /dev/null +++ b/policyengine_us/variables/household/demographic/person/parent_1_id.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class parent_1_id(Variable): + value_type = int + entity = Person + label = "First co-resident parent's person ID" + definition_period = YEAR + default_value = 0 + documentation = "person_id of a co-resident parent; 0 when unknown" diff --git a/policyengine_us/variables/household/demographic/person/parent_2_id.py b/policyengine_us/variables/household/demographic/person/parent_2_id.py new file mode 100644 index 00000000000..e15938d0c3f --- /dev/null +++ b/policyengine_us/variables/household/demographic/person/parent_2_id.py @@ -0,0 +1,10 @@ +from policyengine_us.model_api import * + + +class parent_2_id(Variable): + value_type = int + entity = Person + label = "Second co-resident parent's person ID" + definition_period = YEAR + default_value = 0 + documentation = "person_id of a co-resident parent; 0 when unknown" From c9a9b33d9574b177445176cdc8f29ce2b42df7b1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 7 Sep 2026 17:11:49 -0400 Subject: [PATCH 02/24] test: verify parent-id edge cases and zero-id compatibility Co-Authored-By: Claude Fable 5.1 --- .../gov/hhs/medicaid/income/parent_ids.yaml | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml index db164be2fe3..04934cb5978 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml @@ -1,4 +1,5 @@ # Regression for [policyengine-us#9404](https://github.com/PolicyEngine/policyengine-us/issues/9404). +# Reconstructed from the assignment; original issue unavailable offline. - name: Three generations use each actual parent tax household in Ohio. period: 2026 input: @@ -565,3 +566,66 @@ medicaid_uses_non_filer_rules: [false, false, true, false] medicaid_household_size: [2, 1, 2, 1] medicaid_household_income: [80_000, 20_000, 20_000, 15_000] + + +- name: Sharing a tax unit does not substitute for parents filing jointly. + period: 2026 + input: + people: + parent_a: + person_id: 21 + age: 35 + parent_b: + person_id: 22 + age: 33 + child: + person_id: 23 + age: 10 + parent_1_id: 21 + parent_2_id: 22 + tax_units: + unit: + members: [parent_a, parent_b, child] + tax_unit_is_filer: true + filing_status: SINGLE + households: + household: + members: [parent_a, parent_b, child] + output: + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, true] + medicaid_uses_non_filer_rules: [false, false, true] + +- name: Siblings sharing an absent parent include each other but not unrelated children. + period: 2026 + input: + people: + adult: + person_id: 101 + age: 40 + employment_income: 80_000 + own_children_in_household: 3 + child: + person_id: 102 + age: 10 + parent_1_id: 999 + sibling: + person_id: 103 + age: 16 + employment_income: 18_000 + parent_2_id: 999 + unrelated_child: + person_id: 104 + age: 6 + parent_1_id: 888 + tax_units: + unit: + members: [adult, child, sibling, unrelated_child] + tax_unit_is_filer: true + households: + household: + members: [adult, child, sibling, unrelated_child] + output: + is_parent: [false, false, false, false] + medicaid_uses_non_filer_rules: [false, true, true, true] + medicaid_household_size: [4, 2, 2, 1] + medicaid_household_income: [98_000, 18_000, 18_000, 0] From 08eaff99702b4235ccfd3444101c538eb20889e5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 25 Sep 2026 20:28:16 -0400 Subject: [PATCH 03/24] test: property tests for the parent-id path over seeded random households Three invariants over seeded random households, so a failure reproduces: explicit zero parent ids give the same Medicaid and demographic outputs as omitted ids; when own_children_in_household counts exactly the people whose ids name a person, is_parent from ids agrees with the count path; and is_parent/is_mother do not depend on member order. Co-Authored-By: Claude Opus 5.5 --- .../core/test_parent_links_properties.py | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 policyengine_us/tests/core/test_parent_links_properties.py diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py new file mode 100644 index 00000000000..21783bdea69 --- /dev/null +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -0,0 +1,146 @@ +"""Properties of the parent-id path over seeded random households. + +Each test draws households from a fixed seed, so a failure reproduces exactly. +""" + +from copy import deepcopy + +import numpy as np + +from policyengine_us import Simulation + +PERIOD = "2026" +N_HOUSEHOLDS = 30 + +MEDICAID_OUTPUTS = [ + "is_parent", + "is_mother", + "is_father", + "medicaid_claimed_by_parent_in_tax_unit", + "medicaid_tax_dependent_exception_living_with_both_parents", + "medicaid_uses_non_filer_rules", + "medicaid_household_size", + "medicaid_household_income", + "is_medicaid_eligible", +] + + +def random_households(seed): + """Households of one to six people with random co-resident parent links. + + Each child names up to two distinct adults of the same household; ids are + household-local and start at 1, since 0 means "unknown". + """ + rng = np.random.default_rng(seed) + households = [] + for h in range(N_HOUSEHOLDS): + adults = int(rng.integers(1, 4)) + children = int(rng.integers(0, 4)) + people = [] + for a in range(adults): + people.append( + { + "name": f"h{h}_adult{a}", + "age": int(rng.integers(18, 70)), + "is_female": bool(rng.integers(0, 2)), + "employment_income": float(rng.integers(0, 60_000)) + 0.37, + "parents": [], + } + ) + for c in range(children): + k = int(rng.integers(0, min(adults, 2) + 1)) + parents = list(rng.choice(adults, size=k, replace=False)) if k else [] + people.append( + { + "name": f"h{h}_child{c}", + "age": int(rng.integers(0, 19)), + "is_female": bool(rng.integers(0, 2)), + "employment_income": float(rng.integers(0, 3_000)), + "parents": [int(p) for p in parents], + } + ) + households.append(people) + return households + + +def situation(households, *, with_ids, explicit_zero=False, order=None): + people, groups = {}, {} + for h, members in enumerate(households): + indices = list(range(len(members))) + if order is not None: + indices = [int(i) for i in order[h]] + names = [] + for i in indices: + person = members[i] + inputs = { + "age": {PERIOD: person["age"]}, + "is_female": {PERIOD: person["is_female"]}, + "employment_income": {PERIOD: person["employment_income"]}, + "person_id": {PERIOD: i + 1}, + # The legacy count the CPS carries, consistent with the links. + "own_children_in_household": { + PERIOD: sum(i in other["parents"] for other in members) + }, + } + if with_ids: + ids = [p + 1 for p in person["parents"]] + [0, 0] + inputs["parent_1_id"] = {PERIOD: ids[0]} + inputs["parent_2_id"] = {PERIOD: ids[1]} + elif explicit_zero: + inputs["parent_1_id"] = {PERIOD: 0} + inputs["parent_2_id"] = {PERIOD: 0} + people[person["name"]] = inputs + names.append(person["name"]) + groups[f"h{h}"] = names + return { + "people": people, + "tax_units": {k: {"members": v} for k, v in groups.items()}, + "spm_units": {k: {"members": v} for k, v in groups.items()}, + "families": {k: {"members": v} for k, v in groups.items()}, + "marital_units": {name: {"members": [name]} for name in people}, + "households": { + k: {"members": v, "state_code": {PERIOD: "OH"}} for k, v in groups.items() + }, + } + + +def by_name(simulation, variable): + values = simulation.calculate(variable, PERIOD) + names = simulation.persons.ids + return dict(zip(names, values)) + + +def test_explicit_zero_ids_match_omitted_ids_for_random_households(): + households = random_households(seed=9404) + omitted = Simulation(situation=situation(households, with_ids=False)) + zeros = Simulation( + situation=situation(households, with_ids=False, explicit_zero=True) + ) + for variable in MEDICAID_OUTPUTS: + np.testing.assert_array_equal( + zeros.calculate(variable, PERIOD), + omitted.calculate(variable, PERIOD), + err_msg=variable, + ) + + +def test_is_parent_from_ids_agrees_with_the_consistent_child_count(): + # When own_children_in_household counts exactly the people whose ids name + # a person, the id path and the count path must agree on who is a parent. + households = random_households(seed=884) + linked = Simulation(situation=situation(households, with_ids=True)) + counted = Simulation(situation=situation(households, with_ids=False)) + np.testing.assert_array_equal( + linked.calculate("is_parent", PERIOD), + counted.calculate("is_parent", PERIOD), + ) + + +def test_is_parent_is_invariant_to_member_order(): + households = random_households(seed=435603) + rng = np.random.default_rng(1) + order = [rng.permutation(len(members)) for members in households] + forward = Simulation(situation=situation(households, with_ids=True)) + shuffled = Simulation(situation=situation(households, with_ids=True, order=order)) + assert by_name(forward, "is_parent") == by_name(shuffled, "is_parent") + assert by_name(forward, "is_mother") == by_name(shuffled, "is_mother") From 57fa403eed576e12ab7ab42254bdacf1f1158010 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 12:23:21 -0400 Subject: [PATCH 04/24] fix: build Medicaid MAGI non-filer households from one membership rule Rework per the independent review of #9406 (REQUEST_CHANGES, six findings). - is_parent is now per person: own_children_in_household > 0 OR a co-resident person's parent id names them. Without ids it is main's expression; with consistent links both sources agree; one family's links can no longer erase another family's count-only parent (finding 2). - Households with no nonzero parent id keep main's family-sum expressions verbatim. Households with ids use medicaid_non_filer_member_sum, one pairwise 42 CFR 435.603(f)(3) predicate over co-residents: self, spouse, own child-age children (reverse links, for applicants of any age), and for child-age applicants their parents and child-age siblings. Each member is counted once (findings 1 and 4). - Main's family proxy survives only among unlinked people: child-age members without ids and members whose own_children_in_household exceeds the links naming them. The same rule drives the zero-id fallbacks of the claimed-by-parent test and the both-parents exception (finding 2). - medicaid_household_size, medicaid_household_income and California's ca_medicaid_household_pregnancies sum over that same membership, so an excluded aunt's pregnancy no longer enlarges a niece's household (finding 3). - Sums accumulate in float64; storage rounds (finding 5). - Parent ids resolve among tax-unit members for the claimed-by-parent test (identity) and among household members where the rule requires living together (non-filer membership, both-parents exception) (finding 6). - parent_1_id/parent_2_id document step and adoptive parents, 0 = unknown, nonzero ids, and int32 comparison. Test expectation changes, all from the rules above: - test_parent_links.py: the linked adult's household drops the unrelated child (size 3 -> 2, income 10,300.875 -> 10,100.375). The unrelated child has no ids and no family member reports a child the links omit, so its household is itself (size 3 -> 1, income -> 200.5). Previously the adult branch counted every child-age family member regardless of links. - Demographic parent_ids.yaml and the absent-parent Medicaid case: an adult whose own_children_in_household is positive stays a parent even when no child's id names them. Two resolution-only cases now set that count to 0 so they still isolate id resolution. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_parent_links.py | 10 +- .../gov/hhs/medicaid/income/parent_ids.yaml | 3 +- .../demographic/person/parent_ids.yaml | 18 +-- .../medicaid/income/_non_filer_household.py | 116 +++++++++++++++++ .../medicaid_claimed_by_parent_in_tax_unit.py | 42 ++++--- .../income/medicaid_household_income.py | 24 ++-- .../income/medicaid_household_size.py | 24 ++-- ...dent_exception_living_with_both_parents.py | 25 +++- .../ca_medicaid_household_pregnancies.py | 14 +++ .../demographic/person/_parent_links.py | 119 +++++++++++------- .../household/demographic/person/is_father.py | 3 +- .../household/demographic/person/is_mother.py | 4 +- .../household/demographic/person/is_parent.py | 18 ++- .../demographic/person/parent_1_id.py | 17 ++- .../demographic/person/parent_2_id.py | 17 ++- 15 files changed, 337 insertions(+), 117 deletions(-) create mode 100644 policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py diff --git a/policyengine_us/tests/core/test_parent_links.py b/policyengine_us/tests/core/test_parent_links.py index cc0fe316252..cc8c5cd1395 100644 --- a/policyengine_us/tests/core/test_parent_links.py +++ b/policyengine_us/tests/core/test_parent_links.py @@ -61,15 +61,19 @@ def test_parent_links_are_scoped_to_interleaved_households(): simulation.calculate("is_parent", 2026), [True, True, False, False, False, False], ) + # In the linked household, the adult's household is the adult and the child + # whose id names them (42 CFR 435.603(f)(3)(ii)); the unrelated child is + # nobody's child there. The unrelated child has no ids and no family member + # reports a child the links omit, so no parent or sibling can be inferred. + # The legacy household has no ids and keeps the family-sum proxy. np.testing.assert_array_equal( simulation.calculate("medicaid_household_size", 2026), - [3, 3, 2, 3, 3, 3], + [2, 3, 2, 3, 1, 3], ) # Exact binary fractions expose cross-household joins without tolerances. - # The unrelated child has no links and retains the family-sum fallback. np.testing.assert_array_equal( simulation.calculate("medicaid_household_income", 2026), - [10_300.875, 20_700.75, 10_100.375, 20_700.75, 10_300.875, 20_700.75], + [10_100.375, 20_700.75, 10_100.375, 20_700.75, 200.5, 20_700.75], ) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml index 04934cb5978..1eef8731787 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml @@ -625,7 +625,8 @@ household: members: [adult, child, sibling, unrelated_child] output: - is_parent: [false, false, false, false] + # The adult's own child count still marks a parent; no child's ids name them. + is_parent: [true, false, false, false] medicaid_uses_non_filer_rules: [false, true, true, true] medicaid_household_size: [4, 2, 2, 1] medicaid_household_income: [98_000, 18_000, 18_000, 0] diff --git a/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml b/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml index 50f88c821dd..05cd9e2948c 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml @@ -1,4 +1,6 @@ -- name: Parent ids override disagreeing child counts while breastfeeding still identifies mothers. +# is_parent is the union of the child count and co-resident parent links, so +# links add parents that a count misses and never erase one that a count reports. +- name: Parent ids add parents a zero child count misses, and a positive count still identifies a parent. period: 2026 input: people: @@ -26,8 +28,8 @@ household: members: [mother, father, unrelated_adult, child, breastfeeding_adult] output: - is_parent: [true, true, false, false, false] - is_mother: [true, false, false, false, true] + is_parent: [true, true, true, false, false] + is_mother: [true, false, true, false, true] is_father: [false, true, false, false, false] - name: Zero parent ids preserve the child-count inference and breastfeeding branch. @@ -59,7 +61,7 @@ is_mother: [true, false, true] is_father: [false, true, false] -- name: Parent inference uses household-local pointers and retains zero-id household fallback. +- name: Parent ids resolve only within the household, and child counts still identify parents. period: 2026 input: people: @@ -82,9 +84,9 @@ home: members: [child, unrelated_adult] output: - is_parent: [false, true, false, false] + is_parent: [false, true, false, true] is_mother: [false, true, false, false] - is_father: [false, false, false, false] + is_father: [false, false, false, true] - name: Unknown zero parent ids do not resolve to the default zero person id. period: 2026 @@ -92,7 +94,7 @@ people: zero_id_person: person_id: 0 - own_children_in_household: 2 + own_children_in_household: 0 child: person_id: 91 parent_1_id: 999 @@ -110,7 +112,7 @@ input: person_id: 92 parent_1_id: 92 - own_children_in_household: 1 + own_children_in_household: 0 output: is_parent: false is_mother: false diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py new file mode 100644 index 00000000000..57e87f1b99a --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -0,0 +1,116 @@ +"""Medicaid MAGI non-filer household membership from parent links.""" + +import numpy as np + +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, + has_parent_ids, + household_member_indices, + reports_unlinked_children, +) + + +def medicaid_non_filer_member_sum(person, period, values): + """Sum ``values`` once over each person's MAGI non-filer household. + + Callers use this in households with parent links and keep their original + family-level expressions elsewhere. Under 42 CFR 435.603(f)(3) the + household is the individual and, if living with the individual, (i) the + spouse, (ii) the individual's children under the age limit and (iii) for + an individual under the age limit, their parents and their siblings under + the age limit. Each pair of co-residents is tested once, so each member + counts once in size, income and California pregnancies. + + Links identify parents, children and siblings (siblings share a nonzero + parent id, including an absent parent). A child-age person without ids is + unlinked. The original family-level proxy still relates unlinked children + to each other and to family members who report unlinked children, so one + family's links never add or remove another family's relatives. + + A spouse is the other head or spouse of a joint return, or a cohabiting + spouse under the separate-filer rule, and counts only when co-resident. + + Values accumulate in float64; variable storage rounds the result. + """ + values = np.asarray(values, dtype=np.float64) + total = np.zeros(person.count, dtype=np.float64) + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + if not np.any((parent_1 != 0) | (parent_2 != 0)): + return total + + child = person("medicaid_non_filer_child_age_eligible", period) + unlinked_child = child & ~has_parent_ids(person, period) + reports_unlinked = reports_unlinked_children(person, period) + first, second = co_resident_parent_indices(person, period) + family = person.family.reference_entity.members_entity_id + + def reporting_parent_family(parent): + # Family of a linked parent who also reports unlinked children. + return np.where((parent >= 0) & reports_unlinked[parent], family[parent], -1) + + parent_family_1 = reporting_parent_family(first) + parent_family_2 = reporting_parent_family(second) + + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + head_spouse_count = person.tax_unit("head_spouse_count", period) + joint = head_or_spouse & (head_spouse_count == 2) + separate_spouse = ( + person.tax_unit("cohabitating_spouses", period) + & (head_spouse_count == 1) + & (head_or_spouse | person("claimed_as_dependent_on_another_return", period)) + ) + marital_unit = person.marital_unit.reference_entity.members_entity_id + + own_index = np.arange(person.count) + for member in household_member_indices(person): + is_self = member == own_index + same_family = family[member] == family + spouse = ~is_self & ( + (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) + | (separate_spouse & (marital_unit[member] == marital_unit)) + ) + own_child = child[member] & ( + (first[member] == own_index) + | (second[member] == own_index) + | (reports_unlinked & unlinked_child[member] & same_family) + ) + parent = child & ( + (first == member) + | (second == member) + | (unlinked_child & reports_unlinked[member] & same_family) + ) + shares_parent_id = ( + (parent_1 != 0) + & ((parent_1 == parent_1[member]) | (parent_1 == parent_2[member])) + ) | ( + (parent_2 != 0) + & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) + ) + sibling = ( + child + & child[member] + & ~is_self + & ( + shares_parent_id + | ( + unlinked_child[member] + & ( + (parent_family_1 == family[member]) + | (parent_family_2 == family[member]) + ) + ) + | ( + unlinked_child + & ( + (parent_family_1[member] == family) + | (parent_family_2[member] == family) + ) + ) + | (unlinked_child & unlinked_child[member] & same_family) + ) + ) + included = (member >= 0) & (is_self | spouse | own_child | parent | sibling) + total += np.where(included, values[member], 0.0) + return total diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py index 17df02fb207..6c810641bc3 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py @@ -1,6 +1,9 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( - co_resident_parent_indices, + has_parent_ids, + household_has_parent_ids, + reports_unlinked_children, + tax_unit_parent_indices, ) @@ -14,24 +17,35 @@ class medicaid_claimed_by_parent_in_tax_unit(Variable): reference = "https://www.law.cornell.edu/cfr/text/42/435.603#f_2" def formula(person, period, parameters): + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + qualifying_child = person("is_qualifying_child_dependent", period) # In tax-unit-only inputs, child dependents are usually the filer's # children even when parent-child links are not provided. has_parent_filer_in_tax_unit = person.tax_unit.any( - person("is_parent", period) & person("is_tax_unit_head_or_spouse", period) + person("is_parent", period) & head_or_spouse ) - inferred_parent = ( - person("is_qualifying_child_dependent", period) - | has_parent_filer_in_tax_unit + inferred_parent = qualifying_child | has_parent_filer_in_tax_unit + # In a household with parent links, a filer counts as the parent of a + # dependent without ids only when the filer reports children the links + # do not identify. + has_unlinked_parent_filer_in_tax_unit = person.tax_unit.any( + reports_unlinked_children(person, period) & head_or_spouse ) - first, second = co_resident_parent_indices(person, period) - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - tax_unit = person.tax_unit.reference_entity.members_entity_id - linked_parent = ( - (first >= 0) & head_or_spouse[first] & (tax_unit[first] == tax_unit) - ) | ((second >= 0) & head_or_spouse[second] & (tax_unit[second] == tax_unit)) - has_parent_ids = (person("parent_1_id", period) != 0) | ( - person("parent_2_id", period) != 0 + residual_parent = qualifying_child | has_unlinked_parent_filer_in_tax_unit + # A dependent's own ids name their parents. Parenthood does not depend + # on residence, so resolve the ids among the dependent's tax unit + # members, wherever each lives, and require a parent to be the head or + # spouse of that unit. + first, second = tax_unit_parent_indices(person, period) + linked_parent = ((first >= 0) & head_or_spouse[first]) | ( + (second >= 0) & head_or_spouse[second] ) return person("is_tax_unit_dependent", period) & where( - has_parent_ids, linked_parent, inferred_parent + has_parent_ids(person, period), + linked_parent, + where( + household_has_parent_ids(person, period), + residual_parent, + inferred_parent, + ), ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 51a15b11e00..57f0752b2b6 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -3,8 +3,11 @@ medicaid_claiming_tax_unit_value, medicaid_external_claimed_sum, ) +from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( + medicaid_non_filer_member_sum, +) from policyengine_us.variables.household.demographic.person._parent_links import ( - parent_and_child_sibling_sum, + household_has_parent_ids, ) @@ -51,21 +54,18 @@ def formula(person, period, parameters): family_parent_income = person.family.sum( person("is_parent", period) * member_income ) - has_parent_ids = (person("parent_1_id", period) != 0) | ( - person("parent_2_id", period) != 0 - ) - child_family_income = parent_and_child_sibling_sum( - person, period, child_age_eligible, member_income - ) non_filer_household_income = where( child_age_eligible, - where( - has_parent_ids, - spouse_income + child_family_income, - spouse_income + family_parent_income + family_child_income, - ), + spouse_income + family_parent_income + family_child_income, member_income + spouse_income + family_child_income, ) + # With parent links in the household, sum over the same members that + # medicaid_household_size counts. + non_filer_household_income = where( + household_has_parent_ids(person, period), + medicaid_non_filer_member_sum(person, period, member_income), + non_filer_household_income, + ) tax_household_income = ( person.tax_unit.sum(tax_member_income) + separate_spouse_income ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py index 5f6ddde104c..0e270878bfc 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py @@ -3,8 +3,11 @@ medicaid_claiming_tax_unit_value, medicaid_external_claimed_sum, ) +from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( + medicaid_non_filer_member_sum, +) from policyengine_us.variables.household.demographic.person._parent_links import ( - parent_and_child_sibling_sum, + household_has_parent_ids, ) @@ -40,21 +43,18 @@ def formula(person, period, parameters): int ) * cohabitating_separate.astype(int) spouse_count = same_unit_spouse_count + separate_spouse_count - has_parent_ids = (person("parent_1_id", period) != 0) | ( - person("parent_2_id", period) != 0 - ) - child_family_count = parent_and_child_sibling_sum( - person, period, child_age_eligible, np.ones(person.count, dtype=int) - ) non_filer_household_size = where( child_age_eligible, - where( - has_parent_ids, - spouse_count + child_family_count, - spouse_count + family_parent_count + family_child_count, - ), + spouse_count + family_parent_count + family_child_count, 1 + spouse_count + family_child_count, ) + # With parent links in the household, count each member of the + # applicant's own non-filer household once, spouse included. + non_filer_household_size = where( + household_has_parent_ids(person, period), + medicaid_non_filer_member_sum(person, period, np.ones(person.count)), + non_filer_household_size, + ) tax_household_size = person.tax_unit("tax_unit_size", period) + ( cohabitating_separate.astype(int) ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py index 23878fc62fe..1f0c5a61d2d 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py @@ -1,6 +1,9 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( co_resident_parent_indices, + has_parent_ids, + household_has_parent_ids, + reports_unlinked_children, ) @@ -12,8 +15,9 @@ class medicaid_tax_dependent_exception_living_with_both_parents(Variable): reference = "https://www.law.cornell.edu/cfr/text/42/435.603#f_2_ii" def formula(person, period, parameters): + # Living with both parents requires both to resolve in the household. first, second = co_resident_parent_indices(person, period) - two_parents = (first >= 0) & (second >= 0) & (first != second) + two_parents = (first >= 0) & (second >= 0) head = person("is_tax_unit_head", period) spouse = person("is_tax_unit_spouse", period) tax_unit = person.tax_unit.reference_entity.members_entity_id @@ -25,15 +29,26 @@ def formula(person, period, parameters): & joint[first] & person.tax_unit("tax_unit_is_filer", period)[first] ) - has_parent_ids = (person("parent_1_id", period) != 0) | ( - person("parent_2_id", period) != 0 - ) legacy_proxy = (person.family.sum(person("is_parent", period)) > 1) & ( person.tax_unit.sum(person("is_parent", period)) == 1 ) + # In a household with parent links, the family proxy for a child + # without ids counts only parents with children the links omit. + unlinked_parent = reports_unlinked_children(person, period) + residual_proxy = (person.family.sum(unlinked_parent) > 1) & ( + person.tax_unit.sum(unlinked_parent) == 1 + ) return ( person("is_tax_unit_dependent", period) & person("medicaid_non_filer_child_age_eligible", period) & person("medicaid_claimed_by_parent_in_tax_unit", period) - & where(has_parent_ids, two_parents & ~parents_file_jointly, legacy_proxy) + & where( + has_parent_ids(person, period), + two_parents & ~parents_file_jointly, + where( + household_has_parent_ids(person, period), + residual_proxy, + legacy_proxy, + ), + ) ) diff --git a/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py b/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py index 0d980e1ae08..3df716dc833 100644 --- a/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py +++ b/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py @@ -3,6 +3,12 @@ medicaid_claiming_tax_unit_value, medicaid_external_claimed_sum, ) +from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( + medicaid_non_filer_member_sum, +) +from policyengine_us.variables.household.demographic.person._parent_links import ( + household_has_parent_ids, +) class ca_medicaid_household_pregnancies(Variable): @@ -85,6 +91,14 @@ def spouse_has_flag(flag): non_filer_pregnancies = ( branch_pregnancies + spouse_pregnancies * ~spouse_in_branch ) + # With parent links in the household, sum over the same members that + # medicaid_household_size counts, so an excluded relative's pregnancy + # never enters the applicant's household. + non_filer_pregnancies = where( + household_has_parent_ids(person, period), + medicaid_non_filer_member_sum(person, period, pregnancies), + non_filer_pregnancies, + ) # Include a separately filing spouse for every member of this tax # household, including dependents whose own marital unit is a singleton. diff --git a/policyengine_us/variables/household/demographic/person/_parent_links.py b/policyengine_us/variables/household/demographic/person/_parent_links.py index d66b4d46323..f74f9b84f16 100644 --- a/policyengine_us/variables/household/demographic/person/_parent_links.py +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -1,23 +1,62 @@ -"""Household-scoped parent links, with linear temporary memory use.""" +"""Parent links from the optional parent_1_id and parent_2_id person inputs. + +Rules shared by every formula that reads the links: + +- Each id names the person_id of one of the person's parents (natural, + adoptive or step); 0 means unknown, so real person ids must be nonzero. +- Parenthood is identity and residence is a separate condition. Ids resolve + against the members of one group entity: the household when a rule requires + living together, the tax unit when a rule asks who claims the person. +- A person is a parent when own_children_in_household is positive or when a + co-resident person's id names them. Neither source erases the other. +- A household has links when any member has a nonzero id. Formulas keep + their original expressions in households without links. +- In a household with links, a person reports unlinked children when + own_children_in_household exceeds the co-resident people whose ids name + them. Such a person is the only kind of parent the family-level proxy may + still assign to a child-age member without ids. + +Temporary memory is linear in the number of people, and work is linear in +people times the largest household or tax unit size. +""" import numpy as np -def household_member_indices(person): - """Yield each co-resident's row index, projected to every household member. +def group_member_indices(group): + """Yield each co-member's row index, projected to every member of a group. - Iterate over household positions, not pairs of people in the population. - Membership comes from the entity structure, not optional household ID inputs. + ``group`` is a person-level projector such as ``person.household``. + Iterate over positions within the group, not over pairs of people. + Membership comes from the entity structure, not optional group ID inputs. A missing position is -1 and must be masked before using its values. """ - indices = np.arange(person.count) - positions = person.household.reference_entity.members_position + indices = np.arange(group.reference_entity.members.count) + positions = group.reference_entity.members_position for position in range(int(np.max(positions, initial=-1)) + 1): - yield person.household.value_nth_person(position, indices, default=-1) + yield group.value_nth_person(position, indices, default=-1) -def co_resident_parent_indices(person, period): - """Resolve the two parent slots to person rows; unknown links remain -1.""" +def household_member_indices(person): + """Yield each co-resident's row index, projected to every household member.""" + return group_member_indices(person.household) + + +def has_parent_ids(person, period): + return (person("parent_1_id", period) != 0) | (person("parent_2_id", period) != 0) + + +def household_has_parent_ids(person, period): + """Whether any member of each person's household has a nonzero parent id.""" + return person.household.any(has_parent_ids(person, period)) + + +def _parent_indices(person, period, group): + """Resolve both parent slots among the members of ``group``. + + Unresolved slots are -1. A second slot that repeats the first names no + second parent, so each resolved parent appears once per person. + """ parent_1 = person("parent_1_id", period) parent_2 = person("parent_2_id", period) first = np.full(person.count, -1, dtype=int) @@ -27,7 +66,7 @@ def co_resident_parent_indices(person, period): person_id = person("person_id", period) own_index = np.arange(person.count) - for member in household_member_indices(person): + for member in group_member_indices(group): valid = (member >= 0) & (member != own_index) member_id = person_id[member] first = np.where( @@ -36,41 +75,35 @@ def co_resident_parent_indices(person, period): second = np.where( valid & (parent_2 != 0) & (parent_2 == member_id), member, second ) + second = np.where(second == first, -1, second) return first, second -def parent_and_child_sibling_sum(person, period, child_age_eligible, values): - """Sum applicant, co-resident parents, and child-age siblings once each. +def co_resident_parent_indices(person, period): + """Rows of the parents each person's ids name within their household.""" + return _parent_indices(person, period, person.household) + - Used for the child non-filer household under 42 CFR 435.603(f)(3)(iv). - Siblings share at least one nonzero parent ID, including half siblings. - A shared absent parent can identify siblings without adding that parent. - The caller selects its existing family sum when both parent IDs are zero. +def tax_unit_parent_indices(person, period): + """Rows of the parents each person's ids name within their tax unit. + + A parent who claims the person is recognized wherever either one lives. """ - parent_1 = person("parent_1_id", period) - parent_2 = person("parent_2_id", period) - result = np.zeros(person.count, dtype=np.asarray(values).dtype) - if not np.any((parent_1 != 0) | (parent_2 != 0)): - return result + return _parent_indices(person, period, person.tax_unit) - person_id = person("person_id", period) - own_index = np.arange(person.count) - for member in household_member_indices(person): - member_id = person_id[member] - is_parent = ((parent_1 != 0) & (parent_1 == member_id)) | ( - (parent_2 != 0) & (parent_2 == member_id) - ) - shares_parent = ( - (parent_1 != 0) - & ((parent_1 == parent_1[member]) | (parent_1 == parent_2[member])) - ) | ( - (parent_2 != 0) - & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) - ) - included = (member >= 0) & ( - (member == own_index) - | is_parent - | (child_age_eligible[member] & shares_parent) - ) - result += np.where(included, values[member], 0) - return result + +def linked_child_count(person, period): + """Count the co-resident people whose parent ids name each person.""" + first, second = co_resident_parent_indices(person, period) + parents = np.concatenate((first, second)) + return np.bincount(parents[parents >= 0], minlength=person.count) + + +def reports_unlinked_children(person, period): + """Whether a person reports more own children than the links identify. + + In a household without links this equals own_children_in_household > 0. + """ + return person("own_children_in_household", period) > linked_child_count( + person, period + ) diff --git a/policyengine_us/variables/household/demographic/person/is_father.py b/policyengine_us/variables/household/demographic/person/is_father.py index 908900ecc4b..e3cb5a77e35 100644 --- a/policyengine_us/variables/household/demographic/person/is_father.py +++ b/policyengine_us/variables/household/demographic/person/is_father.py @@ -8,8 +8,7 @@ class is_father(Variable): definition_period = YEAR def formula(person, period, parameters): - # is_parent uses co-resident parent links when available and otherwise - # the child count. + # is_parent combines the child count with co-resident parent links. female = person("is_female", period) has_children = person("is_parent", period) return ~female & has_children diff --git a/policyengine_us/variables/household/demographic/person/is_mother.py b/policyengine_us/variables/household/demographic/person/is_mother.py index 9ffca441122..7c85f7b6892 100644 --- a/policyengine_us/variables/household/demographic/person/is_mother.py +++ b/policyengine_us/variables/household/demographic/person/is_mother.py @@ -8,8 +8,8 @@ class is_mother(Variable): definition_period = YEAR def formula(person, period, parameters): - # is_parent uses co-resident parent links when available and otherwise - # the child count. Breastfeeding independently identifies a mother. + # is_parent combines the child count with co-resident parent links. + # Breastfeeding independently identifies a mother. female = person("is_female", period) has_children = person("is_parent", period) breastfeeding = person("is_breastfeeding", period) diff --git a/policyengine_us/variables/household/demographic/person/is_parent.py b/policyengine_us/variables/household/demographic/person/is_parent.py index f64a0fb2225..1d5c6066583 100644 --- a/policyengine_us/variables/household/demographic/person/is_parent.py +++ b/policyengine_us/variables/household/demographic/person/is_parent.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( - co_resident_parent_indices, + linked_child_count, ) @@ -11,14 +11,10 @@ class is_parent(Variable): definition_period = YEAR def formula(person, period, parameters): - has_parent_ids = (person("parent_1_id", period) != 0) | ( - person("parent_2_id", period) != 0 - ) - first, second = co_resident_parent_indices(person, period) - parents = np.concatenate((first, second)) - linked_parent = np.bincount(parents[parents >= 0], minlength=person.count) > 0 - return where( - person.household.any(has_parent_ids), - linked_parent, - person("own_children_in_household", period) > 0, + # A person is a parent when their own child count is positive or when + # a co-resident person's parent id names them. Neither source erases + # the other, so links for one family never remove another family's + # parent. Without ids this is the child count alone. + return (person("own_children_in_household", period) > 0) | ( + linked_child_count(person, period) > 0 ) diff --git a/policyengine_us/variables/household/demographic/person/parent_1_id.py b/policyengine_us/variables/household/demographic/person/parent_1_id.py index 7b3f6e64148..74e5a586f63 100644 --- a/policyengine_us/variables/household/demographic/person/parent_1_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_1_id.py @@ -4,7 +4,20 @@ class parent_1_id(Variable): value_type = int entity = Person - label = "First co-resident parent's person ID" + label = "First parent's person ID" definition_period = YEAR default_value = 0 - documentation = "person_id of a co-resident parent; 0 when unknown" + documentation = ( + "person_id of one of this person's parents, 0 when unknown. Name " + "natural, adoptive and step parents alike: 42 CFR 435.603(b) defines " + "parents, children and siblings to include step relatives, and CPS " + "PEPAR1 and PEPAR2 name a co-resident parent of any type. An id " + "resolves among household members for rules that require living " + "together and among tax unit members for the claimed-by-parent test; " + "an id that resolves to neither is an absent parent, which still " + "identifies siblings. Because 0 means unknown, real person ids must be " + "nonzero, and the default person_id starts at 0, so supply person_id " + "with parent ids. Ids are compared after int32 storage, so every " + "member of a household and of a tax unit needs a distinct nonzero " + "int32 person_id." + ) diff --git a/policyengine_us/variables/household/demographic/person/parent_2_id.py b/policyengine_us/variables/household/demographic/person/parent_2_id.py index e15938d0c3f..54b2251d9bc 100644 --- a/policyengine_us/variables/household/demographic/person/parent_2_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_2_id.py @@ -4,7 +4,20 @@ class parent_2_id(Variable): value_type = int entity = Person - label = "Second co-resident parent's person ID" + label = "Second parent's person ID" definition_period = YEAR default_value = 0 - documentation = "person_id of a co-resident parent; 0 when unknown" + documentation = ( + "person_id of one of this person's parents, 0 when unknown. Name " + "natural, adoptive and step parents alike: 42 CFR 435.603(b) defines " + "parents, children and siblings to include step relatives, and CPS " + "PEPAR1 and PEPAR2 name a co-resident parent of any type. An id " + "resolves among household members for rules that require living " + "together and among tax unit members for the claimed-by-parent test; " + "an id that resolves to neither is an absent parent, which still " + "identifies siblings. Because 0 means unknown, real person ids must be " + "nonzero, and the default person_id starts at 0, so supply person_id " + "with parent ids. Ids are compared after int32 storage, so every " + "member of a household and of a tax unit needs a distinct nonzero " + "int32 person_id." + ) From df146cfa4252bcb882236559868212e5f5af4c3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 13:11:00 -0400 Subject: [PATCH 05/24] fix: evaluate main's both-parents terms first and skip links without ids The zero-id differential found that, for 1986-1987 emergency Medicaid cases where both main and this branch raise ParameterNotFoundError, the branch raised a different error because it read filing_status before main's own terms. Evaluate main's terms first and compute the link-based condition only when some person has a parent id, so simulations without ids make exactly main's calls. Co-Authored-By: Claude Opus 5.5 --- ...dent_exception_living_with_both_parents.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py index 1f0c5a61d2d..1d524b85c40 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py @@ -15,6 +15,18 @@ class medicaid_tax_dependent_exception_living_with_both_parents(Variable): reference = "https://www.law.cornell.edu/cfr/text/42/435.603#f_2_ii" def formula(person, period, parameters): + claimed_child = ( + person("is_tax_unit_dependent", period) + & person("medicaid_non_filer_child_age_eligible", period) + & person("medicaid_claimed_by_parent_in_tax_unit", period) + ) + legacy_proxy = (person.family.sum(person("is_parent", period)) > 1) & ( + person.tax_unit.sum(person("is_parent", period)) == 1 + ) + own_ids = has_parent_ids(person, period) + if not np.any(own_ids): + return claimed_child & legacy_proxy + # Living with both parents requires both to resolve in the household. first, second = co_resident_parent_indices(person, period) two_parents = (first >= 0) & (second >= 0) @@ -29,26 +41,18 @@ def formula(person, period, parameters): & joint[first] & person.tax_unit("tax_unit_is_filer", period)[first] ) - legacy_proxy = (person.family.sum(person("is_parent", period)) > 1) & ( - person.tax_unit.sum(person("is_parent", period)) == 1 - ) # In a household with parent links, the family proxy for a child # without ids counts only parents with children the links omit. unlinked_parent = reports_unlinked_children(person, period) residual_proxy = (person.family.sum(unlinked_parent) > 1) & ( person.tax_unit.sum(unlinked_parent) == 1 ) - return ( - person("is_tax_unit_dependent", period) - & person("medicaid_non_filer_child_age_eligible", period) - & person("medicaid_claimed_by_parent_in_tax_unit", period) - & where( - has_parent_ids(person, period), - two_parents & ~parents_file_jointly, - where( - household_has_parent_ids(person, period), - residual_proxy, - legacy_proxy, - ), - ) + return claimed_child & where( + own_ids, + two_parents & ~parents_file_jointly, + where( + household_has_parent_ids(person, period), + residual_proxy, + legacy_proxy, + ), ) From 5934620b91308b3fa20b5a2017485601208fa190 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 13:11:01 -0400 Subject: [PATCH 06/24] test: regressions for every review counterexample and composition properties - parent_ids_composition.yaml: teen parent (G40/T17/B1), mixed families (A35/C10 + B40/D8, as separate and as one family entity), another family's linked parent and the both-parents exception, California pregnancies (G60/M35/A30/C10/B2), adult sibling (G60/A25/B15), claimant in another household (P55/C26), stepfamily, married 18-year-olds, and the age-19 boundary for siblings and children. Expected values follow 42 CFR 435.603(f)(3) and ACWDL 20-10; 8 of the 10 fail on 08eaff997. - test_parent_links.py: float64 accumulation (2**24 + 1 + 1) in both member orders. - test_parent_links_properties.py: on seeded random households with teen parents, absent parents, spouses and pregnancies, the linked composition equals the regulation's on consistent data and a scalar statement of the documented rule on data with dropped ids, extra counts and split families; size >= 1; CA pregnancies and income sum over the same members; outputs are invariant to member order; is_parent is exactly the union of the child count and the links. - Changelog fragment describes the reworked behavior. Co-Authored-By: Claude Opus 5.5 --- changelog.d/medicaid-magi-parent-ids.fixed.md | 2 +- .../tests/core/test_parent_links.py | 45 ++ .../core/test_parent_links_properties.py | 255 ++++++++ .../income/parent_ids_composition.yaml | 548 ++++++++++++++++++ 4 files changed, 849 insertions(+), 1 deletion(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml diff --git a/changelog.d/medicaid-magi-parent-ids.fixed.md b/changelog.d/medicaid-magi-parent-ids.fixed.md index 360030ea696..1c8c75ead27 100644 --- a/changelog.d/medicaid-magi-parent-ids.fixed.md +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -1 +1 @@ -Use optional co-resident parent identifiers for Medicaid MAGI household composition and parent status, preserving existing household outputs when all parent identifiers are zero. +Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the tax unit. Households without parent identifiers keep their existing outputs. diff --git a/policyengine_us/tests/core/test_parent_links.py b/policyengine_us/tests/core/test_parent_links.py index cc8c5cd1395..a5a504d2db6 100644 --- a/policyengine_us/tests/core/test_parent_links.py +++ b/policyengine_us/tests/core/test_parent_links.py @@ -152,3 +152,48 @@ def test_explicit_zero_parent_ids_exactly_match_omitted_inputs(): np.testing.assert_array_equal( zeros.calculate("medicaid_household_size", 2026), [2, 1, 3] ) + + +def test_linked_household_income_accumulates_in_float64(): + # float32 accumulation rounds 2**24 + 1 back to 2**24 after each addition; + # the exact total 2**24 + 2 is representable in float32 storage. + def household_income(order): + inputs = { + "parent": {"age": 40, "person_id": 1, "income": 16_777_216}, + "first_child": {"age": 10, "person_id": 2, "income": 1, "parent": 1}, + "second_child": {"age": 8, "person_id": 3, "income": 1, "parent": 1}, + } + people = { + name: { + "age": {"2026": inputs[name]["age"]}, + "person_id": {"2026": inputs[name]["person_id"]}, + "parent_1_id": {"2026": inputs[name].get("parent", 0)}, + "medicaid_uses_non_filer_rules": {"2026": True}, + "medicaid_household_income_member": {"2026": inputs[name]["income"]}, + } + for name in order + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": {name: {"members": [name]} for name in order}, + "families": {"family": {"members": order}}, + "marital_units": {name: {"members": [name]} for name in order}, + "households": { + "household": {"members": order, "state_code": {"2026": "OH"}} + }, + } + ) + values = simulation.calculate("medicaid_household_income", 2026) + return dict(zip(simulation.persons.ids, values)) + + for order in ( + ["parent", "first_child", "second_child"], + ["second_child", "first_child", "parent"], + ): + income = household_income(order) + assert income == { + "parent": 16_777_218, + "first_child": 16_777_218, + "second_child": 16_777_218, + }, order diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index 21783bdea69..50a98b9d9c5 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -144,3 +144,258 @@ def test_is_parent_is_invariant_to_member_order(): shuffled = Simulation(situation=situation(households, with_ids=True, order=order)) assert by_name(forward, "is_parent") == by_name(shuffled, "is_parent") assert by_name(forward, "is_mother") == by_name(shuffled, "is_mother") + + +# Linked households for the Medicaid MAGI non-filer composition. + +AGE_LIMIT = 19 # gov.hhs.medicaid.household.child_age_limit.non_student + + +def random_linked_households( + seed, *, drop_ids=0.0, extra_count=0.0, split_families=0.0, n=40 +): + """Households with true parent-child relationships and recorded ids. + + Children draw zero to two co-resident parents at least 14 years older + (teen parents included) and, when they have fewer than two, possibly an + absent parent whose id another child can share. Every child has at least + one parent. ``drop_ids`` zeroes a child's recorded ids while its parents + still count it; ``extra_count`` adds a child no id names to an adult's + count; ``split_families`` spreads members over two family entities. + Incomes are multiples of 1/8 below 2**20, so float32 storage and float64 + sums are exact in any order. + """ + rng = np.random.default_rng(seed) + households = [] + for h in range(n): + people = [] + for a in range(int(rng.integers(1, 4))): + people.append({"name": f"h{h}_a{a}", "age": int(rng.integers(18, 70))}) + married = len(people) >= 2 and rng.random() < 0.4 + # Older children come first, so a teen can parent a later baby. + ages = sorted(rng.integers(0, 21, size=int(rng.integers(0, 5))), reverse=True) + if rng.random() < 0.3: + ages = [int(rng.integers(14, 19))] + ages + [int(rng.integers(0, 2))] + for c, age in enumerate(ages): + people.append({"name": f"h{h}_c{c}", "age": int(age)}) + for i, person in enumerate(people): + candidates = [k for k in range(i) if people[k]["age"] >= person["age"] + 14] + is_child = person["name"].split("_")[1].startswith("c") + k = int(rng.integers(1 if not candidates else 0, 3)) if is_child else 0 + k = min(k, len(candidates)) + parents = [int(p) for p in rng.choice(candidates, size=k, replace=False)] + absent = [] + if is_child and len(parents) < 2 and (not parents or rng.random() < 0.3): + absent = [900 + int(rng.integers(0, 3))] + recorded = [p + 1 for p in parents] + absent + rng.shuffle(recorded) + person.update( + parents=parents, + absent=absent, + recorded=[0, 0] + if is_child and rng.random() < drop_ids + else (recorded + [0, 0])[:2], + extra=int(not is_child and rng.random() < extra_count), + family=int(rng.random() < split_families), + income=float(rng.integers(0, 80_000 * 8)) / 8, + pregnancies=int(12 <= person["age"] <= 45 and rng.random() < 0.15), + spouse=None, + ) + if married: + people[0]["spouse"], people[1]["spouse"] = 1, 0 + households.append(people) + return households + + +def linked_situation(households, order=None): + people, households_, families, tax_units, marital_units = {}, {}, {}, {}, {} + for h, members in enumerate(households): + indices = range(len(members)) if order is None else order[h] + names = [] + for i in indices: + i = int(i) + person = members[i] + children = sum(i in other["parents"] for other in members) + people[person["name"]] = { + "age": {PERIOD: person["age"]}, + "person_id": {PERIOD: i + 1}, + "parent_1_id": {PERIOD: person["recorded"][0]}, + "parent_2_id": {PERIOD: person["recorded"][1]}, + "own_children_in_household": {PERIOD: children + person["extra"]}, + "current_pregnancies": {PERIOD: person["pregnancies"]}, + "medicaid_uses_non_filer_rules": {PERIOD: True}, + "medicaid_household_income_member": {PERIOD: person["income"]}, + } + names.append(person["name"]) + households_[f"h{h}"] = {"members": names, "state_code": {PERIOD: "CA"}} + for f in (0, 1): + family = [n for n in names if members[_index(members, n)]["family"] == f] + if family: + families[f"h{h}_f{f}"] = {"members": family} + couple = [n for n in names if members[_index(members, n)]["spouse"] is not None] + rest = [n for n in names if n not in couple] + if couple: + tax_units[f"h{h}_couple"] = { + "members": couple, + "filing_status": {PERIOD: "JOINT"}, + } + marital_units[f"h{h}_couple"] = {"members": couple} + # Everyone outside the couple files alone: PE treats any tax unit with + # two adults as married. + for name in rest: + tax_units[name] = {"members": [name]} + marital_units[name] = {"members": [name]} + return { + "people": people, + "households": households_, + "families": families, + "tax_units": tax_units, + "spm_units": {k: {"members": v["members"]} for k, v in households_.items()}, + "marital_units": marital_units, + } + + +def _index(members, name): + return next(i for i, person in enumerate(members) if person["name"] == name) + + +def true_members(members, i): + """42 CFR 435.603(f)(3) membership from the true relationships.""" + person = members[i] + child = person["age"] < AGE_LIMIT + result = {i} + for m, other in enumerate(members): + other_child = other["age"] < AGE_LIMIT + shared = set(person["parents"]) & set(other["parents"]) or ( + set(person["absent"]) & set(other["absent"]) + ) + if ( + person["spouse"] == m + or (other_child and i in other["parents"]) + or (child and m in person["parents"]) + or (child and other_child and m != i and shared) + ): + result.add(m) + return result + + +def rule_members(members, i): + """Scalar statement of the membership rule documented for data with gaps. + + Links resolve by recorded id. Main's family proxy survives only among + unlinked people: child-age members without ids and members reporting + more own children than the ids naming them. + """ + ids = [person["recorded"] for person in members] + by_id = {k + 1: k for k in range(len(members))} + parents = [ + {by_id[x] for x in pair if x in by_id and by_id[x] != k} + for k, pair in enumerate(ids) + ] + child = [person["age"] < AGE_LIMIT for person in members] + unlinked = [child[k] and not any(ids[k]) for k in range(len(members))] + linked_children = [sum(k in p for p in parents) for k in range(len(members))] + reports = [ + sum(k in other["parents"] for other in members) + members[k]["extra"] + > linked_children[k] + for k in range(len(members)) + ] + family = [person["family"] for person in members] + + def reporting_parent_in(k, fam): + return any(reports[p] and family[p] == fam for p in parents[k]) + + result = {i} + for m in range(len(members)): + if m == i: + continue + same_family = family[m] == family[i] + spouse = members[i]["spouse"] == m + own_child = child[m] and ( + i in parents[m] or (reports[i] and unlinked[m] and same_family) + ) + parent = child[i] and ( + m in parents[i] or (unlinked[i] and reports[m] and same_family) + ) + sibling = ( + child[i] + and child[m] + and ( + bool(({*ids[i]} - {0}) & ({*ids[m]} - {0})) + or (unlinked[m] and reporting_parent_in(i, family[m])) + or (unlinked[i] and reporting_parent_in(m, family[i])) + or (unlinked[i] and unlinked[m] and same_family) + ) + ) + if spouse or own_child or parent or sibling: + result.add(m) + return result + + +def check_composition(households, members_of): + simulation = Simulation(situation=linked_situation(households)) + size = by_name(simulation, "medicaid_household_size") + income = by_name(simulation, "medicaid_household_income") + pregnancies = by_name(simulation, "ca_medicaid_household_pregnancies") + # Households without any nonzero id keep main's family-sum expressions, + # which the zero-id differential covers; check every household with ids. + linked = [ + people for people in households if any(any(p["recorded"]) for p in people) + ] + assert len(linked) >= len(households) // 2 + for people in linked: + for i, person in enumerate(people): + members = members_of(people, i) + expected_pregnancies = sum(people[m]["pregnancies"] for m in members) + name = person["name"] + assert size[name] >= 1, name + assert pregnancies[name] == expected_pregnancies, name + # California counts each member's unborn children in the size. + assert size[name] == len(members) + expected_pregnancies, name + assert income[name] == np.float32( + sum(people[m]["income"] for m in members) + ), name + + +def test_linked_composition_matches_the_regulation_on_consistent_data(): + # Consistent ids and counts: every relationship is recorded, so the + # household must be exactly the one 42 CFR 435.603(f)(3) defines. + check_composition(random_linked_households(seed=435603), true_members) + + +def test_linked_composition_follows_the_documented_rule_on_gappy_data(): + households = random_linked_households( + seed=9406, drop_ids=0.3, extra_count=0.2, split_families=0.3 + ) + check_composition(households, rule_members) + + +def test_linked_composition_is_invariant_to_member_order(): + households = random_linked_households( + seed=20260926, drop_ids=0.3, extra_count=0.2, split_families=0.3 + ) + rng = np.random.default_rng(2) + order = [rng.permutation(len(members)) for members in households] + forward = Simulation(situation=linked_situation(households)) + shuffled = Simulation(situation=linked_situation(households, order=order)) + for variable in [ + "is_parent", + "medicaid_household_size", + "medicaid_household_income", + "ca_medicaid_household_pregnancies", + ]: + assert by_name(forward, variable) == by_name(shuffled, variable), variable + + +def test_is_parent_is_the_union_of_the_child_count_and_the_links(): + # Neither source erases the other, whatever the gaps in the data. + households = random_linked_households( + seed=884, drop_ids=0.3, extra_count=0.2, split_families=0.3 + ) + simulation = Simulation(situation=linked_situation(households)) + is_parent = by_name(simulation, "is_parent") + for people in households: + for i, person in enumerate(people): + counted = sum(i in other["parents"] for other in people) + person["extra"] + named = any(i + 1 in other["recorded"] for other in people) + assert is_parent[person["name"]] == (counted > 0 or named), person["name"] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml new file mode 100644 index 00000000000..54895bc9601 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -0,0 +1,548 @@ +# Regressions for the independent review of policyengine-us#9406. +# Expected values come from 42 CFR 435.603(f)(3): a non-filer's household is +# the individual and, if living with them, their spouse, their children under +# 19 and, for an individual under 19, their parents and siblings under 19. +# 42 CFR 435.603(b) defines parents, children and siblings to include step +# relatives. Unless a case says otherwise, non-filer status and countable +# member income are inputs so each case isolates household membership. + +- name: A teen parent's household includes her own baby as well as her parent. + period: 2026 + input: + people: + grandmother: + person_id: 1 + age: 40 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + teen: + person_id: 2 + age: 17 + own_children_in_household: 1 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + baby: + person_id: 3 + age: 1 + parent_1_id: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + grandmother_unit: + members: [grandmother] + teen_unit: + members: [teen] + baby_unit: + members: [baby] + families: + family: + members: [grandmother, teen, baby] + marital_units: + grandmother: + members: [grandmother] + teen: + members: [teen] + baby: + members: [baby] + households: + household: + members: [grandmother, teen, baby] + state_code: OH + output: + # Grandmother: herself and her child under 19 (the teen), not the grandchild. + # Teen: herself, her child (the baby) and her parent (the grandmother). + # Baby: itself and its parent (the teen). + medicaid_household_size: [2, 3, 2] + medicaid_household_income: [30_000, 30_000, 0] + +- name: Links for one family do not erase another family's parent in the same household. + period: 2026 + input: + people: + parent_a: + person_id: 1 + age: 35 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 10_000 + child_c: + person_id: 2 + age: 10 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + parent_b: + person_id: 3 + age: 40 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + child_d: + person_id: 4 + age: 8 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + a_unit: + members: [parent_a, child_c] + b_unit: + members: [parent_b, child_d] + families: + a_family: + members: [parent_a, child_c] + b_family: + members: [parent_b, child_d] + marital_units: + parent_a: + members: [parent_a] + child_c: + members: [child_c] + parent_b: + members: [parent_b] + child_d: + members: [child_d] + households: + household: + members: [parent_a, child_c, parent_b, child_d] + state_code: OH + output: + # B's own child count still makes B a parent though no id names B. + is_parent: [true, false, true, false] + medicaid_household_size: [2, 2, 2, 2] + medicaid_household_income: [10_000, 10_000, 20_000, 20_000] + +- name: Links for one family do not erase another family's parent even in one family group. + period: 2026 + input: + people: + parent_a: + person_id: 1 + age: 35 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 10_000 + child_c: + person_id: 2 + age: 10 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + parent_b: + person_id: 3 + age: 40 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + child_d: + person_id: 4 + age: 8 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + a_unit: + members: [parent_a, child_c] + b_unit: + members: [parent_b, child_d] + families: + family: + members: [parent_a, child_c, parent_b, child_d] + marital_units: + parent_a: + members: [parent_a] + child_c: + members: [child_c] + parent_b: + members: [parent_b] + child_d: + members: [child_d] + households: + household: + members: [parent_a, child_c, parent_b, child_d] + state_code: OH + output: + # A's one reported child is C, whose id names A, so A is not D's parent and + # C is not D's sibling. B reports a child no id names; D has no ids, so D + # is B's child. + is_parent: [true, false, true, false] + medicaid_household_size: [2, 2, 2, 2] + medicaid_household_income: [10_000, 10_000, 20_000, 20_000] + +- name: Another family's linked parent does not make a child without ids live with both parents. + period: 2026 + input: + people: + parent_a: + person_id: 1 + age: 35 + employment_income: 10_000 + own_children_in_household: 1 + child_c: + person_id: 2 + age: 10 + parent_1_id: 1 + parent_b: + person_id: 3 + age: 40 + employment_income: 20_000 + own_children_in_household: 1 + child_d: + person_id: 4 + age: 8 + tax_units: + a_unit: + members: [parent_a, child_c] + tax_unit_is_filer: true + b_unit: + members: [parent_b, child_d] + tax_unit_is_filer: true + families: + family: + members: [parent_a, child_c, parent_b, child_d] + marital_units: + parent_a: + members: [parent_a] + child_c: + members: [child_c] + parent_b: + members: [parent_b] + child_d: + members: [child_d] + households: + household: + members: [parent_a, child_c, parent_b, child_d] + state_code: OH + output: + # D is claimed by its only parent, B, so D uses B's tax household under + # 42 CFR 435.603(f)(2); A is C's parent, not D's. + medicaid_claimed_by_parent_in_tax_unit: [false, true, false, true] + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, false, false] + medicaid_uses_non_filer_rules: [false, false, false, false] + medicaid_household_size: [2, 2, 2, 2] + medicaid_household_income: [10_000, 10_000, 20_000, 20_000] + +- name: California counts only the pregnancies of the applicant's own MAGI household members. + period: 2026 + input: + people: + grandparent: + person_id: 1 + age: 60 + own_children_in_household: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 40_000 + mother: + person_id: 2 + age: 35 + own_children_in_household: 1 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + aunt: + person_id: 3 + age: 30 + is_female: true + current_pregnancies: 1 + own_children_in_household: 1 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 10_000 + child: + person_id: 4 + age: 10 + parent_1_id: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + cousin: + person_id: 5 + age: 2 + parent_1_id: 3 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + grandparent_unit: + members: [grandparent] + mother_unit: + members: [mother, child] + aunt_unit: + members: [aunt, cousin] + families: + family: + members: [grandparent, mother, aunt, child, cousin] + marital_units: + grandparent: + members: [grandparent] + mother: + members: [mother] + aunt: + members: [aunt] + child: + members: [child] + cousin: + members: [cousin] + households: + household: + members: [grandparent, mother, aunt, child, cousin] + state_code: CA + output: + # ACWDL 20-10 adds the unborn children of pregnant members of the + # applicant's own MAGI household. The aunt is in her own household and + # her child's, not the grandparent's, the mother's or the niece's. + ca_medicaid_household_pregnancies: [0, 0, 1, 0, 1] + medicaid_household_size: [1, 2, 3, 2, 3] + medicaid_household_income: [40_000, 20_000, 10_000, 20_000, 10_000] + +- name: A childless adult non-filer excludes a child-age sibling. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 60 + own_children_in_household: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + adult_child: + person_id: 2 + age: 25 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + minor_child: + person_id: 3 + age: 15 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + tax_units: + parent_unit: + members: [parent] + adult_child_unit: + members: [adult_child] + minor_child_unit: + members: [minor_child] + families: + family: + members: [parent, adult_child, minor_child] + marital_units: + parent: + members: [parent] + adult_child: + members: [adult_child] + minor_child: + members: [minor_child] + households: + household: + members: [parent, adult_child, minor_child] + state_code: OH + output: + # The 25-year-old is over the age limit, so siblings are not household + # members and the household is the individual alone. + medicaid_household_size: [2, 1, 2] + medicaid_household_income: [50_000, 0, 50_000] + +- name: A parent who claims an adult child in another household is recognized by identity. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 55 + employment_income: 20_000 + adult_child: + person_id: 2 + age: 26 + parent_1_id: 1 + is_tax_unit_spouse: false + tax_units: + unit: + members: [parent, adult_child] + tax_unit_is_filer: true + families: + parent_family: + members: [parent] + child_family: + members: [adult_child] + marital_units: + parent: + members: [parent] + adult_child: + members: [adult_child] + households: + parent_home: + members: [parent] + state_code: OH + child_home: + members: [adult_child] + state_code: OH + output: + # The child is claimed by a parent, so 42 CFR 435.603(f)(2)(i) does not + # apply and the household is the claiming parent's tax household. + medicaid_claimed_by_parent_in_tax_unit: [false, true] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, false] + medicaid_uses_non_filer_rules: [false, false] + medicaid_household_size: [2, 2] + medicaid_household_income: [20_000, 20_000] + +- name: Step parents and step siblings count once parent ids name them. + period: 2026 + input: + people: + mother: + person_id: 1 + age: 38 + own_children_in_household: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + stepfather: + person_id: 2 + age: 40 + own_children_in_household: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + her_child: + person_id: 3 + age: 10 + # Natural mother and stepfather. + parent_1_id: 1 + parent_2_id: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + his_child: + person_id: 4 + age: 8 + # Natural father and stepmother. + parent_1_id: 2 + parent_2_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + unit: + members: [mother, stepfather, her_child, his_child] + filing_status: JOINT + families: + family: + members: [mother, stepfather, her_child, his_child] + marital_units: + couple: + members: [mother, stepfather] + her_child: + members: [her_child] + his_child: + members: [his_child] + households: + household: + members: [mother, stepfather, her_child, his_child] + state_code: OH + output: + # Each spouse includes the other once, plus both children; each child + # includes both parents and its step sibling. + medicaid_household_size: [4, 4, 4, 4] + medicaid_household_income: [50_000, 50_000, 50_000, 50_000] + +- name: A married 18-year-old counts a spouse once and a parent only through links. + period: 2026 + input: + people: + mother: + person_id: 1 + age: 45 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + wife: + person_id: 2 + age: 18 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 5_000 + husband: + person_id: 3 + age: 18 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 7_000 + tax_units: + mother_unit: + members: [mother] + couple_unit: + members: [wife, husband] + filing_status: JOINT + families: + family: + members: [mother, wife, husband] + marital_units: + mother: + members: [mother] + couple: + members: [wife, husband] + households: + household: + members: [mother, wife, husband] + state_code: OH + output: + # Wife: herself, her spouse and her parent. Husband: himself and his + # spouse; his parents do not live with him. Mother: herself and her child + # under 19. Nobody counts twice though both spouses are child-age. + medicaid_household_size: [2, 3, 2] + medicaid_household_income: [35_000, 42_000, 12_000] + +- name: Age-limit boundaries apply to siblings and to children. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + own_children_in_household: 3 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + child_18: + person_id: 2 + age: 18 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 1_000 + child_19: + person_id: 3 + age: 19 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 2_000 + child_10: + person_id: 4 + age: 10 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 4_000 + tax_units: + parent_unit: + members: [parent] + child_18_unit: + members: [child_18] + child_19_unit: + members: [child_19] + child_10_unit: + members: [child_10] + families: + family: + members: [parent, child_18, child_19, child_10] + marital_units: + parent: + members: [parent] + child_18: + members: [child_18] + child_19: + members: [child_19] + child_10: + members: [child_10] + households: + household: + members: [parent, child_18, child_19, child_10] + state_code: OH + output: + # 18 is under the age limit of 19 and 19 is not, both as a child of the + # parent and as a sibling. The 19-year-old is an adult without children. + medicaid_non_filer_child_age_eligible: [false, true, false, true] + medicaid_household_size: [3, 3, 1, 3] + medicaid_household_income: [35_000, 35_000, 2_000, 35_000] From 2e922171a7338719ef802b864b0b46e8721ac558 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 13:29:56 -0400 Subject: [PATCH 07/24] fix: address the r2 review of the parent-id rework An independent r2 review confirmed all six r1 findings fixed and found: - Blocker: a married dependent lost a co-resident spouse who files separately, because the linked spouse test required a tax-unit role. A spouse is now a co-resident partner in a two-person marital unit, whatever either partner's tax role, or the other head or spouse of a joint return (42 CFR 435.603(f)(3)(i), (f)(4)). People linked as parent and child are never spouses, since PE puts everyone in one marital unit when a situation omits marital units. - A link in one household made its parent the presumed parent of a zero-id dependent in another household through a shared tax unit. The claimed-by-parent and both-parents fallbacks now use reports_unlinked_children (own_children_in_household exceeds the ids naming the person), which equals the original is_parent without links. - Inherited gap: a child claimed on a parent's return in another known tax unit never got the both-parents exception. With parent ids, a known external claiming unit headed by a co-resident parent now counts as being claimed by one parent (42 CFR 435.603(f)(2)(ii)). - The helper docstring no longer claims links never change another family's composition; switching a household to the linked rule can remove another family's legacy over-count. The exception formulas evaluate main's terms in main's order, and the composition file gains a regression per finding; each fails on 5934620b9. Co-Authored-By: Claude Opus 5.5 --- .../income/parent_ids_composition.yaml | 162 ++++++++++++++++++ .../medicaid/income/_non_filer_household.py | 38 ++-- .../medicaid_claimed_by_parent_in_tax_unit.py | 30 ++-- ...dent_exception_living_with_both_parents.py | 54 ++++-- .../demographic/person/_parent_links.py | 10 +- 5 files changed, 236 insertions(+), 58 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index 54895bc9601..9b2254c6c59 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -546,3 +546,165 @@ medicaid_non_filer_child_age_eligible: [false, true, false, true] medicaid_household_size: [3, 3, 1, 3] medicaid_household_income: [35_000, 35_000, 2_000, 35_000] + +# Regressions for the r2 review of the rework. + +- name: A married dependent living with both parents counts a spouse who files separately. + period: 2026 + input: + people: + father: + person_id: 1 + age: 45 + mother: + person_id: 2 + age: 44 + teen: + person_id: 3 + age: 17 + parent_1_id: 1 + parent_2_id: 2 + teen_spouse: + person_id: 4 + age: 18 + employment_income: 30_000 + tax_units: + father_unit: + members: [father, teen] + tax_unit_is_filer: true + mother_unit: + members: [mother] + tax_unit_is_filer: true + spouse_unit: + members: [teen_spouse] + tax_unit_is_filer: true + cohabitating_spouses: true + families: + family: + members: [father, mother, teen, teen_spouse] + marital_units: + father: + members: [father] + mother: + members: [mother] + couple: + members: [teen, teen_spouse] + households: + household: + members: [father, mother, teen, teen_spouse] + state_code: OH + output: + # The teen is claimed by one parent and lives with both, who file + # separately, so 42 CFR 435.603(f)(2)(ii) applies and (f)(3) gives the + # teen, the spouse and both parents. The spouse's income counts because + # the spouse must file. + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, true, false] + medicaid_uses_non_filer_rules: [false, false, true, false] + medicaid_household_size: [2, 1, 4, 2] + medicaid_household_income: [0, 0, 30_000, 30_000] + +- name: A link in one household does not make its parent another household's presumed parent. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 55 + employment_income: 20_000 + child: + person_id: 2 + age: 10 + parent_1_id: 1 + nephew: + person_id: 3 + age: 26 + is_tax_unit_spouse: false + tax_units: + parent_unit: + members: [parent, nephew] + tax_unit_is_filer: true + child_unit: + members: [child] + families: + parent_family: + members: [parent, child] + nephew_family: + members: [nephew] + marital_units: + parent: + members: [parent] + child: + members: [child] + nephew: + members: [nephew] + households: + parent_home: + members: [parent, child] + state_code: OH + nephew_home: + members: [nephew] + state_code: OH + output: + # Nobody reports the nephew as their own child and he has no ids, so he + # is a dependent other than a child: 42 CFR 435.603(f)(2)(i) sends him to + # the non-filer rules, where he lives alone. + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true, true] + medicaid_uses_non_filer_rules: [false, true, true] + medicaid_household_size: [2, 2, 1] + medicaid_household_income: [20_000, 20_000, 0] + +- name: A child claimed on a parent's return elsewhere still gets the both-parents exception. + period: 2026 + input: + people: + parent_p: + person_id: 1 + age: 40 + employment_income: 20_000 + parent_q: + person_id: 2 + age: 39 + employment_income: 30_000 + child: + person_id: 3 + age: 18 + parent_1_id: 1 + parent_2_id: 2 + claimed_as_dependent_on_another_return: true + medicaid_claiming_tax_unit_id: 1 + tax_units: + p_unit: + members: [parent_p] + tax_unit_id: 1 + tax_unit_is_filer: true + q_unit: + members: [parent_q] + tax_unit_id: 2 + tax_unit_is_filer: true + child_unit: + members: [child] + tax_unit_id: 3 + tax_unit_is_filer: false + families: + family: + members: [parent_p, parent_q, child] + marital_units: + parent_p: + members: [parent_p] + parent_q: + members: [parent_q] + child: + members: [child] + households: + household: + members: [parent_p, parent_q, child] + state_code: OH + output: + # P claims the child, the child lives with both unmarried parents, so + # 42 CFR 435.603(f)(2)(ii) applies and (f)(3) gives the child and both + # parents. P's own household includes the claimed child under (f)(1). + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, true] + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [2, 1, 3] + medicaid_household_income: [20_000, 30_000, 50_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py index 57e87f1b99a..0436a338cc4 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -24,11 +24,15 @@ def medicaid_non_filer_member_sum(person, period, values): Links identify parents, children and siblings (siblings share a nonzero parent id, including an absent parent). A child-age person without ids is unlinked. The original family-level proxy still relates unlinked children - to each other and to family members who report unlinked children, so one - family's links never add or remove another family's relatives. + to each other and to family members who report unlinked children. So a + parent that a child count reports is never erased, though moving a + household onto this rule can remove another family's legacy over-count, + such as an adult's child-age sibling. - A spouse is the other head or spouse of a joint return, or a cohabiting - spouse under the separate-filer rule, and counts only when co-resident. + A spouse is a co-resident partner in a two-person marital unit, whatever + either partner's tax role, or the other head or spouse of a joint return. + People linked as parent and child are never spouses, since PE puts + everyone in one marital unit when a situation omits marital units. Values accumulate in float64; variable storage rounds the result. """ @@ -56,29 +60,29 @@ def reporting_parent_family(parent): tax_unit = person.tax_unit.reference_entity.members_entity_id head_spouse_count = person.tax_unit("head_spouse_count", period) joint = head_or_spouse & (head_spouse_count == 2) - separate_spouse = ( - person.tax_unit("cohabitating_spouses", period) - & (head_spouse_count == 1) - & (head_or_spouse | person("claimed_as_dependent_on_another_return", period)) - ) + married = person.marital_unit.nb_persons() == 2 marital_unit = person.marital_unit.reference_entity.members_entity_id own_index = np.arange(person.count) for member in household_member_indices(person): is_self = member == own_index same_family = family[member] == family - spouse = ~is_self & ( - (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) - | (separate_spouse & (marital_unit[member] == marital_unit)) + names_applicant = (first[member] == own_index) | (second[member] == own_index) + named_by_applicant = (first == member) | (second == member) + spouse = ( + ~is_self + & ~names_applicant + & ~named_by_applicant + & ( + (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) + | (married & (marital_unit[member] == marital_unit)) + ) ) own_child = child[member] & ( - (first[member] == own_index) - | (second[member] == own_index) - | (reports_unlinked & unlinked_child[member] & same_family) + names_applicant | (reports_unlinked & unlinked_child[member] & same_family) ) parent = child & ( - (first == member) - | (second == member) + named_by_applicant | (unlinked_child & reports_unlinked[member] & same_family) ) shares_parent_id = ( diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py index 6c810641bc3..f76038e7575 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py @@ -1,7 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( has_parent_ids, - household_has_parent_ids, reports_unlinked_children, tax_unit_parent_indices, ) @@ -18,20 +17,19 @@ class medicaid_claimed_by_parent_in_tax_unit(Variable): def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period) - qualifying_child = person("is_qualifying_child_dependent", period) # In tax-unit-only inputs, child dependents are usually the filer's - # children even when parent-child links are not provided. + # children even when parent-child links are not provided. A filer + # counts as the parent of a dependent without ids only when the filer + # reports own children that no parent id names; without links this is + # own_children_in_household > 0, the original is_parent. has_parent_filer_in_tax_unit = person.tax_unit.any( - person("is_parent", period) & head_or_spouse - ) - inferred_parent = qualifying_child | has_parent_filer_in_tax_unit - # In a household with parent links, a filer counts as the parent of a - # dependent without ids only when the filer reports children the links - # do not identify. - has_unlinked_parent_filer_in_tax_unit = person.tax_unit.any( reports_unlinked_children(person, period) & head_or_spouse ) - residual_parent = qualifying_child | has_unlinked_parent_filer_in_tax_unit + dependent = person("is_tax_unit_dependent", period) + inferred_parent = ( + person("is_qualifying_child_dependent", period) + | has_parent_filer_in_tax_unit + ) # A dependent's own ids name their parents. Parenthood does not depend # on residence, so resolve the ids among the dependent's tax unit # members, wherever each lives, and require a parent to be the head or @@ -40,12 +38,6 @@ def formula(person, period, parameters): linked_parent = ((first >= 0) & head_or_spouse[first]) | ( (second >= 0) & head_or_spouse[second] ) - return person("is_tax_unit_dependent", period) & where( - has_parent_ids(person, period), - linked_parent, - where( - household_has_parent_ids(person, period), - residual_parent, - inferred_parent, - ), + return dependent & where( + has_parent_ids(person, period), linked_parent, inferred_parent ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py index 1d524b85c40..ecfbdcda92f 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py @@ -2,7 +2,6 @@ from policyengine_us.variables.household.demographic.person._parent_links import ( co_resident_parent_indices, has_parent_ids, - household_has_parent_ids, reports_unlinked_children, ) @@ -20,12 +19,14 @@ def formula(person, period, parameters): & person("medicaid_non_filer_child_age_eligible", period) & person("medicaid_claimed_by_parent_in_tax_unit", period) ) - legacy_proxy = (person.family.sum(person("is_parent", period)) > 1) & ( - person.tax_unit.sum(person("is_parent", period)) == 1 - ) + # Without ids, the family and tax unit proxies count parents who report + # own children that no parent id names; without links this is + # own_children_in_household > 0, the original is_parent. + parent = reports_unlinked_children(person, period) + proxy = (person.family.sum(parent) > 1) & (person.tax_unit.sum(parent) == 1) own_ids = has_parent_ids(person, period) if not np.any(own_ids): - return claimed_child & legacy_proxy + return claimed_child & proxy # Living with both parents requires both to resolve in the household. first, second = co_resident_parent_indices(person, period) @@ -41,18 +42,35 @@ def formula(person, period, parameters): & joint[first] & person.tax_unit("tax_unit_is_filer", period)[first] ) - # In a household with parent links, the family proxy for a child - # without ids counts only parents with children the links omit. - unlinked_parent = reports_unlinked_children(person, period) - residual_proxy = (person.family.sum(unlinked_parent) > 1) & ( - person.tax_unit.sum(unlinked_parent) == 1 + # A known claiming tax unit elsewhere claims the child by a parent when + # a co-resident parent is its head or spouse. + tax_unit_id = person.tax_unit("tax_unit_id", period) + claiming_tax_unit_id = person("medicaid_claiming_tax_unit_id", period) + head_or_spouse = head | spouse + claimed_elsewhere = person("medicaid_has_known_claiming_tax_unit", period) & ( + claiming_tax_unit_id != tax_unit_id + ) + claimed_elsewhere_by_parent = claimed_elsewhere & ( + ( + (first >= 0) + & head_or_spouse[first] + & (tax_unit_id[first] == claiming_tax_unit_id) + ) + | ( + (second >= 0) + & head_or_spouse[second] + & (tax_unit_id[second] == claiming_tax_unit_id) + ) ) - return claimed_child & where( - own_ids, - two_parents & ~parents_file_jointly, - where( - household_has_parent_ids(person, period), - residual_proxy, - legacy_proxy, - ), + linked = ( + ( + claimed_child + | ( + person("medicaid_non_filer_child_age_eligible", period) + & claimed_elsewhere_by_parent + ) + ) + & two_parents + & ~parents_file_jointly ) + return where(own_ids, linked, claimed_child & proxy) diff --git a/policyengine_us/variables/household/demographic/person/_parent_links.py b/policyengine_us/variables/household/demographic/person/_parent_links.py index f74f9b84f16..3833e8c3109 100644 --- a/policyengine_us/variables/household/demographic/person/_parent_links.py +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -11,10 +11,12 @@ co-resident person's id names them. Neither source erases the other. - A household has links when any member has a nonzero id. Formulas keep their original expressions in households without links. -- In a household with links, a person reports unlinked children when - own_children_in_household exceeds the co-resident people whose ids name - them. Such a person is the only kind of parent the family-level proxy may - still assign to a child-age member without ids. +- A person reports unlinked children when own_children_in_household exceeds + the co-resident people whose ids name them; without links this is + own_children_in_household > 0. Such a person is the only kind of parent the + family-level and tax-unit-level proxies may still assign to a person + without ids, so a link in one household never makes its parent the + presumed parent of someone else's child in another. Temporary memory is linear in the number of people, and work is linear in people times the largest household or tax unit size. From 0c4fda9cd8c2cdb58494be2a79019152cc599a2c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 13:42:38 -0400 Subject: [PATCH 08/24] fix: honor is_parent inputs without ids and keep siblings from being spouses An independent r3 review found: - Blocker (from the r2 fix): with every parent id zero, the claimed-by- parent and both-parents fallbacks read own_children_in_household instead of is_parent, so an explicit is_parent input no longer matched main (C10 with unmarried P40/Q39 marked is_parent: main 3/$50,000, r2 head 2/$20,000). The fallbacks and the composition proxy now use unlinked_parent: is_parent (formula or input) and either no id names the person or own_children_in_household exceeds the ids that do. Without links this is is_parent exactly; with links a parent whose children are all named is never another child's presumed parent, which keeps the r2 cross-household fix. - PE puts everyone in one marital unit when a situation omits marital units, so two adult siblings with ids became spouses. People sharing a parent id are no longer spouses, as linked parent-child pairs already were not. Each new regression fails on 2e922171a. Co-Authored-By: Claude Opus 5.5 --- .../income/parent_ids_composition.yaml | 113 ++++++++++++++++++ .../medicaid/income/_non_filer_household.py | 46 +++---- .../medicaid_claimed_by_parent_in_tax_unit.py | 15 +-- ...dent_exception_living_with_both_parents.py | 10 +- .../demographic/person/_parent_links.py | 25 ++-- 5 files changed, 164 insertions(+), 45 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index 9b2254c6c59..05db0058d97 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -708,3 +708,116 @@ medicaid_uses_non_filer_rules: [false, false, true] medicaid_household_size: [2, 1, 3] medicaid_household_income: [20_000, 30_000, 50_000] + +# Regressions for the r3 review of the rework. + +- name: Without parent ids, explicit is_parent inputs still give the both-parents exception. + period: 2026 + input: + people: + parent_p: + age: 40 + employment_income: 20_000 + is_parent: true + parent_q: + age: 39 + employment_income: 30_000 + is_parent: true + child: + age: 10 + tax_units: + p_unit: + members: [parent_p, child] + tax_unit_is_filer: true + q_unit: + members: [parent_q] + tax_unit_is_filer: true + families: + family: + members: [parent_p, parent_q, child] + marital_units: + parent_p: + members: [parent_p] + parent_q: + members: [parent_q] + child: + members: [child] + households: + household: + members: [parent_p, parent_q, child] + state_code: OH + output: + # The child is claimed by P and lives with both unmarried parents, so + # 42 CFR 435.603(f)(2)(ii) and (f)(3) give the child and both parents. + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, true] + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [2, 1, 3] + medicaid_household_income: [20_000, 30_000, 50_000] + +- name: Without parent ids, an explicit is_parent input still marks the claiming parent. + period: 2026 + input: + people: + parent: + age: 55 + employment_income: 20_000 + is_parent: true + adult_child: + age: 26 + is_tax_unit_spouse: false + tax_units: + unit: + members: [parent, adult_child] + tax_unit_is_filer: true + families: + family: + members: [parent, adult_child] + marital_units: + parent: + members: [parent] + adult_child: + members: [adult_child] + households: + household: + members: [parent, adult_child] + state_code: OH + output: + # Claimed by a parent, so 42 CFR 435.603(f)(2) gives the claimant's + # household. + medicaid_claimed_by_parent_in_tax_unit: [false, true] + medicaid_uses_non_filer_rules: [false, false] + medicaid_household_size: [2, 2] + medicaid_household_income: [20_000, 20_000] + +- name: Siblings in a default marital unit are not spouses. + period: 2026 + input: + people: + older: + person_id: 1 + age: 30 + employment_income: 2_000 + parent_1_id: 99 + younger: + person_id: 2 + age: 25 + employment_income: 1_000 + parent_1_id: 99 + tax_units: + older_unit: + members: [older] + tax_unit_is_filer: false + younger_unit: + members: [younger] + tax_unit_is_filer: false + households: + household: + members: [older, younger] + state_code: OH + output: + # Marital units are omitted, so PE puts both in one. They share a parent, + # so they are siblings over the age limit and each lives alone under + # 42 CFR 435.603(f)(3). + medicaid_uses_non_filer_rules: [true, true] + medicaid_household_size: [1, 1] + medicaid_household_income: [2_000, 1_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py index 0436a338cc4..359ff35074b 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -6,7 +6,7 @@ co_resident_parent_indices, has_parent_ids, household_member_indices, - reports_unlinked_children, + unlinked_parent, ) @@ -24,15 +24,16 @@ def medicaid_non_filer_member_sum(person, period, values): Links identify parents, children and siblings (siblings share a nonzero parent id, including an absent parent). A child-age person without ids is unlinked. The original family-level proxy still relates unlinked children - to each other and to family members who report unlinked children. So a - parent that a child count reports is never erased, though moving a - household onto this rule can remove another family's legacy over-count, - such as an adult's child-age sibling. + to each other and to unlinked parents in their family (parents some of + whose children no id names). So a parent that a child count reports is + never erased, though moving a household onto this rule can remove another + family's legacy over-count, such as an adult's child-age sibling. A spouse is a co-resident partner in a two-person marital unit, whatever either partner's tax role, or the other head or spouse of a joint return. - People linked as parent and child are never spouses, since PE puts - everyone in one marital unit when a situation omits marital units. + People linked as parent and child, or sharing a parent id, are never + spouses: PE puts everyone in one marital unit when a situation omits + marital units, so supply marital units with parent ids. Values accumulate in float64; variable storage rounds the result. """ @@ -45,16 +46,16 @@ def medicaid_non_filer_member_sum(person, period, values): child = person("medicaid_non_filer_child_age_eligible", period) unlinked_child = child & ~has_parent_ids(person, period) - reports_unlinked = reports_unlinked_children(person, period) + unlinked_parents = unlinked_parent(person, period) first, second = co_resident_parent_indices(person, period) family = person.family.reference_entity.members_entity_id - def reporting_parent_family(parent): - # Family of a linked parent who also reports unlinked children. - return np.where((parent >= 0) & reports_unlinked[parent], family[parent], -1) + def unlinked_parent_family(parent): + # Family of a linked parent who is also an unlinked parent. + return np.where((parent >= 0) & unlinked_parents[parent], family[parent], -1) - parent_family_1 = reporting_parent_family(first) - parent_family_2 = reporting_parent_family(second) + parent_family_1 = unlinked_parent_family(first) + parent_family_2 = unlinked_parent_family(second) head_or_spouse = person("is_tax_unit_head_or_spouse", period) tax_unit = person.tax_unit.reference_entity.members_entity_id @@ -69,28 +70,29 @@ def reporting_parent_family(parent): same_family = family[member] == family names_applicant = (first[member] == own_index) | (second[member] == own_index) named_by_applicant = (first == member) | (second == member) + shares_parent_id = ( + (parent_1 != 0) + & ((parent_1 == parent_1[member]) | (parent_1 == parent_2[member])) + ) | ( + (parent_2 != 0) + & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) + ) spouse = ( ~is_self & ~names_applicant & ~named_by_applicant + & ~shares_parent_id & ( (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) | (married & (marital_unit[member] == marital_unit)) ) ) own_child = child[member] & ( - names_applicant | (reports_unlinked & unlinked_child[member] & same_family) + names_applicant | (unlinked_parents & unlinked_child[member] & same_family) ) parent = child & ( named_by_applicant - | (unlinked_child & reports_unlinked[member] & same_family) - ) - shares_parent_id = ( - (parent_1 != 0) - & ((parent_1 == parent_1[member]) | (parent_1 == parent_2[member])) - ) | ( - (parent_2 != 0) - & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) + | (unlinked_child & unlinked_parents[member] & same_family) ) sibling = ( child diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py index f76038e7575..6116a5263f8 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py @@ -1,8 +1,8 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( has_parent_ids, - reports_unlinked_children, tax_unit_parent_indices, + unlinked_parent, ) @@ -16,14 +16,14 @@ class medicaid_claimed_by_parent_in_tax_unit(Variable): reference = "https://www.law.cornell.edu/cfr/text/42/435.603#f_2" def formula(person, period, parameters): - head_or_spouse = person("is_tax_unit_head_or_spouse", period) # In tax-unit-only inputs, child dependents are usually the filer's - # children even when parent-child links are not provided. A filer - # counts as the parent of a dependent without ids only when the filer - # reports own children that no parent id names; without links this is - # own_children_in_household > 0, the original is_parent. + # children even when parent-child links are not provided. A filer is + # the presumed parent of a dependent without ids only when some of the + # filer's children are named by no parent id; without links this is + # is_parent. has_parent_filer_in_tax_unit = person.tax_unit.any( - reports_unlinked_children(person, period) & head_or_spouse + unlinked_parent(person, period) + & person("is_tax_unit_head_or_spouse", period) ) dependent = person("is_tax_unit_dependent", period) inferred_parent = ( @@ -34,6 +34,7 @@ def formula(person, period, parameters): # on residence, so resolve the ids among the dependent's tax unit # members, wherever each lives, and require a parent to be the head or # spouse of that unit. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) first, second = tax_unit_parent_indices(person, period) linked_parent = ((first >= 0) & head_or_spouse[first]) | ( (second >= 0) & head_or_spouse[second] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py index ecfbdcda92f..4b7cbd4068c 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py @@ -2,7 +2,7 @@ from policyengine_us.variables.household.demographic.person._parent_links import ( co_resident_parent_indices, has_parent_ids, - reports_unlinked_children, + unlinked_parent, ) @@ -19,10 +19,10 @@ def formula(person, period, parameters): & person("medicaid_non_filer_child_age_eligible", period) & person("medicaid_claimed_by_parent_in_tax_unit", period) ) - # Without ids, the family and tax unit proxies count parents who report - # own children that no parent id names; without links this is - # own_children_in_household > 0, the original is_parent. - parent = reports_unlinked_children(person, period) + # For a child without ids, the family and tax unit proxies count only + # parents some of whose children no parent id names; without links + # this is is_parent. + parent = unlinked_parent(person, period) proxy = (person.family.sum(parent) > 1) & (person.tax_unit.sum(parent) == 1) own_ids = has_parent_ids(person, period) if not np.any(own_ids): diff --git a/policyengine_us/variables/household/demographic/person/_parent_links.py b/policyengine_us/variables/household/demographic/person/_parent_links.py index 3833e8c3109..a7943ffca15 100644 --- a/policyengine_us/variables/household/demographic/person/_parent_links.py +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -11,12 +11,13 @@ co-resident person's id names them. Neither source erases the other. - A household has links when any member has a nonzero id. Formulas keep their original expressions in households without links. -- A person reports unlinked children when own_children_in_household exceeds - the co-resident people whose ids name them; without links this is - own_children_in_household > 0. Such a person is the only kind of parent the - family-level and tax-unit-level proxies may still assign to a person - without ids, so a link in one household never makes its parent the - presumed parent of someone else's child in another. +- An unlinked parent is a parent (is_parent, formula or input) some of whose + children no id names: nobody's id names them, or own_children_in_household + exceeds the co-resident ids that do. Without links this is is_parent + itself. Unlinked parents are the only parents the family-level and + tax-unit-level proxies may still assign to a person without ids, so a link + in one household never makes its parent the presumed parent of someone + else's child in another. Temporary memory is linear in the number of people, and work is linear in people times the largest household or tax unit size. @@ -101,11 +102,13 @@ def linked_child_count(person, period): return np.bincount(parents[parents >= 0], minlength=person.count) -def reports_unlinked_children(person, period): - """Whether a person reports more own children than the links identify. +def unlinked_parent(person, period): + """Whether a person is a parent some of whose children no id names. - In a household without links this equals own_children_in_household > 0. + Without links this is is_parent, including an is_parent input. """ - return person("own_children_in_household", period) > linked_child_count( - person, period + is_parent = person("is_parent", period) + linked = linked_child_count(person, period) + return is_parent & ( + (linked == 0) | (person("own_children_in_household", period) > linked) ) From e72e346fc558b83bb76f9e7b270b486309f988e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 13:53:13 -0400 Subject: [PATCH 09/24] fix: require marital-unit spouses to share a family An independent r4 review confirmed the r1-r3 fixes and found that PE's synthesized marital unit (one unit containing everyone when a situation omits marital units) still paired two unrelated roommates as spouses. A married couple living together is one family, so the linked spouse test now also requires the marital-unit partners to share a family entity; joint-return spouses are unchanged. The married dependent from r2 keeps their spouse. Also pins the intended cross-household behavior r4 flagged: a filer whose only reported child is linked is not the presumed parent of a zero-id dependent in another household of the same tax unit. So the no-ids guarantee is for simulations with every parent id zero; a household without ids keeps main's composition expressions but its tax-unit proxies can see links held by a co-member elsewhere. Co-Authored-By: Claude Opus 5.5 --- .../core/test_parent_links_properties.py | 4 +- .../income/parent_ids_composition.yaml | 91 +++++++++++++++++++ .../medicaid/income/_non_filer_household.py | 13 +-- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index 50a98b9d9c5..2c9adc89250 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -202,7 +202,9 @@ def random_linked_households( spouse=None, ) if married: + # A married couple living together is one family. people[0]["spouse"], people[1]["spouse"] = 1, 0 + people[1]["family"] = people[0]["family"] households.append(people) return households @@ -310,7 +312,7 @@ def reporting_parent_in(k, fam): if m == i: continue same_family = family[m] == family[i] - spouse = members[i]["spouse"] == m + spouse = members[i]["spouse"] == m and same_family own_child = child[m] and ( i in parents[m] or (reports[i] and unlinked[m] and same_family) ) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index 05db0058d97..b74153f6552 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -821,3 +821,94 @@ medicaid_uses_non_filer_rules: [true, true] medicaid_household_size: [1, 1] medicaid_household_income: [2_000, 1_000] + +# Regressions for the r4 review of the rework. + +- name: Unrelated roommates in a default marital unit are not spouses. + period: 2026 + input: + people: + roommate_a: + person_id: 1 + age: 25 + employment_income: 1_000 + parent_1_id: 99 + roommate_b: + person_id: 2 + age: 30 + employment_income: 2_000 + parent_1_id: 98 + tax_units: + a_unit: + members: [roommate_a] + tax_unit_is_filer: false + b_unit: + members: [roommate_b] + tax_unit_is_filer: false + families: + a_family: + members: [roommate_a] + b_family: + members: [roommate_b] + households: + household: + members: [roommate_a, roommate_b] + state_code: OH + output: + # Marital units are omitted, so PE puts both in one, but they are separate + # families; a married couple living together is one family. Each lives + # alone under 42 CFR 435.603(f)(3). + medicaid_household_size: [1, 1] + medicaid_household_income: [1_000, 2_000] + +- name: A filer whose only child is linked elsewhere is not a dependent's presumed parent. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 55 + employment_income: 20_000 + own_children_in_household: 1 + child: + person_id: 2 + age: 10 + parent_1_id: 1 + nephew: + person_id: 3 + age: 26 + is_tax_unit_spouse: false + tax_units: + parent_unit: + members: [parent, nephew] + tax_unit_is_filer: true + child_unit: + members: [child] + families: + parent_family: + members: [parent, child] + nephew_family: + members: [nephew] + marital_units: + parent: + members: [parent] + child: + members: [child] + nephew: + members: [nephew] + households: + parent_home: + members: [parent, child] + state_code: OH + nephew_home: + members: [nephew] + state_code: OH + output: + # The parent reports one own child and the child's id names them, so the + # nephew is a dependent other than a child even though his own household + # has no ids: 42 CFR 435.603(f)(2)(i) and (f)(3) give him alone. Without + # any ids the model would presume the parent is his parent (2/$20,000). + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true, true] + medicaid_household_size: [2, 2, 1] + medicaid_household_income: [20_000, 20_000, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py index 359ff35074b..72f3ae311ed 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -29,11 +29,12 @@ def medicaid_non_filer_member_sum(person, period, values): never erased, though moving a household onto this rule can remove another family's legacy over-count, such as an adult's child-age sibling. - A spouse is a co-resident partner in a two-person marital unit, whatever - either partner's tax role, or the other head or spouse of a joint return. - People linked as parent and child, or sharing a parent id, are never - spouses: PE puts everyone in one marital unit when a situation omits - marital units, so supply marital units with parent ids. + A spouse is the other head or spouse of a joint return, or a co-resident + partner in a two-person marital unit (PE's spouse convention) who is in + the same family, whatever either partner's tax role. People linked as + parent and child, or sharing a parent id, are never spouses. PE puts + everyone in one marital unit, family and tax unit when a situation omits + them, so supply those entities with parent ids. Values accumulate in float64; variable storage rounds the result. """ @@ -84,7 +85,7 @@ def unlinked_parent_family(parent): & ~shares_parent_id & ( (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) - | (married & (marital_unit[member] == marital_unit)) + | (married & (marital_unit[member] == marital_unit) & same_family) ) ) own_child = child[member] & ( From 671e35f135f66f5be29b64824fdaa6efdf358046 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 14:06:49 -0400 Subject: [PATCH 10/24] fix: let a shown marriage outrank the shared-parent-id spouse guard An independent r5 review found that spouses who are also step siblings (their parents later married each other) lost each other: the r3 guard against PE's synthesized marital unit rejected any pair sharing a parent id, even with a cohabiting-spouses flag. A joint return or a cohabiting- spouses flag on either partner's tax unit now shows the marriage; only a bare two-person marital unit still needs a shared family and no shared parent id. Linked parent-child pairs are never spouses. Co-Authored-By: Claude Opus 5.5 --- .../income/parent_ids_composition.yaml | 43 +++++++++++++++++++ .../medicaid/income/_non_filer_household.py | 19 ++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index b74153f6552..adec85b8549 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -912,3 +912,46 @@ medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true, true] medicaid_household_size: [2, 2, 1] medicaid_household_income: [20_000, 20_000, 0] + +# Regression for the r5 review of the rework. + +- name: Spouses who are also step siblings stay spouses when the marriage is shown. + period: 2026 + input: + people: + spouse_a: + person_id: 1 + age: 25 + employment_income: 1_000 + # Natural parent 10 and step parent 11, both living elsewhere. + parent_1_id: 10 + parent_2_id: 11 + spouse_b: + person_id: 2 + age: 30 + employment_income: 2_000 + parent_1_id: 11 + parent_2_id: 10 + tax_units: + a_unit: + members: [spouse_a] + tax_unit_is_filer: false + cohabitating_spouses: true + b_unit: + members: [spouse_b] + tax_unit_is_filer: false + cohabitating_spouses: true + families: + family: + members: [spouse_a, spouse_b] + marital_units: + couple: + members: [spouse_a, spouse_b] + households: + household: + members: [spouse_a, spouse_b] + state_code: CA + output: + # Each spouse includes the other under 42 CFR 435.603(f)(3)(i) and (f)(4). + medicaid_household_size: [2, 2] + medicaid_household_income: [3_000, 3_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py index 72f3ae311ed..b35e2972361 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -29,12 +29,13 @@ def medicaid_non_filer_member_sum(person, period, values): never erased, though moving a household onto this rule can remove another family's legacy over-count, such as an adult's child-age sibling. - A spouse is the other head or spouse of a joint return, or a co-resident - partner in a two-person marital unit (PE's spouse convention) who is in - the same family, whatever either partner's tax role. People linked as - parent and child, or sharing a parent id, are never spouses. PE puts - everyone in one marital unit, family and tax unit when a situation omits - them, so supply those entities with parent ids. + A spouse is a co-resident partner in a two-person marital unit (PE's + spouse convention), whatever either partner's tax role, or the other head + or spouse of a joint return. A joint return or a cohabiting-spouses flag + on either partner's tax unit shows the marriage. Otherwise the partners + must share a family and no parent id, since PE puts everyone in one + marital unit when a situation omits marital units. People linked as + parent and child are never spouses. Values accumulate in float64; variable storage rounds the result. """ @@ -64,6 +65,7 @@ def unlinked_parent_family(parent): joint = head_or_spouse & (head_spouse_count == 2) married = person.marital_unit.nb_persons() == 2 marital_unit = person.marital_unit.reference_entity.members_entity_id + cohabiting = person.tax_unit("cohabitating_spouses", period) own_index = np.arange(person.count) for member in household_member_indices(person): @@ -78,14 +80,15 @@ def unlinked_parent_family(parent): (parent_2 != 0) & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) ) + partner = married & (marital_unit[member] == marital_unit) spouse = ( ~is_self & ~names_applicant & ~named_by_applicant - & ~shares_parent_id & ( (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) - | (married & (marital_unit[member] == marital_unit) & same_family) + | (partner & (cohabiting | cohabiting[member])) + | (partner & same_family & ~shares_parent_id) ) ) own_child = child[member] & ( From 4e5c627258a9fca7e1ee25ff7e26ce6975d44729 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 14:17:58 -0400 Subject: [PATCH 11/24] docs: state the no-ids guarantee for simulations with every parent id zero The r6 review noted the changelog still promised that any household without parent ids keeps its outputs, which the pinned cross-household regression contradicts by design. Co-Authored-By: Claude Opus 5.5 --- changelog.d/medicaid-magi-parent-ids.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/medicaid-magi-parent-ids.fixed.md b/changelog.d/medicaid-magi-parent-ids.fixed.md index 1c8c75ead27..f652bb78e20 100644 --- a/changelog.d/medicaid-magi-parent-ids.fixed.md +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -1 +1 @@ -Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the tax unit. Households without parent identifiers keep their existing outputs. +Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the tax unit. Simulations in which every parent identifier is zero keep their existing outputs; a household without identifiers keeps its composition formulas, but a tax unit or family shared with a linked household can change who is presumed a parent. From eaf6cc48cab55695a0a3f50f68a3da5e816aae60 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 26 Sep 2026 15:14:24 -0400 Subject: [PATCH 12/24] test: cover each spouse branch on its own A mutation check over the membership rule showed three branches that no test isolated: every joint couple also had an explicit two-person marital unit, every marital-unit spouse also had a joint return or a cohabiting flag, and no linked parent and child shared a synthesized marital unit. Add one regulation-derived case for each: a joint return with omitted marital units, a married dependent whose non-filing spouse has no flag, and a parent with a 20-year-old child in a default marital unit. Each case fails when its branch is removed. Co-Authored-By: Claude Opus 5.5 --- .../income/parent_ids_composition.yaml | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index adec85b8549..443441d2f82 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -955,3 +955,124 @@ # Each spouse includes the other under 42 CFR 435.603(f)(3)(i) and (f)(4). medicaid_household_size: [2, 2] medicaid_household_income: [3_000, 3_000] + +# Spouse branches, one case each (from the mutation check). + +- name: A joint return shows the marriage when marital units are omitted. + period: 2026 + input: + people: + mother: + person_id: 1 + age: 38 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + father: + person_id: 2 + age: 40 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + child: + person_id: 3 + age: 10 + parent_1_id: 1 + parent_2_id: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + unit: + members: [mother, father, child] + filing_status: JOINT + households: + household: + members: [mother, father, child] + state_code: OH + output: + # PE puts all three in one marital unit, so only the joint return shows + # the marriage; each spouse includes the other and their child. + medicaid_household_size: [3, 3, 3] + medicaid_household_income: [50_000, 50_000, 50_000] + +- name: A two-person marital unit in one family is a marriage without a flag or joint return. + period: 2026 + input: + people: + father: + person_id: 1 + age: 45 + mother: + person_id: 2 + age: 44 + teen: + person_id: 3 + age: 17 + parent_1_id: 1 + parent_2_id: 2 + teen_spouse: + person_id: 4 + age: 18 + employment_income: 30_000 + tax_units: + father_unit: + members: [father, teen] + tax_unit_is_filer: true + mother_unit: + members: [mother] + tax_unit_is_filer: true + spouse_unit: + members: [teen_spouse] + tax_unit_is_filer: false + families: + family: + members: [father, mother, teen, teen_spouse] + marital_units: + father: + members: [father] + mother: + members: [mother] + couple: + members: [teen, teen_spouse] + households: + household: + members: [father, mother, teen, teen_spouse] + state_code: OH + output: + # As in the r2 case, but no cohabiting-spouses flag and the spouse does + # not file: each spouse includes the other under 42 CFR 435.603(f)(3)(i), + # and the teen also includes both parents. + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, true, false] + medicaid_uses_non_filer_rules: [false, false, true, true] + medicaid_household_size: [2, 1, 4, 2] + medicaid_household_income: [0, 0, 30_000, 30_000] + +- name: A parent and adult child in a default marital unit are not spouses. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 45 + employment_income: 20_000 + adult_child: + person_id: 2 + age: 20 + employment_income: 5_000 + parent_1_id: 1 + tax_units: + parent_unit: + members: [parent] + tax_unit_is_filer: false + child_unit: + members: [adult_child] + tax_unit_is_filer: false + households: + household: + members: [parent, adult_child] + state_code: OH + output: + # PE puts both in one marital unit and one family, but the id makes them + # parent and child. A 20-year-old is over the age limit, so each lives + # alone under 42 CFR 435.603(f)(3). + medicaid_uses_non_filer_rules: [true, true] + medicaid_household_size: [1, 1] + medicaid_household_income: [20_000, 5_000] From c5717ce8a55c5bd6858d13cdb54d75c7dd81f654 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 27 Sep 2026 02:39:46 -0400 Subject: [PATCH 13/24] fix: count a tax dependent's co-resident spouse once (r6 finding 1) Link-aware claim recognition correctly routes a married adult child whose id names the parent claiming them to the tax-household rules, but that branch never added the dependent's own spouse. P40 alone claims T19; T lives with spouse S18, who earns $30,000 and files separately. Main routes T to the non-filer rules by mistake and gets 2/$30,000; the rework gave 2/$0; 42 CFR 435.603(f)(2) and (f)(4) give the parent's tax household plus the spouse, 3/$30,000. In households with parent links, a tax dependent on the tax-household branch now counts, once, the co-resident spouse the non-filer spouse rule finds, unless the claiming tax household already holds them (same return, or claimed into it from another unit). Size, income and California pregnancies use the same membership. The spouse rule moves into _spouse_rule so both branches share it. Households without ids keep main's expressions through where(), so the no-ids path is unchanged. Five regressions: the review's case, a California pregnant-spouse variant, a teen claimed by the one parent she lives with, a known claiming unit elsewhere, and a spouse on the same return who must count once. The first four fail on eaf6cc48c; the fifth guards against double counting. Co-Authored-By: Claude Opus 5.5 --- .../income/parent_ids_composition.yaml | 263 ++++++++++++++++++ .../medicaid/income/_non_filer_household.py | 151 +++++++--- .../income/medicaid_household_income.py | 24 +- .../income/medicaid_household_size.py | 26 +- .../ca_medicaid_household_pregnancies.py | 22 +- 5 files changed, 430 insertions(+), 56 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index 443441d2f82..e1514edd806 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -1076,3 +1076,266 @@ medicaid_uses_non_filer_rules: [true, true] medicaid_household_size: [1, 1] medicaid_household_income: [20_000, 5_000] + +# Regressions for the r6 review: a tax dependent's co-resident spouse. Under +# 42 CFR 435.603(f)(2) a tax dependent's household is the claiming taxpayer's, +# and (f)(4) adds a spouse the dependent lives with, even when one spouse is +# claimed as a dependent. Household income is (d)'s sum over those members. + +- name: A married adult child claimed by a parent elsewhere counts the spouse they live with. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + adult_child: + person_id: 2 + age: 19 + parent_1_id: 1 + is_tax_unit_spouse: false + spouse: + person_id: 3 + age: 18 + employment_income: 30_000 + tax_units: + parent_unit: + members: [parent, adult_child] + tax_unit_is_filer: true + spouse_unit: + members: [spouse] + tax_unit_is_filer: true + cohabitating_spouses: true + families: + parent_family: + members: [parent] + couple_family: + members: [adult_child, spouse] + marital_units: + parent: + members: [parent] + couple: + members: [adult_child, spouse] + households: + parent_home: + members: [parent] + state_code: OH + couple_home: + members: [adult_child, spouse] + state_code: OH + output: + # The id names the claiming parent, so (f)(2)(i) does not apply and the + # child's household is the parent's tax household (parent and child) plus + # the spouse under (f)(4): 3 people. The spouse must file, so the $30,000 + # counts. The parent's household is the parent and the claimed child. The + # spouse's is the spouse and the child, whose income is zero. + medicaid_claimed_by_parent_in_tax_unit: [false, true, false] + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [2, 3, 2] + medicaid_household_income: [0, 30_000, 30_000] + +- name: California counts the pregnancy of a claimed dependent's spouse once. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + adult_child: + person_id: 2 + age: 19 + parent_1_id: 1 + is_tax_unit_spouse: false + spouse: + person_id: 3 + age: 18 + employment_income: 30_000 + current_pregnancies: 1 + tax_units: + parent_unit: + members: [parent, adult_child] + tax_unit_is_filer: true + spouse_unit: + members: [spouse] + tax_unit_is_filer: true + cohabitating_spouses: true + families: + parent_family: + members: [parent] + couple_family: + members: [adult_child, spouse] + marital_units: + parent: + members: [parent] + couple: + members: [adult_child, spouse] + households: + parent_home: + members: [parent] + state_code: CA + couple_home: + members: [adult_child, spouse] + state_code: CA + output: + # As above, and California (ACWDL 20-10) adds the unborn child of every + # pregnant member of the household. The child's household is the parent, + # the child, the spouse and the spouse's unborn child: 4. The spouse's is + # the spouse, the child and her unborn child: 3. The pregnant spouse is + # not in the parent's household. + ca_medicaid_household_pregnancies: [0, 1, 1] + medicaid_household_size: [2, 4, 3] + medicaid_household_income: [0, 30_000, 30_000] + +- name: A married dependent claimed by the one parent they live with counts their spouse. + period: 2026 + input: + people: + father: + person_id: 1 + age: 45 + employment_income: 20_000 + teen: + person_id: 2 + age: 17 + parent_1_id: 1 + teen_spouse: + person_id: 3 + age: 18 + employment_income: 30_000 + tax_units: + father_unit: + members: [father, teen] + tax_unit_is_filer: true + spouse_unit: + members: [teen_spouse] + tax_unit_is_filer: true + cohabitating_spouses: true + families: + family: + members: [father, teen, teen_spouse] + marital_units: + father: + members: [father] + couple: + members: [teen, teen_spouse] + households: + household: + members: [father, teen, teen_spouse] + state_code: OH + output: + # The teen lives with only the parent who claims her, so neither (f)(2)(i) + # nor (f)(2)(ii) applies. Her household is her father's tax household plus + # her spouse under (f)(4): 3 people and $20,000 + $30,000. Her father's is + # himself and her; her spouse's is the spouse and her. + medicaid_claimed_by_parent_in_tax_unit: [false, true, false] + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [2, 3, 2] + medicaid_household_income: [20_000, 50_000, 30_000] + +- name: A spouse on the same return as the married dependent counts once. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + adult_child: + person_id: 2 + age: 19 + parent_1_id: 1 + is_tax_unit_spouse: false + spouse: + person_id: 3 + age: 25 + employment_income: 4_000 + is_tax_unit_spouse: false + tax_units: + parent_unit: + members: [parent, adult_child, spouse] + tax_unit_is_filer: true + families: + parent_family: + members: [parent] + couple_family: + members: [adult_child, spouse] + marital_units: + parent: + members: [parent] + couple: + members: [adult_child, spouse] + households: + parent_home: + members: [parent] + state_code: OH + couple_home: + members: [adult_child, spouse] + state_code: OH + output: + # The parent claims the child and the child's spouse. The child's + # household is the parent's tax household, which already holds the + # spouse: 3 people, not 4. The spouse is a dependent other than a spouse + # or child who need not file, so (d)(2)(ii) leaves out the $4,000, and + # both dependents' incomes are excluded from the parent's household + # income. The spouse is not the parent's child, so (f)(2)(i) gives the + # spouse the non-filer household of the spouse and the child. + medicaid_claimed_by_parent_in_tax_unit: [false, true, false] + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [3, 3, 2] + medicaid_household_income: [0, 0, 4_000] + +- name: A married dependent claimed from another tax unit counts the spouse they live with. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + employment_income: 20_000 + adult_child: + person_id: 2 + age: 19 + parent_1_id: 1 + claimed_as_dependent_on_another_return: true + medicaid_claiming_tax_unit_id: 1 + spouse: + person_id: 3 + age: 18 + employment_income: 30_000 + tax_units: + parent_unit: + members: [parent] + tax_unit_id: 1 + tax_unit_is_filer: true + spouse_unit: + members: [spouse] + tax_unit_id: 2 + tax_unit_is_filer: true + cohabitating_spouses: true + child_unit: + members: [adult_child] + tax_unit_id: 3 + tax_unit_is_filer: false + families: + parent_family: + members: [parent] + couple_family: + members: [adult_child, spouse] + marital_units: + parent: + members: [parent] + couple: + members: [adult_child, spouse] + households: + parent_home: + members: [parent] + state_code: OH + couple_home: + members: [adult_child, spouse] + state_code: OH + output: + # The parent's return claims the child. The child's household is the + # parent's (the parent and the child) plus the spouse under (f)(4): 3 + # people and $20,000 + $30,000. + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [2, 3, 2] + medicaid_household_income: [20_000, 50_000, 30_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py index b35e2972361..d49075260b1 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -1,4 +1,4 @@ -"""Medicaid MAGI non-filer household membership from parent links.""" +"""Medicaid MAGI household membership from parent links.""" import numpy as np @@ -10,6 +10,63 @@ ) +def _shares_parent_id(parent_1, parent_2, member): + """Whether each person shares a nonzero parent id with a co-resident.""" + return ( + (parent_1 != 0) + & ((parent_1 == parent_1[member]) | (parent_1 == parent_2[member])) + ) | ( + (parent_2 != 0) + & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) + ) + + +def _spouse_rule(person, period, first, second): + """Return a test of whether a co-resident is each person's spouse. + + ``first`` and ``second`` are the co-resident parent rows. A spouse is a + co-resident partner in a two-person marital unit (PE's spouse + convention), whatever either partner's tax role, or the other head or + spouse of a joint return. A joint return or a cohabiting-spouses flag on + either partner's tax unit shows the marriage. Otherwise the partners must + share a family and no parent id, since PE puts everyone in one marital + unit when a situation omits marital units. People linked as parent and + child are never spouses. + """ + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + family = person.family.reference_entity.members_entity_id + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + head_spouse_count = person.tax_unit("head_spouse_count", period) + joint = head_or_spouse & (head_spouse_count == 2) + married = person.marital_unit.nb_persons() == 2 + marital_unit = person.marital_unit.reference_entity.members_entity_id + cohabiting = person.tax_unit("cohabitating_spouses", period) + own_index = np.arange(person.count) + + def is_spouse(member): + names_applicant = (first[member] == own_index) | (second[member] == own_index) + named_by_applicant = (first == member) | (second == member) + partner = married & (marital_unit[member] == marital_unit) + return ( + (member != own_index) + & ~names_applicant + & ~named_by_applicant + & ( + (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) + | (partner & (cohabiting | cohabiting[member])) + | ( + partner + & (family[member] == family) + & ~_shares_parent_id(parent_1, parent_2, member) + ) + ) + ) + + return is_spouse + + def medicaid_non_filer_member_sum(person, period, values): """Sum ``values`` once over each person's MAGI non-filer household. @@ -29,13 +86,7 @@ def medicaid_non_filer_member_sum(person, period, values): never erased, though moving a household onto this rule can remove another family's legacy over-count, such as an adult's child-age sibling. - A spouse is a co-resident partner in a two-person marital unit (PE's - spouse convention), whatever either partner's tax role, or the other head - or spouse of a joint return. A joint return or a cohabiting-spouses flag - on either partner's tax unit shows the marriage. Otherwise the partners - must share a family and no parent id, since PE puts everyone in one - marital unit when a situation omits marital units. People linked as - parent and child are never spouses. + Spouses follow _spouse_rule. Values accumulate in float64; variable storage rounds the result. """ @@ -59,13 +110,7 @@ def unlinked_parent_family(parent): parent_family_1 = unlinked_parent_family(first) parent_family_2 = unlinked_parent_family(second) - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - tax_unit = person.tax_unit.reference_entity.members_entity_id - head_spouse_count = person.tax_unit("head_spouse_count", period) - joint = head_or_spouse & (head_spouse_count == 2) - married = person.marital_unit.nb_persons() == 2 - marital_unit = person.marital_unit.reference_entity.members_entity_id - cohabiting = person.tax_unit("cohabitating_spouses", period) + is_spouse = _spouse_rule(person, period, first, second) own_index = np.arange(person.count) for member in household_member_indices(person): @@ -73,24 +118,8 @@ def unlinked_parent_family(parent): same_family = family[member] == family names_applicant = (first[member] == own_index) | (second[member] == own_index) named_by_applicant = (first == member) | (second == member) - shares_parent_id = ( - (parent_1 != 0) - & ((parent_1 == parent_1[member]) | (parent_1 == parent_2[member])) - ) | ( - (parent_2 != 0) - & ((parent_2 == parent_1[member]) | (parent_2 == parent_2[member])) - ) - partner = married & (marital_unit[member] == marital_unit) - spouse = ( - ~is_self - & ~names_applicant - & ~named_by_applicant - & ( - (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) - | (partner & (cohabiting | cohabiting[member])) - | (partner & same_family & ~shares_parent_id) - ) - ) + shares_parent_id = _shares_parent_id(parent_1, parent_2, member) + spouse = is_spouse(member) own_child = child[member] & ( names_applicant | (unlinked_parents & unlinked_child[member] & same_family) ) @@ -124,3 +153,59 @@ def unlinked_parent_family(parent): included = (member >= 0) & (is_self | spouse | own_child | parent | sibling) total += np.where(included, values[member], 0.0) return total + + +def medicaid_tax_dependent_spouse_sum(person, period, values): + """Sum ``values`` over a tax dependent's spouse missing from their tax household. + + Callers add this to the tax-household branch in households with parent + links and keep their original expressions elsewhere. Under 42 CFR + 435.603(f)(2) a tax dependent's household is the household of the + taxpayer claiming them, and under (f)(4) each spouse of a married couple + living together is in the other's household even when one is claimed as + a dependent. The tax-household branch counts the members of the claiming + tax unit, the people that unit claims from other units and a separately + filing spouse of its head, but never the dependent's own spouse. So for a + tax dependent who uses that branch, this sums over the co-resident spouse + _spouse_rule finds, unless the spouse already belongs to the tax + household. It is zero for everyone else. + + Values accumulate in float64. + """ + values = np.asarray(values, dtype=np.float64) + total = np.zeros(person.count, dtype=np.float64) + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + if not np.any((parent_1 != 0) | (parent_2 != 0)): + return total + + tax_unit = person.tax_unit.reference_entity.members_entity_id + tax_unit_id = person.tax_unit("tax_unit_id", period) + known_claim = person("medicaid_has_known_claiming_tax_unit", period) + claiming_tax_unit_id = person("medicaid_claiming_tax_unit_id", period) + claimed_elsewhere = known_claim & (claiming_tax_unit_id != tax_unit_id) + # The tax unit whose household the tax-household branch gives each person. + household_tax_unit_id = np.where(known_claim, claiming_tax_unit_id, tax_unit_id) + # A head or spouse whose tax household is their own unit's keeps that + # unit's cohabiting-spouses channel for their own spouse. + dependent = person("medicaid_is_tax_dependent", period) & ( + claimed_elsewhere | ~person("is_tax_unit_head_or_spouse", period) + ) + applies = dependent & ~person("medicaid_uses_non_filer_rules", period) + if not np.any(applies): + return total + + first, second = co_resident_parent_indices(person, period) + is_spouse = _spouse_rule(person, period, first, second) + for member in household_member_indices(person): + in_tax_household = np.where( + known_claim, + tax_unit_id[member] == claiming_tax_unit_id, + tax_unit[member] == tax_unit, + ) | ( + claimed_elsewhere[member] + & (claiming_tax_unit_id[member] == household_tax_unit_id) + ) + included = (member >= 0) & applies & is_spouse(member) & ~in_tax_household + total += np.where(included, values[member], 0.0) + return total diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 57f0752b2b6..8ebca8736c7 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -5,6 +5,7 @@ ) from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( medicaid_non_filer_member_sum, + medicaid_tax_dependent_spouse_sum, ) from policyengine_us.variables.household.demographic.person._parent_links import ( household_has_parent_ids, @@ -79,13 +80,18 @@ def formula(person, period, parameters): claimant_tax_household_income = medicaid_claiming_tax_unit_value( person, period, tax_household_income ) - - return where( - non_filer_rules, - non_filer_household_income, - where( - known_claiming_tax_unit, - claimant_tax_household_income, - tax_household_income, - ), + tax_route_income = where( + known_claiming_tax_unit, + claimant_tax_household_income, + tax_household_income, ) + # With parent links in the household, add a tax dependent's + # co-resident spouse, whom medicaid_household_size adds. + tax_route_income = where( + household_has_parent_ids(person, period), + tax_route_income + + medicaid_tax_dependent_spouse_sum(person, period, member_income), + tax_route_income, + ) + + return where(non_filer_rules, non_filer_household_income, tax_route_income) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py index 0e270878bfc..0a48cb88f47 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py @@ -5,6 +5,7 @@ ) from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( medicaid_non_filer_member_sum, + medicaid_tax_dependent_spouse_sum, ) from policyengine_us.variables.household.demographic.person._parent_links import ( household_has_parent_ids, @@ -69,6 +70,21 @@ def formula(person, period, parameters): claimant_tax_household_size = medicaid_claiming_tax_unit_value( person, period, tax_household_size ).astype(int) + tax_route_size = where( + known_claiming_tax_unit, + claimant_tax_household_size, + tax_household_size, + ) + # With parent links in the household, a tax dependent's co-resident + # spouse joins their tax household once (42 CFR 435.603(f)(4)). + tax_route_size = where( + household_has_parent_ids(person, period), + tax_route_size + + medicaid_tax_dependent_spouse_sum( + person, period, np.ones(person.count) + ).astype(int), + tax_route_size, + ) # California counts the unborn children of all members included in the # applicant's MAGI household. Preserve the existing treatment elsewhere. @@ -79,14 +95,6 @@ def formula(person, period, parameters): person("current_pregnancies", period), ) return ( - where( - non_filer_rules, - non_filer_household_size, - where( - known_claiming_tax_unit, - claimant_tax_household_size, - tax_household_size, - ), - ) + where(non_filer_rules, non_filer_household_size, tax_route_size) + pregnancies ) diff --git a/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py b/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py index 3df716dc833..a8e2ce50524 100644 --- a/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py +++ b/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py @@ -5,6 +5,7 @@ ) from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( medicaid_non_filer_member_sum, + medicaid_tax_dependent_spouse_sum, ) from policyengine_us.variables.household.demographic.person._parent_links import ( household_has_parent_ids, @@ -111,12 +112,23 @@ def spouse_has_flag(flag): claimant_pregnancies = medicaid_claiming_tax_unit_value( person, period, tax_pregnancies ).astype(int) + tax_route_pregnancies = where( + person("medicaid_has_known_claiming_tax_unit", period), + claimant_pregnancies, + tax_pregnancies, + ) + # With parent links in the household, add the pregnancies of a tax + # dependent's co-resident spouse, whom medicaid_household_size adds. + tax_route_pregnancies = where( + household_has_parent_ids(person, period), + tax_route_pregnancies + + medicaid_tax_dependent_spouse_sum(person, period, pregnancies).astype( + int + ), + tax_route_pregnancies, + ) return where( person("medicaid_uses_non_filer_rules", period), non_filer_pregnancies, - where( - person("medicaid_has_known_claiming_tax_unit", period), - claimant_pregnancies, - tax_pregnancies, - ), + tax_route_pregnancies, ) From ab8639bd7ee75b748076a4ebbafb3ac3ee52cb64 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 27 Sep 2026 02:39:58 -0400 Subject: [PATCH 14/24] fix: resolve parent ids against a known claiming tax unit elsewhere Found by the new married-dependent property test. A minor alone in a tax unit has no head, so PE makes them that unit's dependent. When medicaid_claiming_tax_unit_id names a parent's unit elsewhere (main's known-claimant fixtures use exactly this shape), the claimed-by-parent test resolved the child's ids only in the child's own unit, found no parent, and applied the 42 CFR 435.603(f)(2)(i) exception. A child of 10 living apart from the claiming parent got 1/$0 instead of the parent's household, 2/$30,000, which main gives. With ids, a known claiming tax unit other than the dependent's own is the claimant: the ids resolve among its members (person ids are distinct within a tax unit), and its head or spouse must be named. A grandparent's unit still is not a parent's. The no-ids path is unchanged. Adds the two regressions and a seeded property test: parents claiming a child whose id names them, on their return or as a known claiming unit, with the child's spouse unmarried, flagged, in one family, on the same return, claimed into the parent's unit or living elsewhere. It checks size, income and California pregnancies against set-based oracles for the parent and the child, and invariance to member order. Co-Authored-By: Claude Opus 5.5 --- .../core/test_parent_links_properties.py | 212 ++++++++++++++++++ .../income/parent_ids_composition.yaml | 96 ++++++++ .../hhs/medicaid/income/_claiming_tax_unit.py | 43 ++++ .../medicaid_claimed_by_parent_in_tax_unit.py | 16 +- .../demographic/person/_parent_links.py | 5 +- .../demographic/person/parent_1_id.py | 15 +- .../demographic/person/parent_2_id.py | 15 +- 7 files changed, 383 insertions(+), 19 deletions(-) diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index 2c9adc89250..379ea609c33 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -401,3 +401,215 @@ def test_is_parent_is_the_union_of_the_child_count_and_the_links(): counted = sum(i in other["parents"] for other in people) + person["extra"] named = any(i + 1 in other["recorded"] for other in people) assert is_parent[person["name"]] == (counted > 0 or named), person["name"] + + +# A tax dependent's co-resident spouse on the tax-household route. + +SPOUSE_PLACEMENTS = [ + "unmarried", + # Living with the child, filing alone with the cohabiting-spouses flag. + "flagged", + # Living with the child in one family, filing alone without the flag. + "same_family", + # Living with the child and claimed on the parent's return too. + "same_return", + # Living with the child, filing alone, claimed by the parent's return. + "claimed_into", + # Married to the child but living in another household. + "elsewhere", +] + + +def random_married_dependents(seed, n=60): + """Parents claiming a child whose id names them; the child may be married. + + The parent claims the child either on the parent's own return or, as a + known claiming tax unit, from the child's separate unit. Every tax unit + gets a distinct tax_unit_id. Incomes are multiples of 1/8 below 2**20. + """ + rng = np.random.default_rng(seed) + return [ + { + "parent_income": float(rng.integers(0, 80_000 * 8)) / 8, + "child_age": int(rng.integers(15, 26)), + "child_with_parent": bool(rng.random() < 0.5), + "known_claim": bool(rng.random() < 0.5), + "placement": SPOUSE_PLACEMENTS[int(rng.integers(len(SPOUSE_PLACEMENTS)))], + "spouse_age": int(rng.integers(16, 31)), + "spouse_income": float(rng.integers(0, 80_000 * 8)) / 8, + "spouse_must_file": bool(rng.random() < 0.5), + "pregnancies": [int(rng.random() < 0.2) for _ in range(3)], + } + for _ in range(n) + ] + + +def married_dependent_situation(scenarios, reverse=False): + people, households, families, tax_units, marital_units = {}, {}, {}, {}, {} + + def ordered(members): + return members[::-1] if reverse else members + + for s, scenario in enumerate(scenarios): + parent, child, spouse = f"s{s}_parent", f"s{s}_child", f"s{s}_spouse" + placement = scenario["placement"] + spouse_income = scenario["spouse_income"] + # Countable income follows 42 CFR 435.603(d)(2)(i)'s child-age proxy. + spouse_member_income = spouse_income * ( + scenario["spouse_must_file"] or scenario["spouse_age"] >= AGE_LIMIT + ) + for name, person_id, age, magi, member_income, must_file in [ + (parent, 1, 45, scenario["parent_income"], scenario["parent_income"], True), + (child, 2, scenario["child_age"], 0, 0, False), + ( + spouse, + 3, + scenario["spouse_age"], + spouse_income, + spouse_member_income, + scenario["spouse_must_file"], + ), + ]: + people[name] = { + "age": {PERIOD: age}, + "person_id": {PERIOD: person_id}, + "is_tax_unit_spouse": {PERIOD: False}, + "medicaid_magi_person": {PERIOD: magi}, + "medicaid_household_income_member": {PERIOD: member_income}, + "medicaid_person_is_required_to_file": {PERIOD: must_file}, + "current_pregnancies": {PERIOD: scenario["pregnancies"][person_id - 1]}, + } + people[child]["parent_1_id"] = {PERIOD: 1} + parent_unit_id = 10 * s + 1 + + # The parent's return, and the child's own unit under a known claim. + parent_return = [parent] + if scenario["known_claim"]: + people[child]["claimed_as_dependent_on_another_return"] = {PERIOD: True} + people[child]["medicaid_claiming_tax_unit_id"] = {PERIOD: parent_unit_id} + tax_units[f"s{s}_child_unit"] = { + "members": [child], + "tax_unit_id": {PERIOD: 10 * s + 2}, + "tax_unit_is_filer": {PERIOD: False}, + } + else: + parent_return.append(child) + if placement == "same_return": + parent_return.append(spouse) + else: + tax_units[f"s{s}_spouse_unit"] = { + "members": [spouse], + "tax_unit_id": {PERIOD: 10 * s + 3}, + "tax_unit_is_filer": {PERIOD: True}, + "cohabitating_spouses": {PERIOD: placement == "flagged"}, + } + if placement == "claimed_into": + people[spouse]["claimed_as_dependent_on_another_return"] = {PERIOD: True} + people[spouse]["medicaid_claiming_tax_unit_id"] = {PERIOD: parent_unit_id} + tax_units[f"s{s}_parent_unit"] = { + "members": ordered(parent_return), + "tax_unit_id": {PERIOD: parent_unit_id}, + "tax_unit_is_filer": {PERIOD: True}, + } + + # Homes and families. + couple_home = [child] + ([spouse] if placement != "elsewhere" else []) + couple_family = [child] + ([spouse] if placement == "same_family" else []) + if scenario["child_with_parent"]: + homes = [[parent] + couple_home] + family_groups = [[parent] + couple_family] + else: + homes = [[parent], couple_home] + family_groups = [[parent], couple_family] + if placement == "elsewhere": + homes.append([spouse]) + if placement != "same_family": + family_groups.append([spouse]) + for k, members in enumerate(homes): + households[f"s{s}_home{k}"] = { + "members": ordered(members), + "state_code": {PERIOD: "CA"}, + } + for k, members in enumerate(family_groups): + families[f"s{s}_family{k}"] = {"members": ordered(members)} + if placement == "unmarried": + marital_units[f"s{s}_child"] = {"members": [child]} + marital_units[f"s{s}_spouse"] = {"members": [spouse]} + else: + marital_units[f"s{s}_couple"] = {"members": ordered([child, spouse])} + marital_units[f"s{s}_parent"] = {"members": [parent]} + return { + "people": people, + "households": households, + "families": families, + "tax_units": tax_units, + "spm_units": {k: {"members": v["members"]} for k, v in households.items()}, + "marital_units": marital_units, + } + + +def expected_tax_households(scenario): + """Members of the parent's and the child's households, by role.""" + placement = scenario["placement"] + # 42 CFR 435.603(f)(1)-(2): the parent's return and whoever it claims + # from elsewhere form the parent's household, which the claimed child's + # household copies. + parent_household = {"parent", "child"} + if placement in ("same_return", "claimed_into"): + parent_household.add("spouse") + # (f)(4): a co-resident spouse joins the child's household, once. + child_household = set(parent_household) + if placement in ("flagged", "same_family"): + child_household.add("spouse") + return parent_household, child_household + + +def test_tax_dependent_counts_a_co_resident_spouse_exactly_once(): + scenarios = random_married_dependents(seed=435604) + simulation = Simulation(situation=married_dependent_situation(scenarios)) + non_filer = by_name(simulation, "medicaid_uses_non_filer_rules") + size = by_name(simulation, "medicaid_household_size") + income = by_name(simulation, "medicaid_household_income") + pregnancies = by_name(simulation, "ca_medicaid_household_pregnancies") + placements = {scenario["placement"] for scenario in scenarios} + assert placements == set(SPOUSE_PLACEMENTS) + for s, scenario in enumerate(scenarios): + roles = {"parent": 0, "child": 1, "spouse": 2} + preg = {role: scenario["pregnancies"][k] for role, k in roles.items()} + spouse_is_dependent = scenario["placement"] in ("same_return", "claimed_into") + # (d)(2): a dependent who need not file adds no income to the + # claiming taxpayer's household; the spouse the child adds under + # (f)(4) counts with its countable income. + tax_income = { + "parent": scenario["parent_income"], + "child": 0.0, + "spouse": 0.0 + if spouse_is_dependent and not scenario["spouse_must_file"] + else scenario["spouse_income"], + } + spouse_member_income = scenario["spouse_income"] * ( + scenario["spouse_must_file"] or scenario["spouse_age"] >= AGE_LIMIT + ) + parent_household, child_household = expected_tax_households(scenario) + for role, members in [("parent", parent_household), ("child", child_household)]: + name = f"s{s}_{role}" + assert not non_filer[name], name + expected_pregnancies = sum(preg[m] for m in members) + assert pregnancies[name] == expected_pregnancies, name + # California counts each member's unborn children in the size. + assert size[name] == len(members) + expected_pregnancies, name + expected_income = sum(tax_income[m] for m in parent_household) + if members != parent_household: + expected_income += spouse_member_income + assert income[name] == np.float32(expected_income), name + + # Member order within every group does not change any result. + reversed_ = Simulation( + situation=married_dependent_situation(scenarios, reverse=True) + ) + for variable in [ + "medicaid_household_size", + "medicaid_household_income", + "ca_medicaid_household_pregnancies", + ]: + assert by_name(simulation, variable) == by_name(reversed_, variable), variable diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index e1514edd806..a50d13efe72 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -1339,3 +1339,99 @@ medicaid_uses_non_filer_rules: [false, false, false] medicaid_household_size: [2, 3, 2] medicaid_household_income: [20_000, 50_000, 30_000] + +- name: A minor claimed by a parent's tax unit elsewhere uses the parent's household. + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + employment_income: 30_000 + child: + person_id: 2 + age: 10 + parent_1_id: 1 + medicaid_claiming_tax_unit_id: 1 + tax_units: + parent_unit: + members: [parent] + tax_unit_id: 1 + tax_unit_is_filer: true + child_unit: + members: [child] + tax_unit_id: 2 + families: + parent_family: + members: [parent] + child_family: + members: [child] + marital_units: + parent: + members: [parent] + child: + members: [child] + households: + parent_home: + members: [parent] + state_code: OH + child_home: + members: [child] + state_code: OH + output: + # The child has no head in a unit of its own, so PE makes it that unit's + # dependent, but the known claiming unit is the parent's, whose head the + # id names. So (f)(2)(i) does not apply, and under (f)(2) the child's + # household is the parent's: the parent and the child, with the parent's + # $30,000. + medicaid_claimed_by_parent_in_tax_unit: [false, true] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, false] + medicaid_uses_non_filer_rules: [false, false] + medicaid_household_size: [2, 2] + medicaid_household_income: [30_000, 30_000] + +- name: A minor claimed by a grandparent's tax unit uses the non-filer rules. + period: 2026 + input: + people: + grandparent: + person_id: 1 + age: 60 + employment_income: 30_000 + grandchild: + person_id: 2 + age: 10 + # Both parents live elsewhere. + parent_1_id: 50 + parent_2_id: 51 + medicaid_claiming_tax_unit_id: 1 + tax_units: + grandparent_unit: + members: [grandparent] + tax_unit_id: 1 + tax_unit_is_filer: true + grandchild_unit: + members: [grandchild] + tax_unit_id: 2 + families: + family: + members: [grandparent, grandchild] + marital_units: + grandparent: + members: [grandparent] + grandchild: + members: [grandchild] + households: + household: + members: [grandparent, grandchild] + state_code: OH + output: + # The ids name no one in the claiming unit, so the claimant is not a + # parent and (f)(2)(i) applies. Under (f)(3) the grandchild lives with no + # parent, sibling, spouse or child: a household of one with no income. + # The grandparent's household is the grandparent and the claimed child. + medicaid_claimed_by_parent_in_tax_unit: [false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true] + medicaid_uses_non_filer_rules: [false, true] + medicaid_household_size: [2, 1] + medicaid_household_income: [30_000, 0] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py index 0ea315c40c1..c1df8f9f2c2 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py @@ -75,3 +75,46 @@ def medicaid_claiming_tax_unit_value(person, period, values): values, tax_unit_head, ) + + +def medicaid_known_claim_by_named_parent(person, period): + """Resolve parent ids against a known claiming tax unit elsewhere. + + Returns two person arrays: whether a known claiming tax unit other than + the person's own claims them, and whether that unit's head or spouse is a + parent the person's ids name. Person ids are distinct within a tax unit, + so an id resolves among the claiming unit's members wherever they live. + Both are false for everyone when no parent id is set. + """ + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + none = np.zeros(person.count, dtype=bool) + if not np.any((parent_1 != 0) | (parent_2 != 0)): + return none, none + + tax_unit_id = person.tax_unit("tax_unit_id", period) + claiming_tax_unit_id = person("medicaid_claiming_tax_unit_id", period) + claimed_elsewhere = person("medicaid_has_known_claiming_tax_unit", period) & ( + claiming_tax_unit_id != tax_unit_id + ) + person_id = person("person_id", period) + named = none + for role in ["is_tax_unit_head", "is_tax_unit_spouse"]: + selector = person(role, period) + present = ( + _value_by_positive_id( + claiming_tax_unit_id, tax_unit_id, np.ones(person.count), selector + ) + > 0 + ) + claimant_id = _value_by_positive_id( + claiming_tax_unit_id, tax_unit_id, person_id, selector + ) + named = named | ( + present + & ( + ((parent_1 != 0) & (parent_1 == claimant_id)) + | ((parent_2 != 0) & (parent_2 == claimant_id)) + ) + ) + return claimed_elsewhere, claimed_elsewhere & named diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py index 6116a5263f8..1dafce57354 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.medicaid.income._claiming_tax_unit import ( + medicaid_known_claim_by_named_parent, +) from policyengine_us.variables.household.demographic.person._parent_links import ( has_parent_ids, tax_unit_parent_indices, @@ -31,14 +34,21 @@ def formula(person, period, parameters): | has_parent_filer_in_tax_unit ) # A dependent's own ids name their parents. Parenthood does not depend - # on residence, so resolve the ids among the dependent's tax unit - # members, wherever each lives, and require a parent to be the head or - # spouse of that unit. + # on residence, so resolve the ids among the members of the claiming + # tax unit, wherever each lives, and require a parent to be the head + # or spouse of that unit. The claiming unit is the dependent's own + # unless a known claiming tax unit elsewhere claims them. head_or_spouse = person("is_tax_unit_head_or_spouse", period) first, second = tax_unit_parent_indices(person, period) linked_parent = ((first >= 0) & head_or_spouse[first]) | ( (second >= 0) & head_or_spouse[second] ) + claimed_elsewhere, claimed_elsewhere_by_parent = ( + medicaid_known_claim_by_named_parent(person, period) + ) + linked_parent = where( + claimed_elsewhere, claimed_elsewhere_by_parent, linked_parent + ) return dependent & where( has_parent_ids(person, period), linked_parent, inferred_parent ) diff --git a/policyengine_us/variables/household/demographic/person/_parent_links.py b/policyengine_us/variables/household/demographic/person/_parent_links.py index a7943ffca15..6706d8feacd 100644 --- a/policyengine_us/variables/household/demographic/person/_parent_links.py +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -5,8 +5,9 @@ - Each id names the person_id of one of the person's parents (natural, adoptive or step); 0 means unknown, so real person ids must be nonzero. - Parenthood is identity and residence is a separate condition. Ids resolve - against the members of one group entity: the household when a rule requires - living together, the tax unit when a rule asks who claims the person. + against the members of one group: the household when a rule requires + living together, the claiming tax unit when a rule asks who claims the + person. - A person is a parent when own_children_in_household is positive or when a co-resident person's id names them. Neither source erases the other. - A household has links when any member has a nonzero id. Formulas keep diff --git a/policyengine_us/variables/household/demographic/person/parent_1_id.py b/policyengine_us/variables/household/demographic/person/parent_1_id.py index 74e5a586f63..384d829c9f1 100644 --- a/policyengine_us/variables/household/demographic/person/parent_1_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_1_id.py @@ -13,11 +13,12 @@ class parent_1_id(Variable): "parents, children and siblings to include step relatives, and CPS " "PEPAR1 and PEPAR2 name a co-resident parent of any type. An id " "resolves among household members for rules that require living " - "together and among tax unit members for the claimed-by-parent test; " - "an id that resolves to neither is an absent parent, which still " - "identifies siblings. Because 0 means unknown, real person ids must be " - "nonzero, and the default person_id starts at 0, so supply person_id " - "with parent ids. Ids are compared after int32 storage, so every " - "member of a household and of a tax unit needs a distinct nonzero " - "int32 person_id." + "together and, for the claimed-by-parent test, among the members of " + "the claiming tax unit: the person's own, or a known claiming tax " + "unit elsewhere. An id that resolves to no one is an absent parent, " + "which still identifies siblings. Because 0 means unknown, real " + "person ids must be nonzero, and the default person_id starts at 0, " + "so supply person_id with parent ids. Ids are compared after int32 " + "storage, so every member of a household and of a tax unit needs a " + "distinct nonzero int32 person_id." ) diff --git a/policyengine_us/variables/household/demographic/person/parent_2_id.py b/policyengine_us/variables/household/demographic/person/parent_2_id.py index 54b2251d9bc..be47a81021f 100644 --- a/policyengine_us/variables/household/demographic/person/parent_2_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_2_id.py @@ -13,11 +13,12 @@ class parent_2_id(Variable): "parents, children and siblings to include step relatives, and CPS " "PEPAR1 and PEPAR2 name a co-resident parent of any type. An id " "resolves among household members for rules that require living " - "together and among tax unit members for the claimed-by-parent test; " - "an id that resolves to neither is an absent parent, which still " - "identifies siblings. Because 0 means unknown, real person ids must be " - "nonzero, and the default person_id starts at 0, so supply person_id " - "with parent ids. Ids are compared after int32 storage, so every " - "member of a household and of a tax unit needs a distinct nonzero " - "int32 person_id." + "together and, for the claimed-by-parent test, among the members of " + "the claiming tax unit: the person's own, or a known claiming tax " + "unit elsewhere. An id that resolves to no one is an absent parent, " + "which still identifies siblings. Because 0 means unknown, real " + "person ids must be nonzero, and the default person_id starts at 0, " + "so supply person_id with parent ids. Ids are compared after int32 " + "storage, so every member of a household and of a tax unit needs a " + "distinct nonzero int32 person_id." ) From 7f123209a129cc4295531915e29ecfd7c4d5136a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 27 Sep 2026 02:39:58 -0400 Subject: [PATCH 15/24] docs: describe the tax-route spouse and claiming-unit resolution in the changelog The changelog keeps the current guarantee: simulations in which every parent id is zero keep their outputs, and a household without ids keeps its composition formulas though a shared tax unit or family can change who is presumed a parent. Co-Authored-By: Claude Opus 5.5 --- changelog.d/medicaid-magi-parent-ids.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/medicaid-magi-parent-ids.fixed.md b/changelog.d/medicaid-magi-parent-ids.fixed.md index f652bb78e20..7a35e7ccd39 100644 --- a/changelog.d/medicaid-magi-parent-ids.fixed.md +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -1 +1 @@ -Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the tax unit. Simulations in which every parent identifier is zero keep their existing outputs; a household without identifiers keeps its composition formulas, but a tax unit or family shared with a linked household can change who is presumed a parent. +Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. A tax dependent in such a household also counts, once, a co-resident spouse whom the claiming tax household does not already include. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the claiming tax unit, including a known claiming tax unit elsewhere. Simulations in which every parent identifier is zero keep their existing outputs. A household without identifiers keeps its composition formulas, but a tax unit or family shared with a linked household can change who is presumed a parent. From e69bea92a390eeb649c8f18b2f43c434d0da828c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 27 Sep 2026 03:11:05 -0400 Subject: [PATCH 16/24] test: pin the claimant's spouse slot, a head's own claim and id-free households Tests for branches the new code adds that no test reached yet: - a joint claiming return whose spouse, not its head, is the parent the child's ids name; - a head whose known claiming unit is their own: the unit's cohabiting-spouses flag already counts the spouse, so the tax-dependent spouse rule must not add them again; - the married-dependent scenarios without ids give the same values alone and next to their linked copies, so the spouse rule stays inside linked households. Also drops a redundant presence check: a claiming unit with nobody in a role yields id 0, which no nonzero parent id matches. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_parent_links.py | 54 +++++++++++++++++ .../core/test_parent_links_properties.py | 58 ++++++++++++++----- .../income/parent_ids_composition.yaml | 53 +++++++++++++++++ .../hhs/medicaid/income/_claiming_tax_unit.py | 17 ++---- 4 files changed, 154 insertions(+), 28 deletions(-) diff --git a/policyengine_us/tests/core/test_parent_links.py b/policyengine_us/tests/core/test_parent_links.py index a5a504d2db6..b3734522957 100644 --- a/policyengine_us/tests/core/test_parent_links.py +++ b/policyengine_us/tests/core/test_parent_links.py @@ -197,3 +197,57 @@ def household_income(order): "first_child": 16_777_218, "second_child": 16_777_218, }, order + + +def test_a_head_claimed_by_their_own_unit_counts_their_spouse_once(): + # A known claiming tax unit that is the person's own is odd input, but the + # spouse must still count once: for a head, the unit's cohabiting-spouses + # flag already adds the separately filing spouse, so the tax-dependent + # spouse rule must not add them again. + simulation = Simulation( + situation={ + "people": { + "head": { + "age": {"2026": 25}, + "person_id": {"2026": 1}, + # An absent parent, so the household has links. + "parent_1_id": {"2026": 9}, + "employment_income": {"2026": 30_000}, + "medicaid_claiming_tax_unit_id": {"2026": 5}, + }, + "spouse": { + "age": {"2026": 26}, + "person_id": {"2026": 2}, + "employment_income": {"2026": 20_000}, + }, + }, + "tax_units": { + "head_unit": { + "members": ["head"], + "tax_unit_id": {"2026": 5}, + "tax_unit_is_filer": {"2026": True}, + "cohabitating_spouses": {"2026": True}, + }, + "spouse_unit": { + "members": ["spouse"], + "tax_unit_id": {"2026": 6}, + "tax_unit_is_filer": {"2026": True}, + "cohabitating_spouses": {"2026": True}, + }, + }, + "families": {"family": {"members": ["head", "spouse"]}}, + "marital_units": {"couple": {"members": ["head", "spouse"]}}, + "households": { + "home": {"members": ["head", "spouse"], "state_code": {"2026": "OH"}} + }, + } + ) + np.testing.assert_array_equal( + simulation.calculate("medicaid_uses_non_filer_rules", 2026), [False, False] + ) + np.testing.assert_array_equal( + simulation.calculate("medicaid_household_size", 2026), [2, 2] + ) + np.testing.assert_array_equal( + simulation.calculate("medicaid_household_income", 2026), [50_000, 50_000] + ) diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index 379ea609c33..6f23f206374 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -444,14 +444,17 @@ def random_married_dependents(seed, n=60): ] -def married_dependent_situation(scenarios, reverse=False): +def married_dependent_situation(scenarios, reverse=False, with_ids=True, prefix="s"): + """Scenarios as a situation; ``prefix`` keeps names and tax unit ids apart.""" people, households, families, tax_units, marital_units = {}, {}, {}, {}, {} + id_offset = 0 if prefix == "s" else 10_000 def ordered(members): return members[::-1] if reverse else members for s, scenario in enumerate(scenarios): - parent, child, spouse = f"s{s}_parent", f"s{s}_child", f"s{s}_spouse" + name = f"{prefix}{s}" + parent, child, spouse = f"{name}_parent", f"{name}_child", f"{name}_spouse" placement = scenario["placement"] spouse_income = scenario["spouse_income"] # Countable income follows 42 CFR 435.603(d)(2)(i)'s child-age proxy. @@ -479,17 +482,18 @@ def ordered(members): "medicaid_person_is_required_to_file": {PERIOD: must_file}, "current_pregnancies": {PERIOD: scenario["pregnancies"][person_id - 1]}, } - people[child]["parent_1_id"] = {PERIOD: 1} - parent_unit_id = 10 * s + 1 + if with_ids: + people[child]["parent_1_id"] = {PERIOD: 1} + parent_unit_id = id_offset + 10 * s + 1 # The parent's return, and the child's own unit under a known claim. parent_return = [parent] if scenario["known_claim"]: people[child]["claimed_as_dependent_on_another_return"] = {PERIOD: True} people[child]["medicaid_claiming_tax_unit_id"] = {PERIOD: parent_unit_id} - tax_units[f"s{s}_child_unit"] = { + tax_units[f"{name}_child_unit"] = { "members": [child], - "tax_unit_id": {PERIOD: 10 * s + 2}, + "tax_unit_id": {PERIOD: id_offset + 10 * s + 2}, "tax_unit_is_filer": {PERIOD: False}, } else: @@ -497,16 +501,16 @@ def ordered(members): if placement == "same_return": parent_return.append(spouse) else: - tax_units[f"s{s}_spouse_unit"] = { + tax_units[f"{name}_spouse_unit"] = { "members": [spouse], - "tax_unit_id": {PERIOD: 10 * s + 3}, + "tax_unit_id": {PERIOD: id_offset + 10 * s + 3}, "tax_unit_is_filer": {PERIOD: True}, "cohabitating_spouses": {PERIOD: placement == "flagged"}, } if placement == "claimed_into": people[spouse]["claimed_as_dependent_on_another_return"] = {PERIOD: True} people[spouse]["medicaid_claiming_tax_unit_id"] = {PERIOD: parent_unit_id} - tax_units[f"s{s}_parent_unit"] = { + tax_units[f"{name}_parent_unit"] = { "members": ordered(parent_return), "tax_unit_id": {PERIOD: parent_unit_id}, "tax_unit_is_filer": {PERIOD: True}, @@ -526,18 +530,18 @@ def ordered(members): if placement != "same_family": family_groups.append([spouse]) for k, members in enumerate(homes): - households[f"s{s}_home{k}"] = { + households[f"{name}_home{k}"] = { "members": ordered(members), "state_code": {PERIOD: "CA"}, } for k, members in enumerate(family_groups): - families[f"s{s}_family{k}"] = {"members": ordered(members)} + families[f"{name}_family{k}"] = {"members": ordered(members)} if placement == "unmarried": - marital_units[f"s{s}_child"] = {"members": [child]} - marital_units[f"s{s}_spouse"] = {"members": [spouse]} + marital_units[f"{name}_child"] = {"members": [child]} + marital_units[f"{name}_spouse"] = {"members": [spouse]} else: - marital_units[f"s{s}_couple"] = {"members": ordered([child, spouse])} - marital_units[f"s{s}_parent"] = {"members": [parent]} + marital_units[f"{name}_couple"] = {"members": ordered([child, spouse])} + marital_units[f"{name}_parent"] = {"members": [parent]} return { "people": people, "households": households, @@ -613,3 +617,27 @@ def test_tax_dependent_counts_a_co_resident_spouse_exactly_once(): "ca_medicaid_household_pregnancies", ]: assert by_name(simulation, variable) == by_name(reversed_, variable), variable + + +def test_households_without_ids_keep_their_values_next_to_linked_ones(): + # The same scenarios without ids give the same values whether or not the + # simulation also holds linked households: the tax-dependent spouse rule + # applies only in households with links. + scenarios = random_married_dependents(seed=435605) + alone = Simulation( + situation=married_dependent_situation(scenarios, with_ids=False, prefix="z") + ) + linked = married_dependent_situation(scenarios) + unlinked = married_dependent_situation(scenarios, with_ids=False, prefix="z") + mixed = Simulation( + situation={key: {**linked[key], **unlinked[key]} for key in linked} + ) + for variable in [ + "medicaid_uses_non_filer_rules", + "medicaid_household_size", + "medicaid_household_income", + "ca_medicaid_household_pregnancies", + ]: + in_mixed = by_name(mixed, variable) + for name, value in by_name(alone, variable).items(): + assert in_mixed[name] == value, (variable, name) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index a50d13efe72..e216e517cb9 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -1435,3 +1435,56 @@ medicaid_uses_non_filer_rules: [false, true] medicaid_household_size: [2, 1] medicaid_household_income: [30_000, 0] + +- name: A minor claimed by a joint return is claimed by a parent who is the return's spouse. + period: 2026 + input: + people: + stepfather: + person_id: 1 + age: 45 + employment_income: 40_000 + mother: + person_id: 2 + age: 40 + employment_income: 20_000 + child: + person_id: 3 + age: 10 + # The ids record only the mother, the spouse on the claiming return. + parent_1_id: 2 + medicaid_claiming_tax_unit_id: 1 + tax_units: + couple_unit: + members: [stepfather, mother] + tax_unit_id: 1 + filing_status: JOINT + tax_unit_is_filer: true + child_unit: + members: [child] + tax_unit_id: 2 + families: + couple_family: + members: [stepfather, mother] + child_family: + members: [child] + marital_units: + couple: + members: [stepfather, mother] + child: + members: [child] + households: + couple_home: + members: [stepfather, mother] + state_code: OH + child_home: + members: [child] + state_code: OH + output: + # A parent claims the child, so (f)(2)(i) does not apply, and under + # (f)(2) the child's household is the couple's tax household: the couple + # and the child, with $60,000. + medicaid_claimed_by_parent_in_tax_unit: [false, false, true] + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [3, 3, 3] + medicaid_household_income: [60_000, 60_000, 60_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py index c1df8f9f2c2..611b84b7ee3 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py @@ -100,21 +100,12 @@ def medicaid_known_claim_by_named_parent(person, period): person_id = person("person_id", period) named = none for role in ["is_tax_unit_head", "is_tax_unit_spouse"]: - selector = person(role, period) - present = ( - _value_by_positive_id( - claiming_tax_unit_id, tax_unit_id, np.ones(person.count), selector - ) - > 0 - ) + # 0 when the claiming unit has nobody in the role; ids are nonzero. claimant_id = _value_by_positive_id( - claiming_tax_unit_id, tax_unit_id, person_id, selector + claiming_tax_unit_id, tax_unit_id, person_id, person(role, period) ) named = named | ( - present - & ( - ((parent_1 != 0) & (parent_1 == claimant_id)) - | ((parent_2 != 0) & (parent_2 == claimant_id)) - ) + ((parent_1 != 0) & (parent_1 == claimant_id)) + | ((parent_2 != 0) & (parent_2 == claimant_id)) ) return claimed_elsewhere, claimed_elsewhere & named From f5151b45b51dca1d7b5702d62f6fc2f6effdfe2f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 27 Sep 2026 03:22:29 -0400 Subject: [PATCH 17/24] test: show the marriage for spouses already in the parent's tax household The mutation check found that the married-dependent property test never exercised "exactly once" for a spouse on the parent's return or claimed into it: those spouses sat in a family of their own with no flag, so the spouse rule never saw the marriage and nothing could be double counted. They now share the child's family, as the same-family placement does. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_parent_links_properties.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index 6f23f206374..045db550b7a 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -411,13 +411,17 @@ def test_is_parent_is_the_union_of_the_child_count_and_the_links(): "flagged", # Living with the child in one family, filing alone without the flag. "same_family", - # Living with the child and claimed on the parent's return too. + # Living with the child in one family, claimed on the parent's return too. "same_return", - # Living with the child, filing alone, claimed by the parent's return. + # Living with the child in one family, filing alone, claimed by the + # parent's return from the spouse's own unit. "claimed_into", # Married to the child but living in another household. "elsewhere", ] +# Placements whose marriage the family shows, since no flag or joint return +# does; the last two already belong to the parent's tax household. +IN_CHILDS_FAMILY = ("same_family", "same_return", "claimed_into") def random_married_dependents(seed, n=60): @@ -518,7 +522,7 @@ def ordered(members): # Homes and families. couple_home = [child] + ([spouse] if placement != "elsewhere" else []) - couple_family = [child] + ([spouse] if placement == "same_family" else []) + couple_family = [child] + ([spouse] if placement in IN_CHILDS_FAMILY else []) if scenario["child_with_parent"]: homes = [[parent] + couple_home] family_groups = [[parent] + couple_family] @@ -527,7 +531,7 @@ def ordered(members): family_groups = [[parent], couple_family] if placement == "elsewhere": homes.append([spouse]) - if placement != "same_family": + if placement not in IN_CHILDS_FAMILY: family_groups.append([spouse]) for k, members in enumerate(homes): households[f"{name}_home{k}"] = { From 34c6562e2b17bea1793f933f670ccbec33d98ae1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 27 Sep 2026 04:25:36 -0400 Subject: [PATCH 18/24] fix: an id that names a co-resident names that person (r7 findings 2, 4, 6) r7 finding 2: ids are only distinct within a household and within a tax unit, so a claimant in another household can share the person_id of the co-resident parent a child's id names. The claimed-by-parent test then took the claimant for the parent. M35 (id 1) lives with C10 (parent_1_id 1); a grandparent (also id 1) elsewhere claims C, through a known claiming unit or on the grandparent's own return. Main and the r7 head give C the grandparent's household, 2/$30,000; (f)(2)(i) gives the non-filer household of C and M, 2/$20,000. An id that names a co-resident now names that person, in both resolutions the claimed-by-parent test uses: tax_unit_parent_indices drops a tax-unit match that is not the co-resident the id names, and a known claiming unit's head or spouse matches an id only if the id names no co-resident or names that head or spouse. A repeated second id is the first parent again. The no-ids path is unchanged (both helpers return before reading any variable). r7 finding 4: the same-return spouse case's comment now says its income follows main's tax-branch reading of (d)(2)(ii), which a literal reading would contest. r7 finding 6: the changelog limits the tax-route spouse to dependents whose household is the claiming taxpayer's, and states the co-resident rule. The id documentation states it too. Regressions: both collision cases (they fail on f5151b45b) and a unit test for a repeated id naming a co-resident. Co-Authored-By: Claude Opus 5.5 --- changelog.d/medicaid-magi-parent-ids.fixed.md | 2 +- .../tests/core/test_parent_links.py | 47 +++++++ .../income/parent_ids_composition.yaml | 125 +++++++++++++++++- .../hhs/medicaid/income/_claiming_tax_unit.py | 32 +++-- .../demographic/person/_parent_links.py | 11 +- .../demographic/person/parent_1_id.py | 13 +- .../demographic/person/parent_2_id.py | 13 +- 7 files changed, 215 insertions(+), 28 deletions(-) diff --git a/changelog.d/medicaid-magi-parent-ids.fixed.md b/changelog.d/medicaid-magi-parent-ids.fixed.md index 7a35e7ccd39..c06849d3551 100644 --- a/changelog.d/medicaid-magi-parent-ids.fixed.md +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -1 +1 @@ -Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. A tax dependent in such a household also counts, once, a co-resident spouse whom the claiming tax household does not already include. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the claiming tax unit, including a known claiming tax unit elsewhere. Simulations in which every parent identifier is zero keep their existing outputs. A household without identifiers keeps its composition formulas, but a tax unit or family shared with a linked household can change who is presumed a parent. +Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. A tax dependent in such a household whose household is the claiming taxpayer's also counts, once, a co-resident spouse that household does not already include. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the claiming tax unit, including a known claiming tax unit elsewhere, unless an id names a co-resident. Simulations in which every parent identifier is zero keep their existing outputs. A household without identifiers keeps its composition formulas, but a tax unit or family shared with a linked household can change who is presumed a parent. diff --git a/policyengine_us/tests/core/test_parent_links.py b/policyengine_us/tests/core/test_parent_links.py index b3734522957..d340745a2d6 100644 --- a/policyengine_us/tests/core/test_parent_links.py +++ b/policyengine_us/tests/core/test_parent_links.py @@ -251,3 +251,50 @@ def test_a_head_claimed_by_their_own_unit_counts_their_spouse_once(): np.testing.assert_array_equal( simulation.calculate("medicaid_household_income", 2026), [50_000, 50_000] ) + + +def test_a_repeated_id_naming_a_co_resident_does_not_match_a_claimant_elsewhere(): + # Both slots name the co-resident mother (person_id 1). The grandparent + # who claims the child from another household also has person_id 1, but + # neither slot names them, so the claimant is not a parent. + simulation = Simulation( + situation={ + "people": { + "mother": {"age": {"2026": 35}, "person_id": {"2026": 1}}, + "child": { + "age": {"2026": 10}, + "person_id": {"2026": 2}, + "parent_1_id": {"2026": 1}, + "parent_2_id": {"2026": 1}, + "medicaid_claiming_tax_unit_id": {"2026": 1}, + }, + "grandparent": {"age": {"2026": 65}, "person_id": {"2026": 1}}, + }, + "tax_units": { + "grandparent_unit": { + "members": ["grandparent"], + "tax_unit_id": {"2026": 1}, + }, + "child_unit": {"members": ["child"], "tax_unit_id": {"2026": 2}}, + "mother_unit": {"members": ["mother"], "tax_unit_id": {"2026": 3}}, + }, + "families": { + "family": {"members": ["mother", "child"]}, + "grandparent_family": {"members": ["grandparent"]}, + }, + "marital_units": { + name: {"members": [name]} for name in ["mother", "child", "grandparent"] + }, + "households": { + "home": {"members": ["mother", "child"], "state_code": {"2026": "OH"}}, + "grandparent_home": { + "members": ["grandparent"], + "state_code": {"2026": "OH"}, + }, + }, + } + ) + np.testing.assert_array_equal( + simulation.calculate("medicaid_claimed_by_parent_in_tax_unit", 2026), + [False, False, False], + ) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index e216e517cb9..c80a23c3313 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -1273,11 +1273,12 @@ output: # The parent claims the child and the child's spouse. The child's # household is the parent's tax household, which already holds the - # spouse: 3 people, not 4. The spouse is a dependent other than a spouse - # or child who need not file, so (d)(2)(ii) leaves out the $4,000, and - # both dependents' incomes are excluded from the parent's household - # income. The spouse is not the parent's child, so (f)(2)(i) gives the - # spouse the non-filer household of the spouse and the child. + # spouse: 3 people, not 4. The tax-household branch leaves out a claimed + # dependent's income when the dependent need not file, reading (d)(2)(ii) + # as reaching every household that is the taxpayer's; that is main's + # treatment, and a literal reading would count the spouse's $4,000 in the + # child's household. The spouse is not the parent's child, so (f)(2)(i) + # gives the spouse the non-filer household of the spouse and the child. medicaid_claimed_by_parent_in_tax_unit: [false, true, false] medicaid_uses_non_filer_rules: [false, false, true] medicaid_household_size: [3, 3, 2] @@ -1488,3 +1489,117 @@ medicaid_uses_non_filer_rules: [false, false, false] medicaid_household_size: [3, 3, 3] medicaid_household_income: [60_000, 60_000, 60_000] + +# Regressions for the r7 review: an id that names a co-resident names that +# person, even when a claimant elsewhere has the same person_id. + +- name: A claimant elsewhere who shares a co-resident parent's id is not the parent. + period: 2026 + input: + people: + mother: + person_id: 1 + age: 35 + employment_income: 20_000 + child: + person_id: 2 + age: 10 + parent_1_id: 1 + medicaid_claiming_tax_unit_id: 1 + grandparent: + # Ids are distinct only within a household and a tax unit. + person_id: 1 + age: 65 + employment_income: 30_000 + tax_units: + grandparent_unit: + members: [grandparent] + tax_unit_id: 1 + tax_unit_is_filer: true + child_unit: + members: [child] + tax_unit_id: 2 + mother_unit: + members: [mother] + tax_unit_id: 3 + tax_unit_is_filer: true + families: + family: + members: [mother, child] + grandparent_family: + members: [grandparent] + marital_units: + mother: + members: [mother] + child: + members: [child] + grandparent: + members: [grandparent] + households: + home: + members: [mother, child] + state_code: OH + grandparent_home: + members: [grandparent] + state_code: OH + output: + # The id names the mother the child lives with, not the grandparent who + # claims the child, so (f)(2)(i) applies. Under (f)(3) the child's + # household is the child and the mother: 2 people and $20,000. The + # mother's is herself; the grandparent's is the grandparent and the + # claimed child. + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true, false] + medicaid_uses_non_filer_rules: [false, true, false] + medicaid_household_size: [1, 2, 2] + medicaid_household_income: [20_000, 20_000, 30_000] + +- name: A tax unit member elsewhere who shares a co-resident parent's id is not the parent. + period: 2026 + input: + people: + mother: + person_id: 1 + age: 35 + employment_income: 20_000 + child: + person_id: 2 + age: 10 + parent_1_id: 1 + grandparent: + person_id: 1 + age: 65 + employment_income: 30_000 + tax_units: + grandparent_unit: + members: [grandparent, child] + tax_unit_is_filer: true + mother_unit: + members: [mother] + tax_unit_is_filer: true + families: + family: + members: [mother, child] + grandparent_family: + members: [grandparent] + marital_units: + mother: + members: [mother] + child: + members: [child] + grandparent: + members: [grandparent] + households: + home: + members: [mother, child] + state_code: OH + grandparent_home: + members: [grandparent] + state_code: OH + output: + # As above, with the grandparent claiming the child on the same return. + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true, false] + medicaid_uses_non_filer_rules: [false, true, false] + medicaid_household_size: [1, 2, 2] + medicaid_household_income: [20_000, 20_000, 30_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py index 611b84b7ee3..5fbd48d8511 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, +) NO_MEDICAID_CLAIMING_TAX_UNIT_ID = 0 @@ -82,9 +85,10 @@ def medicaid_known_claim_by_named_parent(person, period): Returns two person arrays: whether a known claiming tax unit other than the person's own claims them, and whether that unit's head or spouse is a - parent the person's ids name. Person ids are distinct within a tax unit, - so an id resolves among the claiming unit's members wherever they live. - Both are false for everyone when no parent id is set. + parent the person's ids name. An id matches the head's or spouse's + person_id wherever they live, unless it names a co-resident, in which + case that co-resident must be the head or spouse. Both are false for + everyone when no parent id is set. """ parent_1 = person("parent_1_id", period) parent_2 = person("parent_2_id", period) @@ -98,14 +102,26 @@ def medicaid_known_claim_by_named_parent(person, period): claiming_tax_unit_id != tax_unit_id ) person_id = person("person_id", period) + home_first, home_second = co_resident_parent_indices(person, period) + # A repeated second id is the first parent again. + slots = [ + (parent_1, home_first), + (np.where(parent_2 == parent_1, 0, parent_2), home_second), + ] named = none for role in ["is_tax_unit_head", "is_tax_unit_spouse"]: + in_role = person(role, period) # 0 when the claiming unit has nobody in the role; ids are nonzero. claimant_id = _value_by_positive_id( - claiming_tax_unit_id, tax_unit_id, person_id, person(role, period) - ) - named = named | ( - ((parent_1 != 0) & (parent_1 == claimant_id)) - | ((parent_2 != 0) & (parent_2 == claimant_id)) + claiming_tax_unit_id, tax_unit_id, person_id, in_role ) + for parent_id, home in slots: + home_is_claimant = in_role[home] & ( + tax_unit_id[home] == claiming_tax_unit_id + ) + named = named | ( + (parent_id != 0) + & (parent_id == claimant_id) + & ((home < 0) | home_is_claimant) + ) return claimed_elsewhere, claimed_elsewhere & named diff --git a/policyengine_us/variables/household/demographic/person/_parent_links.py b/policyengine_us/variables/household/demographic/person/_parent_links.py index 6706d8feacd..349246769c8 100644 --- a/policyengine_us/variables/household/demographic/person/_parent_links.py +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -7,7 +7,8 @@ - Parenthood is identity and residence is a separate condition. Ids resolve against the members of one group: the household when a rule requires living together, the claiming tax unit when a rule asks who claims the - person. + person. An id that names a co-resident names that person, wherever else + the same person_id appears. - A person is a parent when own_children_in_household is positive or when a co-resident person's id names them. Neither source erases the other. - A household has links when any member has a nonzero id. Formulas keep @@ -92,8 +93,14 @@ def tax_unit_parent_indices(person, period): """Rows of the parents each person's ids name within their tax unit. A parent who claims the person is recognized wherever either one lives. + An id that names a co-resident names that person, so a tax unit member + elsewhere who shares the person_id is not the parent. """ - return _parent_indices(person, period, person.tax_unit) + first, second = _parent_indices(person, period, person.tax_unit) + home_first, home_second = co_resident_parent_indices(person, period) + first = np.where((home_first >= 0) & (first != home_first), -1, first) + second = np.where((home_second >= 0) & (second != home_second), -1, second) + return first, second def linked_child_count(person, period): diff --git a/policyengine_us/variables/household/demographic/person/parent_1_id.py b/policyengine_us/variables/household/demographic/person/parent_1_id.py index 384d829c9f1..23b5c1f4a18 100644 --- a/policyengine_us/variables/household/demographic/person/parent_1_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_1_id.py @@ -15,10 +15,11 @@ class parent_1_id(Variable): "resolves among household members for rules that require living " "together and, for the claimed-by-parent test, among the members of " "the claiming tax unit: the person's own, or a known claiming tax " - "unit elsewhere. An id that resolves to no one is an absent parent, " - "which still identifies siblings. Because 0 means unknown, real " - "person ids must be nonzero, and the default person_id starts at 0, " - "so supply person_id with parent ids. Ids are compared after int32 " - "storage, so every member of a household and of a tax unit needs a " - "distinct nonzero int32 person_id." + "unit elsewhere. An id that names a co-resident names that person " + "wherever else the person_id appears. An id that resolves to no one " + "is an absent parent, which still identifies siblings. Because 0 " + "means unknown, real person ids must be nonzero, and the default " + "person_id starts at 0, so supply person_id with parent ids. Ids are " + "compared after int32 storage, so every member of a household and of " + "a tax unit needs a distinct nonzero int32 person_id." ) diff --git a/policyengine_us/variables/household/demographic/person/parent_2_id.py b/policyengine_us/variables/household/demographic/person/parent_2_id.py index be47a81021f..98c1d7e3198 100644 --- a/policyengine_us/variables/household/demographic/person/parent_2_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_2_id.py @@ -15,10 +15,11 @@ class parent_2_id(Variable): "resolves among household members for rules that require living " "together and, for the claimed-by-parent test, among the members of " "the claiming tax unit: the person's own, or a known claiming tax " - "unit elsewhere. An id that resolves to no one is an absent parent, " - "which still identifies siblings. Because 0 means unknown, real " - "person ids must be nonzero, and the default person_id starts at 0, " - "so supply person_id with parent ids. Ids are compared after int32 " - "storage, so every member of a household and of a tax unit needs a " - "distinct nonzero int32 person_id." + "unit elsewhere. An id that names a co-resident names that person " + "wherever else the person_id appears. An id that resolves to no one " + "is an absent parent, which still identifies siblings. Because 0 " + "means unknown, real person ids must be nonzero, and the default " + "person_id starts at 0, so supply person_id with parent ids. Ids are " + "compared after int32 storage, so every member of a household and of " + "a tax unit needs a distinct nonzero int32 person_id." ) From 9326dc4db0c9e8b034beaa632d28df2ff9fbecc6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:42:41 -0400 Subject: [PATCH 19/24] fix: step parents, reciprocal filer spouses and the id contract (r8 findings 1-3, 5, 6) - Infer a lone named co-resident parent's established spouse (joint return, or two-person marital unit with the cohabiting-spouses flag) as a step parent for claimant recognition, the both-parents exception and non-filer membership, including step siblings (42 CFR 435.603(b)). - Add a head's or spouse's co-resident spouse outside the unit to the unit's tax household in linked households, deduplicated against same-unit membership, claims into the unit and the cohabiting-spouses flag (435.603(f)(1), (f)(2), (f)(4)). - An id matching two members of one group resolves to neither, and a co-resident resolution no longer depends on tax unit member order. - Document the simulation-wide unique id contract for cross-household tax units and known claiming units. - Add the r8 YAML regressions. Co-Authored-By: Claude Opus 5.5 --- .../income/parent_ids_composition.yaml | 452 ++++++++++++++++++ .../hhs/medicaid/income/_claiming_tax_unit.py | 5 + .../hhs/medicaid/income/_medicaid_parents.py | 94 ++++ .../medicaid/income/_non_filer_household.py | 89 +++- .../medicaid_claimed_by_parent_in_tax_unit.py | 16 +- .../income/medicaid_household_income.py | 6 + .../income/medicaid_household_size.py | 7 + ...dent_exception_living_with_both_parents.py | 8 +- .../ca_medicaid_household_pregnancies.py | 6 + .../demographic/person/_parent_links.py | 50 +- .../demographic/person/parent_1_id.py | 21 +- .../demographic/person/parent_2_id.py | 21 +- 12 files changed, 735 insertions(+), 40 deletions(-) create mode 100644 policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index c80a23c3313..44324a9905f 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -1603,3 +1603,455 @@ medicaid_uses_non_filer_rules: [false, true, false] medicaid_household_size: [1, 2, 2] medicaid_household_income: [20_000, 20_000, 30_000] + +# Regressions for the r8 review. + +# r8 finding 3: under 42 CFR 435.603(f)(4) spouses who live together are in +# each other's households whether or not they file jointly, so a filer counts +# a co-resident spouse the tax household lacks even without the +# cohabiting-spouses flag. + +- name: Separately filing spouses count each other without a cohabiting flag + period: 2026 + input: + people: + parent: {person_id: 1, age: 40} + child: + person_id: 2 + age: 19 + parent_1_id: 1 + is_tax_unit_spouse: false + spouse: {person_id: 3, age: 18, employment_income: 30_000} + tax_units: + parent_unit: {members: [parent, child], tax_unit_is_filer: true} + spouse_unit: {members: [spouse], tax_unit_is_filer: true} + families: + parent_family: {members: [parent]} + couple_family: {members: [child, spouse]} + marital_units: + parent: {members: [parent]} + couple: {members: [child, spouse]} + households: + parent_home: {members: [parent], state_code: OH} + couple_home: {members: [child, spouse], state_code: OH} + output: + # The parent's household is the parent and the claimed child: 2, $0. The + # child's is the parent's tax household plus the spouse under (f)(4): 3, + # $30,000. The spouse files alone and lives with the child, so the + # spouse's household is the spouse and the child: 2, $30,000. + medicaid_claimed_by_parent_in_tax_unit: [false, true, false] + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [2, 3, 2] + medicaid_household_income: [0, 30_000, 30_000] + +- name: California counts each separately filing spouse's pregnancy once without a flag + period: 2026 + input: + people: + parent: {person_id: 1, age: 40} + child: + person_id: 2 + age: 19 + parent_1_id: 1 + is_tax_unit_spouse: false + spouse: + person_id: 3 + age: 18 + employment_income: 30_000 + current_pregnancies: 1 + tax_units: + parent_unit: {members: [parent, child], tax_unit_is_filer: true} + spouse_unit: {members: [spouse], tax_unit_is_filer: true} + families: + parent_family: {members: [parent]} + couple_family: {members: [child, spouse]} + marital_units: + parent: {members: [parent]} + couple: {members: [child, spouse]} + households: + parent_home: {members: [parent], state_code: CA} + couple_home: {members: [child, spouse], state_code: CA} + output: + # As above, and ACWDL 20-10 adds the unborn child of each pregnant member: + # the child's household is 3 people plus 1 unborn child, the spouse's 2 + # people plus 1. The pregnant spouse is not in the parent's household. + ca_medicaid_household_pregnancies: [0, 1, 1] + medicaid_household_size: [2, 4, 3] + medicaid_household_income: [0, 30_000, 30_000] + +- name: A filer's unflagged co-resident spouse joins the households of the filer's dependents + period: 2026 + input: + people: + parent: {person_id: 1, age: 40, employment_income: 40_000} + spouse: {person_id: 2, age: 41, employment_income: 20_000} + child: {person_id: 3, age: 10, parent_1_id: 1} + tax_units: + parent_unit: {members: [parent, child], tax_unit_is_filer: true} + spouse_unit: {members: [spouse], tax_unit_is_filer: true} + families: + family: {members: [parent, spouse, child]} + marital_units: + couple: {members: [parent, spouse]} + child: {members: [child]} + households: + home: {members: [parent, spouse, child], state_code: OH} + output: + # No joint return or flag shows the marriage, so no step parent is + # inferred and the parent's return claims the child: (f)(2) gives the + # child the parent's household. Under (f)(4) that household includes the + # spouse: parent, child and spouse, $60,000. The spouse's is the spouse + # and the parent: 2, $60,000. + medicaid_claimed_by_parent_in_tax_unit: [false, false, true] + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, false] + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [3, 2, 3] + medicaid_household_income: [60_000, 60_000, 60_000] + +- name: A filer's spouse claimed into the filer's unit counts once + period: 2026 + input: + people: + filer: + person_id: 1 + age: 40 + employment_income: 40_000 + # An absent parent, so the household has links. + parent_1_id: 9 + spouse: + person_id: 2 + age: 41 + employment_income: 5_000 + claimed_as_dependent_on_another_return: true + medicaid_claiming_tax_unit_id: 1 + tax_units: + filer_unit: + members: [filer] + tax_unit_id: 1 + tax_unit_is_filer: true + spouse_unit: + members: [spouse] + tax_unit_id: 2 + tax_unit_is_filer: false + families: + family: {members: [filer, spouse]} + marital_units: + couple: {members: [filer, spouse]} + households: + home: {members: [filer, spouse], state_code: OH} + output: + # The filer's return claims the spouse from the spouse's own unit, so the + # spouse is in the filer's tax household once: 2 people. The spouse need + # not file, so (d)(2)(ii) keeps the $5,000 out of the claiming taxpayer's + # household income. + medicaid_uses_non_filer_rules: [false, false] + medicaid_household_size: [2, 2] + medicaid_household_income: [40_000, 40_000] + +# r8 finding 6: a named parent must be the claiming return's head or spouse. + +- name: A dependent teen parent is not the taxpayer claiming the baby + period: 2026 + input: + people: + grandparent: {person_id: 1, age: 40, employment_income: 40_000} + teen: {person_id: 2, age: 17, parent_1_id: 1} + baby: {person_id: 3, age: 1, parent_1_id: 2} + tax_units: + unit: {members: [grandparent, teen, baby], tax_unit_is_filer: true} + families: + family: {members: [grandparent, teen, baby]} + marital_units: + grandparent: {members: [grandparent]} + teen: {members: [teen]} + baby: {members: [baby]} + households: + home: {members: [grandparent, teen, baby], state_code: OH} + output: + # The grandparent claims both. The teen's id names the grandparent, the + # return's head, so the teen uses the grandparent's tax household: 3, + # $40,000. The baby's id names the teen, a dependent on that return, so + # no parent claims the baby and (f)(2)(i) applies. Under (f)(3) the baby's + # household is the baby and the teen, whose income (d)(2)(i) excludes. + medicaid_claimed_by_parent_in_tax_unit: [false, true, false] + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [3, 3, 2] + medicaid_household_income: [40_000, 40_000, 0] + +# r8 finding 2: 42 CFR 435.603(b) counts step parents. When ids name exactly +# one parent the person lives with, that parent's resident spouse, shown by a +# joint return or by a two-person marital unit with the cohabiting-spouses +# flag, is also a parent. + +- name: An established resident stepparent counts when ids name natural parents + period: 2026 + input: + people: + mother: {person_id: 1, age: 35, employment_income: 20_000} + stepfather: {person_id: 2, age: 40, employment_income: 40_000} + child: {person_id: 3, age: 10, parent_1_id: 1, parent_2_id: 99} + tax_units: + mother_unit: + members: [mother] + tax_unit_is_filer: true + cohabitating_spouses: true + stepfather_unit: + members: [stepfather, child] + tax_unit_is_filer: true + cohabitating_spouses: true + families: + family: {members: [mother, stepfather, child]} + marital_units: + couple: {members: [mother, stepfather]} + child: {members: [child]} + households: + home: {members: [mother, stepfather, child], state_code: OH} + output: + # The stepfather is a parent who claims the child, and the child lives + # with both parents, who file separately: (f)(2)(ii) applies. Under (f)(3) + # the child's household is the child, the mother and the stepfather: 3, + # $60,000. The mother's is herself and her husband: 2, $60,000. The + # stepfather's is his return (himself and the child) plus his wife: 3, + # $60,000. + medicaid_claimed_by_parent_in_tax_unit: [false, false, true] + medicaid_tax_dependent_exception_living_with_both_parents: [false, false, true] + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [2, 3, 3] + medicaid_household_income: [60_000, 60_000, 60_000] + +- name: A stepparent shown by a joint return makes step siblings and stepchildren + period: 2026 + input: + people: + mother: + person_id: 1 + age: 38 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + stepfather: + person_id: 2 + age: 40 + own_children_in_household: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + her_child: + person_id: 3 + age: 10 + # Her natural parents: the mother and a father who lives elsewhere. + parent_1_id: 1 + parent_2_id: 98 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + his_child: + person_id: 4 + age: 8 + # His natural parents: the stepfather and a mother who lives elsewhere. + parent_1_id: 99 + parent_2_id: 2 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + unit: + members: [mother, stepfather, her_child, his_child] + filing_status: JOINT + families: + family: + members: [mother, stepfather, her_child, his_child] + marital_units: + couple: + members: [mother, stepfather] + her_child: + members: [her_child] + his_child: + members: [his_child] + households: + household: + members: [mother, stepfather, her_child, his_child] + state_code: OH + output: + # As in "Step parents and step siblings count once parent ids name them", + # but each child's ids name only one co-resident parent. The joint return + # shows the marriage, so each spouse is the other's child's step parent + # and the children are step siblings: every household has all four. + medicaid_household_size: [4, 4, 4, 4] + medicaid_household_income: [50_000, 50_000, 50_000, 50_000] + +- name: A two-person marital unit without a flag or joint return infers no stepparent + period: 2026 + input: + people: + mother: + person_id: 1 + age: 38 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 30_000 + partner: + person_id: 2 + age: 40 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 20_000 + child: + person_id: 3 + age: 10 + parent_1_id: 1 + medicaid_uses_non_filer_rules: true + medicaid_household_income_member: 0 + tax_units: + mother_unit: {members: [mother, child]} + partner_unit: {members: [partner]} + families: + family: {members: [mother, partner, child]} + marital_units: + couple: {members: [mother, partner]} + child: {members: [child]} + households: + household: {members: [mother, partner, child], state_code: OH} + output: + # The spouse rule still makes the partners each other's spouse, but no + # joint return or flag establishes the marriage, so the child's household + # is the child and the mother. + medicaid_household_size: [3, 2, 2] + medicaid_household_income: [50_000, 50_000, 30_000] + +# r8 finding 1: an id that names a co-resident resolves to that co-resident. +# Person ids must be distinct across the simulation whenever a tax unit spans +# households or a known claiming tax unit is set. These cases break that +# contract on purpose: they pin the fallback, not anyone's legal identity. + +- name: Id contract fallback, a claimant elsewhere sharing a co-resident's id is not the parent + period: 2026 + input: + people: + grandparent: + person_id: 1 + age: 65 + employment_income: 30_000 + child: + person_id: 2 + age: 10 + # Meant to name the mother, who reuses the grandparent's id. + parent_1_id: 1 + mother: + person_id: 1 + age: 35 + employment_income: 20_000 + tax_units: + grandparent_unit: + members: [grandparent] + tax_unit_is_filer: true + mother_unit: + members: [mother, child] + tax_unit_is_filer: true + families: + family: {members: [grandparent, child]} + mother_family: {members: [mother]} + marital_units: + grandparent: {members: [grandparent]} + child: {members: [child]} + mother: {members: [mother]} + households: + home: {members: [grandparent, child], state_code: OH} + mother_home: {members: [mother], state_code: OH} + output: + # The mirror image of the case below. With distinct ids the mother + # claims the child and the child's household is hers: 2, $20,000. With + # the collision the id resolves to the co-resident grandparent, so the + # fallback treats the claimant as someone other than a parent: (f)(2)(i) + # and (f)(3) give the child and the grandparent, 2, $30,000. + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_uses_non_filer_rules: [false, true, false] + medicaid_household_size: [1, 2, 2] + medicaid_household_income: [30_000, 30_000, 20_000] + +- name: Id contract fallback, second slot, a claimant elsewhere sharing a co-resident parent's id + period: 2026 + input: + people: + mother: + person_id: 1 + age: 35 + employment_income: 20_000 + child: + person_id: 2 + age: 10 + parent_1_id: 77 + parent_2_id: 1 + medicaid_claiming_tax_unit_id: 1 + grandparent: + person_id: 1 + age: 65 + employment_income: 30_000 + tax_units: + grandparent_unit: + members: [grandparent] + tax_unit_id: 1 + tax_unit_is_filer: true + child_unit: + members: [child] + tax_unit_id: 2 + mother_unit: + members: [mother] + tax_unit_id: 3 + tax_unit_is_filer: true + families: + family: {members: [mother, child]} + grandparent_family: {members: [grandparent]} + marital_units: + mother: {members: [mother]} + child: {members: [child]} + grandparent: {members: [grandparent]} + households: + home: {members: [mother, child], state_code: OH} + grandparent_home: {members: [grandparent], state_code: OH} + output: + # "A claimant elsewhere who shares a co-resident parent's id is not the + # parent", with the mother in the second slot and an absent father in the + # first. + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true, false] + medicaid_uses_non_filer_rules: [false, true, false] + medicaid_household_size: [1, 2, 2] + medicaid_household_income: [20_000, 20_000, 30_000] + +- name: Id contract fallback, second slot, a tax unit member elsewhere sharing a co-resident parent's id + period: 2026 + input: + people: + mother: + person_id: 1 + age: 35 + employment_income: 20_000 + child: + person_id: 2 + age: 10 + parent_1_id: 77 + parent_2_id: 1 + grandparent: + person_id: 1 + age: 65 + employment_income: 30_000 + tax_units: + grandparent_unit: + members: [grandparent, child] + tax_unit_is_filer: true + mother_unit: + members: [mother] + tax_unit_is_filer: true + families: + family: {members: [mother, child]} + grandparent_family: {members: [grandparent]} + marital_units: + mother: {members: [mother]} + child: {members: [child]} + grandparent: {members: [grandparent]} + households: + home: {members: [mother, child], state_code: OH} + grandparent_home: {members: [grandparent], state_code: OH} + output: + # As above, with the grandparent claiming the child on the same return. + medicaid_claimed_by_parent_in_tax_unit: [false, false, false] + medicaid_tax_dependent_exception_other_than_spouse_or_child: [false, true, false] + medicaid_uses_non_filer_rules: [false, true, false] + medicaid_household_size: [1, 2, 2] + medicaid_household_income: [20_000, 20_000, 30_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py index 5fbd48d8511..8f0822e324c 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_claiming_tax_unit.py @@ -89,6 +89,11 @@ def medicaid_known_claim_by_named_parent(person, period): person_id wherever they live, unless it names a co-resident, in which case that co-resident must be the head or spouse. Both are false for everyone when no parent id is set. + + Matching a claimant elsewhere by person_id relies on the id contract in + _parent_links: with a known claiming tax unit, person ids must be + distinct across the simulation. When they collide, the co-resident + preference is a fallback, not a recovered identity. """ parent_1 = person("parent_1_id", period) parent_2 = person("parent_2_id", period) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py new file mode 100644 index 00000000000..740ab42e217 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py @@ -0,0 +1,94 @@ +"""Medicaid MAGI parents: the ids' co-resident parents and one inferred step parent. + +42 CFR 435.603(b) defines parents, children and siblings to include step +relatives. Ids often name a child's natural parents, one of whom lives +elsewhere, and leave out a step parent the child lives with. The inference here +is deliberately bounded: + +- It applies only when the ids name exactly one distinct parent who lives with + the person. +- The step parent is that parent's established co-resident spouse: the other + head or spouse of a joint return, or the partner in a two-person marital unit + whose tax unit (either partner's) carries the cohabiting-spouses flag. A + two-person marital unit alone is not enough, since PE builds one marital unit + for everyone when a situation omits marital units. +- Raw ids, including an absent parent's, are unchanged, so is_parent and + sibling links through an absent parent's id are unaffected. + +Larger parent graphs, such as a third co-resident parent or the step relatives +of a person whose ids name two co-resident parents, are not inferred. +""" + +import numpy as np + +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, + household_member_indices, +) + + +def medicaid_established_spouse_indices(person, period): + """Row of each person's established co-resident spouse, or -1. + + A joint return or a two-person marital unit with the cohabiting-spouses + flag on either partner's tax unit shows the marriage. A person with more + than one such candidate has none. + """ + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + joint = head_or_spouse & (person.tax_unit("head_spouse_count", period) == 2) + married = person.marital_unit.nb_persons() == 2 + marital_unit = person.marital_unit.reference_entity.members_entity_id + cohabiting = person.tax_unit("cohabitating_spouses", period) + own_index = np.arange(person.count) + spouse = np.full(person.count, -1, dtype=int) + candidates = np.zeros(person.count, dtype=int) + for member in household_member_indices(person): + shown = ( + (member >= 0) + & (member != own_index) + & ( + (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) + | ( + married + & (marital_unit[member] == marital_unit) + & (cohabiting | cohabiting[member]) + ) + ) + ) + spouse = np.where(shown, member, spouse) + candidates += shown + return np.where(candidates == 1, spouse, -1) + + +def medicaid_step_parent_index(person, period): + """Row of each person's inferred co-resident step parent, or -1. + + When the ids name exactly one distinct parent the person lives with, this + is that parent's established co-resident spouse, unless the spouse is the + person or someone whose ids name the person as their parent. + """ + first, second = co_resident_parent_indices(person, period) + step = np.full(person.count, -1, dtype=int) + one_parent = (first >= 0) != (second >= 0) + if not np.any(one_parent): + return step + parent = np.where(first >= 0, first, second) + spouse = medicaid_established_spouse_indices(person, period) + step = np.where(one_parent, spouse[parent], -1) + own_index = np.arange(person.count) + names_person = (first[step] == own_index) | (second[step] == own_index) + return np.where((step >= 0) & (step != own_index) & ~names_person, step, -1) + + +def medicaid_parent_indices(person, period): + """Rows of each person's co-resident parents for Medicaid MAGI rules. + + The parents the ids name in the household, with an inferred step parent + (medicaid_step_parent_index) in the empty slot. Unresolved slots are -1. + """ + first, second = co_resident_parent_indices(person, period) + step = medicaid_step_parent_index(person, period) + fill_first = (first < 0) & (step >= 0) + fill_second = (first >= 0) & (second < 0) & (step >= 0) + return np.where(fill_first, step, first), np.where(fill_second, step, second) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py index d49075260b1..79b094796d6 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -2,9 +2,12 @@ import numpy as np +from policyengine_us.variables.gov.hhs.medicaid.income._medicaid_parents import ( + medicaid_parent_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, ) @@ -21,10 +24,18 @@ def _shares_parent_id(parent_1, parent_2, member): ) +def _shares_co_resident_parent(first, second, member): + """Whether each person shares a co-resident parent row with a co-resident.""" + return ((first >= 0) & ((first == first[member]) | (first == second[member]))) | ( + (second >= 0) & ((second == first[member]) | (second == second[member])) + ) + + def _spouse_rule(person, period, first, second): """Return a test of whether a co-resident is each person's spouse. - ``first`` and ``second`` are the co-resident parent rows. A spouse is a + ``first`` and ``second`` are the co-resident parent rows + (medicaid_parent_indices). A spouse is a co-resident partner in a two-person marital unit (PE's spouse convention), whatever either partner's tax role, or the other head or spouse of a joint return. A joint return or a cohabiting-spouses flag on @@ -78,9 +89,12 @@ def medicaid_non_filer_member_sum(person, period, values): the age limit. Each pair of co-residents is tested once, so each member counts once in size, income and California pregnancies. - Links identify parents, children and siblings (siblings share a nonzero - parent id, including an absent parent). A child-age person without ids is - unlinked. The original family-level proxy still relates unlinked children + Links identify parents, children and siblings. Parents are + medicaid_parent_indices: the co-resident parents the ids name, plus the + established spouse of a lone named co-resident parent, since 42 CFR + 435.603(b) counts step relatives. Siblings share a nonzero parent id, + including an absent parent's, or a co-resident parent, including an + inferred step parent. A child-age person without ids is unlinked. The original family-level proxy still relates unlinked children to each other and to unlinked parents in their family (parents some of whose children no id names). So a parent that a child count reports is never erased, though moving a household onto this rule can remove another @@ -100,7 +114,7 @@ def medicaid_non_filer_member_sum(person, period, values): child = person("medicaid_non_filer_child_age_eligible", period) unlinked_child = child & ~has_parent_ids(person, period) unlinked_parents = unlinked_parent(person, period) - first, second = co_resident_parent_indices(person, period) + first, second = medicaid_parent_indices(person, period) family = person.family.reference_entity.members_entity_id def unlinked_parent_family(parent): @@ -118,7 +132,9 @@ def unlinked_parent_family(parent): same_family = family[member] == family names_applicant = (first[member] == own_index) | (second[member] == own_index) named_by_applicant = (first == member) | (second == member) - shares_parent_id = _shares_parent_id(parent_1, parent_2, member) + shares_parent = _shares_parent_id( + parent_1, parent_2, member + ) | _shares_co_resident_parent(first, second, member) spouse = is_spouse(member) own_child = child[member] & ( names_applicant | (unlinked_parents & unlinked_child[member] & same_family) @@ -132,7 +148,7 @@ def unlinked_parent_family(parent): & child[member] & ~is_self & ( - shares_parent_id + shares_parent | ( unlinked_child[member] & ( @@ -165,7 +181,8 @@ def medicaid_tax_dependent_spouse_sum(person, period, values): living together is in the other's household even when one is claimed as a dependent. The tax-household branch counts the members of the claiming tax unit, the people that unit claims from other units and a separately - filing spouse of its head, but never the dependent's own spouse. So for a + filing spouse of its head or spouse (the cohabiting-spouses flag, or + medicaid_filer_spouse_sum), but never the dependent's own spouse. So for a tax dependent who uses that branch, this sums over the co-resident spouse _spouse_rule finds, unless the spouse already belongs to the tax household. It is zero for everyone else. @@ -195,7 +212,7 @@ def medicaid_tax_dependent_spouse_sum(person, period, values): if not np.any(applies): return total - first, second = co_resident_parent_indices(person, period) + first, second = medicaid_parent_indices(person, period) is_spouse = _spouse_rule(person, period, first, second) for member in household_member_indices(person): in_tax_household = np.where( @@ -209,3 +226,55 @@ def medicaid_tax_dependent_spouse_sum(person, period, values): included = (member >= 0) & applies & is_spouse(member) & ~in_tax_household total += np.where(included, values[member], 0.0) return total + + +def medicaid_filer_spouse_sum(person, period, values): + """Sum ``values`` over a head's or spouse's co-resident spouse outside their tax unit. + + Callers add each tax unit's total of this to every member's tax household, + before claimants elsewhere look that household up. Under 42 CFR + 435.603(f)(1) a taxpayer's household is the taxpayer and the dependents + they claim, (f)(4) adds a spouse the taxpayer lives with whether or not + they file jointly, and under (f)(2) a dependent's household is the + claiming taxpayer's. So, in households with parent links, a head or spouse + counts the co-resident spouse _spouse_rule finds unless that spouse is + already in the tax household: on the same return, or claimed into it from + another unit. A unit whose cohabiting-spouses flag already adds its single + head's separately filing spouse keeps that channel alone. It is zero for + everyone else. + + Values accumulate in float64. + """ + values = np.asarray(values, dtype=np.float64) + total = np.zeros(person.count, dtype=np.float64) + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + if not np.any((parent_1 != 0) | (parent_2 != 0)): + return total + + flagged = person.tax_unit("cohabitating_spouses", period) & ( + person.tax_unit("head_spouse_count", period) == 1 + ) + applies = ( + household_has_parent_ids(person, period) + & person("is_tax_unit_head_or_spouse", period) + & ~flagged + ) + if not np.any(applies): + return total + + tax_unit = person.tax_unit.reference_entity.members_entity_id + tax_unit_id = person.tax_unit("tax_unit_id", period) + claiming_tax_unit_id = person("medicaid_claiming_tax_unit_id", period) + claimed_elsewhere = person("medicaid_has_known_claiming_tax_unit", period) & ( + claiming_tax_unit_id != tax_unit_id + ) + first, second = medicaid_parent_indices(person, period) + is_spouse = _spouse_rule(person, period, first, second) + for member in household_member_indices(person): + in_tax_household = (tax_unit[member] == tax_unit) | ( + claimed_elsewhere[member] & (claiming_tax_unit_id[member] == tax_unit_id) + ) + included = (member >= 0) & applies & is_spouse(member) & ~in_tax_household + total += np.where(included, values[member], 0.0) + return total diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py index 1dafce57354..5e4ed6cd440 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_claimed_by_parent_in_tax_unit.py @@ -2,6 +2,9 @@ from policyengine_us.variables.gov.hhs.medicaid.income._claiming_tax_unit import ( medicaid_known_claim_by_named_parent, ) +from policyengine_us.variables.gov.hhs.medicaid.income._medicaid_parents import ( + medicaid_step_parent_index, +) from policyengine_us.variables.household.demographic.person._parent_links import ( has_parent_ids, tax_unit_parent_indices, @@ -46,8 +49,19 @@ def formula(person, period, parameters): claimed_elsewhere, claimed_elsewhere_by_parent = ( medicaid_known_claim_by_named_parent(person, period) ) + # 42 CFR 435.603(b) counts step parents: a lone named co-resident + # parent's established co-resident spouse is a parent too, so their + # claim is a parent's claim. + step = medicaid_step_parent_index(person, period) + tax_unit = person.tax_unit.reference_entity.members_entity_id + tax_unit_id = person.tax_unit("tax_unit_id", period) + claiming_tax_unit_id = person("medicaid_claiming_tax_unit_id", period) + step_claims = (step >= 0) & head_or_spouse[step] linked_parent = where( - claimed_elsewhere, claimed_elsewhere_by_parent, linked_parent + claimed_elsewhere, + claimed_elsewhere_by_parent + | (step_claims & (tax_unit_id[step] == claiming_tax_unit_id)), + linked_parent | (step_claims & (tax_unit[step] == tax_unit)), ) return dependent & where( has_parent_ids(person, period), linked_parent, inferred_parent diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 8ebca8736c7..53d1deaa10b 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -4,6 +4,7 @@ medicaid_external_claimed_sum, ) from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( + medicaid_filer_spouse_sum, medicaid_non_filer_member_sum, medicaid_tax_dependent_spouse_sum, ) @@ -70,6 +71,11 @@ def formula(person, period, parameters): tax_household_income = ( person.tax_unit.sum(tax_member_income) + separate_spouse_income ) + # With parent links, add the co-resident spouse outside the unit whom + # medicaid_household_size adds to the unit's tax household. + tax_household_income = tax_household_income + person.tax_unit.sum( + medicaid_filer_spouse_sum(person, period, member_income) + ) tax_household_income = tax_household_income + medicaid_external_claimed_sum( person, period, diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py index 0a48cb88f47..1b01c40950e 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_size.py @@ -4,6 +4,7 @@ medicaid_external_claimed_sum, ) from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( + medicaid_filer_spouse_sum, medicaid_non_filer_member_sum, medicaid_tax_dependent_spouse_sum, ) @@ -59,6 +60,12 @@ def formula(person, period, parameters): tax_household_size = person.tax_unit("tax_unit_size", period) + ( cohabitating_separate.astype(int) ) + # With parent links, a head's or spouse's co-resident spouse outside + # the unit joins the unit's tax household, which its dependents and + # claimants elsewhere share (42 CFR 435.603(f)(2) and (f)(4)). + tax_household_size = tax_household_size + person.tax_unit.sum( + medicaid_filer_spouse_sum(person, period, np.ones(person.count)) + ).astype(int) person_count = np.ones_like(person.tax_unit("tax_unit_id", period)) tax_household_size = tax_household_size + medicaid_external_claimed_sum( person, diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py index 4b7cbd4068c..74bcd5c1f6c 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_tax_dependent_exception_living_with_both_parents.py @@ -1,6 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.medicaid.income._medicaid_parents import ( + medicaid_parent_indices, +) from policyengine_us.variables.household.demographic.person._parent_links import ( - co_resident_parent_indices, has_parent_ids, unlinked_parent, ) @@ -29,7 +31,9 @@ def formula(person, period, parameters): return claimed_child & proxy # Living with both parents requires both to resolve in the household. - first, second = co_resident_parent_indices(person, period) + # 42 CFR 435.603(b) counts step parents, so a lone named parent's + # established co-resident spouse is the other parent. + first, second = medicaid_parent_indices(person, period) two_parents = (first >= 0) & (second >= 0) head = person("is_tax_unit_head", period) spouse = person("is_tax_unit_spouse", period) diff --git a/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py b/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py index a8e2ce50524..b55af54bc12 100644 --- a/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py +++ b/policyengine_us/variables/gov/states/ca/dhcs/medi_cal/ca_medicaid_household_pregnancies.py @@ -4,6 +4,7 @@ medicaid_external_claimed_sum, ) from policyengine_us.variables.gov.hhs.medicaid.income._non_filer_household import ( + medicaid_filer_spouse_sum, medicaid_non_filer_member_sum, medicaid_tax_dependent_spouse_sum, ) @@ -106,6 +107,11 @@ def spouse_has_flag(flag): tax_pregnancies = person.tax_unit.sum( pregnancies + head_or_spouse * separate_spouse ) + # With parent links, add the pregnancies of the co-resident spouse + # outside the unit whom medicaid_household_size adds. + tax_pregnancies += person.tax_unit.sum( + medicaid_filer_spouse_sum(person, period, pregnancies) + ).astype(int) tax_pregnancies += medicaid_external_claimed_sum( person, period, person.tax_unit("tax_unit_id", period), pregnancies ).astype(int) diff --git a/policyengine_us/variables/household/demographic/person/_parent_links.py b/policyengine_us/variables/household/demographic/person/_parent_links.py index 349246769c8..a00134757c1 100644 --- a/policyengine_us/variables/household/demographic/person/_parent_links.py +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -4,11 +4,22 @@ - Each id names the person_id of one of the person's parents (natural, adoptive or step); 0 means unknown, so real person ids must be nonzero. +- Id contract. Person ids must be nonzero int32 values, distinct within + every household and every tax unit. When a tax unit spans households or a + medicaid_claiming_tax_unit_id is set, an id can resolve outside the + household, so person ids must then be distinct across the whole + simulation. Reusing ids across households is safe only when every tax unit + lies inside one household and no known claiming tax unit is set. - Parenthood is identity and residence is a separate condition. Ids resolve against the members of one group: the household when a rule requires living together, the claiming tax unit when a rule asks who claims the - person. An id that names a co-resident names that person, wherever else - the same person_id appears. + person. An id that names a co-resident resolves to that co-resident: it + can then match a tax unit member elsewhere only if that member is the + co-resident. Under the contract this changes nothing; when ids collide + across households it is a tie-break, not a recovered identity. +- Input that breaks the contract gets a defined, member-order-free fallback, + not a legal identity: an id that matches two members of one group + resolves to neither, so it acts as an absent parent. - A person is a parent when own_children_in_household is positive or when a co-resident person's id names them. Neither source erases the other. - A household has links when any member has a nonzero id. Formulas keep @@ -60,7 +71,9 @@ def _parent_indices(person, period, group): """Resolve both parent slots among the members of ``group``. Unresolved slots are -1. A second slot that repeats the first names no - second parent, so each resolved parent appears once per person. + second parent, so each resolved parent appears once per person. A slot + whose id matches more than one other member breaks the id contract and + resolves to no one, whatever the member order. """ parent_1 = person("parent_1_id", period) parent_2 = person("parent_2_id", period) @@ -71,15 +84,19 @@ def _parent_indices(person, period, group): person_id = person("person_id", period) own_index = np.arange(person.count) + first_matches = np.zeros(person.count, dtype=int) + second_matches = np.zeros(person.count, dtype=int) for member in group_member_indices(group): valid = (member >= 0) & (member != own_index) member_id = person_id[member] - first = np.where( - valid & (parent_1 != 0) & (parent_1 == member_id), member, first - ) - second = np.where( - valid & (parent_2 != 0) & (parent_2 == member_id), member, second - ) + names_first = valid & (parent_1 != 0) & (parent_1 == member_id) + names_second = valid & (parent_2 != 0) & (parent_2 == member_id) + first = np.where(names_first, member, first) + second = np.where(names_second, member, second) + first_matches += names_first + second_matches += names_second + first = np.where(first_matches == 1, first, -1) + second = np.where(second_matches == 1, second, -1) second = np.where(second == first, -1, second) return first, second @@ -94,13 +111,20 @@ def tax_unit_parent_indices(person, period): A parent who claims the person is recognized wherever either one lives. An id that names a co-resident names that person, so a tax unit member - elsewhere who shares the person_id is not the parent. + elsewhere who shares the person_id is not the parent: the slot is the + co-resident when they belong to the tax unit and unresolved otherwise. """ first, second = _parent_indices(person, period, person.tax_unit) home_first, home_second = co_resident_parent_indices(person, period) - first = np.where((home_first >= 0) & (first != home_first), -1, first) - second = np.where((home_second >= 0) & (second != home_second), -1, second) - return first, second + tax_unit = person.tax_unit.reference_entity.members_entity_id + + def prefer_co_resident(slot, home): + in_unit = np.where(tax_unit[home] == tax_unit, home, -1) + return np.where(home >= 0, in_unit, slot) + + return prefer_co_resident(first, home_first), prefer_co_resident( + second, home_second + ) def linked_child_count(person, period): diff --git a/policyengine_us/variables/household/demographic/person/parent_1_id.py b/policyengine_us/variables/household/demographic/person/parent_1_id.py index 23b5c1f4a18..884027da0c8 100644 --- a/policyengine_us/variables/household/demographic/person/parent_1_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_1_id.py @@ -15,11 +15,18 @@ class parent_1_id(Variable): "resolves among household members for rules that require living " "together and, for the claimed-by-parent test, among the members of " "the claiming tax unit: the person's own, or a known claiming tax " - "unit elsewhere. An id that names a co-resident names that person " - "wherever else the person_id appears. An id that resolves to no one " - "is an absent parent, which still identifies siblings. Because 0 " - "means unknown, real person ids must be nonzero, and the default " - "person_id starts at 0, so supply person_id with parent ids. Ids are " - "compared after int32 storage, so every member of a household and of " - "a tax unit needs a distinct nonzero int32 person_id." + "unit elsewhere. An id that names a co-resident resolves to that " + "co-resident. An id that resolves to no one is an absent parent, " + "which still identifies siblings. When the ids name exactly one " + "parent the person lives with, Medicaid MAGI rules also treat that " + "parent's co-resident spouse, shown by a joint return or by a " + "two-person marital unit with the cohabiting-spouses flag, as a step " + "parent. Because 0 means unknown, real person ids must be nonzero, " + "and the default person_id starts at 0, so supply person_id with " + "parent ids. Ids are compared after int32 storage. Every member of a " + "household and of a tax unit needs a distinct nonzero int32 " + "person_id, and when any tax unit spans households or any " + "medicaid_claiming_tax_unit_id is set, person ids must be distinct " + "across the whole simulation. An id that matches two members of one " + "household or tax unit breaks this contract and resolves to neither." ) diff --git a/policyengine_us/variables/household/demographic/person/parent_2_id.py b/policyengine_us/variables/household/demographic/person/parent_2_id.py index 98c1d7e3198..4bdd755d828 100644 --- a/policyengine_us/variables/household/demographic/person/parent_2_id.py +++ b/policyengine_us/variables/household/demographic/person/parent_2_id.py @@ -15,11 +15,18 @@ class parent_2_id(Variable): "resolves among household members for rules that require living " "together and, for the claimed-by-parent test, among the members of " "the claiming tax unit: the person's own, or a known claiming tax " - "unit elsewhere. An id that names a co-resident names that person " - "wherever else the person_id appears. An id that resolves to no one " - "is an absent parent, which still identifies siblings. Because 0 " - "means unknown, real person ids must be nonzero, and the default " - "person_id starts at 0, so supply person_id with parent ids. Ids are " - "compared after int32 storage, so every member of a household and of " - "a tax unit needs a distinct nonzero int32 person_id." + "unit elsewhere. An id that names a co-resident resolves to that " + "co-resident. An id that resolves to no one is an absent parent, " + "which still identifies siblings. When the ids name exactly one " + "parent the person lives with, Medicaid MAGI rules also treat that " + "parent's co-resident spouse, shown by a joint return or by a " + "two-person marital unit with the cohabiting-spouses flag, as a step " + "parent. Because 0 means unknown, real person ids must be nonzero, " + "and the default person_id starts at 0, so supply person_id with " + "parent ids. Ids are compared after int32 storage. Every member of a " + "household and of a tax unit needs a distinct nonzero int32 " + "person_id, and when any tax unit spans households or any " + "medicaid_claiming_tax_unit_id is set, person ids must be distinct " + "across the whole simulation. An id that matches two members of one " + "household or tax unit breaks this contract and resolves to neither." ) From ce6ecb9c337dcab89e18df3ea1ca043c68ebfc58 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 12:40:09 -0400 Subject: [PATCH 20/24] test: pin named-parent roles, both-parent filing units and duplicate-id order; reword the changelog (r8 findings 5-7) - test_named_parent_must_be_claiming_head_or_spouse: a named parent who is another dependent on the claiming return, or files elsewhere, is not the claiming taxpayer (42 CFR 435.603(f)(2)(i)). - test_both_parent_joint_return_requires_same_filing_unit: (f)(2)(ii) holds unless the two co-resident parents head one filed joint return. - Extend the married-dependent property to the spouse's own household with and without the cohabiting-spouses flag, and the random-household oracle to step parents (435.603(b)). - test_duplicate_ids_resolve_the_same_way_in_every_member_order. - Changelog: co-resident ids resolve to that person, the linked both-parent exception, the id contract and the whole-simulation zero-id guarantee. Co-Authored-By: Claude Opus 5.5 --- changelog.d/medicaid-magi-parent-ids.fixed.md | 2 +- .../tests/core/test_parent_links.py | 80 +++++ .../core/test_parent_links_properties.py | 295 +++++++++++++++++- 3 files changed, 364 insertions(+), 13 deletions(-) diff --git a/changelog.d/medicaid-magi-parent-ids.fixed.md b/changelog.d/medicaid-magi-parent-ids.fixed.md index c06849d3551..c642663f988 100644 --- a/changelog.d/medicaid-magi-parent-ids.fixed.md +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -1 +1 @@ -Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. A tax dependent in such a household whose household is the claiming taxpayer's also counts, once, a co-resident spouse that household does not already include. `is_parent` combines the child count with the links, and the claimed-by-parent test resolves ids within the claiming tax unit, including a known claiming tax unit elsewhere, unless an id names a co-resident. Simulations in which every parent identifier is zero keep their existing outputs. A household without identifiers keeps its composition formulas, but a tax unit or family shared with a linked household can change who is presumed a parent. +Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. When the ids name exactly one parent a person lives with, that parent's co-resident spouse, shown by a joint return or by a two-person marital unit with the cohabiting-spouses flag, counts as a step parent. On the tax-filer route, a tax unit's household also counts, once, a co-resident spouse of its head or spouse who files separately, and a tax dependent counts, once, a co-resident spouse the claiming taxpayer's household lacks. `is_parent` combines the child count with the links. The claimed-by-parent test resolves ids within the claiming tax unit, including a known claiming tax unit elsewhere, and an id that names a co-resident resolves to that co-resident. The both-parents exception of 42 CFR 435.603(f)(2)(ii) uses the co-resident parents the ids identify. Person ids must be nonzero and distinct within each household and tax unit, and distinct across the simulation when a tax unit spans households or a known claiming tax unit is set. Simulations in which every parent identifier is zero keep their existing outputs; that guarantee covers the whole simulation, not a zero-id household inside a simulation with ids elsewhere. diff --git a/policyengine_us/tests/core/test_parent_links.py b/policyengine_us/tests/core/test_parent_links.py index d340745a2d6..29756485672 100644 --- a/policyengine_us/tests/core/test_parent_links.py +++ b/policyengine_us/tests/core/test_parent_links.py @@ -298,3 +298,83 @@ def test_a_repeated_id_naming_a_co_resident_does_not_match_a_claimant_elsewhere( simulation.calculate("medicaid_claimed_by_parent_in_tax_unit", 2026), [False, False, False], ) + + +def test_duplicate_ids_resolve_the_same_way_in_every_member_order(): + # Both situations break the id contract, so they pin a fallback, not a + # legal identity. The fallback must not depend on member order: an id + # that matches two members of one group resolves to neither, and an id + # that names a co-resident resolves to that co-resident. + from itertools import permutations + + outputs = [ + "is_parent", + "medicaid_claimed_by_parent_in_tax_unit", + "medicaid_tax_dependent_exception_living_with_both_parents", + "medicaid_uses_non_filer_rules", + "medicaid_household_size", + "medicaid_household_income", + ] + + def run(tax_unit_order, home_order, shared_home): + people = { + "mother": {"person_id": {"2026": 1}, "age": {"2026": 35}}, + "grandparent": {"person_id": {"2026": 1}, "age": {"2026": 60}}, + "child": { + "person_id": {"2026": 2}, + "age": {"2026": 10}, + "parent_1_id": {"2026": 1}, + }, + } + for name, income in [("mother", 20_000), ("grandparent", 30_000)]: + people[name]["employment_income"] = {"2026": income} + if shared_home: + homes = {"home": list(home_order)} + else: + homes = { + "home": [n for n in home_order if n != "grandparent"], + "grandparent_home": ["grandparent"], + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": { + "unit": { + "members": list(tax_unit_order), + "tax_unit_is_filer": {"2026": True}, + } + }, + "families": {k: {"members": v} for k, v in homes.items()}, + "marital_units": {name: {"members": [name]} for name in people}, + "households": { + k: {"members": v, "state_code": {"2026": "OH"}} + for k, v in homes.items() + }, + } + ) + return { + variable: dict( + zip(simulation.persons.ids, simulation.calculate(variable, 2026)) + ) + for variable in outputs + } + + names = ["mother", "grandparent", "child"] + for shared_home in (False, True): + results = [ + run(tax_unit_order, home_order, shared_home) + for tax_unit_order in permutations(names) + for home_order in (names, names[::-1]) + ] + for result in results[1:]: + assert result == results[0], shared_home + claimed = results[0]["medicaid_claimed_by_parent_in_tax_unit"]["child"] + is_parent = results[0]["is_parent"] + if shared_home: + # Two co-residents share the id, so it names neither of them. + assert not is_parent["mother"] and not is_parent["grandparent"] + else: + # The id names the co-resident mother, not the tax unit member + # elsewhere who shares her id. + assert claimed + assert is_parent["mother"] and not is_parent["grandparent"] diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index 045db550b7a..b9aa8267574 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -159,9 +159,13 @@ def random_linked_households( Children draw zero to two co-resident parents at least 14 years older (teen parents included) and, when they have fewer than two, possibly an absent parent whose id another child can share. Every child has at least - one parent. ``drop_ids`` zeroes a child's recorded ids while its parents - still count it; ``extra_count`` adds a child no id names to an adult's - count; ``split_families`` spreads members over two family entities. + one parent. A married couple files jointly, so a child whose ids name one + spouse alone has the other as a step parent (42 CFR 435.603(b)); a child + never names one spouse and a third adult, since two id slots cannot + record three co-resident parents. ``drop_ids`` zeroes a child's recorded + ids while its parents still count it; ``extra_count`` adds a child no id + names to an adult's count; ``split_families`` spreads members over two + family entities. Incomes are multiples of 1/8 below 2**20, so float32 storage and float64 sums are exact in any order. """ @@ -184,6 +188,9 @@ def random_linked_households( k = int(rng.integers(1 if not candidates else 0, 3)) if is_child else 0 k = min(k, len(candidates)) parents = [int(p) for p in rng.choice(candidates, size=k, replace=False)] + in_couple = [p for p in parents if married and p in (0, 1)] + if len(in_couple) == 1 and len(parents) == 2: + parents = [in_couple[0], 1 - in_couple[0]] absent = [] if is_child and len(parents) < 2 and (not parents or rng.random() < 0.3): absent = [900 + int(rng.integers(0, 3))] @@ -205,6 +212,12 @@ def random_linked_households( # A married couple living together is one family. people[0]["spouse"], people[1]["spouse"] = 1, 0 people[1]["family"] = people[0]["family"] + for person in people: + # Legal co-resident parents: a lone parent's spouse is a step parent. + legal = list(person["parents"]) + if len(legal) == 1 and people[legal[0]]["spouse"] is not None: + legal.append(people[legal[0]]["spouse"]) + person["legal"] = legal households.append(people) return households @@ -262,19 +275,22 @@ def _index(members, name): def true_members(members, i): - """42 CFR 435.603(f)(3) membership from the true relationships.""" + """42 CFR 435.603(f)(3) membership from the true relationships. + + Parents, children and siblings include step relatives (435.603(b)). + """ person = members[i] child = person["age"] < AGE_LIMIT result = {i} for m, other in enumerate(members): other_child = other["age"] < AGE_LIMIT - shared = set(person["parents"]) & set(other["parents"]) or ( + shared = set(person["legal"]) & set(other["legal"]) or ( set(person["absent"]) & set(other["absent"]) ) if ( person["spouse"] == m - or (other_child and i in other["parents"]) - or (child and m in person["parents"]) + or (other_child and i in other["legal"]) + or (child and m in person["legal"]) or (child and other_child and m != i and shared) ): result.add(m) @@ -284,19 +300,28 @@ def true_members(members, i): def rule_members(members, i): """Scalar statement of the membership rule documented for data with gaps. - Links resolve by recorded id. Main's family proxy survives only among - unlinked people: child-age members without ids and members reporting - more own children than the ids naming them. + Links resolve by recorded id. When the ids name exactly one co-resident + parent, that parent's spouse (the couple files jointly) is a step parent. + Main's family proxy survives only among unlinked people: child-age + members without ids and members reporting more own children than the ids + naming them. """ ids = [person["recorded"] for person in members] by_id = {k + 1: k for k in range(len(members))} - parents = [ + named = [ {by_id[x] for x in pair if x in by_id and by_id[x] != k} for k, pair in enumerate(ids) ] + parents = [set(p) for p in named] + for k, p in enumerate(named): + if len(p) == 1: + step = members[next(iter(p))]["spouse"] + if step is not None and step != k and k not in named[step]: + parents[k].add(step) child = [person["age"] < AGE_LIMIT for person in members] unlinked = [child[k] and not any(ids[k]) for k in range(len(members))] - linked_children = [sum(k in p for p in parents) for k in range(len(members))] + # is_parent counts only the parents the ids name. + linked_children = [sum(k in p for p in named) for k in range(len(members))] reports = [ sum(k in other["parents"] for other in members) + members[k]["extra"] > linked_children[k] @@ -324,6 +349,7 @@ def reporting_parent_in(k, fam): and child[m] and ( bool(({*ids[i]} - {0}) & ({*ids[m]} - {0})) + or bool(parents[i] & parents[m]) or (unlinked[m] and reporting_parent_in(i, family[m])) or (unlinked[i] and reporting_parent_in(m, family[i])) or (unlinked[i] and unlinked[m] and same_family) @@ -611,6 +637,28 @@ def test_tax_dependent_counts_a_co_resident_spouse_exactly_once(): expected_income += spouse_member_income assert income[name] == np.float32(expected_income), name + # The spouse's own household holds the child exactly once whenever + # they live together as spouses, with or without the flag ((f)(4)). + name = f"s{s}_spouse" + placement = scenario["placement"] + lives_with_child = placement not in ("unmarried", "elsewhere") + if spouse_is_dependent and not non_filer[name]: + # Claimed into the parent's return: the parent's tax household, + # which already holds the child. + members = parent_household + expected_income = sum(tax_income[m] for m in parent_household) + else: + # Files alone, or a dependent under (f)(2)(i)'s non-filer rules. + assert spouse_is_dependent or not non_filer[name], name + members = {"spouse", "child"} if lives_with_child else {"spouse"} + expected_income = ( + spouse_member_income if non_filer[name] else scenario["spouse_income"] + ) + expected_pregnancies = sum(preg[m] for m in members) + assert pregnancies[name] == expected_pregnancies, name + assert size[name] == len(members) + expected_pregnancies, name + assert income[name] == np.float32(expected_income), name + # Member order within every group does not change any result. reversed_ = Simulation( situation=married_dependent_situation(scenarios, reverse=True) @@ -645,3 +693,226 @@ def test_households_without_ids_keep_their_values_next_to_linked_ones(): in_mixed = by_name(mixed, variable) for name, value in by_name(alone, variable).items(): assert in_mixed[name] == value, (variable, name) + + +# Who claims: a named parent counts only as the claiming return's head or spouse. + +NAMED_ROLES = ["head", "spouse", "dependent", "outside"] + + +def named_parent_situation(role, known_claim, co_resident): + """A child whose id names X, claimed by a return in which X has ``role``. + + The claiming return is headed by X or by H. ``outside`` puts X on a return + of their own. The child is on the claiming return, or under a known + claiming tax unit, on a return of their own. X lives with the child or + elsewhere; H lives with X. + """ + x_age = 17 if role == "dependent" else 40 + people = { + "x": {"person_id": {PERIOD: 1}, "age": {PERIOD: x_age}}, + "h": {"person_id": {PERIOD: 5}, "age": {PERIOD: 60}}, + "child": { + "person_id": {PERIOD: 2}, + "age": {PERIOD: 1}, + "parent_1_id": {PERIOD: 1}, + }, + } + claiming = { + "head": ["x"], + "spouse": ["h", "x"], + "dependent": ["h", "x"], + "outside": ["h"], + }[role] + tax_units = {"claiming": {"members": claiming, "tax_unit_id": {PERIOD: 1}}} + if role == "spouse": + tax_units["claiming"]["filing_status"] = {PERIOD: "JOINT"} + if role == "dependent": + people["x"]["is_tax_unit_spouse"] = {PERIOD: False} + if role == "outside": + tax_units["x_unit"] = {"members": ["x"], "tax_unit_id": {PERIOD: 3}} + if role == "head": + tax_units["h_unit"] = {"members": ["h"], "tax_unit_id": {PERIOD: 4}} + if known_claim: + people["child"]["medicaid_claiming_tax_unit_id"] = {PERIOD: 1} + tax_units["child_unit"] = {"members": ["child"], "tax_unit_id": {PERIOD: 2}} + else: + tax_units["claiming"]["members"].append("child") + for unit in tax_units.values(): + unit["tax_unit_is_filer"] = {PERIOD: True} + homes = ( + {"home": ["x", "h", "child"]} + if co_resident + else {"adult_home": ["x", "h"], "child_home": ["child"]} + ) + marital_units = {name: {"members": [name]} for name in people} + if role == "spouse": + marital_units = { + "couple": {"members": ["h", "x"]}, + "child": {"members": ["child"]}, + } + return { + "people": people, + "tax_units": tax_units, + "families": {k: {"members": v} for k, v in homes.items()}, + "spm_units": {k: {"members": v} for k, v in homes.items()}, + "marital_units": marital_units, + "households": { + k: {"members": v, "state_code": {PERIOD: "OH"}} for k, v in homes.items() + }, + } + + +def test_named_parent_must_be_claiming_head_or_spouse(): + # 42 CFR 435.603(f)(2)(i): the exception turns on whether the taxpayer + # claiming the person is a parent. A named parent who is another + # dependent on that return (a teen parent claimed by a grandparent) or + # who files elsewhere is not the claiming taxpayer. + for role in NAMED_ROLES: + for known_claim in (False, True): + for co_resident in (False, True): + case = (role, known_claim, co_resident) + simulation = Simulation( + situation=named_parent_situation(*case) + ) + claimed = by_name(simulation, "medicaid_claimed_by_parent_in_tax_unit") + other = by_name( + simulation, + "medicaid_tax_dependent_exception_other_than_spouse_or_child", + ) + expected = role in ("head", "spouse") + assert claimed["child"] == expected, case + if not known_claim: + assert other["child"] == (not expected), case + + +# Both parents: (f)(2)(ii) needs two co-resident parents, a parent's claim and +# no joint return between those two parents. + +BOTH_PARENT_CASES = { + # arrangement: whether (f)(2)(ii) applies + "joint_filer": False, + "joint_non_filer": True, + "separate_filers": True, + "two_joint_returns": True, + "claimed_by_parent_elsewhere": True, + "claimed_by_grandparent_elsewhere": False, +} + + +def both_parent_situation(arrangement): + """A child whose ids name a mother and father who both live with them.""" + people = { + "mother": {"person_id": {PERIOD: 1}, "age": {PERIOD: 38}}, + "father": {"person_id": {PERIOD: 2}, "age": {PERIOD: 40}}, + "child": { + "person_id": {PERIOD: 3}, + "age": {PERIOD: 10}, + "parent_1_id": {PERIOD: 1}, + "parent_2_id": {PERIOD: 2}, + }, + } + home = ["mother", "father", "child"] + other_homes = {} + marital_units = {name: {"members": [name]} for name in people} + if arrangement in ("joint_filer", "joint_non_filer"): + tax_units = { + "unit": { + "members": ["mother", "father", "child"], + "filing_status": {PERIOD: "JOINT"}, + "tax_unit_is_filer": {PERIOD: arrangement == "joint_filer"}, + } + } + marital_units = { + "couple": {"members": ["mother", "father"]}, + "child": {"members": ["child"]}, + } + elif arrangement == "two_joint_returns": + # Each parent files jointly with someone else: the mother as head with + # her husband, the father as spouse of his wife. + people["husband"] = {"person_id": {PERIOD: 4}, "age": {PERIOD: 39}} + people["wife"] = {"person_id": {PERIOD: 5}, "age": {PERIOD: 45}} + home += ["husband", "wife"] + tax_units = { + "mother_unit": { + "members": ["mother", "husband", "child"], + "filing_status": {PERIOD: "JOINT"}, + "tax_unit_is_filer": {PERIOD: True}, + }, + "father_unit": { + "members": ["wife", "father"], + "filing_status": {PERIOD: "JOINT"}, + "tax_unit_is_filer": {PERIOD: True}, + }, + } + marital_units = { + "mother_couple": {"members": ["mother", "husband"]}, + "father_couple": {"members": ["wife", "father"]}, + "child": {"members": ["child"]}, + } + else: + tax_units = { + "mother_unit": {"members": ["mother"], "tax_unit_id": {PERIOD: 1}}, + "father_unit": {"members": ["father"], "tax_unit_id": {PERIOD: 2}}, + } + if arrangement == "separate_filers": + tax_units["mother_unit"]["members"].append("child") + else: + claimant = 1 + if arrangement == "claimed_by_grandparent_elsewhere": + people["grandparent"] = {"person_id": {PERIOD: 6}, "age": {PERIOD: 65}} + other_homes["grandparent_home"] = ["grandparent"] + tax_units["grandparent_unit"] = { + "members": ["grandparent"], + "tax_unit_id": {PERIOD: 6}, + } + marital_units["grandparent"] = {"members": ["grandparent"]} + claimant = 6 + # A dependent on the mother's return, claimed by a known unit. + tax_units["mother_unit"]["members"].append("child") + people["child"]["medicaid_claiming_tax_unit_id"] = {PERIOD: claimant} + if arrangement == "claimed_by_parent_elsewhere": + # The child's own return, claimed by the mother's unit. + tax_units["mother_unit"]["members"].remove("child") + tax_units["child_unit"] = { + "members": ["child"], + "tax_unit_id": {PERIOD: 3}, + } + for unit in tax_units.values(): + unit["tax_unit_is_filer"] = {PERIOD: True} + homes = {"home": home, **other_homes} + return { + "people": people, + "tax_units": tax_units, + "families": {k: {"members": v} for k, v in homes.items()}, + "spm_units": {k: {"members": v} for k, v in homes.items()}, + "marital_units": marital_units, + "households": { + k: {"members": v, "state_code": {PERIOD: "OH"}} for k, v in homes.items() + }, + } + + +def test_both_parent_joint_return_requires_same_filing_unit(): + # Parents "do not expect to file a joint tax return" unless they are the + # head and spouse of one return that is filed; a known claiming unit must + # have a co-resident parent as its head or spouse. + for arrangement, expected in BOTH_PARENT_CASES.items(): + simulation = Simulation(situation=both_parent_situation(arrangement)) + both = by_name( + simulation, "medicaid_tax_dependent_exception_living_with_both_parents" + ) + non_filer = by_name(simulation, "medicaid_uses_non_filer_rules") + assert both["child"] == expected, arrangement + assert not both["mother"] and not both["father"], arrangement + if expected: + assert non_filer["child"], arrangement + # The same household with the parents' ids swapped between slots. + swapped = both_parent_situation(arrangement) + swapped["people"]["child"]["parent_1_id"] = {PERIOD: 2} + swapped["people"]["child"]["parent_2_id"] = {PERIOD: 1} + swapped_both = by_name( + Simulation(situation=swapped), + "medicaid_tax_dependent_exception_living_with_both_parents", + ) + assert swapped_both["child"] == expected, arrangement From 38880d4aa00d2c87bfc54c9ce7865f3ee8ccd7b3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 13:37:45 -0400 Subject: [PATCH 21/24] style: ruff format the parent-link property tests Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_parent_links_properties.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index b9aa8267574..e7e26315a43 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -772,9 +772,7 @@ def test_named_parent_must_be_claiming_head_or_spouse(): for known_claim in (False, True): for co_resident in (False, True): case = (role, known_claim, co_resident) - simulation = Simulation( - situation=named_parent_situation(*case) - ) + simulation = Simulation(situation=named_parent_situation(*case)) claimed = by_name(simulation, "medicaid_claimed_by_parent_in_tax_unit") other = by_name( simulation, From 7d4ad6901a13a1f68c0c2b777433e80861e3a1d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 20:55:58 -0400 Subject: [PATCH 22/24] test: a spouse who files alone heads their own return at any age CI on 38880d4aa0 failed test_tax_dependent_counts_a_co_resident_spouse_exactly_once at s13_spouse: a 16-year-old spouse alone on a filing return. PE infers only an adult (age 18 or over) as a tax unit head, so the generator's spouse headed no return and became a non-filing dependent with zero MAGI, while the property assumes a taxpayer filing alone. The generator now states every head: the parent, a child on a return of their own only as an adult (as inferred), and a spouse off the parent's return at any age. Only minor spouses on their own returns change. Co-Authored-By: Claude Opus 5.5 --- .../core/test_parent_links_properties.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index e7e26315a43..f367d58f531 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -456,6 +456,7 @@ def random_married_dependents(seed, n=60): The parent claims the child either on the parent's own return or, as a known claiming tax unit, from the child's separate unit. Every tax unit gets a distinct tax_unit_id. Incomes are multiples of 1/8 below 2**20. + A spouse off the parent's return heads a return of their own, at any age. """ rng = np.random.default_rng(seed) return [ @@ -491,9 +492,24 @@ def ordered(members): spouse_member_income = spouse_income * ( scenario["spouse_must_file"] or scenario["spouse_age"] >= AGE_LIMIT ) - for name, person_id, age, magi, member_income, must_file in [ - (parent, 1, 45, scenario["parent_income"], scenario["parent_income"], True), - (child, 2, scenario["child_age"], 0, 0, False), + # Heads are inputs because PE infers only an adult (18 or over) as a + # head, so a spouse under 18 who files alone would head no return and + # count as a non-filing dependent. Everyone else keeps the inferred + # role: the parent heads their return, and a child on a return of + # their own heads it only as an adult. + child_heads = scenario["known_claim"] and scenario["child_age"] >= 18 + spouse_heads = placement != "same_return" + for name, person_id, age, magi, member_income, must_file, heads in [ + ( + parent, + 1, + 45, + scenario["parent_income"], + scenario["parent_income"], + True, + True, + ), + (child, 2, scenario["child_age"], 0, 0, False, child_heads), ( spouse, 3, @@ -501,11 +517,13 @@ def ordered(members): spouse_income, spouse_member_income, scenario["spouse_must_file"], + spouse_heads, ), ]: people[name] = { "age": {PERIOD: age}, "person_id": {PERIOD: person_id}, + "is_tax_unit_head": {PERIOD: heads}, "is_tax_unit_spouse": {PERIOD: False}, "medicaid_magi_person": {PERIOD: magi}, "medicaid_household_income_member": {PERIOD: member_income}, From a4ca85f6fbaab2b8870486b09ee71ca711ed13f0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 06:18:25 -0400 Subject: [PATCH 23/24] Use globally unique IDs in married-dependent property scenarios --- .../tests/core/test_parent_links_properties.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_parent_links_properties.py b/policyengine_us/tests/core/test_parent_links_properties.py index f367d58f531..cdca0878cb7 100644 --- a/policyengine_us/tests/core/test_parent_links_properties.py +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -455,7 +455,9 @@ def random_married_dependents(seed, n=60): The parent claims the child either on the parent's own return or, as a known claiming tax unit, from the child's separate unit. Every tax unit - gets a distinct tax_unit_id. Incomes are multiples of 1/8 below 2**20. + gets a distinct tax_unit_id. Person ids are distinct across the simulation, + as required when tax units span households or claiming units are known. + Incomes are multiples of 1/8 below 2**20. A spouse off the parent's return heads a return of their own, at any age. """ rng = np.random.default_rng(seed) @@ -476,7 +478,7 @@ def random_married_dependents(seed, n=60): def married_dependent_situation(scenarios, reverse=False, with_ids=True, prefix="s"): - """Scenarios as a situation; ``prefix`` keeps names and tax unit ids apart.""" + """Scenarios as a situation; ``prefix`` keeps names and all ids apart.""" people, households, families, tax_units, marital_units = {}, {}, {}, {}, {} id_offset = 0 if prefix == "s" else 10_000 @@ -485,6 +487,7 @@ def ordered(members): for s, scenario in enumerate(scenarios): name = f"{prefix}{s}" + scenario_id_offset = id_offset + 10 * s parent, child, spouse = f"{name}_parent", f"{name}_child", f"{name}_spouse" placement = scenario["placement"] spouse_income = scenario["spouse_income"] @@ -522,7 +525,7 @@ def ordered(members): ]: people[name] = { "age": {PERIOD: age}, - "person_id": {PERIOD: person_id}, + "person_id": {PERIOD: scenario_id_offset + person_id}, "is_tax_unit_head": {PERIOD: heads}, "is_tax_unit_spouse": {PERIOD: False}, "medicaid_magi_person": {PERIOD: magi}, @@ -531,7 +534,7 @@ def ordered(members): "current_pregnancies": {PERIOD: scenario["pregnancies"][person_id - 1]}, } if with_ids: - people[child]["parent_1_id"] = {PERIOD: 1} + people[child]["parent_1_id"] = {PERIOD: scenario_id_offset + 1} parent_unit_id = id_offset + 10 * s + 1 # The parent's return, and the child's own unit under a known claim. From 409e65e8b29feb87b8fc7cf245b35be28bb6b152 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 06:38:55 -0400 Subject: [PATCH 24/24] Align linked spouse membership and income across Medicaid households --- changelog.d/medicaid-magi-parent-ids.fixed.md | 2 + .../income/parent_ids_composition.yaml | 173 ++++++++++++++++++ .../hhs/medicaid/income/_medicaid_parents.py | 12 +- .../medicaid/income/_non_filer_household.py | 32 ++-- .../income/medicaid_household_income.py | 13 +- 5 files changed, 214 insertions(+), 18 deletions(-) diff --git a/changelog.d/medicaid-magi-parent-ids.fixed.md b/changelog.d/medicaid-magi-parent-ids.fixed.md index c642663f988..590eb1ba257 100644 --- a/changelog.d/medicaid-magi-parent-ids.fixed.md +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -1 +1,3 @@ Use optional parent identifiers (`parent_1_id`, `parent_2_id`) for Medicaid MAGI household composition. In households that supply them, each non-filer's household counts the applicant, a co-resident spouse, their own children under the age limit and, for a child-age applicant, co-resident parents and child-age siblings, each once, and California's unborn-child count follows the same members. When the ids name exactly one parent a person lives with, that parent's co-resident spouse, shown by a joint return or by a two-person marital unit with the cohabiting-spouses flag, counts as a step parent. On the tax-filer route, a tax unit's household also counts, once, a co-resident spouse of its head or spouse who files separately, and a tax dependent counts, once, a co-resident spouse the claiming taxpayer's household lacks. `is_parent` combines the child count with the links. The claimed-by-parent test resolves ids within the claiming tax unit, including a known claiming tax unit elsewhere, and an id that names a co-resident resolves to that co-resident. The both-parents exception of 42 CFR 435.603(f)(2)(ii) uses the co-resident parents the ids identify. Person ids must be nonzero and distinct within each household and tax unit, and distinct across the simulation when a tax unit spans households or a known claiming tax unit is set. Simulations in which every parent identifier is zero keep their existing outputs; that guarantee covers the whole simulation, not a zero-id household inside a simulation with ids elsewhere. + +Prevent inferred tax roles from treating named parents and children as spouses when resolving stepparents. Share a flagged separately filing spouse's income across the linked tax household, and count a flagged spouse already included through a tax-unit membership or external claim once in household size, income and California pregnancies. diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index 44324a9905f..dcc0740c669 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -2055,3 +2055,176 @@ medicaid_uses_non_filer_rules: [false, true, false] medicaid_household_size: [1, 2, 2] medicaid_household_income: [20_000, 20_000, 30_000] + +# Additional linked-household membership regressions from the recovery audit. + +- name: A grandparent inferred as the teen parent's tax spouse is not a stepparent + period: 2026 + input: + people: + grandparent: + person_id: 1 + age: 60 + employment_income: 40_000 + teen: + person_id: 2 + age: 18 + parent_1_id: 1 + baby: + person_id: 3 + age: 1 + parent_1_id: 2 + tax_units: + adult_return: + members: [grandparent, teen] + tax_unit_is_filer: true + baby_unit: + members: [baby] + tax_unit_is_filer: false + families: + family: + members: [grandparent, teen, baby] + marital_units: + grandparent: + members: [grandparent] + teen: + members: [teen] + baby: + members: [baby] + households: + home: + members: [grandparent, teen, baby] + state_code: OH + output: + # The default tax roles label the two adults as head and spouse, but the + # parent links show that they are parent and child. That role inference + # cannot establish a marriage or make the grandparent the baby's step + # parent. The baby uses non-filer rules and counts only baby plus teen. + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [2, 2, 2] + medicaid_household_income: [40_000, 40_000, 0] + +- name: A flagged separately filing spouse's income joins the filer's adult dependent household + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + employment_income: 20_000 + child: + person_id: 2 + age: 19 + parent_1_id: 1 + is_tax_unit_spouse: false + spouse: + person_id: 3 + age: 40 + employment_income: 40_000 + tax_units: + parent_unit: + members: [parent, child] + tax_unit_is_filer: true + cohabitating_spouses: true + spouse_unit: + members: [spouse] + tax_unit_is_filer: true + cohabitating_spouses: true + families: + family: {members: [parent, child, spouse]} + marital_units: + couple: {members: [parent, spouse]} + child: {members: [child]} + households: + home: {members: [parent, child, spouse], state_code: OH} + output: + # The child is over the non-filer child age limit. The parent and child + # share the parent's tax household, including the parent's separately + # filing spouse. The spouse's household contains only spouse plus parent. + medicaid_uses_non_filer_rules: [false, false, false] + medicaid_household_size: [3, 3, 2] + medicaid_household_income: [60_000, 60_000, 60_000] + +- name: California propagates a flagged filer spouse's income and pregnancy to the adult dependent + period: 2026 + input: + people: + parent: + person_id: 1 + age: 40 + employment_income: 20_000 + child: + person_id: 2 + age: 19 + parent_1_id: 1 + is_tax_unit_spouse: false + spouse: + person_id: 3 + age: 40 + employment_income: 40_000 + current_pregnancies: 1 + tax_units: + parent_unit: + members: [parent, child] + tax_unit_is_filer: true + cohabitating_spouses: true + spouse_unit: + members: [spouse] + tax_unit_is_filer: true + cohabitating_spouses: true + families: + family: {members: [parent, child, spouse]} + marital_units: + couple: {members: [parent, spouse]} + child: {members: [child]} + households: + home: {members: [parent, child, spouse], state_code: CA} + output: + # ACWDL 20-10 counts the pregnant spouse's unborn child in all three + # households; income follows those same household memberships. + medicaid_uses_non_filer_rules: [false, false, false] + ca_medicaid_household_pregnancies: [1, 1, 1] + medicaid_household_size: [4, 4, 3] + medicaid_household_income: [60_000, 60_000, 60_000] + +- name: A flagged filer's spouse claimed into the filer's unit counts once in California + period: 2026 + input: + people: + filer: + person_id: 1 + age: 40 + employment_income: 40_000 + parent_1_id: 9 + spouse: + person_id: 2 + age: 41 + employment_income: 5_000 + claimed_as_dependent_on_another_return: true + medicaid_claiming_tax_unit_id: 1 + current_pregnancies: 1 + tax_units: + filer_unit: + members: [filer] + tax_unit_id: 1 + tax_unit_is_filer: true + cohabitating_spouses: true + spouse_unit: + members: [spouse] + tax_unit_id: 2 + tax_unit_is_filer: true + cohabitating_spouses: true + families: + family: {members: [filer, spouse]} + marital_units: + couple: {members: [filer, spouse]} + households: + home: {members: [filer, spouse], state_code: CA} + output: + # The external claim already includes the spouse. The flag must not add + # that spouse or pregnancy again. A dependent who need not file remains + # excluded from the taxpayer's income even when filing voluntarily. + medicaid_uses_non_filer_rules: [false, false] + ca_medicaid_household_pregnancies: [1, 1] + medicaid_household_size: [3, 3] + medicaid_household_income: [40_000, 40_000] diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py index 740ab42e217..e0ff821361d 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py @@ -31,9 +31,11 @@ def medicaid_established_spouse_indices(person, period): """Row of each person's established co-resident spouse, or -1. A joint return or a two-person marital unit with the cohabiting-spouses - flag on either partner's tax unit shows the marriage. A person with more - than one such candidate has none. + flag on either partner's tax unit shows the marriage. Parent-child links + rule out a candidate even when inferred tax roles suggest a marriage. + A person with more than one such candidate has none. """ + first, second = co_resident_parent_indices(person, period) head_or_spouse = person("is_tax_unit_head_or_spouse", period) tax_unit = person.tax_unit.reference_entity.members_entity_id joint = head_or_spouse & (person.tax_unit("head_spouse_count", period) == 2) @@ -44,9 +46,15 @@ def medicaid_established_spouse_indices(person, period): spouse = np.full(person.count, -1, dtype=int) candidates = np.zeros(person.count, dtype=int) for member in household_member_indices(person): + # Tax roles can infer an adult child as the head's spouse. Raw + # parent links rule out that marriage, as in _spouse_rule. + names_applicant = (first[member] == own_index) | (second[member] == own_index) + named_by_applicant = (first == member) | (second == member) shown = ( (member >= 0) & (member != own_index) + & ~names_applicant + & ~named_by_applicant & ( (joint & head_or_spouse[member] & (tax_unit[member] == tax_unit)) | ( diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py index 79b094796d6..18ba4c140c3 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -229,21 +229,24 @@ def medicaid_tax_dependent_spouse_sum(person, period, values): def medicaid_filer_spouse_sum(person, period, values): - """Sum ``values`` over a head's or spouse's co-resident spouse outside their tax unit. + """Correct a head's or spouse's tax-household spouse membership. Callers add each tax unit's total of this to every member's tax household, before claimants elsewhere look that household up. Under 42 CFR 435.603(f)(1) a taxpayer's household is the taxpayer and the dependents they claim, (f)(4) adds a spouse the taxpayer lives with whether or not they file jointly, and under (f)(2) a dependent's household is the - claiming taxpayer's. So, in households with parent links, a head or spouse - counts the co-resident spouse _spouse_rule finds unless that spouse is - already in the tax household: on the same return, or claimed into it from - another unit. A unit whose cohabiting-spouses flag already adds its single - head's separately filing spouse keeps that channel alone. It is zero for - everyone else. + claiming taxpayer's. In households with parent links, _spouse_rule + identifies the co-resident spouse. Without a cohabiting-spouses flag, + add their values only when they are missing from the tax household. - Values accumulate in float64. + The single-head flagged channel already adds a separately filing spouse. + When that spouse is on the same return or claimed into it from another + unit, subtract their values to cancel the flag's duplicate contribution. + Otherwise keep that channel alone. If no spouse can be identified, the + original flag fallback remains. The correction is zero for everyone else. + + Signed values accumulate in float64. """ values = np.asarray(values, dtype=np.float64) total = np.zeros(person.count, dtype=np.float64) @@ -255,10 +258,8 @@ def medicaid_filer_spouse_sum(person, period, values): flagged = person.tax_unit("cohabitating_spouses", period) & ( person.tax_unit("head_spouse_count", period) == 1 ) - applies = ( - household_has_parent_ids(person, period) - & person("is_tax_unit_head_or_spouse", period) - & ~flagged + applies = household_has_parent_ids(person, period) & person( + "is_tax_unit_head_or_spouse", period ) if not np.any(applies): return total @@ -275,6 +276,9 @@ def medicaid_filer_spouse_sum(person, period, values): in_tax_household = (tax_unit[member] == tax_unit) | ( claimed_elsewhere[member] & (claiming_tax_unit_id[member] == tax_unit_id) ) - included = (member >= 0) & applies & is_spouse(member) & ~in_tax_household - total += np.where(included, values[member], 0.0) + included = (member >= 0) & applies & is_spouse(member) + adjustment = (~flagged & ~in_tax_household).astype(int) - ( + flagged & in_tax_household + ).astype(int) + total += np.where(included, adjustment * values[member], 0.0) return total diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 53d1deaa10b..880e31ea6b3 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -68,8 +68,17 @@ def formula(person, period, parameters): medicaid_non_filer_member_sum(person, period, member_income), non_filer_household_income, ) - tax_household_income = ( - person.tax_unit.sum(tax_member_income) + separate_spouse_income + tax_member_income_sum = person.tax_unit.sum(tax_member_income) + tax_household_income = tax_member_income_sum + separate_spouse_income + # With parent links, every dependent shares the taxpayer's + # household, including that taxpayer's flagged separately filing + # spouse. Propagate the head's spouse income once across the unit, + # keeping the original expression in households without links. + tax_household_income = where( + household_has_parent_ids(person, period), + tax_member_income_sum + + person.tax_unit.sum(head_or_spouse * separate_spouse_income), + tax_household_income, ) # With parent links, add the co-resident spouse outside the unit whom # medicaid_household_size adds to the unit's tax household.