diff --git a/changelog.d/source-tax-unit-roles.added.md b/changelog.d/source-tax-unit-roles.added.md new file mode 100644 index 00000000000..e2764fe6d0c --- /dev/null +++ b/changelog.d/source-tax-unit-roles.added.md @@ -0,0 +1 @@ +Tax-unit roles supplied by a dataset (tax_unit_role_input: HEAD, SPOUSE or DEPENDENT) now determine is_tax_unit_head, is_tax_unit_spouse and is_tax_unit_dependent in every tax unit whose members all have one, with age ordering as the fallback. Filing status is not supplied: filing_status is still computed from the resulting roles and the filing rules, and a dataset's filing_status_input column is not read. On the certified Populace default build this replaces age-ordered roles with the build's own: adult students and other adult dependents are no longer made a parent's spouse, couples are headed by the build's head, and lone minors head their own returns. Because irs_gross_income excludes tax-unit dependents and the model does not yet compute a dependent's own return (#9618), the income of adults the build makes dependents leaves the income tax base. diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index d1559310db7..041ff825887 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -141,6 +141,46 @@ sim = Microsimulation(dataset=single_year_dataset, dataset_end_year=2024) paths. It does not apply to an existing `USMultiYearDataset` or to the legacy variable-centric HDF5 format. +### Tax-unit roles + +A population file can supply the tax units its constructor built, not just +their membership, through `tax_unit_role_input` (person): `HEAD`, `SPOUSE` or +`DEPENDENT`. The certified Populace build supplies it. When every member of a +tax unit has a supplied role, `is_tax_unit_head` and `is_tax_unit_spouse` +follow it and `is_tax_unit_dependent` covers everyone else. Explicitly set +`is_tax_unit_head`, `is_tax_unit_spouse` or `is_tax_unit_dependent` inputs +still take precedence. + +Without supplied roles, a unit falls back to age ordering. The oldest adult is +the head, skipping adults input as tax-unit dependents unless every adult in +the unit is one. The next-oldest such adult is the spouse, unless any member of +the unit is separated. Without dependent inputs, that pairs an adult student +with a parent as joint filers and leaves a minor living without a parent with +no head. + +The Populace build carries its constructor's filing status in a +`filing_status_input` column. No variable has that name, so the column is +ignored. By default, `filing_status` is computed from the unit's members, +including any supplied roles, and the filing rules. Reforms to those rules +apply when the formula computes the status. A direct `filing_status` input +supplied by a caller still overrides the formula. + +Supplied roles fail closed rather than falling back. Each of these raises a +`ValueError`: + +- roles supplied for only some members of a unit; +- a unit without exactly one `HEAD`, or with more than one `SPOUSE`. + +Two consequences follow from honoring supplied roles. First, a dependent's +income is left out of the return they are claimed on, because +`irs_gross_income` excludes tax-unit dependents, and so are their +above-the-line deductions. The model does not yet compute a dependent's own +return ([#9618](https://github.com/PolicyEngine/policyengine-us/issues/9618)), +so an adult dependent's earnings leave the income tax base. Second, a minor can +head a return, which programs that identify minor parents as a tax-unit head or +spouse under 18 will treat as a minor parent +([#9619](https://github.com/PolicyEngine/policyengine-us/issues/9619)). + ### Filtering by geography The microdata includes geographic identifiers that can be used for state-level analysis: diff --git a/policyengine_us/tests/core/test_tax_unit_role_input.py b/policyengine_us/tests/core/test_tax_unit_role_input.py new file mode 100644 index 00000000000..a15b26ab69e --- /dev/null +++ b/policyengine_us/tests/core/test_tax_unit_role_input.py @@ -0,0 +1,209 @@ +"""Tax-unit roles supplied by a population dataset. + +The Populace US build's tax-unit constructor (microunit, called from +microcosm-frame) assigns every person a role - HEAD, SPOUSE or DEPENDENT - and +writes it to the person column ``tax_unit_role_input``. The role variables use +it when supplied and fall back to age ordering when not. + +The build also writes its constructor's filing status to a tax-unit column, +``filing_status_input``. Filing status is a policy calculation, so the model +does not read that column: ``filing_status`` is computed from the unit's +members, including the supplied roles, and the filing rules. + +The YAML tests cover household situations. These cover what the YAML runner +cannot: the error contract, the column contract with the producer, and the +dataset loading path, where the columns arrive as strings and must hold in +every year the loader extends the data to. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_us import Microsimulation, Simulation +from policyengine_us.data.dataset_schema import USSingleYearDataset +from policyengine_us.system import system + +YEAR = 2024 + + +def _situation(people: dict) -> dict: + return { + "people": { + name: { + "age": {YEAR: age}, + **({"tax_unit_role_input": {YEAR: role}} if role else {}), + } + for name, (age, role) in people.items() + }, + "tax_units": {"tax_unit": {"members": list(people)}}, + "households": {"household": {"members": list(people)}}, + } + + +def test_supplied_input_carries_the_producers_column_name_and_values(): + """The role variable must match the column the Populace build writes. + + The loader sets a dataset column only when a variable has its exact name, + and encodes a string column by Enum member name, so a renamed variable or + member would silently drop the build's roles again. The build's filing + status column must keep no variable of its name, so the model computes + filing status rather than loading it. + """ + role = system.variables["tax_unit_role_input"] + assert role.entity.key == "person" + assert not role.formulas # An input, carried to every later year. + assert role.default_value.name == "UNSPECIFIED" + assert {"HEAD", "SPOUSE", "DEPENDENT"} <= set(role.possible_values.__members__) + assert "filing_status_input" not in system.variables + + +@pytest.mark.parametrize( + "people,message", + [ + ({"head": (50, "HEAD"), "other": (20, None)}, "some members"), + ({"head": (50, "HEAD"), "other": (20, "HEAD")}, "exactly one HEAD"), + ({"only": (50, "DEPENDENT")}, "exactly one HEAD"), + ( + {"head": (50, "HEAD"), "a": (45, "SPOUSE"), "b": (44, "SPOUSE")}, + "at most one", + ), + ], + ids=["partly supplied", "two heads", "no head", "two spouses"], +) +def test_malformed_supplied_roles_raise(people, message): + simulation = Simulation(situation=_situation(people)) + with pytest.raises(ValueError, match=message): + simulation.calculate("is_tax_unit_head", YEAR) + + +def test_an_explicit_head_input_is_never_also_the_spouse(): + """Explicit role flags win over supplied roles without doubling a person.""" + situation = _situation({"a": (50, "HEAD"), "b": (48, "SPOUSE")}) + situation["people"]["b"]["is_tax_unit_head"] = {YEAR: True} + simulation = Simulation(situation=situation) + head = simulation.calculate("is_tax_unit_head", YEAR) + spouse = simulation.calculate("is_tax_unit_spouse", YEAR) + assert head.tolist() == [False, True] + assert not (head & spouse).any() + + +def _dataset( + with_supplied_roles: bool, + filing_status_column: list | None = None, + empty_tax_units: int = 0, +) -> USSingleYearDataset: + """Three tax units in one household, with Populace-style string columns. + + 1. A couple headed by a 30-year-old reference person with a 60-year-old + spouse, and their 20-year-old full-time student. + 2. A 16-year-old living without a parent. + 3. A 50-year-old parent and their 21-year-old full-time student. + + ``empty_tax_units`` appends tax-unit rows that no person belongs to. + """ + ages = [30, 60, 20, 16, 50, 21] + tax_unit_of = [1, 1, 1, 2, 3, 3] + person = pd.DataFrame( + { + "person_id": np.arange(1, 7), + "person_household_id": np.ones(6, dtype=int), + "person_tax_unit_id": tax_unit_of, + "person_spm_unit_id": np.ones(6, dtype=int), + "person_family_id": np.ones(6, dtype=int), + "person_marital_unit_id": np.arange(1, 7), + "age": np.asarray(ages, dtype=float), + "is_full_time_student": [False, False, True, False, False, True], + } + ) + tax_unit = pd.DataFrame({"tax_unit_id": np.arange(1, 4 + empty_tax_units)}) + if with_supplied_roles: + person["tax_unit_role_input"] = pd.array( + ["HEAD", "SPOUSE", "DEPENDENT", "HEAD", "HEAD", "DEPENDENT"], dtype="str" + ) + if filing_status_column is not None: + tax_unit["filing_status_input"] = pd.array(filing_status_column, dtype="str") + return USSingleYearDataset( + person=person, + household=pd.DataFrame( + {"household_id": [1], "state_fips": [48], "household_weight": [1.0]} + ), + tax_unit=tax_unit, + spm_unit=pd.DataFrame({"spm_unit_id": [1]}), + family=pd.DataFrame({"family_id": [1]}), + marital_unit=pd.DataFrame({"marital_unit_id": np.arange(1, 7)}), + time_period=YEAR, + ) + + +def _roles_and_statuses(simulation, year): + return ( + np.asarray(simulation.calculate("is_tax_unit_head", year)).tolist(), + np.asarray(simulation.calculate("is_tax_unit_spouse", year)).tolist(), + np.asarray(simulation.calculate("is_tax_unit_dependent", year)).tolist(), + np.asarray(simulation.calculate("filing_status", year)).tolist(), + ) + + +# The statuses the filing rules give the supplied roles: the couple files +# jointly, the lone 16-year-old is single, and the parent of a full-time +# student under 24 (a qualifying child, 26 USC 152(c)(3)(A)(ii)) is a head of +# household (26 USC 2(b)(1)(A)(i)). +COMPUTED_STATUSES = ["JOINT", "SINGLE", "HEAD_OF_HOUSEHOLD"] + + +@pytest.mark.parametrize("year", [YEAR, 2026]) +def test_dataset_roles_hold_in_every_extended_year(year): + simulation = Microsimulation(dataset=_dataset(True), dataset_end_year=2026) + heads, spouses, dependents, statuses = _roles_and_statuses(simulation, year) + assert heads == [True, False, False, True, True, False] + assert spouses == [False, True, False, False, False, False] + assert dependents == [False, False, True, False, False, True] + assert statuses == COMPUTED_STATUSES + + +@pytest.mark.parametrize("year", [YEAR, 2026]) +def test_a_supplied_filing_status_column_is_not_read(year): + """Filing status is computed from the roles, whatever the build supplies. + + The column below disagrees with the filing rules for two units: it makes + the lone 16-year-old a surviving spouse and the parent of a full-time + student single. Neither holds; the rules decide. + """ + dataset = _dataset(True, ["JOINT", "SURVIVING_SPOUSE", "SINGLE"]) + simulation = Microsimulation(dataset=dataset, dataset_end_year=2026) + _, _, _, statuses = _roles_and_statuses(simulation, year) + assert statuses == COMPUTED_STATUSES + + +def test_a_tax_unit_without_members_falls_back_rather_than_raising(): + """A tax-unit row that no person belongs to supplies no roles. + + The loader declares every tax unit in the dataset's id column, members or + not, and ``all`` over no members is True. Such a unit must fall back to + age ordering, which gives it no head, as before supplied roles existed, + rather than count as supplied with no HEAD and raise. + """ + dataset = _dataset(True, empty_tax_units=1) + simulation = Microsimulation(dataset=dataset, dataset_end_year=YEAR) + supplied = simulation.calculate("tax_unit_roles_supplied", YEAR, use_weights=False) + assert np.asarray(supplied).tolist() == [True, True, True, False] + heads = simulation.calculate("is_tax_unit_head", YEAR, use_weights=False) + spouses = simulation.calculate("is_tax_unit_spouse", YEAR, use_weights=False) + assert np.asarray(heads).tolist() == [True, False, False, True, True, False] + assert np.asarray(spouses).tolist() == [False, True, False, False, False, False] + + +def test_dataset_without_supplied_columns_keeps_age_ordering(): + """The same people without the columns: the oldest adult heads each unit. + + The 60-year-old heads the couple, the 16-year-old heads nothing, and the + student becomes the parent's spouse, so both multi-person units file + jointly - the behaviour the supplied roles exist to correct. + """ + simulation = Microsimulation(dataset=_dataset(False), dataset_end_year=YEAR) + heads, spouses, dependents, statuses = _roles_and_statuses(simulation, YEAR) + assert heads == [False, True, False, False, True, False] + assert spouses == [True, False, False, False, False, True] + assert dependents == [False, False, True, True, False, False] + assert statuses == ["JOINT", "HEAD_OF_HOUSEHOLD", "JOINT"] diff --git a/policyengine_us/tests/core/test_tax_unit_role_input_properties.py b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py new file mode 100644 index 00000000000..b27dfa95508 --- /dev/null +++ b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py @@ -0,0 +1,238 @@ +"""Properties of supplied tax-unit roles over generated units. + +Invariants, each checked for every generated household: + +1. Partition: every person is exactly one of head, spouse and dependent. +2. Supplied roles hold: in a unit whose roles are supplied, the role variables + equal the supplied roles, so the unit has exactly one head and at most one + spouse. +3. Fallback: in a unit without supplied roles, the roles equal an independent + implementation of age ordering. The oldest adult heads the unit, earliest + member on ties, skipping adults input as tax unit dependents unless every + adult is one; the next such adult is the spouse unless any member of the + unit is separated. +4. Filing status is computed: no supplied status is read, so a unit files + jointly exactly when it has a spouse, supplied or inferred. +5. Workaround equivalence (differential): supplying roles through + tax_unit_role_input gives the same filing status and income tax as setting + is_tax_unit_head, is_tax_unit_spouse and is_tax_unit_dependent directly, + the policyengine-taxsim workaround. Neither side sets filing_status, so both + compute it from the same roles. +6. Error contract: supplied roles raise exactly when malformed - supplied for + only some members, or not exactly one HEAD and at most one SPOUSE. +""" + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2024 +SETTINGS = dict( + derandomize=True, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@st.composite +def tax_units(draw, supplied=None, dependent_inputs=False): + """One tax unit, with supplied roles when ``supplied`` (drawn if None). + + With ``dependent_inputs``, a unit without supplied roles also flags some + members as input tax unit dependents, and a supplied unit flags exactly + its supplied dependents. + """ + size = draw(st.integers(1, 4)) + ages = draw(st.lists(st.integers(0, 90), min_size=size, max_size=size)) + separated = draw( + st.lists( + st.sampled_from([False, False, False, True]), + min_size=size, + max_size=size, + ) + ) + earnings = draw( + st.lists(st.sampled_from([0, 15_000, 60_000]), min_size=size, max_size=size) + ) + with_roles = draw(st.booleans()) if supplied is None else supplied + roles = None + if with_roles: + head = draw(st.integers(0, size - 1)) + others = [i for i in range(size) if i != head] + spouse = None + if others: + spouse = draw(st.one_of(st.none(), st.sampled_from(others))) + roles = ["DEPENDENT"] * size + roles[head] = "HEAD" + if spouse is not None: + roles[spouse] = "SPOUSE" + dependent_input = None + if dependent_inputs: + if roles: + dependent_input = [role == "DEPENDENT" for role in roles] + else: + dependent_input = draw( + st.lists(st.booleans(), min_size=size, max_size=size) + ) + return dict( + ages=ages, + separated=separated, + earnings=earnings, + roles=roles, + dependent_input=dependent_input, + ) + + +def _situation(units, pin_explicitly=False): + people, tax_unit_groups = {}, {} + for u, unit in enumerate(units): + members = [] + for i, age in enumerate(unit["ages"]): + name = f"u{u}p{i}" + person = { + "age": {YEAR: age}, + "is_separated": {YEAR: unit["separated"][i]}, + "employment_income": {YEAR: unit["earnings"][i]}, + } + role = unit["roles"][i] if unit["roles"] else None + if role and pin_explicitly: + person["is_tax_unit_head"] = {YEAR: role == "HEAD"} + person["is_tax_unit_spouse"] = {YEAR: role == "SPOUSE"} + person["is_tax_unit_dependent"] = {YEAR: role == "DEPENDENT"} + elif role: + person["tax_unit_role_input"] = {YEAR: role} + if unit["dependent_input"] is not None: + person["is_tax_unit_dependent"] = {YEAR: unit["dependent_input"][i]} + people[name] = person + members.append(name) + tax_unit_groups[f"u{u}"] = {"members": members} + return { + "people": people, + "tax_units": tax_unit_groups, + "households": { + "household": {"members": list(people), "state_name": {YEAR: "TX"}} + }, + } + + +def _age_rule(ages, separated, dependent_input=None): + # Mirrors the fallback as it stands. Adults input as tax unit dependents + # are skipped unless every adult is one (#9630). Any separated member + # blocks every spouse; that unit-wide gate is a known defect (#9620), so + # update this reference when it is fixed. + adults = [i for i, age in enumerate(ages) if age >= 18] + if dependent_input is not None: + non_dependents = [i for i in adults if not dependent_input[i]] + if non_dependents: + adults = non_dependents + head = min(adults, key=lambda i: (-ages[i], i)) if adults else None + candidates = [] if any(separated) else [i for i in adults if i != head] + spouse = min(candidates, key=lambda i: (-ages[i], i)) if candidates else None + return head, spouse + + +def _by_unit(units, values): + out, start = [], 0 + for unit in units: + size = len(unit["ages"]) + out.append(list(values[start : start + size])) + start += size + return out + + +def _check_roles(units, head, spouse): + for unit, h, s in zip(units, _by_unit(units, head), _by_unit(units, spouse)): + if unit["roles"]: + # 2. Supplied roles hold. + assert h == [role == "HEAD" for role in unit["roles"]] + assert s == [role == "SPOUSE" for role in unit["roles"]] + else: + # 3. Fallback equals the independent age-ordering rule. + expected_head, expected_spouse = _age_rule( + unit["ages"], unit["separated"], unit["dependent_input"] + ) + assert h == [i == expected_head for i in range(len(h))] + assert s == [i == expected_spouse for i in range(len(s))] + + +@settings(max_examples=40, **SETTINGS) +@given(st.lists(tax_units(), min_size=1, max_size=4)) +def test_roles_follow_supplied_values_and_otherwise_age_ordering(units): + simulation = Simulation(situation=_situation(units)) + head = simulation.calculate("is_tax_unit_head", YEAR).astype(bool) + spouse = simulation.calculate("is_tax_unit_spouse", YEAR).astype(bool) + dependent = simulation.calculate("is_tax_unit_dependent", YEAR).astype(bool) + status = simulation.calculate("filing_status", YEAR).decode_to_str().tolist() + # 1. Partition. + assert np.all(head.astype(int) + spouse.astype(int) + dependent.astype(int) == 1) + _check_roles(units, head, spouse) + for s, unit_status in zip(_by_unit(units, spouse), status): + # 4. Filing status is computed: joint exactly when there is a spouse. + assert (unit_status == "JOINT") == any(s) + + +@settings(max_examples=40, **SETTINGS) +@given(st.lists(tax_units(dependent_inputs=True), min_size=1, max_size=4)) +def test_supplied_roles_compose_with_input_dependents(units): + # Every person carries an is_tax_unit_dependent input, as a situation that + # sets it for anyone must. Supplied units flag exactly their supplied + # dependents; the others skip their flagged adults when inferring roles. + simulation = Simulation(situation=_situation(units)) + head = simulation.calculate("is_tax_unit_head", YEAR).astype(bool) + spouse = simulation.calculate("is_tax_unit_spouse", YEAR).astype(bool) + assert not np.any(head & spouse) + _check_roles(units, head, spouse) + + +@settings(max_examples=15, **SETTINGS) +@given(st.lists(tax_units(supplied=True), min_size=1, max_size=3)) +def test_supplied_roles_match_the_explicit_pin(units): + # 5. Differential: the role input and a complete explicit pin agree. + supplied = Simulation(situation=_situation(units)) + pinned = Simulation(situation=_situation(units, pin_explicitly=True)) + assert ( + supplied.calculate("filing_status", YEAR).decode_to_str().tolist() + == pinned.calculate("filing_status", YEAR).decode_to_str().tolist() + ) + np.testing.assert_allclose( + supplied.calculate("income_tax", YEAR), + pinned.calculate("income_tax", YEAR), + atol=0.005, + ) + + +@settings(max_examples=40, **SETTINGS) +@given( + st.lists( + st.sampled_from([None, "HEAD", "SPOUSE", "DEPENDENT"]), + min_size=1, + max_size=4, + ) +) +def test_supplied_roles_raise_exactly_when_malformed(roles): + people = { + f"p{i}": { + "age": {YEAR: 40}, + **({"tax_unit_role_input": {YEAR: r}} if r else {}), + } + for i, r in enumerate(roles) + } + situation = { + "people": people, + "tax_units": {"unit": {"members": list(people)}}, + "households": {"household": {"members": list(people)}}, + } + supplied = [r is not None for r in roles] + malformed = (any(supplied) and not all(supplied)) or ( + all(supplied) and (roles.count("HEAD") != 1 or roles.count("SPOUSE") > 1) + ) + # 6. Error contract. + simulation = Simulation(situation=situation) + if malformed: + with pytest.raises(ValueError, match="tax_unit_role_input"): + simulation.calculate("is_tax_unit_head", YEAR) + else: + simulation.calculate("is_tax_unit_head", YEAR) diff --git a/policyengine_us/tests/microsimulation/test_microsim.py b/policyengine_us/tests/microsimulation/test_microsim.py index 7c7470a2198..a7ecdfd7eb2 100644 --- a/policyengine_us/tests/microsimulation/test_microsim.py +++ b/policyengine_us/tests/microsimulation/test_microsim.py @@ -237,3 +237,44 @@ def test_legacy_enhanced_cps_allocates_no_housing_share_to_an_unclassified_unit( "SPM units hold an allocated housing share while classifying no " "measurement adult." ) + + +def test_default_dataset_uses_its_supplied_tax_unit_roles(): + """Every tax-unit role on the certified default build is its own. + + `populace_us_2024` ships its tax-unit constructor's roles + (`tax_unit_role_input`). Without them, age ordering made the oldest adult + the head and the next-oldest the spouse, pairing adult students with their + parents as joint filers, heading couples by the older partner rather than + the reference person, and leaving lone minors with no head. This checks the + whole population, person by person, in the data year and in an extended + year, since the loader carries the column forward rather than the formulas. + Filing status is not checked against the build: the model computes it from + these roles and the filing rules. + """ + import numpy as np + import pandas as pd + + from policyengine_us import Microsimulation + from policyengine_us.system import DEFAULT_DATASET, _resolve_dataset_path + + with pd.HDFStore(_resolve_dataset_path(DEFAULT_DATASET), mode="r") as store: + person = store["person"][["person_id", "tax_unit_role_input"]] + roles = person.set_index("person_id")["tax_unit_role_input"].astype(str) + + simulation = Microsimulation(dataset_end_year=2026) + for year in (2024, 2026): + person_id = np.asarray(simulation.calculate("person_id", year)) + role = roles.reindex(person_id).to_numpy() + for name, variable in ( + ("HEAD", "is_tax_unit_head"), + ("SPOUSE", "is_tax_unit_spouse"), + ("DEPENDENT", "is_tax_unit_dependent"), + ): + modelled = np.asarray(simulation.calculate(variable, year)).astype(bool) + mismatched = int((modelled != (role == name)).sum()) + assert mismatched == 0, ( + f"populace_us_2024 (certified default build), {year}: " + f"{variable} disagrees with tax_unit_role_input == {name} for " + f"{mismatched} of {role.size} people." + ) diff --git a/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml new file mode 100644 index 00000000000..079ab48e48f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/demographic/tax_unit/tax_unit_role_input.yaml @@ -0,0 +1,177 @@ +- name: Supplied roles keep an adult student a dependent rather than a spouse. + # By age ordering the 20-year-old would be the parent's spouse and the unit + # would file jointly. As a dependent full-time student under 24 they are the + # parent's qualifying child (26 USC 152(c)(3)(A)(ii)), so the parent files as + # head of household (26 USC 2(b)(1)(A)(i)). + period: 2024 + input: + people: + parent: + age: 50 + tax_unit_role_input: HEAD + student: + age: 20 + is_full_time_student: true + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, false] + is_tax_unit_dependent: [false, true] + filing_status: HEAD_OF_HOUSEHOLD + +- name: A supplied head can be younger than the spouse. + period: 2024 + input: + people: + reference_person: + age: 30 + tax_unit_role_input: HEAD + partner: + age: 60 + tax_unit_role_input: SPOUSE + tax_units: + tax_unit: + members: [reference_person, partner] + households: + household: + members: [reference_person, partner] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, true] + age_head: 30 + age_spouse: 60 + filing_status: JOINT + +- name: A minor supplied as head heads their own return. + # By age ordering a lone 16-year-old heads nothing: they count as the unit's + # dependent and it files as head of household with them as the qualifying child. + period: 2024 + input: + people: + minor: + age: 16 + tax_unit_role_input: HEAD + tax_units: + tax_unit: + members: [minor] + households: + household: + members: [minor] + output: + is_tax_unit_head: true + is_tax_unit_dependent: false + filing_status: SINGLE + +- name: A separated dependent does not unpair a supplied couple. + period: 2024 + input: + people: + head: + age: 50 + tax_unit_role_input: HEAD + spouse: + age: 48 + tax_unit_role_input: SPOUSE + adult_child: + age: 25 + is_separated: true + tax_unit_role_input: DEPENDENT + tax_units: + tax_unit: + members: [head, spouse, adult_child] + households: + household: + members: [head, spouse, adult_child] + output: + is_tax_unit_spouse: [false, true, false] + is_tax_unit_dependent: [false, false, true] + filing_status: JOINT + +- name: Each tax unit uses its own supplied roles, or age ordering without them. + period: 2024 + input: + people: + supplied_head: + age: 30 + tax_unit_role_input: HEAD + supplied_spouse: + age: 60 + tax_unit_role_input: SPOUSE + unsupplied_younger: + age: 30 + unsupplied_older: + age: 60 + tax_units: + supplied_unit: + members: [supplied_head, supplied_spouse] + unsupplied_unit: + members: [unsupplied_younger, unsupplied_older] + households: + household: + members: [supplied_head, supplied_spouse, unsupplied_younger, unsupplied_older] + output: + is_tax_unit_head: [true, false, false, true] + is_tax_unit_spouse: [false, true, true, false] + tax_unit_roles_supplied: [true, false] + +- name: Without supplied roles the oldest adult is the head. + period: 2024 + input: + people: + younger: + age: 30 + older: + age: 60 + child: + age: 5 + tax_units: + tax_unit: + members: [younger, older, child] + households: + household: + members: [younger, older, child] + output: + tax_unit_roles_supplied: false + is_tax_unit_head: [false, true, false] + is_tax_unit_spouse: [true, false, false] + is_tax_unit_dependent: [false, false, true] + +- name: Without supplied roles, inference skips an input dependent; supplied roles stand beside it. + # The unsupplied unit's 70-year-old is input as a tax unit dependent, so the + # 40-year-old heads it and nobody is the spouse. Inputs set for one person + # cover everyone, so every person carries is_tax_unit_dependent. + period: 2024 + input: + people: + supplied_head: + age: 30 + tax_unit_role_input: HEAD + is_tax_unit_dependent: false + supplied_spouse: + age: 60 + tax_unit_role_input: SPOUSE + is_tax_unit_dependent: false + adult: + age: 40 + is_tax_unit_dependent: false + dependent_parent: + age: 70 + is_tax_unit_dependent: true + tax_units: + supplied_unit: + members: [supplied_head, supplied_spouse] + unsupplied_unit: + members: [adult, dependent_parent] + households: + household: + members: [supplied_head, supplied_spouse, adult, dependent_parent] + output: + tax_unit_roles_supplied: [true, false] + is_tax_unit_head: [true, false, true, false] + is_tax_unit_spouse: [false, true, false, false] 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 8ac534632cb..9882c5a7acb 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 @@ -11,8 +11,12 @@ class is_tax_unit_head(Variable): definition_period = YEAR def formula(person, period, parameters): - # The oldest adult is the head, skipping input dependents. + tax_unit = person.tax_unit + # A dataset's tax-unit constructor can supply every member's role. + role = person("tax_unit_role_input", period) + supplied = tax_unit("tax_unit_roles_supplied", period) + # Otherwise 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 + oldest_adult = person.get_rank(tax_unit, -age, eligible) == 0 + return where(supplied, role == role.possible_values.HEAD, oldest_adult) 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..dd950508fd6 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 @@ -11,11 +11,19 @@ class is_tax_unit_spouse(Variable): definition_period = YEAR 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)) + tax_unit = person.tax_unit + # A dataset's tax-unit constructor can supply every member's role. + role = person("tax_unit_role_input", period) + supplied = tax_unit("tax_unit_roles_supplied", period) + # Otherwise only non-head adults can be spouses, skipping input + # dependents. + is_separated = 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 + next_oldest_adult = person.get_rank(tax_unit, -age, eligible) == 0 + # An explicit is_tax_unit_head input still wins over a supplied role, + # so keep the head out of the spouse role either way. + supplied_spouse = (role == role.possible_values.SPOUSE) & ~head + return where(supplied, supplied_spouse, next_oldest_adult) diff --git a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py new file mode 100644 index 00000000000..74003fcca48 --- /dev/null +++ b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_role_input.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class TaxUnitRole(Enum): + UNSPECIFIED = "Unspecified" + HEAD = "Head" + SPOUSE = "Spouse" + DEPENDENT = "Dependent" + + +class tax_unit_role_input(Variable): + value_type = Enum + entity = Person + possible_values = TaxUnitRole + default_value = TaxUnitRole.UNSPECIFIED + definition_period = YEAR + label = "Supplied tax unit role" + documentation = "The person's role in their tax unit - head, spouse or dependent - as assigned by the tax-unit constructor that built a dataset, such as the Populace US build. When every member of a tax unit has a supplied role, is_tax_unit_head and is_tax_unit_spouse use it, so the constructor's head, its spouse pairing and its dependency claims all hold: a younger reference person can head a couple, an adult student stays a dependent, and a minor living without a parent can head a return. The roles only say who is head, spouse or dependent; filing_status is still computed from the unit's members and the filing rules. UNSPECIFIED (the default) means the role was not supplied, and the role variables fall back to age ordering: the oldest adult is the head, skipping adults input as tax unit dependents unless every adult in the unit is one, and the next-oldest such adult is the spouse unless any member of the unit is separated." diff --git a/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py new file mode 100644 index 00000000000..36b8f797490 --- /dev/null +++ b/policyengine_us/variables/household/demographic/tax_unit/tax_unit_roles_supplied.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * + + +class tax_unit_roles_supplied(Variable): + value_type = bool + entity = TaxUnit + definition_period = YEAR + label = "Tax unit roles are supplied" + documentation = "Whether every member of the tax unit has a supplied tax_unit_role_input, in which case is_tax_unit_head and is_tax_unit_spouse use the supplied roles instead of age ordering. A unit whose roles are supplied for only some members, or whose supplied roles do not name exactly one head and at most one spouse, is an error rather than a silent fallback." + + def formula(tax_unit, period, parameters): + role = tax_unit.members("tax_unit_role_input", period) + roles = role.possible_values + supplied = role != roles.UNSPECIFIED + any_supplied = tax_unit.any(supplied) + # all() is True over no members, so a unit with none supplies no roles. + all_supplied = tax_unit.all(supplied) & any_supplied + partly_supplied = any_supplied & ~all_supplied + if partly_supplied.any(): + raise ValueError( + f"tax_unit_role_input is supplied for some members of " + f"{partly_supplied.sum()} tax units but not for others. " + "Supply a role for every member of a tax unit, or for none." + ) + heads = tax_unit.sum(role == roles.HEAD) + spouses = tax_unit.sum(role == roles.SPOUSE) + malformed = all_supplied & ((heads != 1) | (spouses > 1)) + if malformed.any(): + raise ValueError( + f"tax_unit_role_input gives {malformed.sum()} tax units " + "something other than exactly one HEAD and at most one " + "SPOUSE." + ) + return all_supplied