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..590eb1ba257 --- /dev/null +++ b/changelog.d/medicaid-magi-parent-ids.fixed.md @@ -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. 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..29756485672 --- /dev/null +++ b/policyengine_us/tests/core/test_parent_links.py @@ -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"] 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..cdca0878cb7 --- /dev/null +++ b/policyengine_us/tests/core/test_parent_links_properties.py @@ -0,0 +1,937 @@ +"""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") + + +# 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. 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. + """ + 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)] + 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))] + 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: + # 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 + + +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. + + 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["legal"]) & set(other["legal"]) or ( + set(person["absent"]) & set(other["absent"]) + ) + if ( + person["spouse"] == m + 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) + return result + + +def rule_members(members, i): + """Scalar statement of the membership rule documented for data with gaps. + + 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))} + 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))] + # 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] + 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 and same_family + 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 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) + ) + ) + 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"] + + +# 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 in one family, claimed on the parent's return too. + "same_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): + """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. 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) + 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, with_ids=True, prefix="s"): + """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 + + def ordered(members): + return members[::-1] if reverse else 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"] + # 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 + ) + # 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, + scenario["spouse_age"], + spouse_income, + spouse_member_income, + scenario["spouse_must_file"], + spouse_heads, + ), + ]: + people[name] = { + "age": {PERIOD: age}, + "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}, + "medicaid_household_income_member": {PERIOD: member_income}, + "medicaid_person_is_required_to_file": {PERIOD: must_file}, + "current_pregnancies": {PERIOD: scenario["pregnancies"][person_id - 1]}, + } + if with_ids: + 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. + 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"{name}_child_unit"] = { + "members": [child], + "tax_unit_id": {PERIOD: id_offset + 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"{name}_spouse_unit"] = { + "members": [spouse], + "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"{name}_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 in IN_CHILDS_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 not in IN_CHILDS_FAMILY: + family_groups.append([spouse]) + for k, members in enumerate(homes): + households[f"{name}_home{k}"] = { + "members": ordered(members), + "state_code": {PERIOD: "CA"}, + } + for k, members in enumerate(family_groups): + families[f"{name}_family{k}"] = {"members": ordered(members)} + if placement == "unmarried": + marital_units[f"{name}_child"] = {"members": [child]} + marital_units[f"{name}_spouse"] = {"members": [spouse]} + else: + marital_units[f"{name}_couple"] = {"members": ordered([child, spouse])} + marital_units[f"{name}_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 + + # 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) + ) + for variable in [ + "medicaid_household_size", + "medicaid_household_income", + "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) + + +# 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 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..1eef8731787 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids.yaml @@ -0,0 +1,632 @@ +# 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: + 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] + + +- 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: + # 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/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..dcc0740c669 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -0,0 +1,2230 @@ +# 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] + +# 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] + +# 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] + +# 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] + +# 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] + +# 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] + +# 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 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] + 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] + +- 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] + +- 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] + +# 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] + +# 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] + +# 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/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..05cd9e2948c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/demographic/person/parent_ids.yaml @@ -0,0 +1,119 @@ +# 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: + 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, 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. + 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 ids resolve only within the household, and child counts still identify parents. + 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, true] + is_mother: [false, true, false, false] + is_father: [false, false, false, true] + +- 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: 0 + 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: 0 + output: + is_parent: false + is_mother: false + is_father: false 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..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 @@ -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 @@ -75,3 +78,55 @@ 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. 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. + + 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) + 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) + 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, 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/gov/hhs/medicaid/income/_medicaid_parents.py b/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py new file mode 100644 index 00000000000..e0ff821361d --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_medicaid_parents.py @@ -0,0 +1,102 @@ +"""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. 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) + 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): + # 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)) + | ( + 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 new file mode 100644 index 00000000000..18ba4c140c3 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/income/_non_filer_household.py @@ -0,0 +1,284 @@ +"""Medicaid MAGI household membership from parent links.""" + +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 ( + has_parent_ids, + household_has_parent_ids, + household_member_indices, + unlinked_parent, +) + + +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 _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 + (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 + 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. + + 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. 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 + family's legacy over-count, such as an adult's child-age sibling. + + Spouses follow _spouse_rule. + + 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) + unlinked_parents = unlinked_parent(person, period) + first, second = medicaid_parent_indices(person, period) + family = person.family.reference_entity.members_entity_id + + 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 = unlinked_parent_family(first) + parent_family_2 = unlinked_parent_family(second) + + is_spouse = _spouse_rule(person, period, first, second) + + own_index = np.arange(person.count) + for member in household_member_indices(person): + is_self = member == own_index + same_family = family[member] == family + names_applicant = (first[member] == own_index) | (second[member] == own_index) + named_by_applicant = (first == member) | (second == 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) + ) + parent = child & ( + named_by_applicant + | (unlinked_child & unlinked_parents[member] & same_family) + ) + sibling = ( + child + & child[member] + & ~is_self + & ( + shares_parent + | ( + 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 + + +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 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. + + 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 = 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( + 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 + + +def medicaid_filer_spouse_sum(person, period, values): + """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. 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. + + 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) + 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 + ) + 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) + 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_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..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 @@ -1,4 +1,15 @@ 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.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, + unlinked_parent, +) class medicaid_claimed_by_parent_in_tax_unit(Variable): @@ -12,11 +23,46 @@ class medicaid_claimed_by_parent_in_tax_unit(Variable): def formula(person, period, parameters): # 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 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( - person("is_parent", period) & person("is_tax_unit_head_or_spouse", period) + unlinked_parent(person, period) + & person("is_tax_unit_head_or_spouse", period) ) - return person("is_tax_unit_dependent", period) & ( + 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 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) + ) + # 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 + | (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 0dbd9d927eb..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 @@ -3,6 +3,14 @@ medicaid_claiming_tax_unit_value, 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, +) +from policyengine_us.variables.household.demographic.person._parent_links import ( + household_has_parent_ids, +) class medicaid_household_income(Variable): @@ -53,8 +61,29 @@ def formula(person, period, parameters): spouse_income + family_parent_income + family_child_income, member_income + spouse_income + family_child_income, ) - tax_household_income = ( - person.tax_unit.sum(tax_member_income) + separate_spouse_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_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. + 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, @@ -66,13 +95,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 de55df9bd80..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 @@ -3,6 +3,14 @@ medicaid_claiming_tax_unit_value, 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, +) +from policyengine_us.variables.household.demographic.person._parent_links import ( + household_has_parent_ids, +) class medicaid_household_size(Variable): @@ -42,9 +50,22 @@ def formula(person, period, parameters): 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) ) + # 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, @@ -56,6 +77,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. @@ -66,14 +102,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/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..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,4 +1,11 @@ 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 ( + has_parent_ids, + unlinked_parent, +) class medicaid_tax_dependent_exception_living_with_both_parents(Variable): @@ -9,10 +16,65 @@ 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): - return ( + 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) - & (person.family.sum(person("is_parent", period)) > 1) - & (person.tax_unit.sum(person("is_parent", period)) == 1) ) + # 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): + return claimed_child & proxy + + # Living with both parents requires both to resolve in the household. + # 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) + 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] + ) + # 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) + ) + ) + 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/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..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 @@ -3,6 +3,14 @@ medicaid_claiming_tax_unit_value, 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, +) +from policyengine_us.variables.household.demographic.person._parent_links import ( + household_has_parent_ids, +) class ca_medicaid_household_pregnancies(Variable): @@ -85,24 +93,48 @@ 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. 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) 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, ) 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..a00134757c1 --- /dev/null +++ b/policyengine_us/variables/household/demographic/person/_parent_links.py @@ -0,0 +1,146 @@ +"""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. +- 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 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 + their original expressions in households without links. +- 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. +""" + +import numpy as np + + +def group_member_indices(group): + """Yield each co-member's row index, projected to every member of a group. + + ``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(group.reference_entity.members.count) + positions = group.reference_entity.members_position + for position in range(int(np.max(positions, initial=-1)) + 1): + yield group.value_nth_person(position, indices, default=-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. 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) + 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) + 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] + 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 + + +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) + + +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: 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) + 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): + """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 unlinked_parent(person, period): + """Whether a person is a parent some of whose children no id names. + + Without links this is is_parent, including an is_parent input. + """ + is_parent = person("is_parent", period) + linked = linked_child_count(person, period) + return is_parent & ( + (linked == 0) | (person("own_children_in_household", period) > linked) + ) diff --git a/policyengine_us/variables/household/demographic/person/is_father.py b/policyengine_us/variables/household/demographic/person/is_father.py index 75caa06d1aa..e3cb5a77e35 100644 --- a/policyengine_us/variables/household/demographic/person/is_father.py +++ b/policyengine_us/variables/household/demographic/person/is_father.py @@ -8,9 +8,7 @@ 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 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 1394af0eaa8..7c85f7b6892 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 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 fcb5d85dcf8..1d5c6066583 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 ( + linked_child_count, +) class is_parent(Variable): @@ -8,4 +11,10 @@ class is_parent(Variable): definition_period = YEAR def formula(person, period, parameters): - return 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 new file mode 100644 index 00000000000..884027da0c8 --- /dev/null +++ b/policyengine_us/variables/household/demographic/person/parent_1_id.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class parent_1_id(Variable): + value_type = int + entity = Person + label = "First parent's person ID" + definition_period = YEAR + default_value = 0 + 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, 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 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 new file mode 100644 index 00000000000..4bdd755d828 --- /dev/null +++ b/policyengine_us/variables/household/demographic/person/parent_2_id.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class parent_2_id(Variable): + value_type = int + entity = Person + label = "Second parent's person ID" + definition_period = YEAR + default_value = 0 + 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, 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 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." + )