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/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/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/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 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