diff --git a/changelog.d/restrict-inferred-spouse-to-marital-unit.fixed.md b/changelog.d/restrict-inferred-spouse-to-marital-unit.fixed.md new file mode 100644 index 00000000000..b8dcc4ff90e --- /dev/null +++ b/changelog.d/restrict-inferred-spouse-to-marital-unit.fixed.md @@ -0,0 +1 @@ +Stop inferring an adult outside the head's supplied marital unit as the head's tax unit spouse when that adult meets the qualifying child age test, so an adult child under 19, a student under 24 or a disabled adult child is no longer made the head's spouse. A married couple sharing a marital unit in the tax unit now heads it ahead of an older adult outside the couple. The IRS dependent age limits now start in 1954, with the student limit of 24 from 1989. diff --git a/docs/usage/microsimulation.md b/docs/usage/microsimulation.md index ec2e5b81c33..c7ef7197d5a 100644 --- a/docs/usage/microsimulation.md +++ b/docs/usage/microsimulation.md @@ -153,10 +153,41 @@ 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 unit is one. When two such adults share a two-person marital unit within +the tax unit, the older of that couple is the head instead: a married couple +can file jointly (26 USC 6013(a)), and a couple who files a joint return can't +be claimed as anyone's dependents (26 USC 152(b)(2)). The spouse is the head's +partner in their marital unit. A head without one takes the next-oldest such +adult, except an adult who meets the qualifying child age test against the head +(26 USC 152(c)(3)): under 19, or under 24 if a full-time student, and younger +than the head, or permanently and totally disabled at any age. That adult stays +a dependent. No spouse is inferred when any member of the unit is separated, +and a separated member doesn't count toward a couple. When a situation omits +marital units, Core puts everyone in one marital unit, so plain age order +applies. A minor living without a parent is left with no head. + +This rule has four known limitations. Supply `is_tax_unit_head`, +`is_tax_unit_spouse` or `tax_unit_role_input` for these households: + +- When a tax unit holds a married couple and an older adult outside their + marital unit, the couple heads the unit and the older adult becomes their + dependent, whatever that adult's income. The older adult may file instead, + for example a parent who claims a married child and the child's spouse who + don't file jointly. +- An adult outside the head's marital unit who fails the age test, such as a + 45-year-old child with wages, is still paired with the head, and the unit + files jointly. Under the law each would file their own return, or the adult + could be a qualifying relative, but the pairing keeps the adult's income on + the one return the model computes for the unit. +- A permanently and totally disabled adult outside the head's marital unit + becomes the head's dependent, whatever their income. Examples are a spouse + living apart in a care facility, or a self-supporting sibling, whose income + then leaves the return. +- When a separated adult outside a married couple's marital unit shares their + tax unit, the couple heads the unit but no spouse is inferred, since a unit + with a separated member infers none. The other member of the couple becomes + a dependent. Under the law a spouse can't be the other spouse's dependent, + and the couple could file jointly. The Microcosm build carries its constructor's filing status in a `filing_status_input` column. No variable has that name, so the column is diff --git a/policyengine_us/parameters/gov/irs/dependent/ineligible_age/non_student.yaml b/policyengine_us/parameters/gov/irs/dependent/ineligible_age/non_student.yaml index 3558f57b5ad..e30b9b03213 100644 --- a/policyengine_us/parameters/gov/irs/dependent/ineligible_age/non_student.yaml +++ b/policyengine_us/parameters/gov/irs/dependent/ineligible_age/non_student.yaml @@ -6,5 +6,10 @@ metadata: reference: - title: 26 U.S. Code § 152 - Dependent defined href: https://www.law.cornell.edu/uscode/text/26/152#c_3_A_i + # The section's amendment notes record no change to the age since 1954. + - title: 26 U.S. Code § 151(c)(1)(B)(i) (2000 edition) + href: https://www.govinfo.gov/content/pkg/USCODE-2000-title26/html/USCODE-2000-title26-subtitleA-chap1-subchapB-partV-sec151.htm + - title: 26 U.S. Code § 7851(a)(1)(A) - Applicability of revenue laws + href: https://www.law.cornell.edu/uscode/text/26/7851#a_1_A values: - 2018-01-01: 19 + 1954-01-01: 19 diff --git a/policyengine_us/parameters/gov/irs/dependent/ineligible_age/student.yaml b/policyengine_us/parameters/gov/irs/dependent/ineligible_age/student.yaml index 0381eaab9a6..2bc3436f4dd 100644 --- a/policyengine_us/parameters/gov/irs/dependent/ineligible_age/student.yaml +++ b/policyengine_us/parameters/gov/irs/dependent/ineligible_age/student.yaml @@ -6,5 +6,13 @@ metadata: reference: - title: 26 U.S. Code § 152 - Dependent defined href: https://www.law.cornell.edu/uscode/text/26/152#c_3_A_ii + # The 1988 amendment and its effective date are in the section's notes. + - title: 26 U.S. Code § 151(c)(1)(B)(ii) (2000 edition), with Pub. L. 100-647 § 6010 + href: https://www.govinfo.gov/content/pkg/USCODE-2000-title26/html/USCODE-2000-title26-subtitleA-chap1-subchapB-partV-sec151.htm + - title: 26 U.S. Code § 7851(a)(1)(A) - Applicability of revenue laws + href: https://www.law.cornell.edu/uscode/text/26/7851#a_1_A values: - 2018-01-01: 24 + # The 1954 Code allowed a student of any age; Pub. L. 100-647 added the + # age limit for taxable years beginning after December 31, 1988. + 1954-01-01: .inf + 1989-01-01: 24 diff --git a/policyengine_us/tests/core/test_tax_unit_role_input.py b/policyengine_us/tests/core/test_tax_unit_role_input.py index 9a9de054b62..ec9da8cd5f6 100644 --- a/policyengine_us/tests/core/test_tax_unit_role_input.py +++ b/policyengine_us/tests/core/test_tax_unit_role_input.py @@ -111,7 +111,8 @@ def _dataset( "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), + # The couple shares a marital unit; everyone else has their own. + "person_marital_unit_id": [1, 1, 2, 3, 4, 5], "age": np.asarray(ages, dtype=float), "is_full_time_student": [False, False, True, False, False, True], } @@ -131,7 +132,7 @@ def _dataset( 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)}), + marital_unit=pd.DataFrame({"marital_unit_id": np.arange(1, 6)}), time_period=YEAR, ) @@ -197,13 +198,15 @@ def test_a_tax_unit_without_members_falls_back_rather_than_raising(): 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. + The 60-year-old heads the couple instead of the reference person - the + behaviour the supplied roles exist to correct - and the 16-year-old heads + nothing. The parent's 21-year-old student has their own marital unit and + meets the qualifying child age test, so they are a dependent and the + parent files as head of household. """ 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"] + assert spouses == [True, False, False, False, False, False] + assert dependents == [False, False, True, True, False, True] + assert statuses == ["JOINT", "HEAD_OF_HOUSEHOLD", "HEAD_OF_HOUSEHOLD"] 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 index b27dfa95508..a1e27e688f5 100644 --- a/policyengine_us/tests/core/test_tax_unit_role_input_properties.py +++ b/policyengine_us/tests/core/test_tax_unit_role_input_properties.py @@ -6,11 +6,13 @@ 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. +3. Fallback (marital units omitted): 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. The generated situations pass + no marital units, so Core puts everyone in one marital unit and the + couple-first head and partner-first spouse rules reduce to age order. 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 @@ -119,10 +121,11 @@ def _situation(units, pin_explicitly=False): 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. + # Mirrors the fallback as it stands, with marital units omitted (see + # invariant 3). 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]] diff --git a/policyengine_us/tests/microsimulation/test_microsim.py b/policyengine_us/tests/microsimulation/test_microsim.py index d97f630b67e..674c4864280 100644 --- a/policyengine_us/tests/microsimulation/test_microsim.py +++ b/policyengine_us/tests/microsimulation/test_microsim.py @@ -243,14 +243,18 @@ 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. + (`tax_unit_role_input`). Without supplied roles, the oldest adult heads + the tax unit; when two candidates share a two-person marital unit in the + tax unit, the older of that couple heads instead. An adult outside the + head's marital unit who meets the qualifying child age test is not the + inferred spouse, so supplied marital units no longer pair an adult student + with a parent; when marital units are omitted, plain age order applies. + The fallback still heads couples by the older partner rather than the + reference person and leaves 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 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml index dcc0740c669..ba1cca34267 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/parent_ids_composition.yaml @@ -1239,6 +1239,8 @@ parent: person_id: 1 age: 40 + # Pinned as head: without roles, the married couple would head the unit. + is_tax_unit_head: true adult_child: person_id: 2 age: 19 @@ -2058,7 +2060,7 @@ # Additional linked-household membership regressions from the recovery audit. -- name: A grandparent inferred as the teen parent's tax spouse is not a stepparent +- name: A teen parent in their own marital unit is not the grandparent's spouse, and the grandparent is not a stepparent period: 2026 input: people: @@ -2096,10 +2098,12 @@ members: [grandparent, teen, baby] state_code: OH output: - # The default tax roles label the two adults as head and spouse, but the - # parent links show that they are parent and child. That role inference - # cannot establish a marriage or make the grandparent the baby's step - # parent. The baby uses non-filer rules and counts only baby plus teen. + # The teen has their own marital unit and, at 18, meets the qualifying + # child age test, so the default tax roles make them the grandparent's + # dependent rather than spouse, and the parent links + # show that the two are parent and child. The grandparent is not the + # baby's step parent. The baby uses non-filer rules and counts only baby + # plus teen. medicaid_uses_non_filer_rules: [false, false, true] medicaid_household_size: [2, 2, 2] medicaid_household_income: [40_000, 40_000, 0] @@ -2228,3 +2232,43 @@ ca_medicaid_household_pregnancies: [1, 1] medicaid_household_size: [3, 3] medicaid_household_income: [40_000, 40_000] + +- name: Without marital units, a grandparent inferred as the teen parent's tax spouse is not a stepparent + period: 2026 + input: + people: + grandparent: + person_id: 1 + age: 60 + employment_income: 40_000 + teen: + person_id: 2 + age: 18 + parent_1_id: 1 + baby: + person_id: 3 + age: 1 + parent_1_id: 2 + tax_units: + adult_return: + members: [grandparent, teen] + tax_unit_is_filer: true + baby_unit: + members: [baby] + tax_unit_is_filer: false + families: + family: + members: [grandparent, teen, baby] + households: + home: + members: [grandparent, teen, baby] + state_code: OH + output: + # With marital units omitted, Core puts everyone in one marital unit and + # the default tax roles label the two adults as head and spouse, but the + # parent links show that they are parent and child. That role inference + # cannot establish a marriage or make the grandparent the baby's step + # parent. The baby uses non-filer rules and counts only baby plus teen. + medicaid_uses_non_filer_rules: [false, false, true] + medicaid_household_size: [2, 2, 2] + medicaid_household_income: [40_000, 40_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml index e1968e9c591..9b620bcd8d0 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.yaml @@ -254,3 +254,54 @@ # Alone: (500 - 20) x 12 = 5,760. Deemed: 25,370 - 5,760 = 19,610. is_ssi_spousal_deeming_applies: [true, false] ssi_income_deemed_from_ineligible_spouse: [19_610, 0] + +- name: Spouse income is deemed within a couple heading a unit that includes an older parent (2025) + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + # At 78 she is not an ineligible child. Her son heads the unit with + # his wife, his partner in their marital unit, so she isn't the + # inferred spouse. + age: 78 + social_security_retirement: 15_000 + person2: + age: 50 + employment_income_before_lsr: 30_000 + person3: + age: 48 + is_disabled: true + meets_ssi_disability_criteria: true + person4: + # Ineligible child of the couple. + age: 10 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] + marital_units: + parent_marital_unit: + members: [person1] + couple: + members: [person2, person3] + child_marital_unit: + members: [person4] + spm_units: + spm_unit: + members: [person1, person2, person3, person4] + households: + household: + members: [person1, person2, person3, person4] + state_code: TX + output: + # No roles are supplied: the couple heads the tax unit ahead of the older + # parent, so person2 is the ineligible spouse of person3. + is_tax_unit_head: [false, true, false, false] + is_ssi_ineligible_spouse: [false, true, false, false] + is_ssi_ineligible_child: [false, false, false, true] + # Child allocation: (1,450 - 967) x 12 = 5,796. + # Deemed earnings: 30,000 - 5,796 = 24,204 > 5,796, so deeming applies. + is_ssi_spousal_deeming_applies: [false, false, true, false] + # Couple: (24,204 / 12 - 20 - 65) x 0.5 x 12 = (2,017 - 85) x 6 = 11,592. + # Alone: 0. Deemed: 11,592 - 0 = 11,592. + ssi_income_deemed_from_ineligible_spouse: [0, 0, 11_592, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml index 569eef9cce1..a8abfc90849 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/resources/deemed/is_ssi_spousal_resource_deeming_applies.yaml @@ -359,10 +359,11 @@ ssi_resources_deemed_from_ineligible_spouse: [0, 3001, 0] meets_ssi_resource_test: [true, false, false] -# Explicit marital units stand whatever tax roles the model infers: with -# roles omitted the model may treat one spouse as the mother's tax spouse and -# the other as her dependent, but 416.1205(b) still combines the couple's -# resources: 0 + 3,001 > 3,000. +# With roles omitted, the couple now heads the mother's tax unit and makes a +# joint claim. 416.1205(b) combines their resources against the couple limit +# (0 + 3,001 > 3,000), so nothing is deemed from an ineligible spouse. +# Whatever tax roles are inferred, the resource test result stays the same as +# in the preceding case with pinned roles. - name: Married adults in a parent's tax unit with roles omitted count each other's resources period: 2026-01 input: @@ -394,7 +395,7 @@ home: members: [mother, claimant, spouse] output: - ssi_resources_deemed_from_ineligible_spouse: [0, 3001, 0] + ssi_resources_deemed_from_ineligible_spouse: [0, 0, 0] meets_ssi_resource_test: [true, false, false] # 416.1202(a)(2) excludes retroactive military payments of an ineligible diff --git a/policyengine_us/tests/policy/baseline/gov/states/tx/fpp/tx_fpp_dependent_care_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/tx/fpp/tx_fpp_dependent_care_deduction.yaml index 4f813a881b3..46215943017 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/tx/fpp/tx_fpp_dependent_care_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/tx/fpp/tx_fpp_dependent_care_deduction.yaml @@ -144,6 +144,12 @@ members: [person1] marital_unit2: members: [person2] + # An unrelated adult who is not claimed files their own return. + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] spm_units: spm_unit: members: [person1, person2] @@ -229,3 +235,33 @@ output: # min(1,200, 175 x 12) = 1,200. tx_fpp_dependent_care_deduction: 1_200 + +- name: Case 11, disabled spouse counts when marital units are omitted. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + person2: + age: 38 + is_disabled: true + care_expenses: 3_000 + person3: + age: 8 + # No marital units: Core puts all three in one marital unit. + tax_units: + tax_unit: + members: [person1, person2, person3] + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + # Age order makes person1 the head and person2 the spouse. + is_tax_unit_spouse: [false, true, false] + # min(3,000, 175 x 12) = 2,100 + tx_fpp_dependent_care_deduction: 2_100 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 0fec812cc39..50ff322a476 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 @@ -145,3 +145,111 @@ output: is_tax_unit_head: [false, true] is_tax_unit_spouse: [true, false] + +- name: A married couple heads the unit ahead of an older parent outside their marital unit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 78 + social_security_retirement: 15_000 + head: + age: 50 + employment_income_before_lsr: 80_000 + spouse: + age: 48 + employment_income_before_lsr: 40_000 + child: + age: 10 + tax_units: + tax_unit: + members: [parent, head, spouse, child] + marital_units: + parent_marital_unit: + members: [parent] + couple: + members: [head, spouse] + child_marital_unit: + members: [child] + spm_units: + spm_unit: + members: [parent, head, spouse, child] + households: + household: + members: [parent, head, spouse, child] + state_code: TX + output: + # No roles are supplied. The head and spouse share a two-person marital + # unit, so the older of the couple (50) heads the unit instead of the + # parent (78), who is outside it and becomes a dependent. + is_tax_unit_head: [false, true, false, false] + is_tax_unit_spouse: [false, false, true, false] + is_tax_unit_dependent: [true, false, false, true] + filing_status: JOINT + # Wages 80,000 + 40,000 = 120,000. The parent's 15,000 of Social Security + # is a dependent's income, so it is left off the couple's return. + adjusted_gross_income: 120_000 + +- name: A separated member of a two-person marital unit does not head the unit ahead of an older parent. + period: 2025 + input: + people: + parent: + age: 78 + social_security_retirement: 15_000 + separated_adult: + age: 40 + is_separated: true + employment_income_before_lsr: 30_000 + partner: + age: 38 + employment_income_before_lsr: 20_000 + tax_units: + tax_unit: + members: [parent, separated_adult, partner] + marital_units: + parent_marital_unit: + members: [parent] + couple: + members: [separated_adult, partner] + households: + household: + members: [parent, separated_adult, partner] + state_code: TX + output: + # A unit with a separated member infers no spouse, so the two-person + # marital unit is not a couple that can file jointly. The oldest adult + # heads the unit. + is_tax_unit_head: [true, false, false] + is_tax_unit_spouse: [false, false, false] + filing_status: SINGLE + +- name: With marital units omitted, two adults living with a separated older adult do not head the unit as a couple. + period: 2025 + input: + people: + parent: + age: 78 + is_separated: true + social_security_retirement: 15_000 + adult_1: + age: 50 + employment_income_before_lsr: 80_000 + adult_2: + age: 48 + employment_income_before_lsr: 40_000 + tax_units: + tax_unit: + members: [parent, adult_1, adult_2] + households: + household: + members: [parent, adult_1, adult_2] + state_code: TX + output: + # With marital units omitted, Core puts all three adults in one marital + # unit. It isn't a two-person unit, so the two non-separated adults aren't + # a couple, and the oldest adult heads the unit. A unit with a separated + # member infers no spouse. + is_tax_unit_head: [true, false, false] + is_tax_unit_spouse: [false, false, 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 2bc08f8d0ee..ceef0f946c5 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,230 @@ is_tax_unit_head: [true, false, false, false] is_tax_unit_spouse: [false, true, false, false] tax_unit_dependents: 2 + +- name: Supplied singleton marital units keep an adult child out of the spouse role. + period: 2025 + input: + people: + parent: + age: 45 + employment_income: 20_000 + adult_child: + age: 18 + tax_units: + tax_unit: + members: [parent, adult_child] + marital_units: + parent_unit: + members: [parent] + child_unit: + members: [adult_child] + output: + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, false] + is_tax_unit_dependent: [false, true] + filing_status: HEAD_OF_HOUSEHOLD + eitc_child_count: 1 + eitc: 4_328 + +- name: The supplied marital partner takes precedence over an older adult in the same tax unit. + period: 2025 + input: + people: + head: + age: 60 + relative: + age: 55 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, relative, spouse] + marital_units: + couple: + members: [head, spouse] + relative_unit: + members: [relative] + output: + is_tax_unit_spouse: [false, false, true] + is_tax_unit_dependent: [false, true, false] + +- name: An explicit empty marital mapping gives each person a singleton marital unit. + period: 2025 + input: + people: + parent: + age: 45 + adult_child: + age: 18 + tax_units: + tax_unit: + members: [parent, adult_child] + marital_units: {} + output: + is_tax_unit_spouse: [false, false] + +- name: An adult past the qualifying child age in their own marital unit stays the head's spouse. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 75 + social_security_retirement: 15_000 + adult_child: + age: 45 + employment_income_before_lsr: 50_000 + 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] + state_code: TX + output: + # No roles are supplied, and the parent has no partner in their marital + # unit. At 45 the adult child fails the qualifying child age test + # (26 USC 152(c)(3)), so they can't be the parent's dependent. The age + # order pairs them with the parent, which keeps their wages on the one + # return the model computes for the unit. + is_tax_unit_head: [true, false] + is_tax_unit_spouse: [false, true] + filing_status: JOINT + # Provisional income 50,000 + 15,000 x 0.5 = 57,500 is over the 44,000 + # joint adjusted base. Taxable Social Security is the lesser of + # 15,000 x 0.85 = 12,750 and (57,500 - 44,000) x 0.85 + # + (44,000 - 32,000) x 0.5 = 17,475, so 12,750. + # AGI = 50,000 + 12,750 = 62,750. + adjusted_gross_income: 62_750 + +- name: A 19-year-old non-student in their own marital unit stays the head's spouse. + period: 2025 + input: + people: + parent: + age: 45 + adult_child: + age: 19 + tax_units: + tax_unit: + members: [parent, adult_child] + marital_units: + parent_marital_unit: + members: [parent] + child_marital_unit: + members: [adult_child] + output: + # The non-student limit is under 19, so a 19-year-old fails the age test. + is_tax_unit_spouse: [false, true] + +- name: An 18-year-old the same age as the head stays the head's spouse. + period: 2025 + input: + people: + adult_1: + age: 18 + employment_income_before_lsr: 10_000 + adult_2: + age: 18 + employment_income_before_lsr: 8_000 + tax_units: + tax_unit: + members: [adult_1, adult_2] + marital_units: + marital_unit_1: + members: [adult_1] + marital_unit_2: + members: [adult_2] + households: + household: + members: [adult_1, adult_2] + state_code: TX + output: + # A qualifying child must be younger than the taxpayer unless permanently + # and totally disabled (26 USC 152(c)(3)(A)). adult_2 is under 19 but not + # younger than the head, so they fail the age test and are paired with the + # head. + is_tax_unit_spouse: [false, true] + filing_status: JOINT + +- name: A 23-year-old full-time student in their own marital unit is a dependent. + period: 2025 + input: + people: + parent: + age: 50 + student: + age: 23 + is_full_time_student: true + tax_units: + tax_unit: + members: [parent, student] + marital_units: + parent_marital_unit: + members: [parent] + student_marital_unit: + members: [student] + output: + # The student limit is under 24, and the student is younger than the + # parent, so they meet the age test and are not paired as the spouse. + is_tax_unit_spouse: [false, false] + is_tax_unit_dependent: [false, true] + +- name: A 26-year-old full-time student in their own marital unit is a dependent before 1989. + period: 1988 + input: + people: + parent: + age: 60 + student: + age: 26 + is_full_time_student: true + tax_units: + tax_unit: + members: [parent, student] + marital_units: + parent_marital_unit: + members: [parent] + student_marital_unit: + members: [student] + output: + # Before Pub. L. 100-647 added the under-24 limit for 1989, a student of + # any age met the age test. + is_tax_unit_spouse: [false, false] + is_tax_unit_dependent: [false, true] + +- name: A permanently and totally disabled adult child in their own marital unit is a dependent. + period: 2025 + input: + people: + parent: + age: 70 + employment_income_before_lsr: 30_000 + adult_child: + age: 40 + is_disabled: true + is_permanently_and_totally_disabled: 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] + state_code: TX + output: + # A permanently and totally disabled child meets the age test at any age + # (26 USC 152(c)(3)(B)), so the parent files as head of household. + is_tax_unit_spouse: [false, false] + is_tax_unit_dependent: [false, true] + filing_status: HEAD_OF_HOUSEHOLD diff --git a/policyengine_us/variables/gov/states/tx/fpp/income/tx_fpp_dependent_care_deduction.py b/policyengine_us/variables/gov/states/tx/fpp/income/tx_fpp_dependent_care_deduction.py index aa3c83174e8..806d21c7d3d 100644 --- a/policyengine_us/variables/gov/states/tx/fpp/income/tx_fpp_dependent_care_deduction.py +++ b/policyengine_us/variables/gov/states/tx/fpp/income/tx_fpp_dependent_care_deduction.py @@ -26,15 +26,13 @@ def formula(spm_unit, period, parameters): adult_care_expenses = person("care_expenses", period) age = person("age", period) is_child = age < p.child_age_threshold - # is_tax_unit_spouse flags the oldest non-head adult even when - # unmarried, so require an actual two-person marital unit. - is_spouse = person("is_tax_unit_spouse", period) & ( - person.marital_unit.nb_persons() == 2 - ) is_disabled_adult = ( person("is_disabled", period) & ~is_child - & (person("is_tax_unit_dependent", period) | is_spouse) + & ( + person("is_tax_unit_dependent", period) + | person("is_tax_unit_spouse", period) + ) ) eligible_expenses = ( childcare_expenses * is_child + adult_care_expenses * is_disabled_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 9882c5a7acb..098e2d7cdea 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 @@ -9,6 +9,10 @@ class is_tax_unit_head(Variable): entity = Person label = "Head of tax unit" definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/6013", + "https://www.law.cornell.edu/uscode/text/26/152", + ) def formula(person, period, parameters): tax_unit = person.tax_unit @@ -17,6 +21,28 @@ def formula(person, period, parameters): 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) + # A married couple can file a joint return (26 USC 6013(a)), and a + # couple who files a joint return can't be claimed as anyone's + # dependents (26 USC 152(b)(2)). So when two such adults share a + # two-person marital unit within this tax unit, the older of the + # couple heads it, even if another adult is older. When a situation + # omits marital units, Core puts everyone in one unit, so this changes + # nothing there: a two-person unit gives the same head. + # A separated member doesn't form such a couple: is_tax_unit_spouse + # gives no one the spouse role in a unit with a separated member. + # NOTE: the older adult then becomes the couple's dependent whatever + # their income. If that adult files instead, claiming a couple who + # don't file jointly, supply is_tax_unit_head or tax_unit_role_input. + marital_unit = person.marital_unit + tax_unit_id = tax_unit.reference_entity.members_entity_id + couple_eligible = eligible & ~person("is_separated", period) + couple_candidate = ( + couple_eligible + & (marital_unit.nb_persons() == 2) + & (marital_unit.sum(couple_eligible) == 2) + & (marital_unit.min(tax_unit_id) == marital_unit.max(tax_unit_id)) + ) + eligible = where(tax_unit.any(couple_candidate), couple_candidate, eligible) age = person("age", period) - oldest_adult = person.get_rank(tax_unit, -age, eligible) == 0 - return where(supplied, role == role.possible_values.HEAD, oldest_adult) + oldest_candidate = person.get_rank(tax_unit, -age, eligible) == 0 + return where(supplied, role == role.possible_values.HEAD, oldest_candidate) 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 dd950508fd6..1408fa7e122 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 @@ -9,6 +9,7 @@ class is_tax_unit_spouse(Variable): entity = Person label = "Spouse of tax unit" definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/152#c_3" def formula(person, period, parameters): tax_unit = person.tax_unit @@ -21,7 +22,43 @@ def formula(person, period, parameters): candidate = head_or_spouse_candidates(person, period) head = person("is_tax_unit_head", period) eligible = candidate & ~head & ~is_separated + # A marital unit holds a married, cohabiting couple, and + # is_tax_unit_head takes the head from such a couple when the tax + # unit has one, so the head's partner in their marital unit is the + # spouse. A head without one takes the next-oldest adult, except an + # adult who meets the qualifying child age test against the head: + # under 19, or under 24 if a full-time student, and younger than + # the head, or permanently and totally disabled (26 USC 152(c)(3)). + # That adult can be the head's dependent, so an adult child isn't + # paired with their parent, while an older adult's income stays on + # the return. When a situation omits marital units, Core puts + # everyone in one unit, so the age order applies unchanged. Compare + # memberships, not the ID variable: situations can supply groups + # without numeric ID inputs. + # NOTE: an adult outside the head's marital unit who fails the age + # test is still paired with the head, even if they could be a + # qualifying relative. A permanently and totally disabled adult + # outside the head's marital unit becomes a dependent whatever their + # income. And when a separated adult shares the unit with a couple who + # heads it, the head's partner isn't inferred as the spouse. Supply + # is_tax_unit_spouse or tax_unit_role_input for these households. + marital_unit = person.marital_unit.reference_entity.members_entity_id + head_marital_unit = tax_unit.max(where(head, marital_unit, -1)) + in_head_marital_unit = marital_unit == head_marital_unit age = person("age", period) + p = parameters(period).gov.irs.dependent.ineligible_age + student = person("is_full_time_student", period) + age_limit = where(student, p.student, p.non_student) + head_age = tax_unit.max(where(head, age, 0)) + meets_age_test = ((age < age_limit) & (age < head_age)) | person( + "is_permanently_and_totally_disabled", period + ) + has_partner = tax_unit.any(eligible & in_head_marital_unit) + eligible = where( + has_partner, + eligible & in_head_marital_unit, + eligible & ~meets_age_test, + ) 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. 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 index d27c927bf47..613873695c7 100644 --- 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 @@ -15,4 +15,4 @@ class tax_unit_role_input(Variable): 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 Microcosm 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." + 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 Microcosm 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, except that the older member of a couple sharing a two-person marital unit in the tax unit heads it. The spouse is the head's partner in their marital unit; a head without one takes the next-oldest such adult who doesn't meet the qualifying child age test against the head (under 19, or under 24 if a full-time student, and younger than the head, or permanently and totally disabled). No spouse is inferred when any member of the unit is separated. When marital units are omitted, everyone shares one marital unit, so plain age order applies."