From 0e7a5f7b64470c71e1668f1bbba3eb6d4a46bbee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 13:38:38 -0400 Subject: [PATCH 1/2] Ignore a dependent's separation when inferring the tax unit spouse is_tax_unit_spouse dropped the spouse whenever any member of the tax unit had is_separated set, so a separated adult dependent (or any other separated member) turned a married couple's return into head of household or single. Separation under 26 U.S.C. 7703 concerns the filer's own marriage, and filing_status and head_of_household_eligible already limit it to the head and spouse. The spouse is now dropped only when the head or the would-be spouse is separated. Co-Authored-By: Claude Opus 5.5 --- ...ouse-ignores-dependent-separation.fixed.md | 1 + .../demographic/tax_unit/filing_status.yaml | 39 ++++++ .../tax_unit/is_tax_unit_spouse.yaml | 122 ++++++++++++++++++ .../tax_unit/is_tax_unit_spouse.py | 10 +- 4 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 changelog.d/spouse-ignores-dependent-separation.fixed.md diff --git a/changelog.d/spouse-ignores-dependent-separation.fixed.md b/changelog.d/spouse-ignores-dependent-separation.fixed.md new file mode 100644 index 00000000000..a63a6b5e907 --- /dev/null +++ b/changelog.d/spouse-ignores-dependent-separation.fixed.md @@ -0,0 +1 @@ +A separated dependent or other member no longer stops the inferred tax unit spouse; only the head's or the spouse's own separation does (26 U.S.C. 7703). 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..dd3c7143202 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,42 @@ output: # Each parent has 5,100 of taxable benefits, not below 4,400. filing_status: SINGLE + +- name: A separated adult dependent does not stop a married couple filing jointly. + period: 2024 + input: + people: + head: + age: 40 + is_tax_unit_dependent: false + employment_income: 80_000 + spouse: + age: 38 + is_tax_unit_dependent: false + dependent: + age: 18 + is_tax_unit_dependent: true + is_separated: true + marital_units: + couple_marital_unit: + members: [head, spouse] + dependent_marital_unit: + members: [dependent] + tax_units: + tax_unit: + members: [head, spouse, dependent] + households: + household: + members: [head, spouse, dependent] + state_code: TX + output: + filing_status: JOINT + tax_unit_married: true + # Rev. Proc. 2023-34: 2024 joint standard deduction is 29,200. + standard_deduction: 29_200 + # 80,000 - 29,200. + taxable_income: 50_800 + # 2024 joint brackets: 10% of 23,200 plus 12% of (50,800 - 23,200). + income_tax_before_credits: 5_632 + # The 18-year-old dependent qualifies for the 500 credit for other dependents. + income_tax: 5_132 diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml index 2bc08f8d0ee..2c250275e04 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml @@ -103,3 +103,125 @@ is_tax_unit_head: [true, false, false, false] is_tax_unit_spouse: [false, true, false, false] tax_unit_dependents: 2 + +- name: A separated adult dependent does not remove the filer's spouse. + period: 2024 + input: + people: + head: + age: 40 + is_tax_unit_dependent: false + spouse: + age: 38 + is_tax_unit_dependent: false + dependent: + age: 18 + is_tax_unit_dependent: true + is_separated: true + tax_units: + tax_unit: + members: [head, spouse, dependent] + output: + is_tax_unit_head: [true, false, false] + is_tax_unit_spouse: [false, true, false] + tax_unit_married: true + +- name: A separated elderly dependent older than the couple does not remove the spouse. + period: 2024 + input: + people: + head: + age: 40 + spouse: + age: 38 + parent: + age: 75 + is_tax_unit_dependent: true + is_separated: true + tax_units: + tax_unit: + members: [head, spouse, parent] + output: + is_tax_unit_spouse: [false, true, false] + +- name: A separated younger adult who is neither head nor spouse does not remove the spouse. + period: 2024 + input: + people: + head: + age: 40 + spouse: + age: 38 + other_adult: + age: 30 + is_separated: true + tax_units: + tax_unit: + members: [head, spouse, other_adult] + output: + is_tax_unit_spouse: [false, true, false] + +- name: A separated head has no spouse in the tax unit. + period: 2024 + input: + people: + head: + age: 40 + is_separated: true + other_adult: + age: 38 + tax_units: + tax_unit: + members: [head, other_adult] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, false] + tax_unit_married: false + +- name: A separated second-oldest adult is not the spouse, and no younger adult takes the role. + period: 2024 + input: + people: + head: + age: 40 + separated_adult: + age: 38 + is_separated: true + younger_adult: + age: 30 + tax_units: + tax_unit: + members: [head, separated_adult, younger_adult] + output: + is_tax_unit_spouse: [false, false, false] + tax_unit_married: false + +- name: Separation only affects the spouse in its own tax unit. + period: 2024 + input: + people: + head_a: + age: 40 + spouse_a: + age: 38 + dependent_a: + age: 20 + is_tax_unit_dependent: true + is_separated: true + head_b: + age: 50 + is_separated: true + adult_b: + age: 45 + tax_units: + tax_unit_a: + members: [head_a, spouse_a, dependent_a] + tax_unit_b: + members: [head_b, adult_b] + households: + household: + members: [head_a, spouse_a, dependent_a, head_b, adult_b] + output: + is_tax_unit_head: [true, false, false, true, false] + is_tax_unit_spouse: [false, true, false, false, false] + tax_unit_married: [true, false] diff --git a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py index 24ccbf62c4c..c3b24a61987 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py +++ b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_spouse.py @@ -12,10 +12,14 @@ class is_tax_unit_spouse(Variable): def formula(person, period, parameters): # Only non-head adults can be spouses, skipping input dependents. - is_separated = person.tax_unit.any(person("is_separated", period)) candidate = head_or_spouse_candidates(person, period) head = person("is_tax_unit_head", period) - eligible = candidate & ~head & ~is_separated tax_unit = person.tax_unit age = person("age", period) - return person.get_rank(tax_unit, -age, eligible) == 0 + spouse = person.get_rank(tax_unit, -age, candidate & ~head) == 0 + # Separation under 26 U.S.C. 7703 concerns the filer's own marriage, so + # only the head's or this spouse's separation means there is no spouse. + # A dependent's or other member's separation does not. + separated = person("is_separated", period) + head_or_spouse_separated = tax_unit.any((head | spouse) & separated) + return spouse & ~head_or_spouse_separated From 5f4778243d8d511a0839fb506d2b71e1af0563c5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 17:21:14 -0400 Subject: [PATCH 2/2] Add spouse-separation property tests and edge cases from review A Hypothesis test (plus a seeded population) draws tax units of one to five people with tied ages, children and separated members, with no role inputs, input dependents or an input head. It checks that there is at most one spouse, never the head or a child; that head and spouse match a pure-Python statement of the rule; and that flipping the separation of anyone but the head and the would-be spouse never changes the spouse. New YAML cases cover an input head with a separated dependent, the all-dependent fallback with a separated third adult, and a younger separated adult beside an older non-separated one. The filing-status case now gives the spouse wages, so it exercises the income main dropped. Co-Authored-By: Claude Opus 5.5 --- ...t_tax_unit_spouse_separation_invariants.py | 187 ++++++++++++++++++ .../demographic/tax_unit/filing_status.yaml | 5 +- .../tax_unit/is_tax_unit_spouse.yaml | 60 ++++++ 3 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 policyengine_us/tests/core/test_tax_unit_spouse_separation_invariants.py diff --git a/policyengine_us/tests/core/test_tax_unit_spouse_separation_invariants.py b/policyengine_us/tests/core/test_tax_unit_spouse_separation_invariants.py new file mode 100644 index 00000000000..ae448f7119d --- /dev/null +++ b/policyengine_us/tests/core/test_tax_unit_spouse_separation_invariants.py @@ -0,0 +1,187 @@ +"""Invariants for the inferred tax unit spouse when members are separated. + +Separation under 26 U.S.C. 7703 concerns the filer's own marriage, so only +the head's or the would-be spouse's `is_separated` can leave a tax unit +without a spouse. The would-be spouse is the oldest head-or-spouse candidate +other than the head; candidates are adults not input as dependents (every +adult when all are). Core's `get_rank` puts equal ages in member order. + +Hypothesis draws batches of tax units of one to five people, with tied ages, +children, separated members and, in turn, no role inputs, input dependent +flags, or an input head. A seeded population of 300 units adds breadth. Each +batch runs as one vectorized simulation that also holds a twin of every unit +in which every member other than the head and the would-be spouse has +`is_separated` flipped. For every tax unit: + +1. Structure: at most one spouse, who is neither the head nor a child. +2. Differential: head and spouse equal a pure-Python statement of the rule. +3. Separation of anyone but the head and the would-be spouse never changes + the spouse: each unit and its twin get the same spouse. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2024 +ADULT_AGE = 18 +AGES = [6, 17, 18, 25, 30, 38, 40, 75] +MODES = ["no_roles", "dependents", "head"] + + +@st.composite +def tax_units(draw): + size = draw(st.integers(1, 5)) + members = [ + { + "age": draw(st.sampled_from(AGES)), + "separated": draw(st.booleans()), + "dependent": draw(st.booleans()), + } + for _ in range(size) + ] + return {"members": members, "head": draw(st.integers(0, size - 1))} + + +def _seeded_units(n=300, seed=20261006): + rng = np.random.default_rng(seed) + units = [] + for _ in range(n): + size = int(rng.integers(1, 6)) + members = [ + { + "age": int(rng.choice(AGES)), + "separated": bool(rng.random() < 0.3), + "dependent": bool(rng.random() < 0.4), + } + for _ in range(size) + ] + units.append({"members": members, "head": int(rng.integers(0, size))}) + return units + + +def _first_oldest(ages, eligible): + """Index of the oldest eligible member, the earlier one on a tie.""" + order = [i for i in range(len(ages)) if eligible[i]] + return min(order, key=lambda i: (-ages[i], i)) if order else None + + +def _reference(unit, mode): + """(head index, spouse index) under the rule in the module docstring.""" + members = unit["members"] + ages = [m["age"] for m in members] + adult = [age >= ADULT_AGE for age in ages] + candidate = adult + if mode != "no_roles": + non_dependent = [a and not m["dependent"] for a, m in zip(adult, members)] + if any(non_dependent): + candidate = non_dependent + if mode == "head": + head = unit["head"] + else: + head = _first_oldest(ages, candidate) + would_be = _first_oldest(ages, [c and i != head for i, c in enumerate(candidate)]) + separated = [m["separated"] for m in members] + if would_be is None or (head is not None and separated[head]): + return head, None + return head, None if separated[would_be] else would_be + + +def _twin(unit, mode): + """The unit with every member but the head and would-be spouse's + separation flipped.""" + members = unit["members"] + head, _ = _reference(unit, mode) + keep = {head} + # The would-be spouse is the spouse the rule finds when nobody is separated. + calm = {**unit, "members": [{**m, "separated": False} for m in members]} + keep.add(_reference(calm, mode)[1]) + flipped = [ + m if i in keep else {**m, "separated": not m["separated"]} + for i, m in enumerate(members) + ] + return {**unit, "members": flipped} + + +def _situation(units, mode): + people, tax_unit_groups, households, marital_units = {}, {}, {}, {} + for u, unit in enumerate(units): + names = [] + for i, member in enumerate(unit["members"]): + name = f"p{u}_{i}" + person = { + "age": {YEAR: member["age"]}, + "is_separated": {YEAR: member["separated"]}, + } + if mode in ("dependents", "head"): + person["is_tax_unit_dependent"] = {YEAR: member["dependent"]} + if mode == "head": + person["is_tax_unit_head"] = {YEAR: i == unit["head"]} + people[name] = person + names.append(name) + marital_units[f"m{u}_{i}"] = {"members": [name]} + tax_unit_groups[f"t{u}"] = {"members": names} + households[f"h{u}"] = {"members": names, "state_code": {YEAR: "TX"}} + return { + "people": people, + "tax_units": tax_unit_groups, + "households": households, + "marital_units": marital_units, + } + + +def _check(units, mode): + batch = units + [_twin(unit, mode) for unit in units] + sim = Simulation(situation=_situation(batch, mode)) + head = np.asarray(sim.calculate("is_tax_unit_head", YEAR), dtype=bool) + spouse = np.asarray(sim.calculate("is_tax_unit_spouse", YEAR), dtype=bool) + start = 0 + spouses = [] + for u, unit in enumerate(batch): + size = len(unit["members"]) + unit_head = head[start : start + size] + unit_spouse = spouse[start : start + size] + ages = [m["age"] for m in unit["members"]] + start += size + + # 1. Structure. + assert unit_spouse.sum() <= 1, (mode, unit) + assert not (unit_spouse & unit_head).any(), (mode, unit) + assert all(age >= ADULT_AGE for age, s in zip(ages, unit_spouse) if s) + + # 2. Differential against the reference rule. + expected_head, expected_spouse = _reference(unit, mode) + assert list(np.flatnonzero(unit_head)) == ( + [] if expected_head is None else [expected_head] + ), (mode, unit, unit_head) + assert list(np.flatnonzero(unit_spouse)) == ( + [] if expected_spouse is None else [expected_spouse] + ), (mode, unit, unit_spouse) + spouses.append(list(np.flatnonzero(unit_spouse))) + + # 3. Each unit and its twin share a spouse. + assert spouses[: len(units)] == spouses[len(units) :] + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(MODES), st.lists(tax_units(), min_size=5, max_size=40)) +def test_tax_unit_spouse_separation_invariants(mode, units): + _check(units, mode) + + +@pytest.mark.parametrize("mode", MODES) +def test_seeded_population(mode): + _check(_seeded_units(), mode) 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 dd3c7143202..01a49bcc019 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 @@ -259,10 +259,11 @@ head: age: 40 is_tax_unit_dependent: false - employment_income: 80_000 + employment_income: 50_000 spouse: age: 38 is_tax_unit_dependent: false + employment_income: 30_000 dependent: age: 18 is_tax_unit_dependent: true @@ -284,7 +285,7 @@ tax_unit_married: true # Rev. Proc. 2023-34: 2024 joint standard deduction is 29,200. standard_deduction: 29_200 - # 80,000 - 29,200. + # Both spouses' wages, 50,000 + 30,000, less 29,200. taxable_income: 50_800 # 2024 joint brackets: 10% of 23,200 plus 12% of (50,800 - 23,200). income_tax_before_credits: 5_632 diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml index 2c250275e04..303fba80b91 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_spouse.yaml @@ -178,6 +178,9 @@ is_tax_unit_spouse: [false, false] tax_unit_married: false +# Which co-resident adult is the filer's spouse is an age-order inference, not a +# rule of law. When the would-be spouse is separated, no younger adult is +# promoted into the role, as before separation was limited to head and spouse. - name: A separated second-oldest adult is not the spouse, and no younger adult takes the role. period: 2024 input: @@ -225,3 +228,60 @@ is_tax_unit_head: [true, false, false, true, false] is_tax_unit_spouse: [false, true, false, false, false] tax_unit_married: [true, false] + +- name: A younger separated adult does not stop the next-oldest adult being the spouse. + period: 2024 + input: + people: + head: + age: 40 + separated_adult: + age: 25 + is_separated: true + other_adult: + age: 30 + tax_units: + tax_unit: + members: [head, separated_adult, other_adult] + output: + is_tax_unit_spouse: [false, false, true] + +- name: A separated dependent does not remove the spouse when the head is an input. + period: 2024 + input: + people: + head: + age: 40 + is_tax_unit_head: true + spouse: + age: 38 + dependent: + age: 19 + is_tax_unit_dependent: true + is_separated: true + tax_units: + tax_unit: + members: [head, spouse, dependent] + output: + is_tax_unit_spouse: [false, true, false] + +- name: With every adult input as a dependent, a third adult's separation leaves the spouse. + period: 2024 + input: + people: + oldest: + age: 40 + is_tax_unit_dependent: true + second: + age: 38 + is_tax_unit_dependent: true + third: + age: 20 + is_tax_unit_dependent: true + is_separated: true + tax_units: + tax_unit: + members: [oldest, second, third] + output: + is_tax_unit_head: [true, false, false] + is_tax_unit_spouse: [false, true, false]