From 191a8ae5e0164ce4c286824475f046ac2229d432 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 25 Sep 2026 17:14:58 -0400 Subject: [PATCH 1/2] Exclude input dependents from tax unit head and spouse inference is_tax_unit_head picked the oldest non-child and is_tax_unit_spouse the next oldest adult, ignoring an input is_tax_unit_dependent. A dependent elderly parent therefore became the head (and the real head the spouse), and an adult child input as a dependent became a single parent's spouse, turning the return JOINT. The web app flags every dependent it adds and never sends head or spouse, so this reached app users who set a dependent's age to 18 or over. Head and spouse now rank only adults not input as dependents, unless a tax unit has no such adult, in which case every adult stays a candidate as before. is_tax_unit_dependent's formula depends on head and spouse, so the inputs are read with simulation.get_array, which never runs a formula. Without dependent inputs the rule is unchanged. Co-Authored-By: Claude Opus 5.5 --- ...ix-head-excludes-input-dependents.fixed.md | 1 + .../core/test_tax_unit_role_inference.py | 171 ++++++++++++++++++ .../tax_unit/is_tax_unit_head.yaml | 117 ++++++++++++ .../tax_unit/is_tax_unit_spouse.yaml | 71 ++++++++ .../tax_unit/_head_or_spouse_candidates.py | 24 +++ .../demographic/tax_unit/is_tax_unit_head.py | 7 +- .../tax_unit/is_tax_unit_spouse.py | 9 +- 7 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fix-head-excludes-input-dependents.fixed.md create mode 100644 policyengine_us/tests/core/test_tax_unit_role_inference.py create mode 100644 policyengine_us/variables/household/demographic/tax_unit/_head_or_spouse_candidates.py diff --git a/changelog.d/fix-head-excludes-input-dependents.fixed.md b/changelog.d/fix-head-excludes-input-dependents.fixed.md new file mode 100644 index 00000000000..b8a8eca35f4 --- /dev/null +++ b/changelog.d/fix-head-excludes-input-dependents.fixed.md @@ -0,0 +1 @@ +Stop inferring a person input as a tax unit dependent (such as an elderly parent or adult child) as the tax unit head or spouse; the oldest adult not input as a dependent is now the head, and the next oldest the spouse. diff --git a/policyengine_us/tests/core/test_tax_unit_role_inference.py b/policyengine_us/tests/core/test_tax_unit_role_inference.py new file mode 100644 index 00000000000..eb75efd9b99 --- /dev/null +++ b/policyengine_us/tests/core/test_tax_unit_role_inference.py @@ -0,0 +1,171 @@ +"""Invariants for inferring the tax unit head and spouse. + +``is_tax_unit_head`` picks the oldest adult and ``is_tax_unit_spouse`` the +next oldest, skipping anyone input as a tax unit dependent unless every adult +in the unit is one. These tests enumerate every tax unit of up to three people +drawn from a small grid of ages and dependent inputs, and compare the model +against reference implementations of that rule and of the rule before +dependent inputs were honoured. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2024 +# Covers children, the age-18 adult boundary, and ties between adults. +AGES = (10, 18, 40, 70) +MAX_UNIT_SIZE = 3 + + +def _all_units(flag_values): + options = list(itertools.product(AGES, flag_values)) + units = [] + for size in range(1, MAX_UNIT_SIZE + 1): + units.extend(itertools.product(options, repeat=size)) + return units + + +UNITS = _all_units((False, True)) +UNFLAGGED_UNITS = _all_units((False,)) + + +def _simulate(units, dependent_input: bool) -> Simulation: + people = {} + groups = {} + for unit_index, members in enumerate(units): + names = [] + for position, (age, flagged) in enumerate(members): + name = f"unit_{unit_index}_person_{position}" + person = {"age": {str(YEAR): age}} + if dependent_input: + person["is_tax_unit_dependent"] = {str(YEAR): flagged} + people[name] = person + names.append(name) + groups[f"unit_{unit_index}"] = {"members": names} + return Simulation( + situation={ + "people": people, + "tax_units": groups, + "families": groups, + "spm_units": groups, + "households": groups, + } + ) + + +def _reference_roles(members, honour_dependent_input: bool): + """Positions of the head and spouse, ranking older first, then earlier.""" + adults = [i for i, (age, _) in enumerate(members) if age >= 18] + candidates = adults + if honour_dependent_input: + non_dependents = [i for i in adults if not members[i][1]] + if non_dependents: + candidates = non_dependents + ranked = sorted(candidates, key=lambda i: (-members[i][0], i)) + head = ranked[0] if ranked else None + spouse = ranked[1] if len(ranked) > 1 else None + return head, spouse + + +def _model_roles(sim, units): + head = sim.calculate("is_tax_unit_head", YEAR) + spouse = sim.calculate("is_tax_unit_spouse", YEAR) + roles = [] + start = 0 + for members in units: + stop = start + len(members) + unit_head = np.flatnonzero(head[start:stop]) + unit_spouse = np.flatnonzero(spouse[start:stop]) + roles.append((unit_head.tolist(), unit_spouse.tolist())) + start = stop + return roles + + +def _as_lists(head, spouse): + return ( + [] if head is None else [head], + [] if spouse is None else [spouse], + ) + + +@pytest.fixture(scope="module") +def flagged_sim(): + return _simulate(UNITS, dependent_input=True) + + +def test_roles_follow_reference_rule_with_dependent_inputs(flagged_sim): + model = _model_roles(flagged_sim, UNITS) + mismatches = [ + (members, roles, _as_lists(*_reference_roles(members, True))) + for members, roles in zip(UNITS, model) + if roles != _as_lists(*_reference_roles(members, True)) + ] + assert not mismatches, mismatches[:5] + + +def test_units_without_true_dependent_inputs_keep_previous_roles(flagged_sim): + # Differential check: explicit false inputs match the previous rule. + model = _model_roles(flagged_sim, UNITS) + for members, roles in zip(UNITS, model): + if not any(flagged for _, flagged in members): + assert roles == _as_lists(*_reference_roles(members, False)), members + + +def test_roles_without_dependent_inputs_keep_previous_rule(): + sim = _simulate(UNFLAGGED_UNITS, dependent_input=False) + model = _model_roles(sim, UNFLAGGED_UNITS) + for members, roles in zip(UNFLAGGED_UNITS, model): + assert roles == _as_lists(*_reference_roles(members, False)), members + + +def test_role_structure(flagged_sim): + model = _model_roles(flagged_sim, UNITS) + for members, (head, spouse) in zip(UNITS, model): + has_adult = any(age >= 18 for age, _ in members) + assert len(head) == (1 if has_adult else 0), members + assert len(spouse) <= 1, members + assert not set(head) & set(spouse), members + for position in head + spouse: + assert members[position][0] >= 18, members + has_non_dependent_adult = any( + age >= 18 and not flagged for age, flagged in members + ) + if has_non_dependent_adult: + for position in head + spouse: + assert not members[position][1], members + + +def test_dependent_inputs_are_kept(flagged_sim): + flags = [flagged for members in UNITS for _, flagged in members] + dependent = flagged_sim.calculate("is_tax_unit_dependent", YEAR) + assert dependent.tolist() == flags + + +def test_computed_dependents_do_not_change_roles(): + # Without inputs, is_tax_unit_dependent comes from head and spouse. Asking + # for it first must not create a cycle or change which roles come out. + units = UNFLAGGED_UNITS + dependent_first = _simulate(units, dependent_input=False) + dependent = dependent_first.calculate("is_tax_unit_dependent", YEAR) + head = dependent_first.calculate("is_tax_unit_head", YEAR) + spouse = dependent_first.calculate("is_tax_unit_spouse", YEAR) + np.testing.assert_array_equal(dependent, ~head & ~spouse) + # Recomputing head and spouse after the dependent flags are cached. + for variable in ("is_tax_unit_head", "is_tax_unit_spouse"): + dependent_first.get_holder(variable).delete_arrays() + assert _model_roles(dependent_first, units) == _model_roles( + _simulate(units, dependent_input=False), units + ) + + +def test_dependent_input_in_a_branch_is_honoured(): + members = ((40, False), (70, True)) + sim = _simulate([members], dependent_input=False) + branch = sim.get_branch("dependent_parent") + branch.set_input("is_tax_unit_dependent", YEAR, np.array([False, True])) + assert branch.calculate("is_tax_unit_head", YEAR).tolist() == [True, False] + assert sim.calculate("is_tax_unit_head", YEAR).tolist() == [False, True] diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_head.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_head.yaml index 1529239694f..0fec812cc39 100644 --- a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_head.yaml +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/is_tax_unit_head.yaml @@ -28,3 +28,120 @@ members: [child, head, spouse] output: is_tax_unit_head: [false, true, false] + +- name: Without dependent inputs, the oldest adult is head. + period: 2024 + input: + people: + filer: + age: 40 + parent: + age: 70 + tax_units: + tax_unit: + members: [filer, parent] + output: + is_tax_unit_head: [false, true] + age_head: 70 + +- name: Explicit false dependent inputs leave the oldest adult as head. + period: 2024 + input: + people: + filer: + age: 40 + is_tax_unit_dependent: false + parent: + age: 70 + is_tax_unit_dependent: false + tax_units: + tax_unit: + members: [filer, parent] + output: + is_tax_unit_head: [false, true] + age_head: 70 + +- name: An elderly parent input as a dependent does not displace a married couple. + period: 2015 + input: + people: + head: + age: 40 + spouse: + age: 40 + parent: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, parent] + marital_units: + couple: + members: [head, spouse] + parent_marital_unit: + members: [parent] + households: + household: + members: [head, spouse, parent] + state_code: AR + output: + is_tax_unit_head: [true, false, false] + is_tax_unit_spouse: [false, true, false] + is_tax_unit_dependent: [false, false, true] + age_head: 40 + age_spouse: 40 + filing_status: JOINT + tax_unit_dependents: 1 + +- name: A single filer with an elderly parent input as a dependent stays head. + period: 2024 + input: + people: + filer: + age: 45 + employment_income: 50_000 + parent: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [filer, parent] + households: + household: + members: [filer, parent] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, false] + age_head: 45 + filing_status: HEAD_OF_HOUSEHOLD + +- name: A lone adult input as a dependent is still the head. + period: 2024 + input: + people: + person: + age: 30 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person] + output: + is_tax_unit_head: true + age_head: 30 + +- name: If every adult is input as a dependent, the oldest adult is head. + period: 2024 + input: + people: + younger: + age: 40 + is_tax_unit_dependent: true + older: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [younger, older] + output: + is_tax_unit_head: [false, true] + is_tax_unit_spouse: [true, false] 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 da57d817e21..2bc08f8d0ee 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 @@ -32,3 +32,74 @@ members: [child, head, spouse, other_dep] output: is_tax_unit_spouse: [false, false, true, false] + +- name: An adult child input as a dependent is not the spouse of a single parent. + period: 2024 + input: + people: + parent: + age: 45 + employment_income: 50_000 + adult_child: + age: 20 + is_full_time_student: true + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, adult_child] + marital_units: + parent_marital_unit: + members: [parent] + child_marital_unit: + members: [adult_child] + households: + household: + members: [parent, adult_child] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, false] + is_tax_unit_dependent: [false, true] + tax_unit_married: false + age_spouse: 0 + filing_status: HEAD_OF_HOUSEHOLD + +- name: An elderly dependent is not the spouse when only the head is input. + period: 2024 + input: + people: + head: + age: 40 + is_tax_unit_head: true + spouse: + age: 38 + parent: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, parent] + output: + is_tax_unit_spouse: [false, true, false] + age_spouse: 38 + +- name: Dependents of several ages leave the couple as head and spouse. + period: 2024 + input: + people: + head: + age: 40 + spouse: + age: 38 + child: + age: 10 + is_tax_unit_dependent: true + grandparent: + age: 75 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child, grandparent] + output: + is_tax_unit_head: [true, false, false, false] + is_tax_unit_spouse: [false, true, false, false] + tax_unit_dependents: 2 diff --git a/policyengine_us/variables/household/demographic/tax_unit/_head_or_spouse_candidates.py b/policyengine_us/variables/household/demographic/tax_unit/_head_or_spouse_candidates.py new file mode 100644 index 00000000000..df7ff4cd0c9 --- /dev/null +++ b/policyengine_us/variables/household/demographic/tax_unit/_head_or_spouse_candidates.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +def head_or_spouse_candidates(person, period): + """Adults who can be inferred as the tax unit head or spouse. + + People input as tax unit dependents are excluded, so a dependent parent + or adult child cannot displace the filer or become their spouse. A tax + unit whose adults are all input as dependents keeps every adult as a + candidate, as before. + + ``is_tax_unit_dependent`` is derived from head and spouse status, so this + reads only its stored value for the period and never runs its formula. + A stored value that the formula computed can only exist after head and + spouse were computed for the period, and it flags exactly the people who + are neither, so excluding them leaves the head and spouse unchanged. + """ + adult = ~person("is_child", period) + input_dependent = person.simulation.get_array("is_tax_unit_dependent", period) + if input_dependent is None: + return adult + non_dependent_adult = adult & ~input_dependent + has_non_dependent_adult = person.tax_unit.any(non_dependent_adult) + return where(has_non_dependent_adult, non_dependent_adult, adult) diff --git a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py index 9860297e311..8ac534632cb 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py +++ b/policyengine_us/variables/household/demographic/tax_unit/is_tax_unit_head.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.tax_unit._head_or_spouse_candidates import ( + head_or_spouse_candidates, +) class is_tax_unit_head(Variable): @@ -8,8 +11,8 @@ class is_tax_unit_head(Variable): definition_period = YEAR def formula(person, period, parameters): - # Only adults can be heads. - eligible = ~person("is_child", period) + # The oldest adult is the head, skipping input dependents. + eligible = head_or_spouse_candidates(person, period) age = person("age", period) tax_unit = person.tax_unit return person.get_rank(tax_unit, -age, eligible) == 0 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 6810f6d7a8b..24ccbf62c4c 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 @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.tax_unit._head_or_spouse_candidates import ( + head_or_spouse_candidates, +) class is_tax_unit_spouse(Variable): @@ -8,11 +11,11 @@ class is_tax_unit_spouse(Variable): definition_period = YEAR def formula(person, period, parameters): - # Only non-head adults can be spouses. + # Only non-head adults can be spouses, skipping input dependents. is_separated = person.tax_unit.any(person("is_separated", period)) - adult = ~person("is_child", period) + candidate = head_or_spouse_candidates(person, period) head = person("is_tax_unit_head", period) - eligible = adult & ~head & ~is_separated + eligible = candidate & ~head & ~is_separated tax_unit = person.tax_unit age = person("age", period) return person.get_rank(tax_unit, -age, eligible) == 0 From 17dbd8cd6fd968aefd933d3a46c3c96db883d7d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 01:09:13 -0400 Subject: [PATCH 2/2] Correct two tests that relied on an input dependent being inferred as spouse CI on 9b2c41605b found both. In each, an 18-year-old input dependent was inferred as the filer's spouse on main, so the unit filed jointly. With input dependents excluded from head and spouse inference, the parent files alone as head of household: - MA TAFDC integration Case 2: the dependent care deduction is no longer halved for a joint filer, so 7600 * 0.5 - 528 = 3272 and countable earned income is 3272 * 6/12 = 1636 (was 1768); TAFDC is 6439 (was 6307). The old comment's arithmetic (1900 - 528 = 1768) did not add up. - AFA other dependent credit: the head-of-household higher threshold is 300,000, not the joint 400,000. The "without limited phase-out" case lowers the head's wages to 290,000 to keep its intent; the "fully phased out" case's phase-out is ceil(150,000 / 1,000) * 50 = 7,500. Both files pass locally (3 and 12 cases). Co-Authored-By: Claude Opus 5.5 --- .../states/ma/dta/tcap/tafdc/integration.yaml | 19 +++++++++++-------- .../afa/afa_other_dependent_credit.yaml | 7 +++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/dta/tcap/tafdc/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/dta/tcap/tafdc/integration.yaml index 5b7b8335251..92942509d9f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/dta/tcap/tafdc/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/dta/tcap/tafdc/integration.yaml @@ -70,8 +70,12 @@ is_in_public_housing: false output: ma_tcap_gross_earned_income: [10_000, 0] - ma_tafdc_countable_earned_income: [1_768, 0] - ma_tafdc_applicable_income_grant_amount: 1_768 + # person2 is an input dependent, so person1 files alone (head of + # household), not jointly with their child. The whole dependent care + # deduction therefore applies to person1's earnings; before the head and + # spouse fix, person2 was inferred as spouse and the deduction was halved. + ma_tafdc_countable_earned_income: [1_636, 0] + ma_tafdc_applicable_income_grant_amount: 1_636 ma_tafdc_eligible: true # Payment standard for 2022 (2-person, private housing): # - Jan-Sep 2022: 629 × 9 = 5661 @@ -80,12 +84,11 @@ ma_tafdc_payment_standard: 7725 ma_tafdc_eligible_dependent: [False, True] ma_tafdc_dependent_care_deduction: 528 - # 10000 - 2400 = 7600 - # 7600/2 = 3800 (first 6 months full disregard) - # 50% disregard = 1900 - # 1900 - 44*12 = 1768 - # 7725 - 1768 + 350 = 6307 (clothing allowance is 350 for 2022) - ma_tafdc: 6307 + # Work-related deduction: 10000 - 200*12 = 7600 + # 50% disregard less dependent care: 7600*0.5 - 44*12 = 3800 - 528 = 3272 + # Full disregard for the first 6 months, so countable = 3272 * 6/12 = 1636 + # 7725 - 1636 + 350 = 6439 (clothing allowance is 350 for 2022) + ma_tafdc: 6439 - name: Case 3, applicant and his spouse has 1 dependent child. period: 2022 diff --git a/policyengine_us/tests/policy/contrib/congress/afa/afa_other_dependent_credit.yaml b/policyengine_us/tests/policy/contrib/congress/afa/afa_other_dependent_credit.yaml index 158bcec12dc..c7070aa6666 100644 --- a/policyengine_us/tests/policy/contrib/congress/afa/afa_other_dependent_credit.yaml +++ b/policyengine_us/tests/policy/contrib/congress/afa/afa_other_dependent_credit.yaml @@ -55,7 +55,9 @@ head: age: 30 is_tax_unit_dependent: false - employment_income: 350_000 + # The filer is head of household (the input dependent is not a + # spouse), so the higher threshold is 300,000; AGI stays below it. + employment_income: 290_000 person2: age: 18 is_tax_unit_dependent: true @@ -87,7 +89,8 @@ members: [head, person2] output: other_dependent_credit_maximum: [0, 500] - other_dependent_credit_phase_out: 2_500 + # Head of household: ceil((450,000 - 300,000) / 1,000) * 50 = 7,500. + other_dependent_credit_phase_out: 7_500 other_dependent_credit: 0 income_tax_non_refundable_credits: 0