diff --git a/changelog.d/mo-tanf-dependent-parent-member.fixed.md b/changelog.d/mo-tanf-dependent-parent-member.fixed.md new file mode 100644 index 00000000000..8633616f183 --- /dev/null +++ b/changelog.d/mo-tanf-dependent-parent-member.fixed.md @@ -0,0 +1 @@ +Missouri TANF now counts a parent claimed as someone else's tax dependent, such as a young mother claimed by her own parent, as a member of the assistance unit with their child. Explicit parent IDs now take precedence over relationship imputation, with the unchanged age-window fallback used only for children whose parent IDs are unknown. The Missouri parent input documents the shared IDs' step-parent ambiguity and annual-flag override, and known-ID non-parent caretakers must be explicitly marked. diff --git a/policyengine_us/tests/core/test_mo_tanf_dependent_parent_properties.py b/policyengine_us/tests/core/test_mo_tanf_dependent_parent_properties.py new file mode 100644 index 00000000000..5312218b384 --- /dev/null +++ b/policyengine_us/tests/core/test_mo_tanf_dependent_parent_properties.py @@ -0,0 +1,767 @@ +"""Invariants of Missouri TANF membership for parents claimed as tax dependents. + +13 CSR 40-2.300(5)(C) and DSS Manual 0210.005.05 put the "biological or +adoptive parents of one or more of the eligible children" in the assistance +unit, whoever claims them for taxes. SSI recipients are excluded (13 CSR +40-2.310(1)(F)). A parent who is also a cash-eligible child is a member as a +child. For a minor parent (the grid's 16-year-old), DSS Manual 0210.005.30 +lets that three-generation family file as one group. For an 18-year-old +parent in secondary school Max's d1049 approves interpretation (i); the +grid checks one combined unit with her baby and her own parent. A +non-parent caretaker relative is excluded while a parent is in the home +(0210.005.10). + +Every combination in a grid of households shares one simulation and is +checked against those rules as restated here. Where an expectation still +reads a model output, the item says so: + +1. Membership: children not on SSI; unmarked heads and spouses not on SSI; + a tax-dependent adult when they are a dependent child, or when they are a + parent of the children and not on SSI; a marked head or spouse only as the + non-parent caretaker identified in item 2, when the model includes them. + Inclusion (neediness and the grant comparison) is the model's own + mo_tanf_non_parent_caretaker_included, not restated here. +2. Non-parent caretaker identification, person by person: with no parent in + the home, the marked head, or the marked spouse when the head is on SSI; + with a parent in the home, nobody. A dependent parent who is a member + always excludes the non-parent caretaker. +3. Marking the dependent adult as a parent, against the same households with + the adult marked not a parent, adds exactly that adult to the unit and + removes, at most, an included non-parent caretaker. Both sides run the + current code; the comparison does not execute the code before this rule. +4. Unit income is the sum of members' income, less the student exemption. +5. With no income anywhere, the grant is the payment standard for the unit + size, and marking a parent never lowers it (against the model's grant + with the adult marked not a parent), unless the parent receives SSI: then + they stay out of the unit but still exclude the non-parent caretaker + (intended). +6. The default input (own children in the household) gives the same answer + as marking the parent directly, and each household gets the same answer + alone as in the shared simulation. +7. With unknown parent IDs, the default marks a person with own children in + the household only when some dependent child in the tax unit is 12 to + 50 years younger, checked pair by pair against the formula's + youngest-and-oldest shortcut. +8. An explicit parent flag adds a person only when their own tax unit has a + dependent child. +9. Known parent IDs identify the named parent independently of ages and + own-child counts. Reusing IDs in separate households cannot link them. +10. Omitted and explicitly zero parent IDs preserve the frozen pre-d1049 + parent flags, NPCR identification, membership, unit sizes and zero-income + grants, checked against independent expectations from b9e7fff. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +PERIOD = "2026-01" +YEAR = 2026 +# 13 CSR 40-2.310(13) standard of need and (12) payment percentage. +STANDARD_OF_NEED = {1: 393, 2: 678, 3: 846, 4: 990, 5: 1_123, 6: 1_247} +PAYMENT_PERCENTAGE = 0.34526 + +# How the tax-dependent adult is described to the model. +# parent: marked a parent of the children +# non_parent: own_children_in_household = 1 but marked not a parent of the +# children (for example the head's own mother) +# ssi_parent: marked a parent, and receives SSI +# own_children: only own_children_in_household = 1, leaving +# mo_tanf_is_parent_of_dependent_child to its default. A situation that +# sets an input for anyone sets it for everyone (unset people get the +# default value, not the formula), so these households get a +# simulation of their own. +ADULT_KINDS = ["parent", "non_parent", "ssi_parent"] +PARENT_KINDS = {"parent", "own_children", "ssi_parent"} + + +def _adult_configs(): + configs = [dict(adult=None)] + for kind, age, school, earned, unearned in itertools.product( + ADULT_KINDS, (18, 22), (False, True), (0, 300), (0, 150) + ): + configs.append( + dict( + adult=kind, + adult_age=age, + adult_school=school, + adult_earned=earned, + adult_unearned=unearned, + ) + ) + return configs + + +def _households(): + households = [] + for marked, married, head_ssi, minor, child_ssi, adult in itertools.product( + (False, True), + (False, True), + (False, True), + (False, True), + (False, True), + _adult_configs(), + ): + households.append( + dict( + marked=marked, + married=married, + head_ssi=head_ssi, + minor_parent=minor, + child_ssi=child_ssi, + **adult, + ) + ) + return households + + +HOUSEHOLDS = _households() +N = len(HOUSEHOLDS) + + +def _roles(h): + roles = ["head"] + if h["married"]: + roles.append("spouse") + if h["adult"]: + roles.append("adult") + if h["minor_parent"]: + roles += ["minor_parent", "baby"] + roles.append("child") + return roles + + +def _person(h, role, adult_kind=None): + if role in ("head", "spouse"): + record = { + "age": {YEAR: 55 if role == "head" else 53}, + "is_tax_unit_dependent": {YEAR: False}, + "receives_ssi": {YEAR: role == "head" and h["head_ssi"]}, + "mo_tanf_is_non_parent_caretaker": {YEAR: h["marked"]}, + } + if role == "head" and h["adult"] == "own_children": + # The head's own children in the home are the dependent adult and + # the 16-year-old mother, so the default marks the head a parent + # too; the non-parent mark must still decide for a head. + count = 1 + h["minor_parent"] + record["own_children_in_household"] = {YEAR: count} + return record + if role == "adult": + kind = adult_kind or h["adult"] + record = { + "age": {YEAR: h["adult_age"]}, + "is_tax_unit_dependent": {YEAR: True}, + "is_in_secondary_school": {YEAR: h["adult_school"]}, + "employment_income_before_lsr": {YEAR: 12 * h["adult_earned"]}, + "unemployment_compensation": {YEAR: 12 * h["adult_unearned"]}, + # SSI receipt stays with the household when adult_kind overrides + # only whether the adult is marked a parent. + "receives_ssi": {YEAR: h["adult"] == "ssi_parent"}, + } + if kind != "parent" and kind != "ssi_parent": + record["own_children_in_household"] = {YEAR: 1} + if kind != "own_children": + record["mo_tanf_is_parent_of_dependent_child"] = { + YEAR: kind in PARENT_KINDS + } + return record + if role == "minor_parent": + return { + "age": {YEAR: 16}, + "is_tax_unit_dependent": {YEAR: True}, + "own_children_in_household": {YEAR: 1}, + } + if role == "baby": + return {"age": {YEAR: 0}, "is_tax_unit_dependent": {YEAR: True}} + return { + "age": {YEAR: 6}, + "is_tax_unit_dependent": {YEAR: True}, + "receives_ssi": {YEAR: h["child_ssi"]}, + } + + +def _situation(households, adult_kind=None): + people, tax_units, spm_units, units = {}, {}, {}, {} + for i, h in enumerate(households): + names = [] + for role in _roles(h): + name = f"h{i}_{role}" + people[name] = _person(h, role, adult_kind) + names.append(name) + tax_units[f"t{i}"] = {"members": names} + spm_units[f"s{i}"] = {"members": names} + units[f"hh{i}"] = {"members": names, "state_code": {YEAR: "MO"}} + return { + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": units, + } + + +PEOPLE = [(i, role) for i, h in enumerate(HOUSEHOLDS) for role in _roles(h)] + + +def _calc(sim, variable): + return sim.calculate(variable, PERIOD) + + +def _payment(size): + return STANDARD_OF_NEED.get(int(size), 0) * PAYMENT_PERCENTAGE + + +def _adult_is_dependent_child(h): + # 13 CSR 40-2.325(1)(A): under 18, or 18 and in secondary school. + return h["adult"] is not None and h["adult_age"] == 18 and h["adult_school"] + + +def _adult_is_parent(h, kind=None): + kind = kind or h["adult"] + return kind in PARENT_KINDS + + +def _parent_in_home(h, kind=None): + """A parent of the children lives in the home (DSS Manual 0210.005.10).""" + unmarked_caretaker = not h["marked"] + adult_ssi = h["adult"] == "ssi_parent" + # A parent who is a cash-eligible child does not count (0210.005.05); the + # 16-year-old mother is always one. + cash_eligible_child = _adult_is_dependent_child(h) and not adult_ssi + adult_parent = ( + h["adult"] is not None and _adult_is_parent(h, kind) and not cash_eligible_child + ) + return unmarked_caretaker or adult_parent + + +def _expected_non_parent_caretaker(h, role): + """The non-parent caretaker considered for the unit, restated. + + 13 CSR 40-2.300(5)(D) admits one only "if there are no natural or + adoptive parents in the home"; DSS Manual 0210.005.35 bars one on SSI. + One per couple: the head, or the spouse when the head is on SSI. Only + the head and spouse are marked, and the spouse is never on SSI here. + """ + eligible = h["marked"] and not _parent_in_home(h) + if role == "head": + return eligible and not h["head_ssi"] + if role == "spouse": + return eligible and h["head_ssi"] + return False + + +def _expected_member(h, role, kind=None): + """Restated membership, or None where the non-parent caretaker rules decide.""" + kind = kind or h["adult"] + if role == "head": + if not h["marked"]: + return not h["head_ssi"] + return None + if role == "spouse": + return None if h["marked"] else True + if role == "adult": + ssi = h["adult"] == "ssi_parent" + if _adult_is_dependent_child(h): + return not ssi + return _adult_is_parent(h, kind) and not ssi + if role in ("minor_parent", "baby"): + return True + return not h["child_ssi"] + + +@pytest.fixture(scope="module") +def sim(): + return Simulation(situation=_situation(HOUSEHOLDS)) + + +@pytest.fixture(scope="module") +def non_parent_sim(): + # The same households with the dependent adult marked as not a parent of + # the children: the rule before parents claimed as dependents were members. + return Simulation(situation=_situation(HOUSEHOLDS, adult_kind="non_parent")) + + +def test_membership_matches_the_restated_rule(sim): + member = _calc(sim, "mo_tanf_is_assistance_unit_member") + # Inclusion of an identified caretaker is the model's own decision. + included = _calc(sim, "mo_tanf_non_parent_caretaker_included") + for k, (i, role) in enumerate(PEOPLE): + h = HOUSEHOLDS[i] + expected = _expected_member(h, role) + if expected is None: + # A marked head or spouse is a member only as the identified + # non-parent caretaker, and only when included. + npcr = _expected_non_parent_caretaker(h, role) + assert member[k] == (npcr and included[i]), (h, role) + else: + assert member[k] == expected, (h, role) + + +def test_non_parent_caretaker_identification_matches_independent_rule(sim): + npcr = _calc(sim, "mo_tanf_non_parent_caretaker") + expected = np.array( + [_expected_non_parent_caretaker(HOUSEHOLDS[i], role) for i, role in PEOPLE] + ) + mismatches = [ + (HOUSEHOLDS[i], role, bool(npcr[k])) + for k, (i, role) in enumerate(PEOPLE) + if npcr[k] != expected[k] + ] + assert not mismatches, mismatches[:5] + has_npcr = np.zeros(N, dtype=bool) + for k, (i, role) in enumerate(PEOPLE): + has_npcr[i] |= npcr[k] + expected_has_npcr = np.array( + [ + h["marked"] + and not _parent_in_home(h) + and (not h["head_ssi"] or h["married"]) + for h in HOUSEHOLDS + ] + ) + assert has_npcr.tolist() == expected_has_npcr.tolist() + # The grid has households where a caretaker must be found and where none + # may be, so neither "never" nor "always" passes. + assert expected_has_npcr.any() and not expected_has_npcr.all() + + +def test_a_dependent_parent_member_excludes_the_non_parent_caretaker(sim): + member = _calc(sim, "mo_tanf_is_assistance_unit_member") + npcr = _calc(sim, "mo_tanf_non_parent_caretaker") + has_npcr = np.zeros(N, dtype=bool) + parent_member = np.zeros(N, dtype=bool) + for k, (i, role) in enumerate(PEOPLE): + has_npcr[i] |= npcr[k] + if role == "adult" and not _adult_is_dependent_child(HOUSEHOLDS[i]): + parent_member[i] |= member[k] + assert parent_member.any() + assert not (parent_member & has_npcr).any() + # A parent in the home rules out a caretaker; the converse, that one is + # found when no parent is home, is in the identification test above. + for i, h in enumerate(HOUSEHOLDS): + if _parent_in_home(h): + assert not has_npcr[i], h + + +def test_marking_a_parent_adds_only_that_parent(sim, non_parent_sim): + on = _calc(sim, "mo_tanf_is_assistance_unit_member") + off = _calc(non_parent_sim, "mo_tanf_is_assistance_unit_member") + off_npcr = _calc(non_parent_sim, "mo_tanf_non_parent_caretaker") + for k, (i, role) in enumerate(PEOPLE): + if on[k] and not off[k]: + assert role == "adult", (HOUSEHOLDS[i], role) + if off[k] and not on[k]: + # Only an included non-parent caretaker can leave, displaced by + # the parent now seen in the home. + assert off_npcr[k], (HOUSEHOLDS[i], role) + # With the adult marked not a parent, a dependent adult who is not a + # dependent child is never a member. This runs the current code with + # that input, not the code before parents claimed as dependents counted. + for k, (i, role) in enumerate(PEOPLE): + if role == "adult" and not _adult_is_dependent_child(HOUSEHOLDS[i]): + assert not off[k] + + +def test_unit_income_is_members_income(sim): + member = _calc(sim, "mo_tanf_is_assistance_unit_member") + earned = _calc(sim, "mo_tanf_gross_earned_income") + unearned = _calc(sim, "mo_tanf_gross_unearned_income") + expected_earned = np.zeros(N) + expected_unearned = np.zeros(N) + for k, (i, role) in enumerate(PEOPLE): + h = HOUSEHOLDS[i] + if role != "adult" or not member[k]: + continue + expected_unearned[i] += h["adult_unearned"] + # 13 CSR 40-2.310(9)(A)1: a student dependent child's earnings are + # exempt. + if not _adult_is_dependent_child(h): + expected_earned[i] += h["adult_earned"] + assert earned == pytest.approx(expected_earned) + assert unearned == pytest.approx(expected_unearned) + + +def test_zero_income_grant_is_the_payment_standard(sim, non_parent_sim): + size = _calc(sim, "mo_tanf_assistance_unit_size") + grant = _calc(sim, "mo_tanf") + off_grant = _calc(non_parent_sim, "mo_tanf") + checked = 0 + for i, h in enumerate(HOUSEHOLDS): + assert 0 <= grant[i] <= _payment(size[i]) + 1e-3 + no_income = h["adult"] is None or ( + h["adult_earned"] == 0 and h["adult_unearned"] == 0 + ) + if not no_income: + continue + checked += 1 + assert grant[i] == pytest.approx(_payment(size[i]), abs=1e-3), h + # A parent with no income adds needs and no income, so marking them + # never lowers the grant. Intended exception: a parent on SSI is not a + # member but still excludes the non-parent caretaker (0210.005.10). + if h["adult"] != "ssi_parent": + assert grant[i] >= off_grant[i] - 1e-3, h + assert checked > N / 4 + + +def test_default_input_matches_marking_the_parent(): + marked = [h for h in HOUSEHOLDS if h["adult"] == "parent"] + own_children = [{**h, "adult": "own_children"} for h in marked] + default_sim = Simulation(situation=_situation(own_children)) + marked_sim = Simulation(situation=_situation(marked)) + flag = default_sim.calculate("mo_tanf_is_parent_of_dependent_child", YEAR) + roles = [role for h in own_children for role in _roles(h)] + # The default is own_children_in_household > 0 and being 12 to 50 years + # older than some dependent child in the tax unit. Here the head (55), + # the dependent adult (18 or 22) and the 16-year-old mother all have + # their own children in the home and pass the age window; nobody else + # has own children. + assert flag.tolist() == [ + role in ("head", "adult", "minor_parent") for role in roles + ] + member = _calc(default_sim, "mo_tanf_is_assistance_unit_member") + k = 0 + for h in own_children: + for role in _roles(h): + expected = _expected_member(h, role) + if expected is not None: + assert member[k] == expected, (h, role) + k += 1 + for variable in ( + "mo_tanf_is_assistance_unit_member", + "mo_tanf_non_parent_caretaker", + "mo_tanf_assistance_unit_size", + "mo_tanf", + ): + assert _calc(default_sim, variable) == pytest.approx( + _calc(marked_sim, variable) + ), variable + + +@pytest.mark.parametrize("i", range(0, N, 97)) +def test_each_household_alone_matches_the_shared_simulation(sim, i): + alone = Simulation(situation=_situation([HOUSEHOLDS[i]])) + for variable in ("mo_tanf_assistance_unit_size", "mo_tanf"): + assert _calc(alone, variable)[0] == pytest.approx(_calc(sim, variable)[i]) + + +def test_parent_flag_needs_a_dependent_child_in_the_persons_tax_unit(): + # Each household: a mother (30) heading a tax unit with her 5-year-old, + # and an adult explicitly marked a parent who either files alone (no + # dependent child in their tax unit) or is claimed in the mother's unit. + cases = list(itertools.product((18, 25, 40, 60), (False, True))) + people, tax_units, spm_units, units = {}, {}, {}, {} + for i, (age, files_alone) in enumerate(cases): + mother, child, adult = (f"g{i}_{r}" for r in ("mother", "child", "adult")) + people[mother] = {"age": {YEAR: 30}, "is_tax_unit_dependent": {YEAR: False}} + people[child] = {"age": {YEAR: 5}, "is_tax_unit_dependent": {YEAR: True}} + people[adult] = { + "age": {YEAR: age}, + "is_tax_unit_dependent": {YEAR: not files_alone}, + "mo_tanf_is_parent_of_dependent_child": {YEAR: True}, + } + if files_alone: + tax_units[f"g{i}_family"] = {"members": [mother, child]} + tax_units[f"g{i}_adult"] = {"members": [adult]} + else: + tax_units[f"g{i}_family"] = {"members": [mother, child, adult]} + spm_units[f"g{i}_spm"] = {"members": [mother, child, adult]} + units[f"g{i}_hh"] = { + "members": [mother, child, adult], + "state_code": {YEAR: "MO"}, + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": units, + } + ) + member = _calc(simulation, "mo_tanf_is_assistance_unit_member") + size = _calc(simulation, "mo_tanf_assistance_unit_size") + grant = _calc(simulation, "mo_tanf") + for i, (age, files_alone) in enumerate(cases): + expected = [True, True, not files_alone] + assert member[3 * i : 3 * i + 3].tolist() == expected, (age, files_alone) + assert size[i] == sum(expected), (age, files_alone) + assert grant[i] == pytest.approx(_payment(sum(expected)), abs=1e-3) + + +def test_default_parent_input_matches_the_pairwise_age_rule(): + # The default is own_children_in_household > 0 and being 12 to 50 years + # older than some dependent child in the tax unit. The formula checks only + # the youngest and oldest child; restate it pair by pair here. + adult_ages = [14, 17, 19, 22, 30, 40, 45, 50, 55, 58, 60, 62, 64, 68, 70, 80] + # Each entry: ages of the other children, and whether an 18-year-old + # secondary-school student (a dependent child) is also in the unit. + child_sets = [ + ([0], False), + ([8], False), + ([10], False), + ([17], False), + ([0, 17], False), + ([5, 12], False), + ([2], True), + ([], True), + ] + cases = [ + (age, children, student, own) + for age, (children, student), own in itertools.product( + adult_ages, child_sets, (0, 1) + ) + ] + people, tax_units, spm_units, units = {}, {}, {}, {} + for i, (age, children, student, own) in enumerate(cases): + names = [f"c{i}_head", f"c{i}_person"] + people[names[0]] = {"age": {YEAR: 45}, "is_tax_unit_dependent": {YEAR: False}} + people[names[1]] = { + "age": {YEAR: age}, + "is_tax_unit_dependent": {YEAR: True}, + "own_children_in_household": {YEAR: own}, + } + for j, child_age in enumerate(children): + names.append(f"c{i}_child{j}") + people[names[-1]] = { + "age": {YEAR: child_age}, + "is_tax_unit_dependent": {YEAR: True}, + } + if student: + names.append(f"c{i}_student") + people[names[-1]] = { + "age": {YEAR: 18}, + "is_tax_unit_dependent": {YEAR: True}, + "is_in_secondary_school": {YEAR: True}, + } + tax_units[f"t{i}"] = {"members": names} + spm_units[f"s{i}"] = {"members": names} + units[f"hh{i}"] = {"members": names, "state_code": {YEAR: "MO"}} + simulation = Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": units, + } + ) + flag = simulation.calculate("mo_tanf_is_parent_of_dependent_child", YEAR) + position = 0 + for age, children, student, own in cases: + person_flag = flag[position + 1] + position += 2 + len(children) + student + child_ages = list(children) + ([18] if student else []) + # A person under 18 is a dependent child too, but their own age + # difference of zero never falls in the window. + plausible = any(12 <= age - c <= 50 for c in child_ages) + assert person_flag == (own > 0 and plausible), (age, children, student, own) + + +def test_known_parent_ids_override_ages_and_stay_within_households(): + # Pin dependency status to isolate parent identification from the age + # rules deciding who is a dependent child. The expected relationship + # stays the same across adult and child ages and both own-child counts. + cases = list( + itertools.product( + (19, 20, 35, 60, 90), + (0, 5, 17), + (0, 1), + ("first", "second", "both"), + ("adult", "head", "absent"), + ) + ) + people, tax_units, spm_units, households = {}, {}, {}, {} + expected = [] + for i, (adult_age, child_age, own_children, slot, parent) in enumerate(cases): + head, adult, child = ( + f"linked{i}_{role}" for role in ("head", "adult", "child") + ) + named_id = {"head": 11, "adult": 22, "absent": 99}[parent] + # The ID contract allows these IDs to repeat in separate households + # when every tax unit stays within its household. This deliberately + # catches a global match that connects unrelated households. + people[head] = { + "person_id": {YEAR: 11}, + "age": {YEAR: 45}, + "is_tax_unit_dependent": {YEAR: False}, + "own_children_in_household": {YEAR: 1}, + "mo_tanf_dependent_child": {PERIOD: False}, + } + people[adult] = { + "person_id": {YEAR: 22}, + "age": {YEAR: adult_age}, + "is_tax_unit_dependent": {YEAR: True}, + "own_children_in_household": {YEAR: own_children}, + "mo_tanf_dependent_child": {PERIOD: False}, + } + people[child] = { + "person_id": {YEAR: 33}, + "age": {YEAR: child_age}, + "is_tax_unit_dependent": {YEAR: True}, + "mo_tanf_dependent_child": {PERIOD: True}, + "parent_1_id": {YEAR: named_id if slot in ("first", "both") else 0}, + "parent_2_id": {YEAR: named_id if slot in ("second", "both") else 0}, + } + members = [head, adult, child] + tax_units[f"linked{i}_tax"] = {"members": members} + spm_units[f"linked{i}_spm"] = {"members": members} + households[f"linked{i}_household"] = { + "members": members, + "state_code": {YEAR: "MO"}, + } + expected.append([parent == "head", parent == "adult", False]) + simulation = Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": households, + } + ) + flag = simulation.calculate("mo_tanf_is_parent_of_dependent_child", YEAR) + for case, actual, wanted in zip(cases, flag.reshape(-1, 3), expected): + assert actual.tolist() == wanted, case + + +@pytest.mark.parametrize("explicit_zero_ids", (False, True)) +def test_unknown_parent_ids_reproduce_the_frozen_pre_d1049_rule(explicit_zero_ids): + """Unknown ids preserve pre-d1049 flags, caretakers, members, sizes and grants. + + Independently restate the three changed formulas at immutable commit + b9e7fff948caf4f33313c942c09caa7c7ccc3dea, rather than reading any current + model output to construct expectations. Cover 224 households (784 people) + for each of omitted parent ids and explicitly zero parent ids. The grid + includes both age-window boundaries, own-child counts, caretaker markings, + couples, SSI exclusions and an 18-year-old secondary-school student. + """ + cases = list( + itertools.product( + ( + (11, False), + (12, False), + (18, False), + (18, True), + (19, False), + (50, False), + (51, False), + ), + (0, 1), + (False, True), + (False, True), + ("none", "head", "adult", "child"), + ) + ) + people, tax_units, spm_units, households = {}, {}, {}, {} + expected = { + "mo_tanf_is_parent_of_dependent_child": [], + "mo_tanf_non_parent_caretaker": [], + "mo_tanf_is_assistance_unit_member": [], + "mo_tanf_assistance_unit_size": [], + "mo_tanf": [], + } + for i, ((adult_age, student), own, marked, married, ssi_role) in enumerate(cases): + roles = np.array( + ["head"] + (["spouse"] if married else []) + ["adult", "child"] + ) + ages = np.array( + [ + {"head": 55, "spouse": 53, "adult": adult_age, "child": 0}[role] + for role in roles + ] + ) + own_children = np.where(roles == "adult", own, 0) + dependent_child = (roles == "child") | ( + (roles == "adult") & ((adult_age < 18) | ((adult_age == 18) & student)) + ) + receives_ssi = roles == ssi_role + caretaker = np.isin(roles, ("head", "spouse")) + non_parent = caretaker & marked + + # Frozen b9e7fff parent formula, expressed pairwise instead of through + # the youngest/oldest shortcut. Unknown ids must retain this fallback. + parent = np.array( + [ + count > 0 + and any( + 12 <= age - child_age <= 50 for child_age in ages[dependent_child] + ) + for age, count in zip(ages, own_children) + ] + ) + cash_eligible_child = dependent_child & ~receives_ssi + parent_in_home = np.any( + (caretaker | (parent & ~cash_eligible_child)) & ~non_parent + ) + candidate = caretaker & non_parent & ~parent_in_home & ~receives_ssi + # Frozen b9e7fff NPCR formula: head first, otherwise the spouse. + npcr = candidate & ((roles == "head") | ~candidate[0]) + # With zero non-SSI income and resources, every identified NPCR is + # needy and adding their needs raises the grant, so they are included. + member = ( + cash_eligible_child + | (caretaker & ~non_parent & ~receives_ssi) + | (parent & ~non_parent & ~dependent_child & ~receives_ssi) + | npcr + ) + expected["mo_tanf_is_parent_of_dependent_child"].extend(parent) + expected["mo_tanf_non_parent_caretaker"].extend(npcr) + expected["mo_tanf_is_assistance_unit_member"].extend(member) + expected["mo_tanf_assistance_unit_size"].append(int(member.sum())) + expected["mo_tanf"].append(_payment(member.sum())) + + names = [] + for role, age, count, ssi in zip(roles, ages, own_children, receives_ssi): + name = f"unknown{i}_{role}" + names.append(name) + people[name] = { + "age": {YEAR: int(age)}, + "is_tax_unit_head": {YEAR: bool(role == "head")}, + "is_tax_unit_spouse": {YEAR: bool(role == "spouse")}, + "is_tax_unit_dependent": {YEAR: role in ("adult", "child")}, + "own_children_in_household": {YEAR: int(count)}, + "is_in_secondary_school": {YEAR: bool(role == "adult" and student)}, + "mo_tanf_is_non_parent_caretaker": { + YEAR: role in ("head", "spouse") and marked + }, + # Freeze both SSI signals; computing SSI from otherwise empty + # income inputs would introduce unrelated benefit eligibility. + "receives_ssi": {YEAR: bool(ssi)}, + "ssi": {PERIOD: 100 if ssi else 0}, + "employment_income_before_lsr": {YEAR: 0}, + "unemployment_compensation": {YEAR: 0}, + } + if explicit_zero_ids: + people[name]["parent_1_id"] = {YEAR: 0} + people[name]["parent_2_id"] = {YEAR: 0} + tax_units[f"unknown{i}_tax"] = {"members": names} + spm_units[f"unknown{i}_spm"] = {"members": names} + households[f"unknown{i}_household"] = { + "members": names, + "state_code": {YEAR: "MO"}, + } + + assert len(cases) == 224 and len(people) == 784 + # Both sides of each boolean rule occur, so constant outputs cannot pass. + for variable in ( + "mo_tanf_is_parent_of_dependent_child", + "mo_tanf_non_parent_caretaker", + "mo_tanf_is_assistance_unit_member", + ): + assert any(expected[variable]) and not all(expected[variable]), variable + simulation = Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": households, + } + ) + for variable, values in expected.items(): + period = YEAR if variable == "mo_tanf_is_parent_of_dependent_child" else PERIOD + actual = simulation.calculate(variable, period) + if variable == "mo_tanf": + np.testing.assert_allclose( + actual, values, rtol=0, atol=1e-3, err_msg=variable + ) + else: + np.testing.assert_array_equal(actual, values, err_msg=variable) diff --git a/policyengine_us/tests/core/test_mo_tanf_non_parent_caretaker_properties.py b/policyengine_us/tests/core/test_mo_tanf_non_parent_caretaker_properties.py index 1f21026fc09..dc5ce09318e 100644 --- a/policyengine_us/tests/core/test_mo_tanf_non_parent_caretaker_properties.py +++ b/policyengine_us/tests/core/test_mo_tanf_non_parent_caretaker_properties.py @@ -13,9 +13,10 @@ those rules, restated here independently of the variable formulas: 1. With no one marked as a non-parent caretaker, unit membership, countable - resources and the teen-parent earnings exemption equal the pre-NPCR rule. - No other existing Missouri TANF formula reads the new variables, so every - existing household is unchanged. + resources and the teen-parent earnings exemption equal the pre-NPCR rule, + plus the dependent adult parent, who is a member as a parent of the + children (DSS Manual 0210.005.05; see + test_mo_tanf_dependent_parent_properties.py). 2. Identification and neediness equal the restated rules: no NPCR when a parent is in the home (an unmarked caretaker, or a dependent adult with their own children in the household); otherwise the NPCR is needy when @@ -181,7 +182,7 @@ def _payment(size): return STANDARD_OF_NEED.get(size, 0) * PAYMENT_PERCENTAGE -def test_unflagged_households_follow_the_pre_npcr_rule(unflagged_sim): +def test_unflagged_households_include_dependent_parents(unflagged_sim): member = _calc(unflagged_sim, "mo_tanf_is_assistance_unit_member") assert not _calc(unflagged_sim, "mo_tanf_non_parent_caretaker").any() assert not _calc(unflagged_sim, "mo_tanf_non_parent_caretaker_included").any() @@ -195,8 +196,9 @@ def test_unflagged_households_follow_the_pre_npcr_rule(unflagged_sim): elif role == "spouse": expected.append(not h["spouse_ssi"]) elif role == "adult_parent": - # A dependent aged 22 is neither a caretaker nor a child. - expected.append(False) + # A dependent aged 22 with a child of her own in the home is a + # parent of the children, so she is a member (0210.005.05). + expected.append(True) else: expected.append(not (h["child_ssi"] and c == 0)) assert member.tolist() == expected diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_is_assistance_unit_member.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_is_assistance_unit_member.yaml new file mode 100644 index 00000000000..c723459a5e5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_is_assistance_unit_member.yaml @@ -0,0 +1,610 @@ +# Parents in the Missouri TANF assistance unit when someone else claims them +# as a tax dependent: 13 CSR 40-2.300(5)(C), 13 CSR 40-2.310(1)(F) and +# DSS Manual 0210.005.05, 0210.005.10 and 0210.005.30. +# Standard of need: size 1 $393, size 2 $678, size 3 $846. Payment is 34.526% +# of the standard: size 1 $135.687, size 2 $234.086, size 3 $292.090. + +- name: A mother claimed as her own mother's tax dependent is the caretaker for her child + period: 2026-01 + input: + people: + grandparent: + age: 55 + mo_tanf_is_non_parent_caretaker: true + mother: + age: 20 + is_tax_unit_dependent: true + mo_tanf_is_parent_of_dependent_child: true + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, child] + spm_units: + spm_unit: + members: [grandparent, mother, child] + households: + household: + members: [grandparent, mother, child] + state_code: MO + output: + # DSS Manual 0210.005.05 includes the "Biological or adoptive parents of + # one or more of the eligible children" whatever their tax filing. The + # grandparent is excluded because a parent is in the home (0210.005.10). + mo_tanf_non_parent_caretaker: [false, false, false] + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf_assistance_unit_size: 2 + mo_tanf: 234.086 + +- name: The dependent mother's earnings count and the excluded grandparent's do not + period: 2026-01 + input: + people: + grandparent: + age: 55 + employment_income_before_lsr: 24_000 # $2,000/month + mo_tanf_is_non_parent_caretaker: true + mother: + age: 20 + is_tax_unit_dependent: true + employment_income_before_lsr: 3_600 # $300/month + own_children_in_household: 1 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, child] + spm_units: + spm_unit: + members: [grandparent, mother, child] + households: + household: + members: [grandparent, mother, child] + state_code: MO + output: + # 0210.005.05: "Include the needs and income of the following persons". + # The mother's $300 less the $90 work expense, $30 and one-third of the + # remaining $180 leaves $120 (13 CSR 40-2.310(9)(A)2-3): + # 234.086 - 120 = 114.086. The mother is 20, so no minor-parent deeming + # of the grandparent's income applies (13 CSR 40-2.310(8)(B)1.F). + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf_gross_earned_income: 300 + mo_tanf_countable_income: 120 + mo_tanf: 114.086 + +- name: An 18-year-old mother out of school is the caretaker, not a child + period: 2026-01 + input: + people: + grandparent: + age: 50 + mo_tanf_is_non_parent_caretaker: true + mother: + age: 18 + is_tax_unit_dependent: true + is_in_secondary_school: false + own_children_in_household: 1 + baby: + age: 0 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, baby] + spm_units: + spm_unit: + members: [grandparent, mother, baby] + households: + household: + members: [grandparent, mother, baby] + state_code: MO + output: + # An 18-year-old not in secondary school is not a dependent child + # (13 CSR 40-2.325(1)(A)), so she is the parent member. + mo_tanf_dependent_child: [false, false, true] + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf_assistance_unit_size: 2 + mo_tanf: 234.086 + +- name: An 18-year-old mother in high school is a cash-eligible child, grouped with her own mother under one reading + period: 2026-01 + input: + people: + grandparent: + age: 50 + mother: + age: 18 + is_tax_unit_dependent: true + is_in_secondary_school: true + employment_income_before_lsr: 6_000 # $500/month + own_children_in_household: 1 + baby: + age: 0 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, baby] + spm_units: + spm_unit: + members: [grandparent, mother, baby] + households: + household: + members: [grandparent, mother, baby] + state_code: MO + output: + # She is a dependent child (under 19 and in secondary school), so she is a + # member as an eligible child and her student earnings are exempt + # (13 CSR 40-2.310(9)(A)1). School enrollment alone does not make her a + # minor parent: archived DSS Manual + # 0210.005.30 defines one as "a parent under the age of 18, including the + # month turning 18", and current Combined Manual 4.3 as a parent "under + # the age of 18, including the month of their 18th birthday". The sources + # do not settle her grouping. This case records the model's retained + # interpretation (i), one combined three-generation unit with her mother + # (size 3). The other readings give a unit of two ($234.08628 at zero + # income). + mo_tanf_dependent_child: [false, true, true] + mo_tanf_is_assistance_unit_member: [true, true, true] + mo_tanf_gross_earned_income: 0 + mo_tanf_assistance_unit_size: 3 + mo_tanf: 292.090 + +- name: A 16-year-old mother, her baby and her mother form one three-generation group + period: 2026-01 + input: + people: + grandparent: + age: 40 + mother: + age: 16 + is_tax_unit_dependent: true + own_children_in_household: 1 + baby: + age: 0 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, baby] + spm_units: + spm_unit: + members: [grandparent, mother, baby] + households: + household: + members: [grandparent, mother, baby] + state_code: MO + output: + # DSS Manual 0210.005.30: "If a three-generation family ... files together + # as one assistance group, consider all their needs and income as a single + # assistance group". The minor mother is a member as an eligible child. + mo_tanf_is_assistance_unit_member: [true, true, true] + mo_tanf_assistance_unit_size: 3 + mo_tanf: 292.090 + +- name: An unmarried father claimed as the mother's dependent is a member with their child + period: 2026-01 + input: + people: + mother: + age: 30 + father: + age: 28 + is_tax_unit_dependent: true + own_children_in_household: 1 + child: + age: 2 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [mother, father, child] + spm_units: + spm_unit: + members: [mother, father, child] + households: + household: + members: [mother, father, child] + state_code: MO + output: + # DSS Manual 0210.005.05: "Ms. Devin and Mr. Roberts are not married, but + # they have an in-common child ... Complete an application for the + # household with everyone included." + mo_tanf_is_assistance_unit_member: [true, true, true] + mo_tanf_assistance_unit_size: 3 + mo_tanf: 292.090 + +- name: Two dependent parents living with the child's grandparents are both members + period: 2026-01 + input: + people: + grandfather: + age: 60 + mo_tanf_is_non_parent_caretaker: true + grandmother: + age: 58 + mo_tanf_is_non_parent_caretaker: true + mother: + age: 22 + is_tax_unit_dependent: true + own_children_in_household: 1 + father: + age: 21 + is_tax_unit_dependent: true + own_children_in_household: 1 + child: + age: 1 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandfather, grandmother, mother, father, child] + spm_units: + spm_unit: + members: [grandfather, grandmother, mother, father, child] + households: + household: + members: [grandfather, grandmother, mother, father, child] + state_code: MO + output: + mo_tanf_non_parent_caretaker: [false, false, false, false, false] + mo_tanf_is_assistance_unit_member: [false, false, true, true, true] + mo_tanf_assistance_unit_size: 3 + mo_tanf: 292.090 + +- name: A dependent adult who is a parent, but not of an eligible child, is not a member + period: 2026-01 + input: + people: + grandmother: + age: 58 + is_tax_unit_dependent: true + unemployment_compensation: 6_000 # $500/month + # Her own child in the household is the mother, an adult. + own_children_in_household: 1 + mo_tanf_is_parent_of_dependent_child: false + mother: + age: 38 + child: + age: 12 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandmother, mother, child] + spm_units: + spm_unit: + members: [grandmother, mother, child] + households: + household: + members: [grandmother, mother, child] + state_code: MO + output: + # 0210.005.05 includes only parents "of one or more of the eligible + # children"; 0210.005.10 excludes other household members' needs and + # income. + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf_assistance_unit_size: 2 + mo_tanf_gross_unearned_income: 0 + mo_tanf: 234.086 + +- name: Without the input, a grandmother young enough to be the child's parent is counted as one + period: 2026-01 + input: + people: + grandmother: + age: 58 + is_tax_unit_dependent: true + unemployment_compensation: 6_000 + own_children_in_household: 1 + mother: + age: 38 + child: + age: 12 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandmother, mother, child] + spm_units: + spm_unit: + members: [grandmother, mother, child] + households: + household: + members: [grandmother, mother, child] + state_code: MO + output: + # Known limit of the default: own_children_in_household cannot say whose + # parent she is, and a 46-year gap is possible for a parent, so she is + # counted with her $500 (292.090 - 500 < 0). Callers who know she is the + # mother's mother set mo_tanf_is_parent_of_dependent_child to false, as in + # the case above. + mo_tanf_is_parent_of_dependent_child: [true, false, false] + mo_tanf_is_assistance_unit_member: [true, true, true] + mo_tanf: 0 + +- name: The default rules out a dependent too old to be the child's parent + period: 2026-01 + input: + people: + grandmother: + age: 64 + is_tax_unit_dependent: true + unemployment_compensation: 6_000 + own_children_in_household: 1 + mother: + age: 40 + child: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandmother, mother, child] + spm_units: + spm_unit: + members: [grandmother, mother, child] + households: + household: + members: [grandmother, mother, child] + state_code: MO + output: + # 64 - 10 = 54 years, more than the 50 the default allows between a + # parent and child, so her own child in the home must be someone else. + mo_tanf_is_parent_of_dependent_child: [false, false, false] + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf: 234.086 + +- name: The default rules out a dependent too young to be the child's parent + period: 2026-01 + input: + people: + parent: + age: 40 + sibling: + age: 19 + is_tax_unit_dependent: true + # Her baby lives elsewhere; the child here is her 9-year-old brother. + own_children_in_household: 1 + child: + age: 9 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, sibling, child] + spm_units: + spm_unit: + members: [parent, sibling, child] + households: + household: + members: [parent, sibling, child] + state_code: MO + output: + # 19 - 9 = 10 years, fewer than the 12 the default requires. + mo_tanf_is_parent_of_dependent_child: [false, false, false] + mo_tanf_is_assistance_unit_member: [true, false, true] + mo_tanf: 234.086 + +- name: A dependent parent on SSI is not a member but still excludes the non-parent caretaker + period: 2026-01 + input: + people: + grandparent: + age: 55 + mo_tanf_is_non_parent_caretaker: true + mother: + age: 20 + is_tax_unit_dependent: true + receives_ssi: true + own_children_in_household: 1 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, child] + spm_units: + spm_unit: + members: [grandparent, mother, child] + households: + household: + members: [grandparent, mother, child] + state_code: MO + output: + # 13 CSR 40-2.310(1)(F) excludes the SSI recipient; 0210.005.10 excludes + # "A legal guardian or NPCR if a biological or adoptive parent is in the + # home." + mo_tanf_non_parent_caretaker: [false, false, false] + mo_tanf_is_assistance_unit_member: [false, false, true] + mo_tanf_assistance_unit_size: 1 + mo_tanf: 135.687 + +- name: A grandparent marked as a non-parent stays out even when their own adult child is counted + period: 2026-01 + input: + people: + grandparent: + age: 45 + # The grandparent's own child in the household is the mother. + own_children_in_household: 1 + mo_tanf_is_non_parent_caretaker: true + mother: + age: 20 + is_tax_unit_dependent: true + own_children_in_household: 1 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, child] + spm_units: + spm_unit: + members: [grandparent, mother, child] + households: + household: + members: [grandparent, mother, child] + state_code: MO + output: + # own_children_in_household cannot say whose parent the grandparent is, + # and at 45 she is young enough to be the child's parent, so the default + # marks her; the non-parent mark decides for a head or spouse. + mo_tanf_is_parent_of_dependent_child: [true, true, false] + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf_assistance_unit_size: 2 + mo_tanf: 234.086 + +- name: A mandatory dependent parent's income can eliminate the children's grant + period: 2026-01 + input: + people: + grandparent: + age: 55 + mo_tanf_is_non_parent_caretaker: true + mother: + age: 31 + is_tax_unit_dependent: true + own_children_in_household: 3 + disability_benefits: 14_400 # $1,200/month + child1: + age: 10 + is_tax_unit_dependent: true + child2: + age: 8 + is_tax_unit_dependent: true + child3: + age: 6 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, child1, child2, child3] + spm_units: + spm_unit: + members: [grandparent, mother, child1, child2, child3] + households: + household: + members: [grandparent, mother, child1, child2, child3] + state_code: MO + output: + # The mother is a mandatory member (13 CSR 40-2.300(5)(C)), so her + # $14,400 / 12 = $1,200 counts. It exceeds the $990 standard of need for + # four, so the grant is 0. Without her, the three children alone would + # get 846 * 0.34526 = $292.08996. + mo_tanf_is_assistance_unit_member: [false, true, true, true, true] + mo_tanf_assistance_unit_size: 4 + mo_tanf_gross_unearned_income: 1_200 + mo_tanf_countable_income: 1_200 + mo_tanf_standard_of_need: 990 + mo_tanf_standard_of_need_test: false + mo_tanf: 0 + +- name: The same household with no income gets the payment standard for four + period: 2026-01 + input: + people: + grandparent: + age: 55 + mo_tanf_is_non_parent_caretaker: true + mother: + age: 31 + is_tax_unit_dependent: true + own_children_in_household: 3 + disability_benefits: 0 + child1: + age: 10 + is_tax_unit_dependent: true + child2: + age: 8 + is_tax_unit_dependent: true + child3: + age: 6 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [grandparent, mother, child1, child2, child3] + spm_units: + spm_unit: + members: [grandparent, mother, child1, child2, child3] + households: + household: + members: [grandparent, mother, child1, child2, child3] + state_code: MO + output: + # 990 * 0.34526 = 341.8074, $49.71744 more than the three children's + # $292.08996 without their mother. + mo_tanf_is_assistance_unit_member: [false, true, true, true, true] + mo_tanf_assistance_unit_size: 4 + mo_tanf_gross_earned_income: 0 + mo_tanf_gross_unearned_income: 0 + mo_tanf_countable_income: 0 + mo_tanf_standard_of_need_test: true + mo_tanf: 341.8074 + +- name: An explicit parent flag requires a dependent child in that person's tax unit + period: 2026-01 + input: + people: + mother: + age: 30 + is_tax_unit_dependent: false + child: + age: 5 + is_tax_unit_dependent: true + adult: + age: 25 + is_tax_unit_dependent: false + mo_tanf_is_parent_of_dependent_child: true + tax_units: + family: + members: [mother, child] + other: + members: [adult] + spm_units: + spm_unit: + members: [mother, child, adult] + households: + household: + members: [mother, child, adult] + state_code: MO + output: + # The adult files alone, so their tax unit has no dependent child and the + # flag does not make them a member. Two members: 678 * 0.34526 = + # 234.08628. Without the dependent-child guard the adult would join, for + # size 3 and 846 * 0.34526 = 292.08996. + mo_tanf_is_assistance_unit_member: [true, true, false] + mo_tanf_assistance_unit_size: 2 + mo_tanf: 234.08628 + +- name: A real second parent filing separately stays excluded despite an explicit true parent flag + period: 2026-01 + input: + people: + mother: + age: 30 + is_tax_unit_dependent: false + child: + age: 5 + is_tax_unit_dependent: true + father: + age: 35 + is_tax_unit_dependent: false + own_children_in_household: 1 + mo_tanf_is_parent_of_dependent_child: true + tax_units: + mother_and_child: + members: [mother, child] + father_return: + members: [father] + spm_units: + spm_unit: + members: [mother, child, father] + households: + household: + members: [mother, child, father] + state_code: MO + output: + # The father is the child's biological parent and legally mandatory + # under 13 CSR 40-2.300(5)(C), but child grouping uses each person's own + # tax unit. The explicit flag remains true without attaching the child + # from the mother's return: this unmodeled cross-tax-unit relationship + # leaves size 2 and 678 * 0.34526 = 234.08628 rather than size 3. + # Annual explicit input also sets unspecified people's parent flags to + # false; the mother still qualifies as the head with a dependent child. + mo_tanf_is_parent_of_dependent_child: [false, false, true] + mo_tanf_is_assistance_unit_member: [true, true, false] + mo_tanf_assistance_unit_size: 2 + mo_tanf: 234.08628 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_is_parent_of_dependent_child.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_is_parent_of_dependent_child.yaml new file mode 100644 index 00000000000..29a88109f97 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_is_parent_of_dependent_child.yaml @@ -0,0 +1,500 @@ +- name: An explicit annual parent flag leaves other years using the default + period: 2026 + input: + people: + head: + age: + 2025: 45 + 2026: 45 + 2027: 45 + own_children_in_household: + 2025: 1 + 2026: 1 + 2027: 1 + mother: + age: + 2025: 25 + 2026: 25 + 2027: 25 + is_tax_unit_dependent: + 2025: true + 2026: true + 2027: true + own_children_in_household: + 2025: 1 + 2026: 1 + 2027: 1 + mo_tanf_is_parent_of_dependent_child: + 2026: false + child: + age: + 2025: 5 + 2026: 5 + 2027: 5 + is_tax_unit_dependent: + 2025: true + 2026: true + 2027: true + tax_units: + tax_unit: + members: [head, mother, child] + spm_units: + spm_unit: + members: [head, mother, child] + households: + household: + members: [head, mother, child] + state_code: + 2025: MO + 2026: MO + 2027: MO + output: + mo_tanf_is_parent_of_dependent_child: + 2025: [true, true, false] + # Providing one person's flag fills unset people with false only for + # that year; it does not suppress their formulas in other years. + 2026: [false, false, false] + 2027: [true, true, false] + +- name: January's parent snapshot remains in use after a child ages out + period: 2026-01 + input: + people: + head: + age: 40 + monthly_age: + 2026-01: 40 + 2026-07: 40 + dependent_adult: + age: 60 + monthly_age: + 2026-01: 60 + 2026-07: 60 + is_tax_unit_dependent: true + own_children_in_household: 1 + older_child: + age: 17 + monthly_age: + 2026-01: 17 + 2026-07: 18 + is_tax_unit_dependent: true + is_in_secondary_school: false + younger_child: + age: 0 + monthly_age: + 2026-01: 0 + 2026-07: 0 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent_adult, older_child, younger_child] + spm_units: + spm_unit: + members: [head, dependent_adult, older_child, younger_child] + households: + household: + members: [head, dependent_adult, older_child, younger_child] + state_code: MO + output: + mo_tanf_dependent_child: + 2026-01: [false, false, true, true] + 2026-07: [false, false, false, true] + mo_tanf_is_parent_of_dependent_child: + 2026: [false, true, false, false] + mo_tanf_is_assistance_unit_member: + 2026-01: [true, true, true, true] + # January's 43-year gap still establishes the adult's parent flag. + # Recomputing against July's remaining infant would fail the window + # because the age gap is 60, but the annual snapshot stays in force. + 2026-07: [true, true, false, true] + +- name: An explicit parent ID identifies an adoptive parent with a 55-year gap + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 85 + adoptive_parent: + person_id: 22 + age: 60 + is_tax_unit_dependent: true + own_children_in_household: 0 + child: + person_id: 33 + age: 5 + is_tax_unit_dependent: true + parent_1_id: 22 + parent_2_id: 0 + tax_units: + tax_unit: + members: [head, adoptive_parent, child] + spm_units: + spm_unit: + members: [head, adoptive_parent, child] + households: + household: + members: [head, adoptive_parent, child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [false, true, false] + +- name: A known parent ID excludes an unrelated adult with a 30-year gap + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 50 + own_children_in_household: 1 + unrelated_adult: + person_id: 22 + age: 35 + is_tax_unit_dependent: true + own_children_in_household: 1 + child: + person_id: 33 + age: 5 + is_tax_unit_dependent: true + # One known slot suffices: the unknown second slot does not allow + # an age-based relationship to the unrelated adult. + parent_1_id: 11 + parent_2_id: 0 + tax_units: + tax_unit: + members: [head, unrelated_adult, child] + spm_units: + spm_unit: + members: [head, unrelated_adult, child] + households: + household: + members: [head, unrelated_adult, child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [true, false, false] + +- name: The second parent ID also overrides the age window and own-child count + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 85 + adoptive_parent: + person_id: 22 + age: 60 + is_tax_unit_dependent: true + own_children_in_household: 0 + child: + person_id: 33 + age: 5 + is_tax_unit_dependent: true + parent_1_id: 0 + parent_2_id: 22 + tax_units: + tax_unit: + members: [head, adoptive_parent, child] + spm_units: + spm_unit: + members: [head, adoptive_parent, child] + households: + household: + members: [head, adoptive_parent, child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [false, true, false] + +- name: A known absent parent suppresses the age fallback + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 50 + unrelated_adult: + person_id: 22 + age: 35 + is_tax_unit_dependent: true + own_children_in_household: 1 + child: + person_id: 33 + age: 5 + is_tax_unit_dependent: true + parent_1_id: 99 + parent_2_id: 0 + tax_units: + tax_unit: + members: [head, unrelated_adult, child] + spm_units: + spm_unit: + members: [head, unrelated_adult, child] + households: + household: + members: [head, unrelated_adult, child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [false, false, false] + +- name: Two unknown parent IDs preserve the own-child and age-window fallback + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 85 + adult_in_window: + person_id: 22 + age: 35 + is_tax_unit_dependent: true + own_children_in_household: 1 + adult_outside_window: + person_id: 33 + age: 60 + is_tax_unit_dependent: true + own_children_in_household: 1 + adult_without_own_children: + person_id: 44 + age: 35 + is_tax_unit_dependent: true + own_children_in_household: 0 + child: + person_id: 55 + age: 5 + is_tax_unit_dependent: true + parent_1_id: 0 + parent_2_id: 0 + tax_units: + tax_unit: + members: [head, adult_in_window, adult_outside_window, adult_without_own_children, child] + spm_units: + spm_unit: + members: [head, adult_in_window, adult_outside_window, adult_without_own_children, child] + households: + household: + members: [head, adult_in_window, adult_outside_window, adult_without_own_children, child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [false, true, false, false, false] + +- name: A child with unknown IDs still permits fallback beside a linked child + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 45 + adult: + person_id: 22 + age: 35 + is_tax_unit_dependent: true + own_children_in_household: 1 + linked_child: + person_id: 33 + age: 0 + is_tax_unit_dependent: true + parent_1_id: 11 + parent_2_id: 0 + unlinked_child: + person_id: 44 + age: 5 + is_tax_unit_dependent: true + parent_1_id: 0 + parent_2_id: 0 + tax_units: + tax_unit: + members: [head, adult, linked_child, unlinked_child] + spm_units: + spm_unit: + members: [head, adult, linked_child, unlinked_child] + households: + household: + members: [head, adult, linked_child, unlinked_child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [true, true, false, false] + +- name: A linked child cannot supply the age fallback for an unrelated adult + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 45 + adult: + person_id: 22 + age: 60 + is_tax_unit_dependent: true + own_children_in_household: 1 + linked_child: + person_id: 33 + age: 17 + is_tax_unit_dependent: true + parent_1_id: 11 + parent_2_id: 0 + unlinked_child: + person_id: 44 + age: 0 + is_tax_unit_dependent: true + parent_1_id: 0 + parent_2_id: 0 + tax_units: + tax_unit: + members: [head, adult, linked_child, unlinked_child] + spm_units: + spm_unit: + members: [head, adult, linked_child, unlinked_child] + households: + household: + members: [head, adult, linked_child, unlinked_child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [true, false, false, false] + +- name: Known parent IDs exclude an unrelated unmarked tax-unit head + period: 2026-01 + input: + people: + unrelated_head: + person_id: 11 + age: 30 + own_children_in_household: 1 + mother: + person_id: 22 + age: 25 + is_tax_unit_dependent: true + own_children_in_household: 0 + baby: + person_id: 33 + age: 0 + is_tax_unit_dependent: true + parent_1_id: 22 + parent_2_id: 0 + tax_units: + tax_unit: + members: [unrelated_head, mother, baby] + spm_units: + spm_unit: + members: [unrelated_head, mother, baby] + households: + household: + members: [unrelated_head, mother, baby] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [false, true, false] + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf_assistance_unit_size: 2 + +- name: Known parent IDs retain a named head and spouse outside the age window + period: 2026-01 + input: + people: + head: + person_id: 11 + age: 70 + # Isolate parent identification from computed old-age SSI. + ssi: 0 + is_tax_unit_head: true + is_tax_unit_dependent: false + own_children_in_household: 0 + spouse: + person_id: 22 + age: 60 + ssi: 0 + is_tax_unit_spouse: true + is_tax_unit_dependent: false + own_children_in_household: 0 + child: + person_id: 33 + age: 5 + is_tax_unit_dependent: true + parent_1_id: 11 + parent_2_id: 22 + tax_units: + tax_unit: + members: [head, spouse, child] + spm_units: + spm_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [true, true, false] + mo_tanf_is_assistance_unit_member: [true, true, true] + mo_tanf_assistance_unit_size: 3 + +- name: Known IDs stop an unrelated unmarked head from blocking a non-parent caretaker + period: 2026-01 + input: + people: + unrelated_head: + person_id: 11 + age: 50 + is_tax_unit_head: true + is_tax_unit_dependent: false + own_children_in_household: 1 + mo_tanf_is_non_parent_caretaker: false + caretaker_spouse: + person_id: 22 + age: 35 + is_tax_unit_spouse: true + is_tax_unit_dependent: false + own_children_in_household: 1 + mo_tanf_is_non_parent_caretaker: true + child: + person_id: 33 + age: 5 + is_tax_unit_dependent: true + parent_1_id: 99 + parent_2_id: 0 + tax_units: + tax_unit: + members: [unrelated_head, caretaker_spouse, child] + spm_units: + spm_unit: + members: [unrelated_head, caretaker_spouse, child] + households: + household: + members: [unrelated_head, caretaker_spouse, child] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [false, false, false] + mo_tanf_non_parent_caretaker: [false, true, false] + +- name: Explicit parent IDs retain the approved three-generation unit for an 18-year-old student mother + period: 2026-01 + input: + people: + grandmother: + person_id: 11 + age: 45 + own_children_in_household: 0 + mother: + person_id: 22 + age: 18 + is_tax_unit_dependent: true + is_in_secondary_school: true + own_children_in_household: 0 + parent_1_id: 11 + parent_2_id: 0 + baby: + person_id: 33 + age: 0 + is_tax_unit_dependent: true + parent_1_id: 22 + parent_2_id: 0 + tax_units: + tax_unit: + members: [grandmother, mother, baby] + spm_units: + spm_unit: + members: [grandmother, mother, baby] + households: + household: + members: [grandmother, mother, baby] + state_code: MO + output: + mo_tanf_is_parent_of_dependent_child: [true, true, false] + mo_tanf_is_assistance_unit_member: [true, true, true] + mo_tanf_assistance_unit_size: 3 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_parent_input_contract.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_parent_input_contract.yaml new file mode 100644 index 00000000000..b8fb815e7d8 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/mo_tanf_parent_input_contract.yaml @@ -0,0 +1,224 @@ +# R3 P2: shared parent IDs include step-parents. These paired years retain +# those leaf IDs and demonstrate the documented Missouri annual-flag remedy. +# The automatic result is preserved as evidence of the disclosed limitation; +# exclusion from member income does not establish step-parent income deeming. +- name: A named step-parent is excluded by a complete annual parent-flag override + period: 2026-01 + input: + people: + mother: + person_id: + 2026: 11 + 2027: 11 + age: + 2026: 35 + 2027: 35 + is_tax_unit_dependent: + 2026: false + 2027: false + own_children_in_household: + 2026: 0 + 2027: 0 + mo_tanf_is_parent_of_dependent_child: + 2027: true + stepfather: + person_id: + 2026: 22 + 2027: 22 + age: + 2026: 37 + 2027: 37 + is_tax_unit_dependent: + 2026: false + 2027: false + own_children_in_household: + 2026: 0 + 2027: 0 + unemployment_compensation: + 2026: 2400 + 2027: 2400 + mo_tanf_is_parent_of_dependent_child: + 2027: false + child: + person_id: + 2026: 33 + 2027: 33 + age: + 2026: 5 + 2027: 5 + is_tax_unit_dependent: + 2026: true + 2027: true + parent_1_id: + 2026: 11 + 2027: 11 + parent_2_id: + 2026: 22 + 2027: 22 + mo_tanf_is_parent_of_dependent_child: + 2027: false + tax_units: + tax_unit: + members: [mother, stepfather, child] + spm_units: + spm_unit: + members: [mother, stepfather, child] + households: + household: + members: [mother, stepfather, child] + state_code: + 2026: MO + 2027: MO + output: + mo_tanf_is_parent_of_dependent_child: + 2026: [true, true, false] + 2027: [true, false, false] + mo_tanf_is_assistance_unit_member: + 2026-01: [true, true, true] + 2027-01: [true, false, true] + mo_tanf_assistance_unit_size: + 2026-01: 3 + 2027-01: 2 + mo_tanf_gross_unearned_income: + 2026-01: 200 + 2027-01: 0 + +- name: A named step-parent does not block a marked caretaker after the annual override + period: 2026-01 + input: + people: + grandmother: + person_id: + 2026: 11 + 2027: 11 + age: + 2026: 55 + 2027: 55 + is_tax_unit_dependent: + 2026: false + 2027: false + mo_tanf_is_non_parent_caretaker: + 2026: true + 2027: true + mo_tanf_is_parent_of_dependent_child: + 2027: false + stepfather: + person_id: + 2026: 22 + 2027: 22 + age: + 2026: 35 + 2027: 35 + is_tax_unit_dependent: + 2026: true + 2027: true + own_children_in_household: + 2026: 0 + 2027: 0 + mo_tanf_is_parent_of_dependent_child: + 2027: false + child: + person_id: + 2026: 33 + 2027: 33 + age: + 2026: 5 + 2027: 5 + is_tax_unit_dependent: + 2026: true + 2027: true + # The natural parent is absent; the co-resident ID names only a step-parent. + parent_1_id: + 2026: 99 + 2027: 99 + parent_2_id: + 2026: 22 + 2027: 22 + mo_tanf_is_parent_of_dependent_child: + 2027: false + tax_units: + tax_unit: + members: [grandmother, stepfather, child] + spm_units: + spm_unit: + members: [grandmother, stepfather, child] + households: + household: + members: [grandmother, stepfather, child] + state_code: + 2026: MO + 2027: MO + output: + mo_tanf_is_parent_of_dependent_child: + 2026: [false, true, false] + 2027: [false, false, false] + mo_tanf_non_parent_caretaker: + 2026-01: [false, false, false] + 2027-01: [true, false, false] + mo_tanf_is_assistance_unit_member: + 2026-01: [false, true, true] + 2027-01: [true, false, true] + +# R3 P3: known IDs suppress the head's parent proxy but cannot identify an NPCR. +# In otherwise identical years, only the explicit caretaker mark changes. +- name: Known absent parent IDs require an explicit mark to include a needy grandmother + period: 2026-01 + input: + people: + grandmother: + person_id: + 2026: 11 + 2027: 11 + age: + 2026: 55 + 2027: 55 + is_tax_unit_dependent: + 2026: false + 2027: false + own_children_in_household: + 2026: 1 + 2027: 1 + mo_tanf_is_non_parent_caretaker: + 2026: false + 2027: true + grandchild: + person_id: + 2026: 33 + 2027: 33 + age: + 2026: 5 + 2027: 5 + is_tax_unit_dependent: + 2026: true + 2027: true + parent_1_id: + 2026: 99 + 2027: 99 + parent_2_id: + 2026: 0 + 2027: 0 + tax_units: + tax_unit: + members: [grandmother, grandchild] + spm_units: + spm_unit: + members: [grandmother, grandchild] + households: + household: + members: [grandmother, grandchild] + state_code: + 2026: MO + 2027: MO + output: + mo_tanf_is_parent_of_dependent_child: + 2026: [false, false] + 2027: [false, false] + mo_tanf_non_parent_caretaker: + 2026-01: [false, false] + 2027-01: [true, false] + mo_tanf_is_assistance_unit_member: + 2026-01: [false, true] + 2027-01: [true, true] + mo_tanf_assistance_unit_size: + 2026-01: 1 + 2027-01: 2 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/integration.yaml index a5ed11fa0fd..fc30561c8db 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/integration.yaml @@ -441,13 +441,14 @@ # DSS Manual 0210.005.10 excludes "A legal guardian or NPCR if a # biological or adoptive parent is in the home." The mother is an adult, # not a cash-eligible child, so the grandparent cannot be included. - # Known underpayment, not changed here: under 0210.005.05 the mother is - # the caretaker payee and belongs in the unit, but the existing rule - # leaves out adults claimed as tax dependents, so the unit is the child - # alone. + # Under 0210.005.05 the mother is the caretaker payee and a member even + # though the grandparent claims her as a tax dependent, so the unit is + # the mother and child (size 2), not the child alone (size 1, $135.687). mo_tanf_non_parent_caretaker: [false, false, false] mo_tanf_non_parent_caretaker_needy: false - mo_tanf_is_assistance_unit_member: [false, false, true] + mo_tanf_is_assistance_unit_member: [false, true, true] + mo_tanf_assistance_unit_size: 2 + mo_tanf: 234.086 - name: A mother who is herself a dependent child does not block her aunt as caretaker relative period: 2026-01 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/mo_tanf_non_parent_caretaker.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/mo_tanf_non_parent_caretaker.yaml index d13a33a219f..aeb93628404 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/mo_tanf_non_parent_caretaker.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/dss/tanf/non_parent_caretaker/mo_tanf_non_parent_caretaker.yaml @@ -200,7 +200,7 @@ mo_tanf_assistance_unit_size: 2 mo_tanf: 234.086 -- name: Without the override the same count of own children excludes the grandparent +- name: Without the override the default rules out a great-grandparent too old to be the child's parent period: 2026-01 input: people: @@ -225,8 +225,13 @@ members: [grandparent, great_grandparent, grandchild] state_code: MO output: - # mo_tanf_is_parent_of_dependent_child defaults to is_parent, which - # cannot tell whose parent she is. - mo_tanf_is_parent_of_dependent_child: [false, true, false] - mo_tanf_non_parent_caretaker: [false, false, false] + # own_children_in_household cannot tell whose parent she is, but the + # default also requires being 12 to 50 years older than a dependent + # child, and 80 - 8 = 72. So no parent is in the home and the + # grandparent is the caretaker relative, as with the override above. + mo_tanf_is_parent_of_dependent_child: [false, false, false] + mo_tanf_non_parent_caretaker: [true, false, false] + mo_tanf_non_parent_caretaker_included: true + mo_tanf_assistance_unit_size: 2 + mo_tanf: 234.086 diff --git a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/mo_tanf_is_assistance_unit_member.py b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/mo_tanf_is_assistance_unit_member.py index d9e3d58f619..e7edffcb50a 100644 --- a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/mo_tanf_is_assistance_unit_member.py +++ b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/mo_tanf_is_assistance_unit_member.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + has_parent_ids, +) class mo_tanf_is_assistance_unit_member(Variable): @@ -7,6 +10,7 @@ class mo_tanf_is_assistance_unit_member(Variable): label = "Missouri TANF assistance unit member" definition_period = MONTH reference = ( + "https://my.mo.gov/cms_fsd?id=kb_article_view&sys_kb_id=98e1ef0c1b543650ba12657ae54bcbd1", "https://dssmanuals.mo.gov/temporary-assistance-case-management/0210-005-05/", "https://dssmanuals.mo.gov/temporary-assistance-case-management/0210-005-10/", "https://dssmanuals.mo.gov/temporary-assistance-case-management/0210-005-35/", @@ -41,19 +45,20 @@ def formula(person, period, parameters): # 13 CSR 40-2.300(5)(C) admits only natural or adoptive parents, # and a stepparent's needs are not taken into account per # 2.310(8)(B)1.D.(II); the tax-unit structure cannot distinguish - # a stepparent from a parent, so a spouse who is not the child's - # parent is counted here. + # a stepparent from a parent when child parent ids are unknown. + # Shared parent ids also include stepparents and cannot distinguish + # parent type. When those ids name a stepparent, use the documented + # annual parent-flag override for children with known links. This + # does not implement the required stepparent income deeming. # The caretaker test looks for any dependent child in the home, not # only a payable one. Per DSS Manual 0210.005.05, "when the only # child in the EU receives SSI, explore Temporary Assistance (TA) # eligibility for the payee and/or second parent" — the SSI child # is excluded from the unit's needs but still establishes the case # for the caretaker. + has_dependent_child = person.tax_unit.any(dependent_child) caretaker = ( - head_or_spouse - & ~is_dependent - & ~is_ssi_recipient - & person.tax_unit.any(dependent_child) + head_or_spouse & ~is_dependent & ~is_ssi_recipient & has_dependent_child ) # A parent is a mandatory member (DSS Manual 0210.005.05). A # non-parent caretaker relative or legal guardian is an optional @@ -61,7 +66,40 @@ def formula(person, period, parameters): # and otherwise included per mo_tanf_non_parent_caretaker_included # (13 CSR 40-2.300(5)(D); DSS Manual 0210.005.15 and 0210.005.35). non_parent = person("mo_tanf_is_non_parent_caretaker", period.this_year) - parent_caretaker = caretaker & ~non_parent + parent = person("mo_tanf_is_parent_of_dependent_child", period.this_year) + unknown_child = dependent_child & ~has_parent_ids(person, period.this_year) + # Known parent ids take precedence over the legacy head/spouse proxy. + # Preserve that proxy when a child's two parent ids are unknown. + presumed_parent = parent | person.tax_unit.any(unknown_child) + parent_caretaker = caretaker & ~non_parent & presumed_parent + # Membership as a parent does not depend on who claims whom for + # taxes. 13 CSR 40-2.300(5)(C) and the Combined IM Policy Manual + # 4.2.2 (formerly DSS Manual 0210.005.05) make the "Biological or + # adoptive parents of one or more of the eligible children" + # mandatory members, so a parent claimed as someone else's tax + # dependent, such as a 20-year-old mother claimed by her own mother, + # is a member too. These are the same people + # mo_tanf_non_parent_caretaker treats as a parent in the home, less + # those on SSI. The formulas group children within tax units; the + # boolean parent input cannot attach a child in another tax unit. + # The parent's own tax unit must therefore have a dependent child, + # so even an explicit true excludes a real parent filing separately + # when their own tax unit has none. Mandatory membership for that + # parent under 13 CSR 40-2.300(5)(C) remains an unmodeled limitation. + # A parent who is a dependent child is a member as an eligible child + # instead, with their own parent as the caretaker. For a minor + # parent (under 18, including the month of turning 18), the + # manual's minor parent provision (4.2.4; formerly 0210.005.30) lets + # that three-generation family file as one assistance group. For an + # 18-year-old parent in secondary school, Max's d1049 approves + # interpretation (i): her, her baby and her own parent form one unit. + other_parent = ( + parent + & ~non_parent + & ~dependent_child + & ~is_ssi_recipient + & has_dependent_child + ) npcr = person("mo_tanf_non_parent_caretaker", period) npcr_included = person.spm_unit("mo_tanf_non_parent_caretaker_included", period) - return eligible_child | parent_caretaker | (npcr & npcr_included) + return eligible_child | parent_caretaker | other_parent | (npcr & npcr_included) diff --git a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_non_parent_caretaker.py b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_non_parent_caretaker.py index d45c1c84bfe..0b3fe2c04fc 100644 --- a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_non_parent_caretaker.py +++ b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_non_parent_caretaker.py @@ -12,8 +12,14 @@ class mo_tanf_is_non_parent_caretaker(Variable): "guardian. Missouri calls a related caretaker a non-parent caretaker " "relative (NPCR) and applies the same rules to a related or " "unrelated legal guardian. Mark both spouses when neither is a " - "parent of the children; a spouse left false is treated as a parent, " - "which excludes the other. Leave false for parents. A stepparent " + "parent of the children. With unknown child parent ids, a spouse left " + "false is presumed to be a parent and excludes the other. Known " + "parent_1_id and parent_2_id inputs instead identify named adults " + "without distinguishing step-parents; see the parent flag's " + "documented override. A caretaker relative is not inferred from " + "known child parent ids: explicitly mark the NPCR. When no parent " + "is present, leaving the NPCR unmarked keeps the unit child-only. " + "Leave false for parents. A stepparent " "living with the child's parent is not a non-parent caretaker, and " "neither is a grandparent whose own minor child (the children's " "parent) is one of the dependent children. A non-parent caretaker " diff --git a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_parent_of_dependent_child.py b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_parent_of_dependent_child.py index 4b913f26818..68d462715ff 100644 --- a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_parent_of_dependent_child.py +++ b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_is_parent_of_dependent_child.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + co_resident_parent_indices, + has_parent_ids, +) class mo_tanf_is_parent_of_dependent_child(Variable): @@ -7,22 +11,80 @@ class mo_tanf_is_parent_of_dependent_child(Variable): label = "Missouri TANF parent of a dependent child in the tax unit" documentation = ( "Whether this person is the biological or adoptive parent of one of " - "the tax unit's dependent children. It matters only when someone in " - "the tax unit is marked as a non-parent caretaker: a parent in the " - "home, other than a cash-eligible child, excludes that caretaker. " - "Defaults to having one's own children in the household " - "(own_children_in_household), which also counts adult children and " - "children outside the tax unit; set this input directly when that " - "count does not match. Heads and spouses not marked as non-parent " - "caretakers are always treated as parents; this input applies to " - "other tax-unit members." + "the tax unit's dependent children. Such a parent is a member of the " + "assistance unit even when someone else claims them as a tax " + "dependent, unless they receive SSI or are a dependent child " + "themselves (then they are a member as a child). A parent in the " + "home, other than a cash-eligible child, also excludes a non-parent " + "caretaker. Reads January's dependent children in the person's tax " + "unit. First uses each child's parent_1_id and parent_2_id, resolved " + "to a co-resident person_id: a named parent qualifies regardless of " + "ages or own_children_in_household. Only a child whose two parent " + "ids are both unknown (0) permits the existing fallback: is_parent " + "and an age gap of 12 to 50 years. Known ids naming another person " + "or an absent parent do not permit age inference for that child. " + "This follows d1049 and the principle to make inputs as leaf-nodey " + "as possible; the age window imputes a relationship, not law. " + "The shared parent ids include step-parents and do not record parent " + "type, so this default also treats a named step-parent as biological " + "or adoptive. Missouri admits only natural or adoptive parents under " + "13 CSR 40-2.300(5)(C); a step-parent must not add needs or block an " + "NPCR under (5)(D). Preserve shared parent ids for other programs. " + "When they include a step-parent, explicitly set this flag true for " + "every applicable biological or adoptive parent and false for " + "step-parents and everyone else for that year. This corrects parent " + "membership and NPCR identification only when all relevant children " + "have known links: any child with two unknown ids retains the " + "head/spouse proxy even with an explicit false. Step-parent income " + "deeming under 13 CSR 40-2.310(8)(B)1.D.(II) is not modeled. " + "The fallback's own-child count can include adult children and " + "children outside the tax unit, so supply parent ids when known, " + "or set this input directly when the fallback does not match. " + "Setting it for one " + "person for a year sets it to false for everyone else not given a " + "value for that year, so set it for every parent it applies to; other " + "years still use the default. Heads and spouses not marked as " + "non-parent caretakers retain their presumed-parent status only " + "when a dependent child's parent ids are unknown, or this flag " + "identifies them as a parent. This input cannot associate a " + "parent filing a separate tax return with a child in another tax " + "unit: even an explicit true leaves that parent excluded when their " + "own tax unit has no dependent child. The assistance-unit formulas " + "use tax-unit child grouping, so mandatory parent membership across " + "tax units is not modeled." ) definition_period = YEAR reference = ( + "https://my.mo.gov/cms_fsd?id=kb_article_view&sys_kb_id=98e1ef0c1b543650ba12657ae54bcbd1", "https://dssmanuals.mo.gov/temporary-assistance-case-management/0210-005-05/", "https://dssmanuals.mo.gov/temporary-assistance-case-management/0210-005-10/", + "https://www.sos.mo.gov/cmsimages/adrules/csr/current/13csr/13c40-2.pdf#page=30", ) defined_for = StateCode.MO def formula(person, period, parameters): - return person("is_parent", period) + # The flag is annual and reads January's dependent children, so a + # change in who is a dependent child later in the year (for example + # a child aging out through monthly_age inputs) does not update it. + dependent_child = person("mo_tanf_dependent_child", period.first_month) + # Use relationship leaf inputs first (Max's d1049). Resolve within + # households using the shared helpers, so repeated ids in different + # households cannot join unrelated families. Preserve the tax-unit + # child grouping used by the assistance-unit formulas. + tax_unit = person.tax_unit.reference_entity.members_entity_id + linked_parent = np.zeros(person.count, dtype=bool) + for parent in co_resident_parent_indices(person, period): + relevant = dependent_child & (parent >= 0) & (tax_unit[parent] == tax_unit) + linked_parent |= np.bincount(parent[relevant], minlength=person.count) > 0 + + # Only fully unknown child links permit the unchanged 12–50 fallback. + # Known but absent parents are still known and do not permit it. + # Dependent children's ages span less than the 38-year window, so + # the youngest/oldest shortcut is equivalent to checking each child. + unknown_child = dependent_child & ~has_parent_ids(person, period) + has_own_children = person("is_parent", period) + age = person("age", period) + youngest = person.tax_unit.min(where(unknown_child, age, np.inf)) + oldest = person.tax_unit.max(where(unknown_child, age, -np.inf)) + fallback = has_own_children & (age - youngest >= 12) & (age - oldest <= 50) + return linked_parent | fallback diff --git a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_non_parent_caretaker.py b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_non_parent_caretaker.py index e8b193c6825..a5f4ab181e1 100644 --- a/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_non_parent_caretaker.py +++ b/policyengine_us/variables/gov/states/mo/dss/tanf/assistance_unit/non_parent_caretaker/mo_tanf_non_parent_caretaker.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + has_parent_ids, +) class mo_tanf_non_parent_caretaker(Variable): @@ -32,8 +35,10 @@ def formula(person, period, parameters): # the home", and DSS Manual 0210.005.10 excludes "A legal guardian # or NPCR if a biological or adoptive parent is in the home." Two # signals mark a parent in the home: - # - a caretaker not marked as a non-parent, including one who - # receives SSI (excluded from the unit, but still in the home); + # - a caretaker not marked as a non-parent, presumed to be a parent + # only for unknown child parent ids, or identified by the parent + # flag, including one who receives SSI (excluded from the unit, + # but still in the home); # - any other member of the tax unit marked as a parent of a # dependent child, such as an adult daughter claimed as the # grandparent's dependent. A parent who is a cash-eligible child @@ -45,11 +50,15 @@ def formula(person, period, parameters): # separate return is not seen. is_ssi_recipient = (person("ssi", period) > 0) | person("receives_ssi", period) cash_eligible_child = dependent_child & ~is_ssi_recipient - other_parent = ( - person("mo_tanf_is_parent_of_dependent_child", period.this_year) - & ~cash_eligible_child + parent = person("mo_tanf_is_parent_of_dependent_child", period.this_year) + other_parent = parent & ~cash_eligible_child + unknown_child = dependent_child & ~has_parent_ids(person, period.this_year) + # A known child's ids override the presumed parenthood of an + # unmarked head/spouse; retain the proxy only for unknown links. + parent_caretaker = caretaker & (parent | person.tax_unit.any(unknown_child)) + parent_in_home = person.tax_unit.any( + (parent_caretaker | other_parent) & ~non_parent ) - parent_in_home = person.tax_unit.any((caretaker | other_parent) & ~non_parent) # DSS Manual 0210.005.35: an NPCR who receives SSI, SSI-SP or SP # cannot have their needs and income included. SP receipt is not # observable (see mo_tanf_is_assistance_unit_member). diff --git a/policyengine_us/variables/gov/states/mo/dss/tanf/income/is_mo_tanf_earned_income_exempt.py b/policyengine_us/variables/gov/states/mo/dss/tanf/income/is_mo_tanf_earned_income_exempt.py index 78a99b6ab67..4ec38613809 100644 --- a/policyengine_us/variables/gov/states/mo/dss/tanf/income/is_mo_tanf_earned_income_exempt.py +++ b/policyengine_us/variables/gov/states/mo/dss/tanf/income/is_mo_tanf_earned_income_exempt.py @@ -30,7 +30,9 @@ def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) is_dependent = person("is_tax_unit_dependent", period.this_year) # The exemption is for a parent; a non-parent caretaker relative or - # legal guardian does not qualify. + # legal guardian does not qualify. A parent claimed as someone else's + # tax dependent needs no separate test: under 19 and in secondary + # school, they are a dependent child, exempt above. non_parent = person("mo_tanf_is_non_parent_caretaker", period.this_year) parent = ( head_or_spouse