Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
06f0b3f
fix: derive Medicaid MAGI households from parent ids
MaxGhenis Sep 7, 2026
c9a9b33
test: verify parent-id edge cases and zero-id compatibility
MaxGhenis Sep 7, 2026
08eaff9
test: property tests for the parent-id path over seeded random househ…
MaxGhenis Sep 26, 2026
57fa403
fix: build Medicaid MAGI non-filer households from one membership rule
MaxGhenis Sep 26, 2026
df146cf
fix: evaluate main's both-parents terms first and skip links without ids
MaxGhenis Sep 26, 2026
5934620
test: regressions for every review counterexample and composition pro…
MaxGhenis Sep 26, 2026
2e92217
fix: address the r2 review of the parent-id rework
MaxGhenis Sep 26, 2026
0c4fda9
fix: honor is_parent inputs without ids and keep siblings from being …
MaxGhenis Sep 26, 2026
e72e346
fix: require marital-unit spouses to share a family
MaxGhenis Sep 26, 2026
671e35f
fix: let a shown marriage outrank the shared-parent-id spouse guard
MaxGhenis Sep 26, 2026
4e5c627
docs: state the no-ids guarantee for simulations with every parent id…
MaxGhenis Sep 26, 2026
eaf6cc4
test: cover each spouse branch on its own
MaxGhenis Sep 26, 2026
c5717ce
fix: count a tax dependent's co-resident spouse once (r6 finding 1)
MaxGhenis Sep 27, 2026
ab8639b
fix: resolve parent ids against a known claiming tax unit elsewhere
MaxGhenis Sep 27, 2026
7f12320
docs: describe the tax-route spouse and claiming-unit resolution in t…
MaxGhenis Sep 27, 2026
e69bea9
test: pin the claimant's spouse slot, a head's own claim and id-free …
MaxGhenis Sep 27, 2026
f5151b4
test: show the marriage for spouses already in the parent's tax house…
MaxGhenis Sep 27, 2026
34c6562
fix: an id that names a co-resident names that person (r7 findings 2,…
MaxGhenis Sep 27, 2026
884eacf
Merge upstream/main into medicaid-magi-parent-ids
MaxGhenis Oct 6, 2026
9326dc4
fix: step parents, reciprocal filer spouses and the id contract (r8 f…
MaxGhenis Oct 6, 2026
ce6ecb9
test: pin named-parent roles, both-parent filing units and duplicate-…
MaxGhenis Oct 6, 2026
b79b5c7
Merge upstream/main into medicaid-magi-parent-ids
MaxGhenis Oct 6, 2026
38880d4
style: ruff format the parent-link property tests
MaxGhenis Oct 6, 2026
7d4ad69
test: a spouse who files alone heads their own return at any age
MaxGhenis Oct 7, 2026
62aee62
Merge upstream/main into medicaid-magi-parent-ids
MaxGhenis Oct 7, 2026
b6ad662
Merge upstream/main into medicaid-magi-parent-ids
MaxGhenis Oct 7, 2026
a4ca85f
Use globally unique IDs in married-dependent property scenarios
MaxGhenis Oct 7, 2026
409e65e
Align linked spouse membership and income across Medicaid households
MaxGhenis Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/medicaid-magi-parent-ids.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
380 changes: 380 additions & 0 deletions policyengine_us/tests/core/test_parent_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,380 @@
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],
)
# 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),
[2, 3, 2, 3, 1, 3],
)
# Exact binary fractions expose cross-household joins without tolerances.
np.testing.assert_array_equal(
simulation.calculate("medicaid_household_income", 2026),
[10_100.375, 20_700.75, 10_100.375, 20_700.75, 200.5, 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]
)


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


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]
)


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],
)


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"]
Loading
Loading