From a48c87742fe7507f7ccd69499051161971bff245 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 09:22:57 -0400 Subject: [PATCH 1/8] Count car loan interest on the return of the taxpayer who incurred it 26 U.S.C. 163(h)(4)(B)(i) counts interest on indebtedness incurred by the taxpayer, and 26 CFR 1.163-16(h)(1) applies the $10,000 limit per return. The household-level input was split across a household's tax units by head count (core's group-to-group add()), so roommates and adult children each deducted a share of one loan's interest. Add a person-level input for each filer's own loans (head and spouse count; a dependent's loans do not), and give a household-level amount in full to the household head's tax unit (oldest member when no head is designated). Co-Authored-By: Claude Opus 5.5 --- .../auto-loan-interest-per-tax-unit.fixed.md | 1 + .../auto_loan_interest_deduction.py | 6 +-- ...alified_passenger_vehicle_loan_interest.py | 45 +++++++++++++++++++ ...alified_passenger_vehicle_loan_interest.py | 6 ++- ..._passenger_vehicle_loan_interest_person.py | 21 +++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 changelog.d/auto-loan-interest-per-tax-unit.fixed.md create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py create mode 100644 policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py diff --git a/changelog.d/auto-loan-interest-per-tax-unit.fixed.md b/changelog.d/auto-loan-interest-per-tax-unit.fixed.md new file mode 100644 index 00000000000..0f3ba64d1ce --- /dev/null +++ b/changelog.d/auto-loan-interest-per-tax-unit.fixed.md @@ -0,0 +1 @@ +Count qualified passenger vehicle loan interest on the return of the taxpayer who incurred the loan (26 U.S.C. 163(h)(4)(B)(i)): add a person-level input for each filer's own loans and give a household-level amount to the household head's tax unit instead of splitting it across the household's tax units by head count. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py index c12ab856515..80243a67b04 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py @@ -14,10 +14,8 @@ class auto_loan_interest_deduction(Variable): ) def formula(tax_unit, period, parameters): - auto_loan_interest = add( - tax_unit, - period, - ["qualified_passenger_vehicle_loan_interest"], + auto_loan_interest = tax_unit( + "tax_unit_qualified_passenger_vehicle_loan_interest", period ) p = parameters(period).gov.irs.deductions.auto_loan_interest capped_interest = min_(auto_loan_interest, p.cap) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py new file mode 100644 index 00000000000..ead6b204f8a --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py @@ -0,0 +1,45 @@ +from policyengine_us.model_api import * + + +class tax_unit_qualified_passenger_vehicle_loan_interest(Variable): + value_type = float + entity = TaxUnit + label = "Tax unit qualified passenger vehicle loan interest" + unit = USD + documentation = ( + "Qualified passenger vehicle loan interest on the tax unit's return, " + "before the dollar limit and the modified adjusted gross income " + "phase-out." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/163#h_4_B", + "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", + "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=28", + ) + + def formula(tax_unit, period, parameters): + person = tax_unit.members + # 26 U.S.C. 163(h)(4)(B)(i) counts interest on indebtedness incurred + # by the taxpayer: both spouses' loans on a joint return (26 CFR + # 1.163-16(h)(3), Example 1), and not a dependent's loans. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + own_interest = tax_unit.sum( + head_or_spouse + * person("qualified_passenger_vehicle_loan_interest_person", period) + ) + # A household-level amount does not say who incurred the loan. It + # goes in full to the tax unit of the household head (the oldest + # member when no head is designated), so it counts on one return. + is_household_head = person("is_household_head", period) + has_household_head = person.household.any(is_household_head) + age = person("age", period) + holds_household_amount = where( + has_household_head, + person.get_rank(person.household, -age, condition=is_household_head) == 0, + person.get_rank(person.household, -age) == 0, + ) + household_interest = person.household( + "qualified_passenger_vehicle_loan_interest", period + ) + return own_interest + tax_unit.sum(holds_household_amount * household_interest) diff --git a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py index 2b58143257c..186f65a81ae 100644 --- a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py +++ b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py @@ -9,7 +9,11 @@ class qualified_passenger_vehicle_loan_interest(Variable): documentation = ( "Interest on loans that meet the federal qualified passenger " "vehicle loan interest requirements, including qualifying vehicle " - "and loan requirements." + "and loan requirements, entered for the household as a whole. The " + "whole amount counts on the return of the household head's tax unit " + "(the oldest member's when no head is designated). To count a loan " + "on the return of the person who incurred it, enter it in " + "qualified_passenger_vehicle_loan_interest_person instead." ) definition_period = YEAR reference = ( diff --git a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py new file mode 100644 index 00000000000..d8b8d7eb496 --- /dev/null +++ b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class qualified_passenger_vehicle_loan_interest_person(Variable): + value_type = float + entity = Person + label = "qualified passenger vehicle loan interest on this person's loans" + unit = USD + documentation = ( + "Interest paid by this person on loans this person incurred that meet " + "the federal qualified passenger vehicle loan interest requirements, " + "including qualifying vehicle and loan requirements. It counts on the " + "return of the tax unit this person heads or is the spouse in; a tax " + "unit dependent's loans do not reach the filer's return." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/163#h_4_B", + "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", + ) + uprating = "gov.bls.cpi.cpi_u" From e4831fdeba02ccd5a0cde34595588c5dffb208cb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 09:28:02 -0400 Subject: [PATCH 2/8] Test car loan interest attribution across tax units Multi-tax-unit households, each filer's own loans, a dependent's loan, the per-return limit, several or no flagged heads, and 26 CFR 1.163-16(h)(3) Examples 1 and 2; Indiana follows the federal deduction. Co-Authored-By: Claude Opus 5.5 --- .../auto_loan_interest_deduction.yaml | 42 ++ ...ified_passenger_vehicle_loan_interest.yaml | 402 ++++++++++++++++++ .../in_auto_loan_interest_deduction.yaml | 36 ++ 3 files changed, 480 insertions(+) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml index ff5e3321024..573cceff4ca 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml @@ -48,3 +48,45 @@ # 26 U.S.C. 163(h)(4)(C)(ii)(II): modified AGI 105,000; five $1,000 # steps over 100,000 at $200 each. auto_loan_interest_deduction: 4_000 + +- name: Spouses' separate loans share one $10,000 limit on a joint return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + a: + age: 40 + qualified_passenger_vehicle_loan_interest_person: 6_000 + b: + age: 40 + qualified_passenger_vehicle_loan_interest_person: 5_000 + tax_units: + tax_unit: + members: [a, b] + adjusted_gross_income: 150_000 + marital_units: + couple: + members: [a, b] + households: + household: + members: [a, b] + state_code: TX + output: + # 26 CFR 1.163-16(h)(3), Example 1 (91 FR 57241): A pays $6,000 and B + # $5,000 on their own loans; they "can deduct no more than $10,000" on + # their joint return. + tax_unit_qualified_passenger_vehicle_loan_interest: 11_000 + auto_loan_interest_deduction: 10_000 + +- name: Single filer phase-out from the regulation's example + period: 2025 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 124_200 + filing_status: SINGLE + qualified_passenger_vehicle_loan_interest_person: 7_000 + output: + # 26 CFR 1.163-16(h)(3), Example 2 (91 FR 57241): modified AGI $124,200 + # exceeds $100,000 by $24,200, rounded up to 25 steps of $200, so the + # $7,000 is reduced by $5,000 to $2,000. + auto_loan_interest_deduction: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml new file mode 100644 index 00000000000..685478cece6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml @@ -0,0 +1,402 @@ +# Qualified passenger vehicle loan interest counts on the return of the +# taxpayer who incurred the loan. 26 U.S.C. 163(h)(4)(B)(i): "interest which +# is paid or accrued during the taxable year on indebtedness incurred by the +# taxpayer". 26 CFR 1.163-16(d)(1) (T.D. 10054, 91 FR 57237) repeats it, and +# 1.163-16(h)(1) limits the amount to $10,000 "per Federal tax return +# regardless of filing status". +# +# qualified_passenger_vehicle_loan_interest_person is a person's own loans +# and counts for the tax unit head and spouse. The household-level +# qualified_passenger_vehicle_loan_interest does not say who incurred the +# loan, so it goes in full to the tax unit holding the household head (the +# oldest member when no head is designated). +# +# Each "Before" figure is the previous formula, which split the household +# amount across tax units by member count. Unless noted: 2025, Texas, each +# adult has $50,000 of wages (under the $100,000 phase-out start). + +- name: The household amount goes to the household head's tax unit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + older_roommate: + age: 50 + employment_income: 50_000 + head: + age: 25 + employment_income: 50_000 + is_household_head: true + tax_units: + roommate_unit: + members: [older_roommate] + head_unit: + members: [head] + marital_units: + roommate_marital_unit: + members: [older_roommate] + head_marital_unit: + members: [head] + spm_units: + roommate_spm_unit: + members: [older_roommate] + head_spm_unit: + members: [head] + households: + household: + members: [older_roommate, head] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # Before: [2_500, 2_500]. + tax_unit_qualified_passenger_vehicle_loan_interest: [0, 5_000] + auto_loan_interest_deduction: [0, 5_000] + +- name: With no household head designated, the oldest member's tax unit takes it + period: 2025 + absolute_error_margin: 0.01 + input: + people: + older_roommate: + age: 50 + employment_income: 50_000 + younger_roommate: + age: 25 + employment_income: 50_000 + tax_units: + older_unit: + members: [older_roommate] + younger_unit: + members: [younger_roommate] + marital_units: + older_marital_unit: + members: [older_roommate] + younger_marital_unit: + members: [younger_roommate] + spm_units: + older_spm_unit: + members: [older_roommate] + younger_spm_unit: + members: [younger_roommate] + households: + household: + members: [older_roommate, younger_roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # Before: [2_500, 2_500]. + tax_unit_qualified_passenger_vehicle_loan_interest: [5_000, 0] + auto_loan_interest_deduction: [5_000, 0] + +- name: A parent's three-person unit and a roommate no longer split by head count + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + employment_income: 50_000 + is_household_head: true + child_1: + age: 10 + is_tax_unit_dependent: true + child_2: + age: 8 + is_tax_unit_dependent: true + roommate: + age: 30 + employment_income: 50_000 + tax_units: + family_unit: + members: [parent, child_1, child_2] + roommate_unit: + members: [roommate] + marital_units: + parent_marital_unit: + members: [parent] + child_1_marital_unit: + members: [child_1] + child_2_marital_unit: + members: [child_2] + roommate_marital_unit: + members: [roommate] + spm_units: + family_spm_unit: + members: [parent, child_1, child_2] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [parent, child_1, child_2, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 8_000 + output: + # Before: [6_000, 2_000], 3/4 and 1/4 of the household amount. + tax_unit_qualified_passenger_vehicle_loan_interest: [8_000, 0] + auto_loan_interest_deduction: [8_000, 0] + +- name: Each filer's own loan counts on that filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 52 + employment_income: 60_000 + is_household_head: true + qualified_passenger_vehicle_loan_interest_person: 4_000 + adult_child: + age: 24 + employment_income: 30_000 + qualified_passenger_vehicle_loan_interest_person: 3_000 + tax_units: + parent_unit: + members: [parent] + adult_child_unit: + members: [adult_child] + marital_units: + parent_marital_unit: + members: [parent] + adult_child_marital_unit: + members: [adult_child] + spm_units: + spm_unit: + members: [parent, adult_child] + households: + household: + members: [parent, adult_child] + state_code: TX + output: + tax_unit_qualified_passenger_vehicle_loan_interest: [4_000, 3_000] + auto_loan_interest_deduction: [4_000, 3_000] + +- name: A dependent's own loan does not reach the filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + employment_income: 50_000 + qualified_passenger_vehicle_loan_interest_person: 2_000 + child: + age: 17 + is_tax_unit_dependent: true + qualified_passenger_vehicle_loan_interest_person: 3_000 + tax_units: + tax_unit: + members: [parent, child] + marital_units: + parent_marital_unit: + members: [parent] + child_marital_unit: + members: [child] + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # The child's interest is on indebtedness the child incurred, not the + # parent; only the parent's $2,000 is on the parent's return. + tax_unit_qualified_passenger_vehicle_loan_interest: 2_000 + auto_loan_interest_deduction: 2_000 + +- name: Person and household amounts add on the head's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + is_household_head: true + qualified_passenger_vehicle_loan_interest_person: 1_000 + roommate: + age: 60 + employment_income: 50_000 + qualified_passenger_vehicle_loan_interest_person: 500 + tax_units: + head_unit: + members: [head] + roommate_unit: + members: [roommate] + marital_units: + head_marital_unit: + members: [head] + roommate_marital_unit: + members: [roommate] + spm_units: + head_spm_unit: + members: [head] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [head, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 2_000 + output: + # Head: 1,000 own + 2,000 household. Roommate: 500 own. + tax_unit_qualified_passenger_vehicle_loan_interest: [3_000, 500] + auto_loan_interest_deduction: [3_000, 500] + +- name: The $10,000 limit applies to the one return that holds the household amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 50_000 + is_household_head: true + roommate: + age: 25 + employment_income: 50_000 + tax_units: + head_unit: + members: [head] + roommate_unit: + members: [roommate] + marital_units: + head_marital_unit: + members: [head] + roommate_marital_unit: + members: [roommate] + spm_units: + head_spm_unit: + members: [head] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [head, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 15_000 + output: + # Before: 7,500 on each return, 15,000 in all, each under the limit. + tax_unit_qualified_passenger_vehicle_loan_interest: [15_000, 0] + auto_loan_interest_deduction: [10_000, 0] + +- name: Several people flagged as household head still count the amount once + period: 2025 + absolute_error_margin: 0.01 + input: + people: + younger_head: + age: 30 + employment_income: 50_000 + is_household_head: true + older_head: + age: 55 + employment_income: 50_000 + is_household_head: true + oldest_member: + age: 80 + employment_income: 50_000 + tax_units: + younger_unit: + members: [younger_head] + older_unit: + members: [older_head] + oldest_unit: + members: [oldest_member] + marital_units: + younger_marital_unit: + members: [younger_head] + older_marital_unit: + members: [older_head] + oldest_marital_unit: + members: [oldest_member] + spm_units: + younger_spm_unit: + members: [younger_head] + older_spm_unit: + members: [older_head] + oldest_spm_unit: + members: [oldest_member] + households: + household: + members: [younger_head, older_head, oldest_member] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # The older of the two flagged heads takes it; the unflagged oldest + # member does not. + tax_unit_qualified_passenger_vehicle_loan_interest: [0, 5_000, 0] + +- name: A household head claimed as a dependent puts the amount on the claiming return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + son: + age: 50 + employment_income: 50_000 + grandmother: + age: 80 + is_household_head: true + is_tax_unit_dependent: true + roommate: + age: 35 + employment_income: 50_000 + tax_units: + son_unit: + members: [son, grandmother] + roommate_unit: + members: [roommate] + marital_units: + son_marital_unit: + members: [son] + grandmother_marital_unit: + members: [grandmother] + roommate_marital_unit: + members: [roommate] + spm_units: + family_spm_unit: + members: [son, grandmother] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [son, grandmother, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 3_000 + output: + # Before: [2_000, 1_000]. + tax_unit_qualified_passenger_vehicle_loan_interest: [3_000, 0] + +- name: One tax unit in the household takes the whole household amount, as before + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + spouse: + age: 38 + child: + age: 6 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + couple: + members: [head, spouse] + child_marital_unit: + members: [child] + spm_units: + spm_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: TX + qualified_passenger_vehicle_loan_interest: 4_000 + output: + tax_unit_qualified_passenger_vehicle_loan_interest: 4_000 + auto_loan_interest_deduction: 4_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml index d564dd764ce..d840f9f0062 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml @@ -56,3 +56,39 @@ output: in_auto_loan_interest_deduction: 0 in_deductions: 0 + +- name: Indiana follows the federal deduction to the household head's return + period: 2026 + absolute_error_margin: 0.01 + input: + people: + roommate: + age: 50 + employment_income: 50_000 + head: + age: 30 + employment_income: 50_000 + is_household_head: true + tax_units: + roommate_unit: + members: [roommate] + head_unit: + members: [head] + marital_units: + roommate_marital_unit: + members: [roommate] + head_marital_unit: + members: [head] + spm_units: + roommate_spm_unit: + members: [roommate] + head_spm_unit: + members: [head] + households: + household: + members: [roommate, head] + state_code: IN + qualified_passenger_vehicle_loan_interest: 2_000 + output: + # Before: [1_000, 1_000]. + in_auto_loan_interest_deduction: [0, 2_000] From e5c1b8d16e14a6478c902da5edab1535e677ecd0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 09:33:54 -0400 Subject: [PATCH 3/8] Add property tests for car loan interest attribution Conservation of the household total, one holder per household amount, locality across tax units, and a differential against the statute's limit and phase-out. Co-Authored-By: Claude Opus 5.5 --- ...to_loan_interest_attribution_invariants.py | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py diff --git a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py new file mode 100644 index 00000000000..47fb32e4423 --- /dev/null +++ b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py @@ -0,0 +1,303 @@ +"""Invariants for attributing car loan interest to tax units. + +26 U.S.C. 163(h)(4)(B)(i) counts interest "on indebtedness incurred by the +taxpayer", and 26 CFR 1.163-16(h)(1) limits it to $10,000 "per Federal tax +return regardless of filing status". The model takes each person's own loans +(qualified_passenger_vehicle_loan_interest_person, for the tax unit head and +spouse) and a household-level amount that goes in full to the tax unit of the +household head (the oldest member when no head is designated, the older flagged +head when several are). + +Invariants, for every household of one to four tax units, written from the +statute and the regulation rather than read from the model's parameters: + +1. Conservation: the household's tax units together hold the household amount + plus every head's and spouse's own interest. Nothing is counted twice and + only a dependent's own interest is dropped. +2. One holder: the household amount lands on exactly one tax unit, the one + holding the designated (or fallback) household head. +3. Locality: a tax unit's deduction depends only on its own filers' loans and + on the household amount when it holds the head. Zeroing other tax units' + loans leaves it unchanged. +4. Differential against the statute: the deduction is + max(min(interest, 10,000) - 200 x ceil(max(MAGI - threshold, 0) / 1,000), 0), + with a $200,000 threshold for a joint return and $100,000 otherwise. +5. Bounds: 0 <= deduction <= min(tax unit interest, 10,000). +""" + +import math + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +CAP = 10_000 # 26 U.S.C. 163(h)(4)(C)(i) +THRESHOLD = {"JOINT": 200_000} # 163(h)(4)(C)(ii)(I); 100,000 otherwise +OTHER_THRESHOLD = 100_000 +STEP, INCREMENT = 200, 1_000 +KINDS = ["single", "joint", "hoh", "joint_with_child"] + + +def money(high): + return st.one_of(st.just(0.0), st.integers(1, high).map(float)) + + +@st.composite +def tax_unit_specs(draw): + kind = draw(st.sampled_from(KINDS)) + adults = 2 if kind.startswith("joint") else 1 + children = 1 if kind in ("hoh", "joint_with_child") else 0 + people = [] + for i in range(adults + children): + is_child = i >= adults + people.append( + { + "age": draw(st.integers(1, 17) if is_child else st.integers(18, 90)), + "dependent": is_child, + "wages": 0.0 if is_child else draw(money(260_000)), + "interest": draw(money(12_000)), + "head_flag": draw(st.sampled_from([False, False, False, True])), + } + ) + return {"kind": kind, "people": people} + + +@st.composite +def household_specs(draw): + return { + "tax_units": draw(st.lists(tax_unit_specs(), min_size=1, max_size=4)), + "amount": draw(money(16_000)), + } + + +def _seeded_households(n=80, seed=20261023): + rng = np.random.default_rng(seed) + + def some(high, p=0.6): + return float(round(rng.uniform(1, high))) if rng.random() < p else 0.0 + + households = [] + for h in range(n): + units = [] + for _ in range(int(rng.integers(1, 5))): + kind = str(rng.choice(KINDS)) + adults = 2 if kind.startswith("joint") else 1 + children = 1 if kind in ("hoh", "joint_with_child") else 0 + units.append( + { + "kind": kind, + "people": [ + { + "age": int(rng.integers(1, 18)) + if i >= adults + else int(rng.integers(18, 91)), + "dependent": i >= adults, + "wages": 0.0 if i >= adults else some(260_000, 0.9), + "interest": some(12_000, 0.5), + # A third of households flag no head, so the + # oldest-member fallback runs beside flagged ones. + "head_flag": bool(h % 3) and rng.random() < 0.3, + } + for i in range(adults + children) + ], + } + ) + households.append({"tax_units": units, "amount": some(16_000, 0.7)}) + # Ties: two 40-year-old roommates, no head flagged; and two flagged + # heads of the same age. The first listed takes the household amount. + tie = {"age": 40, "dependent": False, "wages": 50_000.0, "interest": 0.0} + households.append( + { + "tax_units": [ + {"kind": "single", "people": [dict(tie, head_flag=False)]}, + {"kind": "single", "people": [dict(tie, head_flag=False)]}, + ], + "amount": 5_000.0, + } + ) + households.append( + { + "tax_units": [ + {"kind": "single", "people": [dict(tie, head_flag=True)]}, + {"kind": "single", "people": [dict(tie, head_flag=True)]}, + ], + "amount": 5_000.0, + } + ) + return households + + +def _situation(households, year): + people, tax_units, marital_units, spm_units, hh = {}, {}, {}, {}, {} + for h, household in enumerate(households): + members = [] + for t, unit in enumerate(household["tax_units"]): + unit_members = [] + for i, p in enumerate(unit["people"]): + name = f"p_{h}_{t}_{i}" + people[name] = { + "age": {year: p["age"]}, + "employment_income": {year: p["wages"]}, + "qualified_passenger_vehicle_loan_interest_person": { + year: p["interest"] + }, + "is_tax_unit_dependent": {year: p["dependent"]}, + "is_household_head": {year: p["head_flag"]}, + } + unit_members.append(name) + tax_units[f"t_{h}_{t}"] = {"members": unit_members} + adults = [ + m for m, p in zip(unit_members, unit["people"]) if not p["dependent"] + ] + marital_units[f"m_{h}_{t}"] = {"members": adults} + for m, p in zip(unit_members, unit["people"]): + if p["dependent"]: + marital_units[f"m_{m}"] = {"members": [m]} + spm_units[f"s_{h}_{t}"] = {"members": unit_members} + members += unit_members + hh[f"h_{h}"] = { + "members": members, + "state_code": {year: "TX"}, + "qualified_passenger_vehicle_loan_interest": {year: household["amount"]}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "households": hh, + } + + +def _holder(household): + """(tax unit index) holding the household amount, from the rule's text.""" + flat = [ + (t, p) for t, unit in enumerate(household["tax_units"]) for p in unit["people"] + ] + flagged = [(t, p) for t, p in flat if p["head_flag"]] + pool = flagged or flat + # Oldest first; ties go to the first listed (max returns the first). + return max(pool, key=lambda tp: tp[1]["age"])[0] + + +def _expected_interest(households): + expected = [] + for household in households: + holder = _holder(household) + for t, unit in enumerate(household["tax_units"]): + own = sum(p["interest"] for p in unit["people"] if not p["dependent"]) + expected.append(own + (household["amount"] if t == holder else 0.0)) + return np.array(expected) + + +def _statute_deduction(interest, magi, filing_status): + out = [] + for i, m, fs in zip(interest, magi, filing_status): + threshold = THRESHOLD.get(fs, OTHER_THRESHOLD) + steps = math.ceil(max(m - threshold, 0) / INCREMENT) + out.append(max(min(i, CAP) - STEP * steps, 0)) + return np.array(out) + + +def _calculate(households, year): + sim = Simulation(situation=_situation(households, year)) + return { + "interest": sim.calculate( + "tax_unit_qualified_passenger_vehicle_loan_interest", year + ), + "deduction": sim.calculate("auto_loan_interest_deduction", year), + "magi": sim.calculate("agi_plus_section_911_931_933_exclusions", year), + "filing_status": sim.calculate("filing_status", year).decode_to_str(), + } + + +def _check(households, year): + result = _calculate(households, year) + interest, deduction = result["interest"], result["deduction"] + expected = _expected_interest(households) + + # 2. One holder, and the tax unit totals are exactly the rule's. + np.testing.assert_allclose(interest, expected, atol=TOLERANCE) + + # 1. Conservation per household. + start = 0 + for household in households: + n = len(household["tax_units"]) + filers_own = sum( + p["interest"] + for unit in household["tax_units"] + for p in unit["people"] + if not p["dependent"] + ) + assert ( + abs(interest[start : start + n].sum() - (household["amount"] + filers_own)) + < TOLERANCE + ) + start += n + + # 4. Differential against the statute. + np.testing.assert_allclose( + deduction, + _statute_deduction(expected, result["magi"], result["filing_status"]), + atol=TOLERANCE, + ) + + # 5. Bounds. + assert (deduction >= -TOLERANCE).all() + assert (deduction <= np.minimum(interest, CAP) + TOLERANCE).all() + return result + + +def _zero_odd_units(households): + """Copy with every odd-numbered tax unit's own loans set to zero.""" + out = [] + for household in households: + units = [] + for t, unit in enumerate(household["tax_units"]): + people = [ + dict(p, interest=0.0) if t % 2 else dict(p) for p in unit["people"] + ] + units.append(dict(unit, people=people)) + out.append(dict(household, tax_units=units)) + return out + + +def _check_locality(households, year): + before = _calculate(households, year)["deduction"] + after = _calculate(_zero_odd_units(households), year)["deduction"] + unit_index = np.array( + [t for household in households for t in range(len(household["tax_units"]))] + ) + # 3. Even-numbered tax units kept their own loans and their deduction. + even = unit_index % 2 == 0 + np.testing.assert_allclose(after[even], before[even], atol=TOLERANCE) + + +def test_seeded_mixed_batch_2025(): + households = _seeded_households() + result = _check(households, "2025") + # The two tie households at the end: the first listed tax unit holds it. + assert result["interest"][-4:].tolist() == [5_000, 0, 5_000, 0] + + +def test_seeded_mixed_batch_2026_locality(): + households = _seeded_households(seed=20261024) + _check(households, "2026") + _check_locality(households, "2026") + + +@settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists(household_specs(), min_size=1, max_size=6), + st.sampled_from(["2025", "2026", "2028"]), +) +def test_attribution_invariants(households, year): + _check(households, year) + _check_locality(households, year) From 71d8d651d7f1f5b6bce2134f87bb3ce9b917260a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 09:51:48 -0400 Subject: [PATCH 4/8] Test attribution with people listed out of household order Co-Authored-By: Claude Opus 5.5 --- ...to_loan_interest_attribution_invariants.py | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py index 47fb32e4423..de08776c319 100644 --- a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py +++ b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py @@ -73,7 +73,7 @@ def household_specs(draw): } -def _seeded_households(n=80, seed=20261023): +def _seeded_households(n=80, seed=20261023, ties=True): rng = np.random.default_rng(seed) def some(high, p=0.6): @@ -106,6 +106,17 @@ def some(high, p=0.6): } ) households.append({"tax_units": units, "amount": some(16_000, 0.7)}) + if not ties: + # Distinct ages within each household, so the holder is unambiguous + # whatever order the members are listed in. + for household in households: + seen = set() + for unit in household["tax_units"]: + for p in unit["people"]: + while p["age"] in seen: + p["age"] += 1 + seen.add(p["age"]) + return households # Ties: two 40-year-old roommates, no head flagged; and two flagged # heads of the same age. The first listed takes the household amount. tie = {"age": 40, "dependent": False, "wages": 50_000.0, "interest": 0.0} @@ -130,7 +141,7 @@ def some(high, p=0.6): return households -def _situation(households, year): +def _situation(households, year, shuffle_seed=None): people, tax_units, marital_units, spm_units, hh = {}, {}, {}, {}, {} for h, household in enumerate(households): members = [] @@ -163,6 +174,10 @@ def _situation(households, year): "state_code": {year: "TX"}, "qualified_passenger_vehicle_loan_interest": {year: household["amount"]}, } + if shuffle_seed is not None: + names = list(people) + order = np.random.default_rng(shuffle_seed).permutation(len(names)) + people = {names[i]: people[names[i]] for i in order} return { "people": people, "tax_units": tax_units, @@ -202,8 +217,8 @@ def _statute_deduction(interest, magi, filing_status): return np.array(out) -def _calculate(households, year): - sim = Simulation(situation=_situation(households, year)) +def _calculate(households, year, shuffle_seed=None): + sim = Simulation(situation=_situation(households, year, shuffle_seed)) return { "interest": sim.calculate( "tax_unit_qualified_passenger_vehicle_loan_interest", year @@ -214,8 +229,8 @@ def _calculate(households, year): } -def _check(households, year): - result = _calculate(households, year) +def _check(households, year, shuffle_seed=None): + result = _calculate(households, year, shuffle_seed) interest, deduction = result["interest"], result["deduction"] expected = _expected_interest(households) @@ -289,6 +304,12 @@ def test_seeded_mixed_batch_2026_locality(): _check_locality(households, "2026") +def test_people_listed_out_of_household_order(): + # Microdata need not list a household's members next to each other. + households = _seeded_households(n=60, seed=20261025, ties=False) + _check(households, "2025", shuffle_seed=7) + + @settings( max_examples=25, deadline=None, From 04cbbdefc87a881b6b851fcd0c50978d8ff841d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 10:50:41 -0400 Subject: [PATCH 5/8] Address review: adult dependents, fallback through a dependent, citations Test an adult dependent's own loan and the oldest-member fallback when the oldest member is a dependent, and draw adult dependents in the property tests. Document the several-heads and dependent-head rules on the household input, cite the payor statement legend (91 FR 57243) and point Cornell at 163(h)(4)(B)(i). Co-Authored-By: Claude Opus 5.5 --- ...to_loan_interest_attribution_invariants.py | 30 ++++--- ...ified_passenger_vehicle_loan_interest.yaml | 84 +++++++++++++++++++ ...alified_passenger_vehicle_loan_interest.py | 2 +- ...alified_passenger_vehicle_loan_interest.py | 8 +- ..._passenger_vehicle_loan_interest_person.py | 3 +- 5 files changed, 112 insertions(+), 15 deletions(-) diff --git a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py index de08776c319..12417d23c48 100644 --- a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py +++ b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py @@ -38,7 +38,15 @@ THRESHOLD = {"JOINT": 200_000} # 163(h)(4)(C)(ii)(I); 100,000 otherwise OTHER_THRESHOLD = 100_000 STEP, INCREMENT = 200, 1_000 -KINDS = ["single", "joint", "hoh", "joint_with_child"] +KINDS = ["single", "joint", "hoh", "joint_with_child", "adult_dependent"] +# Each kind's dependent, if any, and its age range. An adult dependent (a +# student, an elderly parent) can hold their own loan and be the household's +# oldest member. +DEPENDENT_AGES = { + "hoh": (1, 17), + "joint_with_child": (1, 17), + "adult_dependent": (18, 90), +} def money(high): @@ -49,15 +57,16 @@ def money(high): def tax_unit_specs(draw): kind = draw(st.sampled_from(KINDS)) adults = 2 if kind.startswith("joint") else 1 - children = 1 if kind in ("hoh", "joint_with_child") else 0 + dependents = 1 if kind in DEPENDENT_AGES else 0 people = [] - for i in range(adults + children): - is_child = i >= adults + for i in range(adults + dependents): + is_dependent = i >= adults + ages = DEPENDENT_AGES[kind] if is_dependent else (18, 90) people.append( { - "age": draw(st.integers(1, 17) if is_child else st.integers(18, 90)), - "dependent": is_child, - "wages": 0.0 if is_child else draw(money(260_000)), + "age": draw(st.integers(*ages)), + "dependent": is_dependent, + "wages": 0.0 if is_dependent else draw(money(260_000)), "interest": draw(money(12_000)), "head_flag": draw(st.sampled_from([False, False, False, True])), } @@ -85,13 +94,14 @@ def some(high, p=0.6): for _ in range(int(rng.integers(1, 5))): kind = str(rng.choice(KINDS)) adults = 2 if kind.startswith("joint") else 1 - children = 1 if kind in ("hoh", "joint_with_child") else 0 + dependents = 1 if kind in DEPENDENT_AGES else 0 + low, high = DEPENDENT_AGES.get(kind, (18, 90)) units.append( { "kind": kind, "people": [ { - "age": int(rng.integers(1, 18)) + "age": int(rng.integers(low, high + 1)) if i >= adults else int(rng.integers(18, 91)), "dependent": i >= adults, @@ -101,7 +111,7 @@ def some(high, p=0.6): # oldest-member fallback runs beside flagged ones. "head_flag": bool(h % 3) and rng.random() < 0.3, } - for i in range(adults + children) + for i in range(adults + dependents) ], } ) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml index 685478cece6..b1452a813ba 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml @@ -204,6 +204,90 @@ tax_unit_qualified_passenger_vehicle_loan_interest: 2_000 auto_loan_interest_deduction: 2_000 +- name: Adult dependents' own loans do not reach the filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + employment_income: 80_000 + qualified_passenger_vehicle_loan_interest_person: 1_000 + student: + age: 20 + is_tax_unit_dependent: true + is_full_time_student: true + qualified_passenger_vehicle_loan_interest_person: 2_500 + grandparent: + age: 82 + is_tax_unit_dependent: true + qualified_passenger_vehicle_loan_interest_person: 1_500 + tax_units: + tax_unit: + members: [parent, student, grandparent] + marital_units: + parent_marital_unit: + members: [parent] + student_marital_unit: + members: [student] + grandparent_marital_unit: + members: [grandparent] + spm_units: + spm_unit: + members: [parent, student, grandparent] + households: + household: + members: [parent, student, grandparent] + state_code: TX + output: + # The student's and grandparent's loans are their own indebtedness + # (26 CFR 1.163-16(d)(1)); only the parent's $1,000 is on this return. + tax_unit_qualified_passenger_vehicle_loan_interest: 1_000 + auto_loan_interest_deduction: 1_000 + +- name: The oldest-member fallback counts dependents, so the amount follows the oldest member + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 50_000 + grandparent: + age: 85 + is_tax_unit_dependent: true + roommate: + age: 60 + employment_income: 50_000 + tax_units: + family_unit: + members: [parent, grandparent] + roommate_unit: + members: [roommate] + marital_units: + parent_marital_unit: + members: [parent] + grandparent_marital_unit: + members: [grandparent] + roommate_marital_unit: + members: [roommate] + spm_units: + family_spm_unit: + members: [parent, grandparent] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [parent, grandparent, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # No head is designated. The oldest member is the 85-year-old dependent, + # so the amount goes to the return that claims them, not to the 60-year-old + # roommate, who is the oldest filer. Before: [3_333.33, 1_666.67]. + tax_unit_qualified_passenger_vehicle_loan_interest: [5_000, 0] + auto_loan_interest_deduction: [5_000, 0] + - name: Person and household amounts add on the head's return period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py index ead6b204f8a..80aadfc58e5 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py @@ -13,7 +13,7 @@ class tax_unit_qualified_passenger_vehicle_loan_interest(Variable): ) definition_period = YEAR reference = ( - "https://www.law.cornell.edu/uscode/text/26/163#h_4_B", + "https://www.law.cornell.edu/uscode/text/26/163#h_4_B_i", "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=28", ) diff --git a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py index 186f65a81ae..f0c39c12aa7 100644 --- a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py +++ b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py @@ -10,9 +10,11 @@ class qualified_passenger_vehicle_loan_interest(Variable): "Interest on loans that meet the federal qualified passenger " "vehicle loan interest requirements, including qualifying vehicle " "and loan requirements, entered for the household as a whole. The " - "whole amount counts on the return of the household head's tax unit " - "(the oldest member's when no head is designated). To count a loan " - "on the return of the person who incurred it, enter it in " + "whole amount counts on the return of the tax unit that includes the " + "household head: the oldest flagged head when several are flagged, " + "and the oldest member when none is. A head claimed as a dependent " + "puts it on the claiming filer's return. To count a loan on the " + "return of the person who incurred it, enter it in " "qualified_passenger_vehicle_loan_interest_person instead." ) definition_period = YEAR diff --git a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py index d8b8d7eb496..c7ed8d55723 100644 --- a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py +++ b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py @@ -15,7 +15,8 @@ class qualified_passenger_vehicle_loan_interest_person(Variable): ) definition_period = YEAR reference = ( - "https://www.law.cornell.edu/uscode/text/26/163#h_4_B", + "https://www.law.cornell.edu/uscode/text/26/163#h_4_B_i", "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", + "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=30", ) uprating = "gov.bls.cpi.cpi_u" From 8b53211eefb644d907b122afee3d20c379dbbd89 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 11:04:05 -0400 Subject: [PATCH 6/8] Move the regulation examples into the new test file Keeps auto_loan_interest_deduction.yaml as on main, so this PR no longer conflicts with #10395, which appends cases to that file. Co-Authored-By: Claude Opus 5.5 --- .../auto_loan_interest_deduction.yaml | 42 ------------------- ...ified_passenger_vehicle_loan_interest.yaml | 42 +++++++++++++++++++ 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml index 573cceff4ca..ff5e3321024 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml @@ -48,45 +48,3 @@ # 26 U.S.C. 163(h)(4)(C)(ii)(II): modified AGI 105,000; five $1,000 # steps over 100,000 at $200 each. auto_loan_interest_deduction: 4_000 - -- name: Spouses' separate loans share one $10,000 limit on a joint return - period: 2025 - absolute_error_margin: 0.01 - input: - people: - a: - age: 40 - qualified_passenger_vehicle_loan_interest_person: 6_000 - b: - age: 40 - qualified_passenger_vehicle_loan_interest_person: 5_000 - tax_units: - tax_unit: - members: [a, b] - adjusted_gross_income: 150_000 - marital_units: - couple: - members: [a, b] - households: - household: - members: [a, b] - state_code: TX - output: - # 26 CFR 1.163-16(h)(3), Example 1 (91 FR 57241): A pays $6,000 and B - # $5,000 on their own loans; they "can deduct no more than $10,000" on - # their joint return. - tax_unit_qualified_passenger_vehicle_loan_interest: 11_000 - auto_loan_interest_deduction: 10_000 - -- name: Single filer phase-out from the regulation's example - period: 2025 - absolute_error_margin: 0.01 - input: - adjusted_gross_income: 124_200 - filing_status: SINGLE - qualified_passenger_vehicle_loan_interest_person: 7_000 - output: - # 26 CFR 1.163-16(h)(3), Example 2 (91 FR 57241): modified AGI $124,200 - # exceeds $100,000 by $24,200, rounded up to 25 steps of $200, so the - # $7,000 is reduced by $5,000 to $2,000. - auto_loan_interest_deduction: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml index b1452a813ba..5eb199883c3 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml @@ -484,3 +484,45 @@ output: tax_unit_qualified_passenger_vehicle_loan_interest: 4_000 auto_loan_interest_deduction: 4_000 + +- name: Spouses' separate loans share one $10,000 limit on a joint return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + a: + age: 40 + qualified_passenger_vehicle_loan_interest_person: 6_000 + b: + age: 40 + qualified_passenger_vehicle_loan_interest_person: 5_000 + tax_units: + tax_unit: + members: [a, b] + adjusted_gross_income: 150_000 + marital_units: + couple: + members: [a, b] + households: + household: + members: [a, b] + state_code: TX + output: + # 26 CFR 1.163-16(h)(3), Example 1 (91 FR 57241): A pays $6,000 and B + # $5,000 on their own loans; they "can deduct no more than $10,000" on + # their joint return. + tax_unit_qualified_passenger_vehicle_loan_interest: 11_000 + auto_loan_interest_deduction: 10_000 + +- name: Single filer phase-out from the regulation's example + period: 2025 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 124_200 + filing_status: SINGLE + qualified_passenger_vehicle_loan_interest_person: 7_000 + output: + # 26 CFR 1.163-16(h)(3), Example 2 (91 FR 57241): modified AGI $124,200 + # exceeds $100,000 by $24,200, rounded up to 25 steps of $200, so the + # $7,000 is reduced by $5,000 to $2,000. + auto_loan_interest_deduction: 2_000 From afc7741f8bcd16dc0387401e3d7cd63d412bbb47 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 07:33:18 -0400 Subject: [PATCH 7/8] Address Sol review: separate returns, one reference per document, cheaper locality check Cover married people filing separately in YAML and in the property generator, keep one Federal Register reference per variable with the pages in a comment, and reuse the first simulation in the locality check so each property example builds two simulations instead of three. Co-Authored-By: Claude Opus 5.5 --- ...to_loan_interest_attribution_invariants.py | 15 ++++---- ...ified_passenger_vehicle_loan_interest.yaml | 37 +++++++++++++++++++ ...alified_passenger_vehicle_loan_interest.py | 2 +- ..._passenger_vehicle_loan_interest_person.py | 2 +- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py index 12417d23c48..50bc96fbdca 100644 --- a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py +++ b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py @@ -38,7 +38,7 @@ THRESHOLD = {"JOINT": 200_000} # 163(h)(4)(C)(ii)(I); 100,000 otherwise OTHER_THRESHOLD = 100_000 STEP, INCREMENT = 200, 1_000 -KINDS = ["single", "joint", "hoh", "joint_with_child", "adult_dependent"] +KINDS = ["single", "separate", "joint", "hoh", "joint_with_child", "adult_dependent"] # Each kind's dependent, if any, and its age range. An adult dependent (a # student, an elderly parent) can hold their own loan and be the household's # oldest member. @@ -167,6 +167,7 @@ def _situation(households, year, shuffle_seed=None): }, "is_tax_unit_dependent": {year: p["dependent"]}, "is_household_head": {year: p["head_flag"]}, + "is_separated": {year: unit["kind"] == "separate"}, } unit_members.append(name) tax_units[f"t_{h}_{t}"] = {"members": unit_members} @@ -290,8 +291,7 @@ def _zero_odd_units(households): return out -def _check_locality(households, year): - before = _calculate(households, year)["deduction"] +def _check_locality(households, year, before): after = _calculate(_zero_odd_units(households), year)["deduction"] unit_index = np.array( [t for household in households for t in range(len(household["tax_units"]))] @@ -306,12 +306,13 @@ def test_seeded_mixed_batch_2025(): result = _check(households, "2025") # The two tie households at the end: the first listed tax unit holds it. assert result["interest"][-4:].tolist() == [5_000, 0, 5_000, 0] + assert "SEPARATE" in result["filing_status"] def test_seeded_mixed_batch_2026_locality(): households = _seeded_households(seed=20261024) - _check(households, "2026") - _check_locality(households, "2026") + result = _check(households, "2026") + _check_locality(households, "2026", result["deduction"]) def test_people_listed_out_of_household_order(): @@ -330,5 +331,5 @@ def test_people_listed_out_of_household_order(): st.sampled_from(["2025", "2026", "2028"]), ) def test_attribution_invariants(households, year): - _check(households, year) - _check_locality(households, year) + result = _check(households, year) + _check_locality(households, year, result["deduction"]) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml index 5eb199883c3..460e67d825a 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml @@ -485,6 +485,43 @@ tax_unit_qualified_passenger_vehicle_loan_interest: 4_000 auto_loan_interest_deduction: 4_000 +- name: Married people filing separately each have their own $10,000 limit + # 26 CFR 1.163-16(h)(1): the limit applies "per Federal tax return regardless + # of filing status". A joint return would share one $10,000. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + first_spouse: + age: 40 + employment_income: 50_000 + is_separated: true + qualified_passenger_vehicle_loan_interest_person: 12_000 + second_spouse: + age: 38 + employment_income: 50_000 + is_separated: true + qualified_passenger_vehicle_loan_interest_person: 11_000 + tax_units: + first_return: + members: [first_spouse] + second_return: + members: [second_spouse] + marital_units: + marital_unit: + members: [first_spouse, second_spouse] + spm_units: + spm_unit: + members: [first_spouse, second_spouse] + households: + household: + members: [first_spouse, second_spouse] + state_code: TX + output: + filing_status: [SEPARATE, SEPARATE] + tax_unit_qualified_passenger_vehicle_loan_interest: [12_000, 11_000] + auto_loan_interest_deduction: [10_000, 10_000] + - name: Spouses' separate loans share one $10,000 limit on a joint return period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py index 80aadfc58e5..0b5d04b8677 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py @@ -14,8 +14,8 @@ class tax_unit_qualified_passenger_vehicle_loan_interest(Variable): definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/163#h_4_B_i", + # PDF pages 24, 28 "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", - "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=28", ) def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py index c7ed8d55723..650a267f7c9 100644 --- a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py +++ b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py @@ -16,7 +16,7 @@ class qualified_passenger_vehicle_loan_interest_person(Variable): definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/163#h_4_B_i", + # PDF pages 24, 30 "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", - "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=30", ) uprating = "gov.bls.cpi.cpi_u" From b611bfef45cb1b82cf53a1f7fa79c8b17235751f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 07:35:46 -0400 Subject: [PATCH 8/8] List the younger roommate first in the oldest-member fallback case So the case also fails a first-listed-member fallback, which only the property tests caught. Co-Authored-By: Claude Opus 5.5 --- ...ified_passenger_vehicle_loan_interest.yaml | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml index 460e67d825a..a1edfc1ac7f 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml @@ -57,36 +57,36 @@ absolute_error_margin: 0.01 input: people: - older_roommate: - age: 50 - employment_income: 50_000 younger_roommate: age: 25 employment_income: 50_000 + older_roommate: + age: 50 + employment_income: 50_000 tax_units: - older_unit: - members: [older_roommate] younger_unit: members: [younger_roommate] - marital_units: - older_marital_unit: + older_unit: members: [older_roommate] + marital_units: younger_marital_unit: members: [younger_roommate] - spm_units: - older_spm_unit: + older_marital_unit: members: [older_roommate] + spm_units: younger_spm_unit: members: [younger_roommate] + older_spm_unit: + members: [older_roommate] households: household: - members: [older_roommate, younger_roommate] + members: [younger_roommate, older_roommate] state_code: TX qualified_passenger_vehicle_loan_interest: 5_000 output: # Before: [2_500, 2_500]. - tax_unit_qualified_passenger_vehicle_loan_interest: [5_000, 0] - auto_loan_interest_deduction: [5_000, 0] + tax_unit_qualified_passenger_vehicle_loan_interest: [0, 5_000] + auto_loan_interest_deduction: [0, 5_000] - name: A parent's three-person unit and a roommate no longer split by head count period: 2025