Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fix-head-excludes-input-dependents.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
171 changes: 171 additions & 0 deletions policyengine_us/tests/core/test_tax_unit_role_inference.py
Original file line number Diff line number Diff line change
@@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading
Loading