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/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 f993c6326da..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 @@ -251,3 +251,43 @@ 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: 50_000 + spouse: + age: 38 + is_tax_unit_dependent: false + employment_income: 30_000 + 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 + # 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 + # 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..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 @@ -103,3 +103,185 @@ 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 + +# 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: + 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] + +- 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] 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