From bd9545e83ef45c36610c7d9ab574b0e028de4209 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 08:25:01 -0400 Subject: [PATCH 1/3] Disregard the gross income test for surviving spouse filing status IRC 2(a)(1)(B) determines the surviving spouse's son or daughter's dependency "without regard to subsections (b)(1), (b)(2), and (d)(1)(B)" of section 152. surviving_spouse_eligible counted tax_unit_child_dependents, which sums is_child_dependent and so applied the 152(d)(1)(B) qualifying relative gross income test. An adult child with gross income at or above the exemption amount ($5,200 in 2025) therefore left the parent filing single. The formula now counts any tax unit dependent who is the head's child. 2(a)(1)(B)(i) requires a son, stepson, daughter or stepdaughter. Where the household has parent ids, the child is a dependent whose parent_1_id or parent_2_id names the head (natural, adoptive or step). Without parent ids, it is any dependent not marked as a parent or grandparent of the filer or as unrelated. Co-Authored-By: Claude Opus 5.5 --- ...urviving-spouse-gross-income-test.fixed.md | 1 + .../demographic/tax_unit/filing_status.yaml | 82 +++++ .../tax_unit/surviving_spouse_eligible.yaml | 337 +++++++++++++++++- ...st_surviving_spouse_eligible_properties.py | 210 +++++++++++ .../tax_unit/surviving_spouse_eligible.py | 44 ++- 5 files changed, 667 insertions(+), 7 deletions(-) create mode 100644 changelog.d/surviving-spouse-gross-income-test.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py diff --git a/changelog.d/surviving-spouse-gross-income-test.fixed.md b/changelog.d/surviving-spouse-gross-income-test.fixed.md new file mode 100644 index 00000000000..16da67c45d6 --- /dev/null +++ b/changelog.d/surviving-spouse-gross-income-test.fixed.md @@ -0,0 +1 @@ +Stop applying the qualifying relative gross income test to surviving spouse filing status, which IRC 2(a)(1)(B) disregards, and count only a dependent son, daughter or stepchild: one whose parent id names the filer, or, in a household without parent ids, one not marked as the filer's parent, grandparent or an unrelated person. diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml index f993c6326da..fafce81f78d 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml @@ -251,3 +251,85 @@ output: # Each parent has 5,100 of taxable benefits, not below 4,400. filing_status: SINGLE + +- name: Widowed parent of a 29-year-old dependent with $6,238 of income files as surviving spouse. + period: 2025 + input: + people: + head: + age: 67 + is_surviving_spouse: true + is_tax_unit_head: true + adult_child: + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + # IRC 2(a)(1)(B) disregards the IRC 152(d)(1)(B) gross income test, + # so income above the $5,200 limit does not matter. + employment_income: 6_238 + tax_units: + tax_unit: + members: [head, adult_child] + households: + household: + members: [head, adult_child] + output: + filing_status: SURVIVING_SPOUSE + basic_standard_deduction: 31_500 + +- name: Widowed filer with a dependent parent files as head of household, not surviving spouse. + period: 2025 + input: + people: + head: + age: 67 + is_surviving_spouse: true + is_tax_unit_head: true + parent: + age: 88 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_parent_of_filer_or_spouse: true + tax_units: + tax_unit: + members: [head, parent] + households: + household: + members: [head, parent] + output: + filing_status: HEAD_OF_HOUSEHOLD + +- name: Widowed grandparent of a dependent grandchild files as head of household, not surviving spouse. + period: 2025 + input: + people: + head: + person_id: 1 + age: 60 + is_tax_unit_head: true + is_surviving_spouse: true + mother: + person_id: 2 + parent_1_id: 1 + age: 35 + is_tax_unit_head: true + is_tax_unit_spouse: false + grandchild: + person_id: 3 + parent_1_id: 2 + age: 8 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + head_unit: + members: [head, grandchild] + mother_unit: + members: [mother] + households: + household: + members: [head, mother, grandchild] + output: + filing_status: [HEAD_OF_HOUSEHOLD, SINGLE] diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml index c84c6758396..30a01c382b4 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml @@ -2,7 +2,7 @@ period: 2022 input: people: - head: + head: is_surviving_spouse: true tax_units: tax_unit: @@ -14,14 +14,343 @@ period: 2022 input: people: - head: + head: is_tax_unit_head: true is_surviving_spouse: true - child: {} + child: + age: 10 tax_units: tax_unit: members: [head, child] - tax_unit_child_dependents: 1 tax_unit_married: false output: surviving_spouse_eligible: true + +# IRC 2(a)(1)(B) determines the son or daughter's dependency "without regard +# to subsections (b)(1), (b)(2), and (d)(1)(B)" of section 152. The 2025 Form +# 1040 instructions (page 15) apply this as a child the filer "could claim as a +# dependent except that ... the child had gross income of $5,200 or more". +- name: Adult dependent child with gross income above the exemption amount still qualifies. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + # Above the $5,200 2025 qualifying relative gross income limit. + employment_income: 6_238 + tax_units: + tax_unit: + members: [head, adult_child] + output: + dependent_gross_income: [0, 6_238] + is_qualifying_relative_dependent: [false, false] + surviving_spouse_eligible: true + +- name: Traced household, adult dependent child with $6,238 of disability benefits qualifies. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + disability_benefits: 6_238 + tax_units: + tax_unit: + members: [head, adult_child] + output: + surviving_spouse_eligible: true + +- name: Adult dependent child with gross income at the exemption amount qualifies. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 5_200 + tax_units: + tax_unit: + members: [head, adult_child] + output: + surviving_spouse_eligible: true + +- name: Adult dependent child with gross income below the exemption amount qualifies. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 3_000 + tax_units: + tax_unit: + members: [head, adult_child] + output: + is_qualifying_relative_dependent: [false, true] + surviving_spouse_eligible: true + +- name: Permanently disabled adult child with high income qualifies. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + age: 40 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_permanently_and_totally_disabled: true + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, adult_child] + output: + surviving_spouse_eligible: true + +# IRC 2(a)(1)(B)(i) requires a son, stepson, daughter or stepdaughter. A +# dependent parent is a qualifying relative under IRC 152(d)(2)(C), but not a +# child, so the filer does not qualify. +- name: Dependent parent of the filer with income below the exemption amount does not qualify. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + parent: + age: 88 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_parent_of_filer_or_spouse: true + tax_units: + tax_unit: + members: [head, parent] + output: + is_qualifying_relative_dependent: [false, true] + surviving_spouse_eligible: false + +- name: Dependent grandparent of the filer does not qualify. + period: 2025 + input: + people: + head: + age: 50 + is_tax_unit_head: true + is_surviving_spouse: true + grandparent: + age: 92 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_grandparent_of_filer_or_spouse: true + tax_units: + tax_unit: + members: [head, grandparent] + output: + surviving_spouse_eligible: false + +- name: Unrelated dependent member of the household does not qualify. + period: 2025 + input: + people: + head: + age: 50 + is_tax_unit_head: true + is_surviving_spouse: true + household_member: + age: 30 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_related_to_head_or_spouse: false + tax_units: + tax_unit: + members: [head, household_member] + output: + surviving_spouse_eligible: false + +- name: Dependent parent plus an adult child with high income qualifies through the child. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + parent: + age: 88 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_parent_of_filer_or_spouse: true + adult_child: + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 30_000 + tax_units: + tax_unit: + members: [head, parent, adult_child] + output: + surviving_spouse_eligible: true + +- name: Remarried surviving spouse with a dependent child does not qualify. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + spouse: + age: 65 + is_tax_unit_head: false + is_tax_unit_spouse: true + child: + age: 10 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + output: + surviving_spouse_eligible: false + +- name: Filer who is not a surviving spouse does not qualify with an adult child. + period: 2025 + input: + people: + head: + age: 67 + is_tax_unit_head: true + is_surviving_spouse: false + adult_child: + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 6_238 + tax_units: + tax_unit: + members: [head, adult_child] + output: + surviving_spouse_eligible: false + +# Parent ids (natural, adoptive or step) identify the filer's own children. +- name: Adult dependent whose parent id names the widowed head qualifies regardless of income. + period: 2025 + input: + people: + head: + person_id: 1 + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + person_id: 2 + parent_1_id: 1 + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 30_000 + tax_units: + tax_unit: + members: [head, adult_child] + households: + household: + members: [head, adult_child] + output: + surviving_spouse_eligible: true + +- name: Dependent grandchild whose parent id names another household member does not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + age: 60 + is_tax_unit_head: true + is_surviving_spouse: true + mother: + person_id: 2 + parent_1_id: 1 + age: 35 + is_tax_unit_head: true + is_tax_unit_spouse: false + grandchild: + person_id: 3 + parent_1_id: 2 + age: 8 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + head_unit: + members: [head, grandchild] + mother_unit: + members: [mother] + households: + household: + members: [head, mother, grandchild] + output: + surviving_spouse_eligible: [false, false] + +- name: Dependent parent without parent ids in a household with ids does not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + # The head's own parent id names the dependent mother. + parent_1_id: 2 + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + mother: + person_id: 2 + age: 90 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + taxable_pension_income: 20_000 + tax_units: + tax_unit: + members: [head, mother] + households: + household: + members: [head, mother] + output: + surviving_spouse_eligible: false diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py new file mode 100644 index 00000000000..cb3c2d01c8c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py @@ -0,0 +1,210 @@ +"""Exhaustive property checks for surviving spouse filing status. + +YAML cases check one household each. These tests build every combination +on the grid below as its own tax unit in one vectorized simulation and +check properties that must hold for all of them: eligibility never depends +on the dependent's income, never comes from a non-child dependent, and adds +to the previous rule only the units it rejected for the gross income test. + +IRC 2(a)(1)(B) requires a dependent son, stepson, daughter or stepdaughter, +with dependency determined without regard to IRC 152(b)(1), (b)(2) and +(d)(1)(B), so the qualifying relative gross income test does not apply. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.system import system + +PERIOD = 2025 +EXEMPTION_AMOUNT = system.parameters(PERIOD).gov.irs.income.exemption.amount + +SURVIVING_SPOUSE = [True, False] +MARRIED = [False, True] +# Under 19 (qualifying child by age), an adult child, an elderly relative. +AGES = [10, 29, 88] +INCOMES = [0, 3_000, EXEMPTION_AMOUNT - 1, EXEMPTION_AMOUNT, 6_238, 50_000] +DISABLED = [False, True] +# Households without parent ids: an unmarked dependent, or one marked as a +# parent, grandparent or unrelated person. Households with parent ids: the +# dependent's id names the head, names an absent parent, or is unknown +# while the head's own id is set. +RELATIONSHIPS = [ + "unmarked", + "parent", + "grandparent", + "unrelated", + "linked_to_head", + "linked_elsewhere", + "unlinked_in_linked_household", +] +CHILD = {"unmarked", "linked_to_head"} + +UNITS = [ + dict( + surviving=surviving, + married=married, + age=age, + income=income, + disabled=disabled, + relationship=relationship, + ) + for surviving, married, age, income, disabled, relationship in itertools.product( + SURVIVING_SPOUSE, MARRIED, AGES, INCOMES, DISABLED, RELATIONSHIPS + ) +] + [ + dict(surviving=surviving, married=married, relationship=None) + for surviving in SURVIVING_SPOUSE + for married in MARRIED +] +# Ids that name no one in the simulation. +ABSENT_ID = 10_000_000 + + +def _situation(): + year = str(PERIOD) + people, tax_units, marital_units = {}, {}, {} + for i, unit in enumerate(UNITS): + head, spouse, dependent = (f"{role}_{i}" for role in ("h", "s", "d")) + head_id = 3 * i + 1 + members = [head] + people[head] = { + "person_id": {year: head_id}, + "age": {year: 67}, + "is_tax_unit_head": {year: True}, + "is_tax_unit_spouse": {year: False}, + "is_surviving_spouse": {year: unit["surviving"]}, + } + if unit["married"]: + members.append(spouse) + people[spouse] = { + "person_id": {year: head_id + 1}, + "age": {year: 65}, + "is_tax_unit_head": {year: False}, + "is_tax_unit_spouse": {year: True}, + } + marital_units[f"m_{i}"] = {"members": members[:]} + relationship = unit["relationship"] + if relationship is not None: + members.append(dependent) + parent_1_id = { + "linked_to_head": head_id, + "linked_elsewhere": ABSENT_ID + i, + }.get(relationship, 0) + people[dependent] = { + "person_id": {year: head_id + 2}, + "parent_1_id": {year: parent_1_id}, + "age": {year: unit["age"]}, + "is_tax_unit_head": {year: False}, + "is_tax_unit_spouse": {year: False}, + "is_tax_unit_dependent": {year: True}, + "employment_income": {year: unit["income"]}, + "is_permanently_and_totally_disabled": {year: unit["disabled"]}, + "is_parent_of_filer_or_spouse": {year: relationship == "parent"}, + "is_grandparent_of_filer_or_spouse": { + year: relationship == "grandparent" + }, + "is_related_to_head_or_spouse": {year: relationship != "unrelated"}, + } + if relationship == "unlinked_in_linked_household": + people[head]["parent_1_id"] = {year: ABSENT_ID + i} + marital_units[f"dm_{i}"] = {"members": [dependent]} + tax_units[f"t_{i}"] = {"members": members} + + def groups(): + return { + name: {"members": unit["members"][:]} for name, unit in tax_units.items() + } + + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": groups(), + "families": groups(), + "households": groups(), + } + + +def _field(name, default): + return np.array([unit.get(name, default) for unit in UNITS]) + + +@pytest.fixture(scope="module") +def grid(): + sim = Simulation(situation=_situation()) + + def calc(variable): + return np.asarray(sim.calculate(variable, PERIOD)) + + relationship = _field("relationship", "") + return { + "eligible": calc("surviving_spouse_eligible"), + "hoh": calc("head_of_household_eligible"), + "filing_status": sim.calculate("filing_status", PERIOD).decode_to_str(), + "child_dependents": calc("tax_unit_child_dependents"), + "surviving": _field("surviving", False), + "married": _field("married", False), + "has_dependent": relationship != "", + "is_child": np.isin(relationship, list(CHILD)), + "age": _field("age", -1), + "income": _field("income", -1), + "disabled": _field("disabled", False), + } + + +def test_matches_reference_rule(grid): + # A surviving spouse head, not remarried, with a dependent child. + expected = grid["surviving"] & ~grid["married"] & grid["is_child"] + assert len(grid["eligible"]) == len(UNITS) + np.testing.assert_array_equal(grid["eligible"], expected) + + +def test_dependent_income_never_changes_eligibility(grid): + groups = {} + for i, unit in enumerate(UNITS): + if unit["relationship"] is not None: + key = tuple(value for name, value in unit.items() if name != "income") + groups.setdefault(key, set()).add(bool(grid["eligible"][i])) + with_dependent = sum(unit["relationship"] is not None for unit in UNITS) + assert len(groups) * len(INCOMES) == with_dependent + assert all(len(values) == 1 for values in groups.values()) + + +def test_non_child_dependents_never_qualify(grid): + not_child = grid["has_dependent"] & ~grid["is_child"] + assert not_child.any() + assert not grid["eligible"][not_child].any() + + +def test_eligibility_requires_unmarried_surviving_spouse_with_dependent(grid): + eligible = grid["eligible"] + assert eligible.any() + assert grid["surviving"][eligible].all() + assert not grid["married"][eligible].any() + assert grid["has_dependent"][eligible].all() + + +def test_filing_status_follows_eligibility(grid): + eligible = grid["eligible"] + np.testing.assert_array_equal(grid["filing_status"] == "SURVIVING_SPOUSE", eligible) + assert not (eligible & grid["hoh"]).any() + + +def test_only_change_for_children_is_dropping_income_test(grid): + # Differential against the previous rule, which counted child dependents + # under is_child_dependent and so applied the gross income test. + previous = grid["surviving"] & ~grid["married"] & (grid["child_dependents"] > 0) + is_child = grid["is_child"] + # Every child unit the previous rule accepted stays eligible. + assert grid["eligible"][previous & is_child].all() + # Units added are adult, non-disabled children whose income fails the + # qualifying relative gross income test. + added = grid["eligible"] & ~previous + assert added.any() + assert (grid["age"][added] >= 19).all() + assert not grid["disabled"][added].any() + assert (grid["income"][added] >= EXEMPTION_AMOUNT).all() diff --git a/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py b/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py index 01ef59677b2..f4554b04eac 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py +++ b/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.person._parent_links import ( + household_has_parent_ids, + tax_unit_parent_indices, +) class surviving_spouse_eligible(Variable): @@ -6,7 +10,22 @@ class surviving_spouse_eligible(Variable): entity = TaxUnit definition_period = YEAR label = "Qualifies for surviving spouse filing status" - reference = "https://www.law.cornell.edu/uscode/text/26/2#a" + documentation = ( + "IRC 2(a)(1)(B) requires a dependent son, stepson, daughter or " + "stepdaughter, with dependency determined without regard to IRC " + "152(b)(1), (b)(2) and (d)(1)(B). The qualifying relative gross " + "income test of 152(d)(1)(B) does not apply, so an adult child with " + "gross income at or above the exemption amount still qualifies the " + "filer. A dependent is the filer's child when their parent_1_id or " + "parent_2_id names the tax unit head; these ids cover natural, " + "adoptive and step parents. In a household with no parent ids, every " + "dependent counts as a child unless marked as a parent or grandparent " + "of the filer or as unrelated to the filer." + ) + reference = ( + "https://www.law.cornell.edu/uscode/text/26/2#a_1_B", + "https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=15", + ) def formula(tax_unit, period, parameters): # The surviving spouse filing status should only apply to surviving spouse heads @@ -15,6 +34,25 @@ def formula(tax_unit, period, parameters): is_head = person("is_tax_unit_head", period) is_surviving_spouse = person("is_surviving_spouse", period) surviving_spouse_head = tax_unit.any(is_head & is_surviving_spouse) - has_child_dependents = tax_unit("tax_unit_child_dependents", period) > 0 + # Without the 152(d)(1)(B) gross income test, the qualifying child, + # disabled dependent and qualifying relative paths of + # is_child_dependent together cover every tax unit dependent. + is_dependent = person("is_tax_unit_dependent", period) + # IRC 2(a)(1)(B)(i) limits the dependent to a son, stepson, daughter + # or stepdaughter of the filer. + names_head = np.zeros(person.count, dtype=bool) + for parent in tax_unit_parent_indices(person, period): + names_head |= (parent >= 0) & is_head[np.maximum(parent, 0)] + marked_not_child = ( + person("is_parent_of_filer_or_spouse", period) + | person("is_grandparent_of_filer_or_spouse", period) + | ~person("is_related_to_head_or_spouse", period) + ) + is_child = where( + household_has_parent_ids(person, period), + names_head, + ~marked_not_child, + ) + has_qualifying_child = tax_unit.any(is_dependent & is_child) married = tax_unit("tax_unit_married", period) - return surviving_spouse_head & has_child_dependents & ~married + return surviving_spouse_head & has_qualifying_child & ~married From 5661605b8873a1fb4ebb80c316ccf3db92d9fd2a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 09:16:44 -0400 Subject: [PATCH 2/3] Judge each dependent's parent ids separately for surviving spouse status Review of bd9545e83e found that switching to parent ids for a whole household removed surviving spouse status from a real child whose own ids were unknown, whenever the head or a housemate had any parent id. A parent id of 0 is unknown, so it cannot show that the filer is not the parent. Each dependent is now judged on its own ids: a dependent is the head's child when an id names the head, and is not when two different parents are named and neither is the head, when an id names another household member, or when the head's own ids name the dependent as the head's parent or grandparent. Otherwise the relationship flags decide, as before. Adds YAML regressions for each case the review reproduced, a sibling and a two-generation ancestor case, and widens the property grid to ten relationship setups. Fixes the grid's has_dependent field, which counted the four units without a dependent as having one. Co-Authored-By: Claude Opus 5.5 --- ...urviving-spouse-gross-income-test.fixed.md | 2 +- .../demographic/tax_unit/filing_status.yaml | 27 +++ .../tax_unit/surviving_spouse_eligible.yaml | 206 ++++++++++++++++++ ...st_surviving_spouse_eligible_properties.py | 100 ++++++--- .../tax_unit/surviving_spouse_eligible.py | 59 +++-- 5 files changed, 342 insertions(+), 52 deletions(-) diff --git a/changelog.d/surviving-spouse-gross-income-test.fixed.md b/changelog.d/surviving-spouse-gross-income-test.fixed.md index 16da67c45d6..4f781a86a04 100644 --- a/changelog.d/surviving-spouse-gross-income-test.fixed.md +++ b/changelog.d/surviving-spouse-gross-income-test.fixed.md @@ -1 +1 @@ -Stop applying the qualifying relative gross income test to surviving spouse filing status, which IRC 2(a)(1)(B) disregards, and count only a dependent son, daughter or stepchild: one whose parent id names the filer, or, in a household without parent ids, one not marked as the filer's parent, grandparent or an unrelated person. +Stop applying the qualifying relative gross income test to surviving spouse filing status, which IRC 2(a)(1)(B) disregards, and count only a dependent son, daughter or stepchild: one whose parent id names the filer, or, when the parent ids leave it open, one not marked as the filer's parent, grandparent or an unrelated person. diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml index fafce81f78d..2c3f50fec24 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/filing_status.yaml @@ -333,3 +333,30 @@ members: [head, mother, grandchild] output: filing_status: [HEAD_OF_HOUSEHOLD, SINGLE] + +- name: Widow whose own parent id is set still files as surviving spouse with a dependent child without ids. + period: 2025 + input: + people: + head: + person_id: 1 + parent_1_id: 90 + age: 67 + is_surviving_spouse: true + is_tax_unit_head: true + adult_child: + person_id: 2 + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 3_000 + tax_units: + tax_unit: + members: [head, adult_child] + households: + household: + members: [head, adult_child] + output: + filing_status: SURVIVING_SPOUSE + basic_standard_deduction: 31_500 diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml index 30a01c382b4..c9217e27f6c 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml @@ -354,3 +354,209 @@ members: [head, mother] output: surviving_spouse_eligible: false + +# A parent id of 0 is unknown, not evidence that the filer is not the parent. +- name: Head's own parent id does not stop a dependent child without ids from qualifying. + period: 2025 + input: + people: + head: + person_id: 1 + # Names the head's absent parent. + parent_1_id: 90 + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + person_id: 2 + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 3_000 + tax_units: + tax_unit: + members: [head, adult_child] + households: + household: + members: [head, adult_child] + output: + surviving_spouse_eligible: true + +- name: Housemate's parent id in another tax unit does not stop a dependent child without ids from qualifying. + period: 2025 + input: + people: + head: + person_id: 1 + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + person_id: 2 + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 3_000 + housemate: + person_id: 3 + parent_1_id: 90 + age: 40 + is_tax_unit_head: true + is_tax_unit_spouse: false + tax_units: + head_unit: + members: [head, adult_child] + housemate_unit: + members: [housemate] + households: + household: + members: [head, adult_child, housemate] + output: + surviving_spouse_eligible: [true, false] + +- name: Dependent child naming only the deceased parent, with the other id unknown, qualifies. + period: 2025 + input: + people: + head: + person_id: 1 + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + person_id: 2 + # The deceased spouse, who resolves to no household member. + parent_1_id: 91 + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 30_000 + tax_units: + tax_unit: + members: [head, adult_child] + households: + household: + members: [head, adult_child] + output: + surviving_spouse_eligible: true + +- name: Dependent child whose second parent id names the head qualifies. + period: 2025 + input: + people: + head: + person_id: 1 + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + adult_child: + person_id: 2 + parent_1_id: 91 + parent_2_id: 1 + age: 29 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 30_000 + tax_units: + tax_unit: + members: [head, adult_child] + households: + household: + members: [head, adult_child] + output: + surviving_spouse_eligible: true + +- name: Dependent whose two parent ids name other people does not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + age: 67 + is_tax_unit_head: true + is_surviving_spouse: true + nephew: + person_id: 2 + parent_1_id: 91 + parent_2_id: 92 + age: 15 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, nephew] + households: + household: + members: [head, nephew] + output: + surviving_spouse_eligible: false + +- name: Dependent sibling who shares a co-resident parent with the head does not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + parent_1_id: 3 + age: 45 + is_tax_unit_head: true + is_surviving_spouse: true + sibling: + person_id: 2 + parent_1_id: 3 + age: 30 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + mother: + person_id: 3 + age: 70 + is_tax_unit_head: true + is_tax_unit_spouse: false + tax_units: + head_unit: + members: [head, sibling] + mother_unit: + members: [mother] + households: + household: + members: [head, sibling, mother] + output: + surviving_spouse_eligible: [false, false] + +- name: Dependent parent and grandparent named through the head's own parent ids do not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + parent_1_id: 2 + age: 50 + is_tax_unit_head: true + is_surviving_spouse: true + mother: + person_id: 2 + parent_1_id: 3 + age: 75 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + grandmother: + person_id: 3 + age: 95 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, mother, grandmother] + households: + household: + members: [head, mother, grandmother] + output: + surviving_spouse_eligible: false diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py index cb3c2d01c8c..3278201ceda 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py @@ -1,10 +1,13 @@ """Exhaustive property checks for surviving spouse filing status. -YAML cases check one household each. These tests build every combination -on the grid below as its own tax unit in one vectorized simulation and -check properties that must hold for all of them: eligibility never depends -on the dependent's income, never comes from a non-child dependent, and adds -to the previous rule only the units it rejected for the gross income test. +The YAML cases check named households. These tests check properties that +must hold for every combination on the grid below, which YAML cannot state: +eligibility never changes with the dependent's income (units grouped by +everything but income), matches an independent reference rule written from +the grid's relationship labels, and differs from the previous rule (read from +tax_unit_child_dependents) only for children who fail the gross income test. +Every combination is its own tax unit in one vectorized simulation built on +the shared model. IRC 2(a)(1)(B) requires a dependent son, stepson, daughter or stepdaughter, with dependency determined without regard to IRC 152(b)(1), (b)(2) and @@ -28,20 +31,28 @@ AGES = [10, 29, 88] INCOMES = [0, 3_000, EXEMPTION_AMOUNT - 1, EXEMPTION_AMOUNT, 6_238, 50_000] DISABLED = [False, True] -# Households without parent ids: an unmarked dependent, or one marked as a -# parent, grandparent or unrelated person. Households with parent ids: the -# dependent's id names the head, names an absent parent, or is unknown -# while the head's own id is set. -RELATIONSHIPS = [ - "unmarked", - "parent", - "grandparent", - "unrelated", - "linked_to_head", - "linked_elsewhere", - "unlinked_in_linked_household", -] -CHILD = {"unmarked", "linked_to_head"} +# How the dependent's relationship to the head is described. A parent id of +# 0 is unknown. +RELATIONSHIPS = { + # No parent ids and no relationship flags. + "unmarked": True, + # Relationship flags without parent ids. + "parent": False, + "grandparent": False, + "unrelated": False, + # The dependent's first id names the head. + "names_head": True, + # The dependent's first id names an absent parent; the second is unknown. + "names_absent_parent_only": True, + # The dependent's ids name two absent parents. + "names_two_absent_parents": False, + # The dependent's first id names another (unrelated) tax unit member. + "names_other_member": False, + # The head's own id names an absent parent; the dependent has no ids. + "head_has_ids": True, + # The head's own id names the dependent as the head's parent. + "named_as_head_parent": False, +} UNITS = [ dict( @@ -68,8 +79,8 @@ def _situation(): year = str(PERIOD) people, tax_units, marital_units = {}, {}, {} for i, unit in enumerate(UNITS): - head, spouse, dependent = (f"{role}_{i}" for role in ("h", "s", "d")) - head_id = 3 * i + 1 + head, spouse, dependent, other = (f"{r}_{i}" for r in ("h", "s", "d", "o")) + head_id, spouse_id, dependent_id, other_id = (4 * i + k for k in (1, 2, 3, 4)) members = [head] people[head] = { "person_id": {year: head_id}, @@ -81,7 +92,7 @@ def _situation(): if unit["married"]: members.append(spouse) people[spouse] = { - "person_id": {year: head_id + 1}, + "person_id": {year: spouse_id}, "age": {year: 65}, "is_tax_unit_head": {year: False}, "is_tax_unit_spouse": {year: True}, @@ -90,13 +101,16 @@ def _situation(): relationship = unit["relationship"] if relationship is not None: members.append(dependent) - parent_1_id = { - "linked_to_head": head_id, - "linked_elsewhere": ABSENT_ID + i, - }.get(relationship, 0) + parent_ids = { + "names_head": (head_id, 0), + "names_absent_parent_only": (ABSENT_ID + 2 * i, 0), + "names_two_absent_parents": (ABSENT_ID + 2 * i, ABSENT_ID + 2 * i + 1), + "names_other_member": (other_id, 0), + }.get(relationship, (0, 0)) people[dependent] = { - "person_id": {year: head_id + 2}, - "parent_1_id": {year: parent_1_id}, + "person_id": {year: dependent_id}, + "parent_1_id": {year: parent_ids[0]}, + "parent_2_id": {year: parent_ids[1]}, "age": {year: unit["age"]}, "is_tax_unit_head": {year: False}, "is_tax_unit_spouse": {year: False}, @@ -109,9 +123,24 @@ def _situation(): }, "is_related_to_head_or_spouse": {year: relationship != "unrelated"}, } - if relationship == "unlinked_in_linked_household": - people[head]["parent_1_id"] = {year: ABSENT_ID + i} marital_units[f"dm_{i}"] = {"members": [dependent]} + if relationship == "head_has_ids": + people[head]["parent_1_id"] = {year: ABSENT_ID + 2 * i} + if relationship == "named_as_head_parent": + people[head]["parent_1_id"] = {year: dependent_id} + if relationship == "names_other_member": + # The dependent's parent, also a dependent of the head but + # marked unrelated, so never the head's child. + members.append(other) + people[other] = { + "person_id": {year: other_id}, + "age": {year: 40}, + "is_tax_unit_head": {year: False}, + "is_tax_unit_spouse": {year: False}, + "is_tax_unit_dependent": {year: True}, + "is_related_to_head_or_spouse": {year: False}, + } + marital_units[f"om_{i}"] = {"members": [other]} tax_units[f"t_{i}"] = {"members": members} def groups(): @@ -140,7 +169,6 @@ def grid(): def calc(variable): return np.asarray(sim.calculate(variable, PERIOD)) - relationship = _field("relationship", "") return { "eligible": calc("surviving_spouse_eligible"), "hoh": calc("head_of_household_eligible"), @@ -148,8 +176,10 @@ def calc(variable): "child_dependents": calc("tax_unit_child_dependents"), "surviving": _field("surviving", False), "married": _field("married", False), - "has_dependent": relationship != "", - "is_child": np.isin(relationship, list(CHILD)), + "has_dependent": np.array([u["relationship"] is not None for u in UNITS]), + "is_child": np.array( + [RELATIONSHIPS.get(u["relationship"], False) for u in UNITS] + ), "age": _field("age", -1), "income": _field("income", -1), "disabled": _field("disabled", False), @@ -169,8 +199,7 @@ def test_dependent_income_never_changes_eligibility(grid): if unit["relationship"] is not None: key = tuple(value for name, value in unit.items() if name != "income") groups.setdefault(key, set()).add(bool(grid["eligible"][i])) - with_dependent = sum(unit["relationship"] is not None for unit in UNITS) - assert len(groups) * len(INCOMES) == with_dependent + assert len(groups) * len(INCOMES) == grid["has_dependent"].sum() assert all(len(values) == 1 for values in groups.values()) @@ -183,6 +212,7 @@ def test_non_child_dependents_never_qualify(grid): def test_eligibility_requires_unmarried_surviving_spouse_with_dependent(grid): eligible = grid["eligible"] assert eligible.any() + assert (~grid["has_dependent"]).sum() == len(SURVIVING_SPOUSE) * len(MARRIED) assert grid["surviving"][eligible].all() assert not grid["married"][eligible].any() assert grid["has_dependent"][eligible].all() diff --git a/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py b/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py index f4554b04eac..71c171114a5 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py +++ b/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.household.demographic.person._parent_links import ( - household_has_parent_ids, + co_resident_parent_indices, tax_unit_parent_indices, ) @@ -16,11 +16,17 @@ class surviving_spouse_eligible(Variable): "152(b)(1), (b)(2) and (d)(1)(B). The qualifying relative gross " "income test of 152(d)(1)(B) does not apply, so an adult child with " "gross income at or above the exemption amount still qualifies the " - "filer. A dependent is the filer's child when their parent_1_id or " - "parent_2_id names the tax unit head; these ids cover natural, " - "adoptive and step parents. In a household with no parent ids, every " - "dependent counts as a child unless marked as a parent or grandparent " - "of the filer or as unrelated to the filer." + "filer. Each dependent's parent_1_id and parent_2_id (natural, " + "adoptive or step parents) decide the relationship where they " + "settle it. A dependent is the head's child when either id names the " + "head. A dependent is not when two different parents are named and " + "neither is the head, when an id names another household member, or " + "when the head's own parent ids name the dependent as the head's " + "parent or grandparent. Otherwise, including when both ids are " + "unknown (0), a dependent counts as the head's child unless marked " + "as a parent or grandparent of the filer or as unrelated to the " + "filer. Without parent ids the model cannot tell a child from a " + "sibling, niece, nephew or grandchild." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/2#a_1_B", @@ -38,21 +44,42 @@ def formula(tax_unit, period, parameters): # disabled dependent and qualifying relative paths of # is_child_dependent together cover every tax unit dependent. is_dependent = person("is_tax_unit_dependent", period) + # IRC 2(a)(1)(B)(i) limits the dependent to a son, stepson, daughter # or stepdaughter of the filer. - names_head = np.zeros(person.count, dtype=bool) - for parent in tax_unit_parent_indices(person, period): - names_head |= (parent >= 0) & is_head[np.maximum(parent, 0)] - marked_not_child = ( - person("is_parent_of_filer_or_spouse", period) + unit = person.tax_unit.reference_entity.members_entity_id + + def is_unit_head(row): + safe = np.maximum(row, 0) + return (row >= 0) & is_head[safe] & (unit[safe] == unit) + + unit_parents = tax_unit_parent_indices(person, period) + names_head = is_unit_head(unit_parents[0]) | is_unit_head(unit_parents[1]) + names_other_member = np.zeros(person.count, dtype=bool) + for parent in co_resident_parent_indices(person, period): + names_other_member |= (parent >= 0) & ~is_unit_head(parent) + parent_1 = person("parent_1_id", period) + parent_2 = person("parent_2_id", period) + names_two_parents = (parent_1 != 0) & (parent_2 != 0) & (parent_1 != parent_2) + # The head's own ids can name a dependent as the head's parent, and + # that parent's ids as the head's grandparent. + ancestor_of_head = np.zeros(person.count, dtype=bool) + generation = is_head + for _ in range(2): + rows = np.concatenate([slot[generation] for slot in unit_parents]) + rows = rows[rows >= 0] + ancestor_of_head[rows] = True + generation = np.zeros(person.count, dtype=bool) + generation[rows] = True + not_child = ( + names_two_parents + | names_other_member + | ancestor_of_head + | person("is_parent_of_filer_or_spouse", period) | person("is_grandparent_of_filer_or_spouse", period) | ~person("is_related_to_head_or_spouse", period) ) - is_child = where( - household_has_parent_ids(person, period), - names_head, - ~marked_not_child, - ) + is_child = names_head | ~not_child has_qualifying_child = tax_unit.any(is_dependent & is_child) married = tax_unit("tax_unit_married", period) return surviving_spouse_head & has_qualifying_child & ~married From 7ae2a26a0bedef5341e012fd29efd03964b6f8f9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 10:49:47 -0400 Subject: [PATCH 3/3] Rule out surviving spouse dependents only on relationship evidence Review of 5661605b88 found two remaining defects: - Naming another parent was treated as proof that the head is not a parent. A widower's daughter who named only her co-resident mother, and a stepchild naming both biological parents, lost the status. A parent id of 0 is unknown and two ids cannot list every step parent, so neither settles the relationship. - Ancestor links stopped at tax unit boundaries, so a grandmother linked through a mother filing separately still qualified the head. A dependent is still the head's child when an id names the head. The ids now rule a dependent out only when they show another relationship: the head's parent or grandparent, a sibling (a shared parent id), a grandchild (the dependent's parent names the head) or a niece or nephew (the dependent's parent shares a parent id with the head). Parents are resolved among both household and tax unit members, and shared ids identify siblings even when the parent lives elsewhere. Adds YAML for both review cases, an absent-parent sibling and a nephew, and widens the property grid to thirteen relationship setups. Co-Authored-By: Claude Opus 5.5 --- ...urviving-spouse-gross-income-test.fixed.md | 2 +- .../tax_unit/surviving_spouse_eligible.yaml | 136 +++++++++++++++- ...st_surviving_spouse_eligible_properties.py | 148 ++++++++++-------- .../tax_unit/surviving_spouse_eligible.py | 99 +++++++----- 4 files changed, 280 insertions(+), 105 deletions(-) diff --git a/changelog.d/surviving-spouse-gross-income-test.fixed.md b/changelog.d/surviving-spouse-gross-income-test.fixed.md index 4f781a86a04..dee3be0c8fd 100644 --- a/changelog.d/surviving-spouse-gross-income-test.fixed.md +++ b/changelog.d/surviving-spouse-gross-income-test.fixed.md @@ -1 +1 @@ -Stop applying the qualifying relative gross income test to surviving spouse filing status, which IRC 2(a)(1)(B) disregards, and count only a dependent son, daughter or stepchild: one whose parent id names the filer, or, when the parent ids leave it open, one not marked as the filer's parent, grandparent or an unrelated person. +Stop applying the qualifying relative gross income test to surviving spouse filing status, which IRC 2(a)(1)(B) disregards, and stop counting a dependent whose parent ids or relationship flags show they are not the filer's son, daughter or stepchild. diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml index c9217e27f6c..9f0ef280682 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/surviving_spouse_eligible.yaml @@ -470,7 +470,9 @@ output: surviving_spouse_eligible: true -- name: Dependent whose two parent ids name other people does not qualify. +# Two parent ids can name both biological parents and leave out a step +# parent, so naming two other parents does not rule a dependent out. +- name: Stepchild whose two parent ids name absent biological parents qualifies. period: 2025 input: people: @@ -479,8 +481,9 @@ age: 67 is_tax_unit_head: true is_surviving_spouse: true - nephew: + stepchild: person_id: 2 + # The deceased spouse and the child's other biological parent. parent_1_id: 91 parent_2_id: 92 age: 15 @@ -489,10 +492,103 @@ is_tax_unit_dependent: true tax_units: tax_unit: - members: [head, nephew] + members: [head, stepchild] + households: + household: + members: [head, stepchild] + output: + surviving_spouse_eligible: true + +- name: Daughter naming only her co-resident mother, with the other id unknown, qualifies her widowed father. + period: 2025 + input: + people: + head: + person_id: 1 + age: 45 + is_tax_unit_head: true + is_surviving_spouse: true + daughter: + person_id: 2 + parent_1_id: 3 + age: 15 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + mother: + person_id: 3 + age: 40 + is_tax_unit_head: true + is_tax_unit_spouse: false + tax_units: + head_unit: + members: [head, daughter] + mother_unit: + members: [mother] households: household: + members: [head, daughter, mother] + output: + surviving_spouse_eligible: [true, false] + +- name: Dependent nephew whose parent shares a parent id with the head does not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + # The head's absent mother. + parent_1_id: 90 + age: 50 + is_tax_unit_head: true + is_surviving_spouse: true + nephew: + person_id: 2 + parent_1_id: 3 + age: 15 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + sister: + person_id: 3 + parent_1_id: 90 + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + tax_units: + head_unit: members: [head, nephew] + sister_unit: + members: [sister] + households: + household: + members: [head, nephew, sister] + output: + surviving_spouse_eligible: [false, false] + +- name: Dependent sibling who shares an absent parent id with the head does not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + parent_1_id: 90 + age: 45 + is_tax_unit_head: true + is_surviving_spouse: true + sibling: + person_id: 2 + parent_1_id: 90 + age: 30 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, sibling] + households: + household: + members: [head, sibling] output: surviving_spouse_eligible: false @@ -560,3 +656,37 @@ members: [head, mother, grandmother] output: surviving_spouse_eligible: false + +- name: Dependent grandmother linked through a mother in another tax unit does not qualify. + period: 2025 + input: + people: + head: + person_id: 1 + parent_1_id: 2 + age: 45 + is_tax_unit_head: true + is_surviving_spouse: true + mother: + person_id: 2 + parent_1_id: 3 + age: 75 + is_tax_unit_head: true + is_tax_unit_spouse: false + grandmother: + person_id: 3 + age: 95 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + taxable_pension_income: 20_000 + tax_units: + head_unit: + members: [head, grandmother] + mother_unit: + members: [mother] + households: + household: + members: [head, mother, grandmother] + output: + surviving_spouse_eligible: [false, false] diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py index 3278201ceda..71cb8f97c72 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/test_surviving_spouse_eligible_properties.py @@ -1,13 +1,14 @@ """Exhaustive property checks for surviving spouse filing status. -The YAML cases check named households. These tests check properties that -must hold for every combination on the grid below, which YAML cannot state: -eligibility never changes with the dependent's income (units grouped by -everything but income), matches an independent reference rule written from -the grid's relationship labels, and differs from the previous rule (read from +The relationship examples live in surviving_spouse_eligible.yaml and +filing_status.yaml. These tests check invariants over every combination on +the grid below, which named YAML cases cannot state: eligibility never +changes with the dependent's income (units grouped by everything but +income), matches a reference rule written from the grid's relationship +labels, and differs from the previous rule (read from tax_unit_child_dependents) only for children who fail the gross income test. -Every combination is its own tax unit in one vectorized simulation built on -the shared model. +Every combination is its own tax unit in one vectorized simulation on the +shared model, so mixed populations are exercised too. IRC 2(a)(1)(B) requires a dependent son, stepson, daughter or stepdaughter, with dependency determined without regard to IRC 152(b)(1), (b)(2) and @@ -31,8 +32,9 @@ AGES = [10, 29, 88] INCOMES = [0, 3_000, EXEMPTION_AMOUNT - 1, EXEMPTION_AMOUNT, 6_238, 50_000] DISABLED = [False, True] -# How the dependent's relationship to the head is described. A parent id of -# 0 is unknown. +# How the dependent's relationship to the head is described, and whether it +# makes the dependent the head's child. A parent id of 0 is unknown. "Other" +# is a relative who lives in the household and files in their own tax unit. RELATIONSHIPS = { # No parent ids and no relationship flags. "unmarked": True, @@ -44,15 +46,23 @@ "names_head": True, # The dependent's first id names an absent parent; the second is unknown. "names_absent_parent_only": True, - # The dependent's ids name two absent parents. - "names_two_absent_parents": False, - # The dependent's first id names another (unrelated) tax unit member. - "names_other_member": False, + # Two absent parents named: the head can still be a step parent. + "names_two_absent_parents": True, + # The dependent names the other, who has no ids: the head can still be + # the second parent. + "names_other": True, # The head's own id names an absent parent; the dependent has no ids. "head_has_ids": True, - # The head's own id names the dependent as the head's parent. + # The head's own id names the dependent. "named_as_head_parent": False, + # The head and the dependent name the same absent parent. + "shares_parent_with_head": False, + # The dependent names the other, whose id names the head. + "parent_names_head": False, + # The dependent names the other, who shares an absent parent with the head. + "parent_shares_parent_with_head": False, } +NEEDS_OTHER = {"names_other", "parent_names_head", "parent_shares_parent_with_head"} UNITS = [ dict( @@ -77,10 +87,11 @@ def _situation(): year = str(PERIOD) - people, tax_units, marital_units = {}, {}, {} + people, tax_units, marital_units, households = {}, {}, {}, {} for i, unit in enumerate(UNITS): head, spouse, dependent, other = (f"{r}_{i}" for r in ("h", "s", "d", "o")) head_id, spouse_id, dependent_id, other_id = (4 * i + k for k in (1, 2, 3, 4)) + absent_id, second_absent_id = ABSENT_ID + 2 * i, ABSENT_ID + 2 * i + 1 members = [head] people[head] = { "person_id": {year: head_id}, @@ -98,63 +109,68 @@ def _situation(): "is_tax_unit_spouse": {year: True}, } marital_units[f"m_{i}"] = {"members": members[:]} + tax_units[f"t_{i}"] = {"members": members} + households[f"g_{i}"] = {"members": members[:]} relationship = unit["relationship"] - if relationship is not None: - members.append(dependent) - parent_ids = { - "names_head": (head_id, 0), - "names_absent_parent_only": (ABSENT_ID + 2 * i, 0), - "names_two_absent_parents": (ABSENT_ID + 2 * i, ABSENT_ID + 2 * i + 1), - "names_other_member": (other_id, 0), - }.get(relationship, (0, 0)) - people[dependent] = { - "person_id": {year: dependent_id}, - "parent_1_id": {year: parent_ids[0]}, - "parent_2_id": {year: parent_ids[1]}, - "age": {year: unit["age"]}, - "is_tax_unit_head": {year: False}, + if relationship is None: + continue + members.append(dependent) + households[f"g_{i}"]["members"].append(dependent) + parent_ids = { + "names_head": (head_id, 0), + "names_absent_parent_only": (absent_id, 0), + "names_two_absent_parents": (absent_id, second_absent_id), + "shares_parent_with_head": (absent_id, 0), + }.get(relationship, (other_id, 0) if relationship in NEEDS_OTHER else (0, 0)) + people[dependent] = { + "person_id": {year: dependent_id}, + "parent_1_id": {year: parent_ids[0]}, + "parent_2_id": {year: parent_ids[1]}, + "age": {year: unit["age"]}, + "is_tax_unit_head": {year: False}, + "is_tax_unit_spouse": {year: False}, + "is_tax_unit_dependent": {year: True}, + "employment_income": {year: unit["income"]}, + "is_permanently_and_totally_disabled": {year: unit["disabled"]}, + "is_parent_of_filer_or_spouse": {year: relationship == "parent"}, + "is_grandparent_of_filer_or_spouse": {year: relationship == "grandparent"}, + "is_related_to_head_or_spouse": {year: relationship != "unrelated"}, + } + marital_units[f"dm_{i}"] = {"members": [dependent]} + head_parent_id = { + "head_has_ids": absent_id, + "named_as_head_parent": dependent_id, + "shares_parent_with_head": absent_id, + "parent_shares_parent_with_head": absent_id, + }.get(relationship) + if head_parent_id is not None: + people[head]["parent_1_id"] = {year: head_parent_id} + if relationship in NEEDS_OTHER: + other_parent_id = { + "parent_names_head": head_id, + "parent_shares_parent_with_head": absent_id, + }.get(relationship, 0) + people[other] = { + "person_id": {year: other_id}, + "parent_1_id": {year: other_parent_id}, + "age": {year: 40}, + "is_tax_unit_head": {year: True}, "is_tax_unit_spouse": {year: False}, - "is_tax_unit_dependent": {year: True}, - "employment_income": {year: unit["income"]}, - "is_permanently_and_totally_disabled": {year: unit["disabled"]}, - "is_parent_of_filer_or_spouse": {year: relationship == "parent"}, - "is_grandparent_of_filer_or_spouse": { - year: relationship == "grandparent" - }, - "is_related_to_head_or_spouse": {year: relationship != "unrelated"}, } - marital_units[f"dm_{i}"] = {"members": [dependent]} - if relationship == "head_has_ids": - people[head]["parent_1_id"] = {year: ABSENT_ID + 2 * i} - if relationship == "named_as_head_parent": - people[head]["parent_1_id"] = {year: dependent_id} - if relationship == "names_other_member": - # The dependent's parent, also a dependent of the head but - # marked unrelated, so never the head's child. - members.append(other) - people[other] = { - "person_id": {year: other_id}, - "age": {year: 40}, - "is_tax_unit_head": {year: False}, - "is_tax_unit_spouse": {year: False}, - "is_tax_unit_dependent": {year: True}, - "is_related_to_head_or_spouse": {year: False}, - } - marital_units[f"om_{i}"] = {"members": [other]} - tax_units[f"t_{i}"] = {"members": members} + marital_units[f"om_{i}"] = {"members": [other]} + tax_units[f"ot_{i}"] = {"members": [other]} + households[f"g_{i}"]["members"].append(other) - def groups(): - return { - name: {"members": unit["members"][:]} for name, unit in tax_units.items() - } + def copy(groups): + return {name: {"members": g["members"][:]} for name, g in groups.items()} return { "people": people, "tax_units": tax_units, "marital_units": marital_units, - "spm_units": groups(), - "families": groups(), - "households": groups(), + "spm_units": copy(households), + "families": copy(households), + "households": households, } @@ -165,14 +181,16 @@ def _field(name, default): @pytest.fixture(scope="module") def grid(): sim = Simulation(situation=_situation()) + position = {name: i for i, name in enumerate(sim.populations["tax_unit"].ids)} + order = [position[f"t_{i}"] for i in range(len(UNITS))] def calc(variable): - return np.asarray(sim.calculate(variable, PERIOD)) + return np.asarray(sim.calculate(variable, PERIOD))[order] return { "eligible": calc("surviving_spouse_eligible"), "hoh": calc("head_of_household_eligible"), - "filing_status": sim.calculate("filing_status", PERIOD).decode_to_str(), + "filing_status": sim.calculate("filing_status", PERIOD).decode_to_str()[order], "child_dependents": calc("tax_unit_child_dependents"), "surviving": _field("surviving", False), "married": _field("married", False), diff --git a/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py b/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py index 71c171114a5..abf7e783058 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py +++ b/policyengine_us/variables/household/demographic/tax_unit/surviving_spouse_eligible.py @@ -16,17 +16,18 @@ class surviving_spouse_eligible(Variable): "152(b)(1), (b)(2) and (d)(1)(B). The qualifying relative gross " "income test of 152(d)(1)(B) does not apply, so an adult child with " "gross income at or above the exemption amount still qualifies the " - "filer. Each dependent's parent_1_id and parent_2_id (natural, " - "adoptive or step parents) decide the relationship where they " - "settle it. A dependent is the head's child when either id names the " - "head. A dependent is not when two different parents are named and " - "neither is the head, when an id names another household member, or " - "when the head's own parent ids name the dependent as the head's " - "parent or grandparent. Otherwise, including when both ids are " - "unknown (0), a dependent counts as the head's child unless marked " - "as a parent or grandparent of the filer or as unrelated to the " - "filer. Without parent ids the model cannot tell a child from a " - "sibling, niece, nephew or grandchild." + "filer. A dependent is the head's child when their parent_1_id or " + "parent_2_id (natural, adoptive or step parents) names the tax unit " + "head. Because a parent id of 0 is unknown and a person can have more " + "parents than the two ids hold, naming other parents does not rule a " + "dependent out. The ids rule a dependent out only when they show " + "another relationship to the head: the head's parent or grandparent, " + "a sibling (a shared parent id), a grandchild (the dependent's parent " + "names the head) or a niece or nephew (the dependent's parent shares " + "a parent id with the head). Otherwise a dependent counts as the " + "head's child unless marked as a parent or grandparent of the filer " + "or as unrelated to the filer. Without such ids the model cannot tell " + "a child from a sibling, niece, nephew or grandchild." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/2#a_1_B", @@ -46,35 +47,61 @@ def formula(tax_unit, period, parameters): is_dependent = person("is_tax_unit_dependent", period) # IRC 2(a)(1)(B)(i) limits the dependent to a son, stepson, daughter - # or stepdaughter of the filer. + # or stepdaughter of the filer. Each person's parents, as rows: both + # id slots resolved among household members and among tax unit + # members, -1 when unresolved. + rows = np.arange(person.count) + parent_rows = [ + *co_resident_parent_indices(person, period), + *tax_unit_parent_indices(person, period), + ] + parent_ids = [person("parent_1_id", period), person("parent_2_id", period)] + + def parents_of(of): + safe = np.maximum(of, 0) + return [where(of >= 0, parent[safe], -1) for parent in parent_rows] + + def ids_of(of): + safe = np.maximum(of, 0) + return [where(of >= 0, ids[safe], 0) for ids in parent_ids] + + def shares_id(ids, other_ids): + shared = np.zeros(person.count, dtype=bool) + for a in ids: + for b in other_ids: + shared |= (a != 0) & (a == b) + return shared + + # Row of the head of each person's tax unit. unit = person.tax_unit.reference_entity.members_entity_id + head_by_unit = np.full(tax_unit.count, -1) + head_by_unit[unit[is_head]] = rows[is_head] + head = head_by_unit[unit] + head_parents = parents_of(head) + head_ids = ids_of(head) - def is_unit_head(row): - safe = np.maximum(row, 0) - return (row >= 0) & is_head[safe] & (unit[safe] == unit) + names_head = np.zeros(person.count, dtype=bool) + is_head_grandchild = np.zeros(person.count, dtype=bool) + is_head_niece_or_nephew = np.zeros(person.count, dtype=bool) + for parent in parent_rows: + names_head |= (head >= 0) & (parent == head) + for grandparent in parents_of(parent): + is_head_grandchild |= (head >= 0) & (grandparent == head) + is_head_niece_or_nephew |= (parent >= 0) & shares_id( + ids_of(parent), head_ids + ) + is_head_ancestor = np.zeros(person.count, dtype=bool) + for parent in head_parents: + is_head_ancestor |= parent == rows + for grandparent in parents_of(parent): + is_head_ancestor |= grandparent == rows + is_head_sibling = shares_id(parent_ids, head_ids) - unit_parents = tax_unit_parent_indices(person, period) - names_head = is_unit_head(unit_parents[0]) | is_unit_head(unit_parents[1]) - names_other_member = np.zeros(person.count, dtype=bool) - for parent in co_resident_parent_indices(person, period): - names_other_member |= (parent >= 0) & ~is_unit_head(parent) - parent_1 = person("parent_1_id", period) - parent_2 = person("parent_2_id", period) - names_two_parents = (parent_1 != 0) & (parent_2 != 0) & (parent_1 != parent_2) - # The head's own ids can name a dependent as the head's parent, and - # that parent's ids as the head's grandparent. - ancestor_of_head = np.zeros(person.count, dtype=bool) - generation = is_head - for _ in range(2): - rows = np.concatenate([slot[generation] for slot in unit_parents]) - rows = rows[rows >= 0] - ancestor_of_head[rows] = True - generation = np.zeros(person.count, dtype=bool) - generation[rows] = True not_child = ( - names_two_parents - | names_other_member - | ancestor_of_head + is_head_ancestor + | is_head_sibling + | is_head_grandchild + | is_head_niece_or_nephew | person("is_parent_of_filer_or_spouse", period) | person("is_grandparent_of_filer_or_spouse", period) | ~person("is_related_to_head_or_spouse", period)