From 63539e7fd9f18558fc280e0650698f8fe19bf046 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:53:17 +0000 Subject: [PATCH 1/4] Bar only the claimed spouse from the student loan interest deduction 26 U.S.C. 221(c) denies the deduction to the individual whom another taxpayer claims, not to that individual's unclaimed spouse. The Form 1040 worksheet bars the whole joint return; per the 2026-10-20 ruling the statute governs. Joint MAGI now includes both filers regardless of eligibility, so a claimed spouse's income still counts in the phase-out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6K4grkKNkfjkW6hbaN --- ...dent-loan-interest-claimed-spouse.fixed.md | 1 + ...loan_interest_claimed_spouse_invariants.py | 153 ++++++++++++++++++ .../student_loan_interest_ald.yaml | 104 ++++++++++++ .../student_loan_interest_ald_eligible.yaml | 12 +- .../student_loan_interest_ald_eligible.py | 21 +-- .../student_loan_interest_ald_magi.py | 6 +- 6 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 changelog.d/student-loan-interest-claimed-spouse.fixed.md create mode 100644 policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py diff --git a/changelog.d/student-loan-interest-claimed-spouse.fixed.md b/changelog.d/student-loan-interest-claimed-spouse.fixed.md new file mode 100644 index 00000000000..5790737c6cc --- /dev/null +++ b/changelog.d/student-loan-interest-claimed-spouse.fixed.md @@ -0,0 +1 @@ +Let the unclaimed spouse on a joint return deduct their own student loan interest when the other spouse is claimed as a dependent elsewhere (26 U.S.C. 221(c)), and count both spouses in joint MAGI. diff --git a/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py b/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py new file mode 100644 index 00000000000..6728021af79 --- /dev/null +++ b/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py @@ -0,0 +1,153 @@ +"""Student loan interest deduction on a joint return with a claimed spouse. + +IRC 221(c) denies the deduction "to an individual" for whom another taxpayer +is allowed a dependency deduction. The bar reaches that individual only: on a +joint return the unclaimed spouse keeps the deduction for their own interest, +under the single return-level cap of IRC 221(b)(1) and the joint phase-out of +IRC 221(b)(2), whose MAGI includes both spouses. + +Hypothesis draws batches of joint couples whose head is never claimed. Each +couple runs in one vectorized simulation in four variants: + +- A: the spouse is claimed and has the drawn interest; +- B: the spouse is claimed and has no interest; +- C: the spouse is not claimed and has no interest; +- D: the spouse is not claimed and has the drawn interest. + +The checks: + +1. The unclaimed head's deduction is the same in A, B and C: it never depends + on the claimed spouse's own interest, or on whether a spouse with no + interest is claimed. +2. A claimed spouse's own interest is never deducted: their deduction is zero + and the return's total in A is no more than the head's own interest. +3. The return's deduction never exceeds the cap, in every variant. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 +VARIANTS = { + "A": dict(claimed=True, spouse_interest=True), + "B": dict(claimed=True, spouse_interest=False), + "C": dict(claimed=False, spouse_interest=False), + "D": dict(claimed=False, spouse_interest=True), +} + +money = st.integers(0, 200_000).map(float) +interest = st.one_of(st.just(0.0), st.integers(1, 4_000).map(float)) + + +@st.composite +def couples(draw): + return { + "head_income": draw(money), + "spouse_income": draw(st.one_of(st.just(0.0), money)), + "head_interest": draw(interest), + "spouse_interest": draw(interest), + } + + +def _situation(units, year): + people, tax_units, households = {}, {}, {} + for i, unit in enumerate(units): + for key, variant in VARIANTS.items(): + head, spouse = f"head_{i}_{key}", f"spouse_{i}_{key}" + people[head] = { + "age": {year: 30}, + "employment_income": {year: unit["head_income"]}, + "student_loan_interest": {year: unit["head_interest"]}, + "claimed_as_dependent_on_another_return": {year: False}, + "is_tax_unit_head": {year: True}, + "is_tax_unit_spouse": {year: False}, + } + spouse_interest = ( + unit["spouse_interest"] if variant["spouse_interest"] else 0.0 + ) + people[spouse] = { + "age": {year: 22}, + "employment_income": {year: unit["spouse_income"]}, + "student_loan_interest": {year: spouse_interest}, + "claimed_as_dependent_on_another_return": {year: variant["claimed"]}, + "is_tax_unit_head": {year: False}, + "is_tax_unit_spouse": {year: True}, + } + members = [head, spouse] + tax_units[f"tu_{i}_{key}"] = { + "members": members, + "filing_status": {year: "JOINT"}, + } + households[f"hh_{i}_{key}"] = { + "members": members, + "state_code": {year: "TX"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def _check(units, year): + sim = Simulation(situation=_situation(units, year)) + ald = sim.calculate("student_loan_interest_ald", year).values + own = sim.calculate("student_loan_interest", year).values + cap = sim.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.irs.ald.student_loan_interest.cap.JOINT + n_variants = len(VARIANTS) + # People are ordered unit by unit, variant by variant, head then spouse. + ald = ald.reshape(len(units), n_variants, 2) + own = own.reshape(len(units), n_variants, 2) + a, b, c, d = range(n_variants) + head_ald = ald[:, :, 0] + + # 1. The unclaimed head's deduction ignores the claimed spouse. + np.testing.assert_allclose(head_ald[:, a], head_ald[:, b], atol=TOLERANCE) + np.testing.assert_allclose(head_ald[:, a], head_ald[:, c], atol=TOLERANCE) + + # 2. The claimed spouse's own interest is never deducted. + assert (ald[:, [a, b], 1] == 0).all() + assert (ald[:, a].sum(axis=1) <= own[:, a, 0] + TOLERANCE).all() + + # 3. The return's deduction never exceeds the cap. + assert (ald.sum(axis=2) <= cap + TOLERANCE).all() + assert (ald >= 0).all() + # The claimed spouse doesn't change the unclaimed spouse's deduction + # through the shared cap: with the spouse's interest, the head's share + # in D is never above the head's deduction in A. + assert (head_ald[:, d] <= head_ald[:, a] + TOLERANCE).all() + + +SETTINGS = dict( + max_examples=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(couples(), min_size=10, max_size=30), st.sampled_from([2019, 2025])) +def test_claimed_spouse_invariants(units, year): + _check(units, year) + + +def test_crafted_witnesses(): + # A claimed spouse with interest beside an unclaimed head below and in + # the 2025 joint phase-out ($170,000 start, $30,000 range). + units = [ + dict( + head_income=2_000, + spouse_income=0, + head_interest=1_000, + spouse_interest=700, + ), + dict( + head_income=150_000, + spouse_income=30_000, + head_interest=4_000, + spouse_interest=3_000, + ), + ] + _check(units, 2025) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml index 717160aaf00..dc624c3781b 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml @@ -259,3 +259,107 @@ output: # MAGI 70,000 + 15,000 = 85,000; reduced by (85,000 - 80,000) / 15,000. student_loan_interest_ald: 1_666.67 + +# IRC 221(c) denies the deduction "to an individual" whom another taxpayer +# claims. On a joint return filed only to claim a refund (IRC 152(c)(1)(E)), +# the unclaimed spouse keeps the deduction for their own interest. +- name: Unclaimed spouse retains own loan-interest deduction on a refund-only joint return + period: 2025 + input: + people: + unclaimed: + age: 30 + is_tax_unit_head: true + is_tax_unit_spouse: false + employment_income: 2_000 + student_loan_interest: 1_000 + claimed_student: + age: 22 + is_full_time_student: true + is_tax_unit_head: false + is_tax_unit_spouse: true + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: true + student_loan_interest: 700 + tax_units: + joint: + members: [unclaimed, claimed_student] + filing_status: JOINT + households: + household: + members: [unclaimed, claimed_student] + state_code: TX + output: + # Eligible interest 1,000 + 0; the 2,500 cap and the 170,000 phase-out + # start don't bind. AGI = 2,000 - 1,000. + student_loan_interest_ald_eligible: [true, false] + student_loan_interest_ald: [1_000, 0] + adjusted_gross_income: 1_000 + +- name: Claimed head does not bar the unclaimed spouse or drop out of joint MAGI + period: 2025 + input: + people: + claimed_student: + age: 22 + is_full_time_student: true + is_tax_unit_head: true + is_tax_unit_spouse: false + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: true + employment_income: 1_000 + student_loan_interest: 700 + unclaimed: + age: 30 + is_tax_unit_head: false + is_tax_unit_spouse: true + employment_income: 4_000 + student_loan_interest: 4_000 + tax_units: + joint: + members: [claimed_student, unclaimed] + filing_status: JOINT + households: + household: + members: [claimed_student, unclaimed] + state_code: TX + output: + # Eligible interest 4,000, capped once at 2,500. Joint MAGI + # 1,000 + 4,000 = 5,000. AGI = 5,000 - 2,500. + student_loan_interest_ald_eligible: [false, true] + student_loan_interest_ald_magi: [1_000, 4_000] + student_loan_interest_ald: [0, 2_500] + adjusted_gross_income: 2_500 + +# Mechanics only: the claim is set as an input. The joint phase-out reads +# both spouses' MAGI (IRC 221(b)(2)(B)), including a claimed spouse's. +- name: Joint phase-out counts the claimed spouse's MAGI + period: 2025 + absolute_error_margin: 0.01 + input: + people: + unclaimed: + age: 30 + is_tax_unit_head: true + is_tax_unit_spouse: false + employment_income: 150_000 + student_loan_interest: 2_500 + claimed_spouse: + age: 22 + is_tax_unit_head: false + is_tax_unit_spouse: true + claimed_as_dependent_on_another_return: true + employment_income: 30_000 + tax_units: + joint: + members: [unclaimed, claimed_spouse] + filing_status: JOINT + households: + household: + members: [unclaimed, claimed_spouse] + state_code: TX + output: + # Joint MAGI 180,000; reduced by (180,000 - 170,000) / 30,000: + # 2,500 x (1 - 1/3) = 1,666.67. + student_loan_interest_ald_magi: [150_000, 30_000] + student_loan_interest_ald: [1_666.67, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_eligible.yaml index e161077097f..4241b0edb0c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_eligible.yaml @@ -56,7 +56,7 @@ # IRC 221(c). student_loan_interest_ald_eligible: false -- name: 2024 joint return with one spouse claimed elsewhere cannot deduct student loan interest (the claimable spouse is the head) +- name: 2024 joint return with the head claimed elsewhere; only the claimed head is barred period: 2024 input: people: @@ -78,11 +78,11 @@ members: [person_a, person_b] state_code: TX output: - # Form 1040 instructions: "you, or your spouse if filing jointly, aren't - # claimed as a dependent". - student_loan_interest_ald_eligible: [false, false] + # IRC 221(c) bars the claimed individual only; the statute governs the + # Form 1040 instructions, which bar the whole joint return. + student_loan_interest_ald_eligible: [false, true] -- name: 2024 joint return with one spouse claimed elsewhere cannot deduct student loan interest (the claimable spouse is the spouse) +- name: 2024 joint return with the spouse claimed elsewhere; only the claimed spouse is barred period: 2024 input: people: @@ -104,7 +104,7 @@ members: [person_b, person_a] state_code: TX output: - student_loan_interest_ald_eligible: [false, false] + student_loan_interest_ald_eligible: [true, false] # IRC 221(c): the bar applies to an individual who is claimed. - name: 2024 single filer who can be claimed but is not claimed can deduct student loan interest diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py index fd1bcc2bab0..5ba456d3ce1 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py @@ -6,11 +6,13 @@ class student_loan_interest_ald_eligible(Variable): entity = Person label = "Eligible for the Student loan interest ALD" definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/221" + reference = "https://www.law.cornell.edu/uscode/text/26/221#c" documentation = ( "Eligibility for the student loan interest above-the-line deduction. " "Per IRC § 221, the taxpayer must not file as Married Filing Separately " - "(§ 221(e)(2)) and cannot be claimed as a dependent (§ 221(c)). " + "(§ 221(e)(2)) and must not be an individual whom another taxpayer claims as a dependent " + "(§ 221(c)); on a joint return that bar applies to the claimed spouse " + "only. " "The MAGI phase-out is handled separately in the deduction calculation. " "Note: While § 221(d)(3) references § 25A(b)(3) for the definition of " "'eligible student', this refers to the student's status when the loan " @@ -20,15 +22,14 @@ class student_loan_interest_ald_eligible(Variable): def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period) - # Per IRC § 221(c), no deduction for an individual for whom a section - # 151 deduction "is allowed to another taxpayer"; the Form 1040 - # instructions require that "you, or your spouse if filing jointly, - # aren't claimed as a dependent", so either filer being claimed bars - # the return. This turns on an actual claim, so it reads the claimed - # input rather than a claimability helper. + # Per IRC § 221(c), no deduction "to an individual" for whom a + # section 151 deduction "is allowed to another taxpayer". The bar + # reaches that individual only: on a joint return the other spouse + # keeps the deduction for their own interest, although the Form 1040 + # instructions bar the whole return. This turns on an actual claim, + # so it reads the claimed input rather than a claimability helper. claimed = person("claimed_as_dependent_on_another_return", period) - filer_claimed = person.tax_unit.any(head_or_spouse & claimed) # Per IRC § 221(e)(2), taxpayer cannot file as Married Filing Separately filing_status = person.tax_unit("filing_status", period) separate = filing_status == filing_status.possible_values.SEPARATE - return head_or_spouse & ~separate & ~filer_claimed + return head_or_spouse & ~separate & ~claimed diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py index 5fa8a8768cc..e5717748c10 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py @@ -14,13 +14,15 @@ class student_loan_interest_ald_magi(Variable): "222), and after application of sections 86, 135, 137, 219 and 469. " "Built from gross income sources rather than adjusted gross income, " "which includes this deduction. On a joint return each spouse " - "carries half of the tax unit's deductions and exclusions." + "carries half of the tax unit's deductions and exclusions. Joint " + "MAGI includes both spouses, including one claimed as a dependent " + "elsewhere who cannot take the deduction." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/221#b_2_C", "https://www.irs.gov/pub/irs-prior/p970--2024.pdf#page=36", ) - defined_for = "student_loan_interest_ald_eligible" + defined_for = "is_tax_unit_head_or_spouse" def formula(person, period, parameters): p_irs = parameters(period).gov.irs From 6ba66caa5bdbd02fe69ec700e3f4259bf579f3f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:16:17 +0000 Subject: [PATCH 2/4] Test the claimed-spouse student loan interest invariants A Hypothesis property test checks that an unclaimed filer's deduction ignores the claimed spouse, that a claimed spouse's own interest is never deducted, and that the return never exceeds the 221(b)(1) cap. The float32 determinism probe sets is_tax_unit_head_or_spouse, which MAGI's defined_for now reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6K4grkKNkfjkW6hbaN --- policyengine_us/tests/core/test_ald_determinism.py | 1 + .../test_student_loan_interest_claimed_spouse_invariants.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 4dd95f6dd2c..4196b389ea8 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -38,6 +38,7 @@ "alimony_expense_ald": 1, "health_savings_account_ald": 1, "student_loan_interest_ald_eligible": True, + "is_tax_unit_head_or_spouse": True, "is_tax_unit_dependent": False, "is_tax_unit_head": True, "is_tax_unit_spouse": False, diff --git a/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py b/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py index 6728021af79..adf5f0a67da 100644 --- a/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py +++ b/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py @@ -90,8 +90,8 @@ def _situation(units, year): def _check(units, year): sim = Simulation(situation=_situation(units, year)) - ald = sim.calculate("student_loan_interest_ald", year).values - own = sim.calculate("student_loan_interest", year).values + ald = sim.calculate("student_loan_interest_ald", year) + own = sim.calculate("student_loan_interest", year) cap = sim.tax_benefit_system.parameters( f"{year}-01-01" ).gov.irs.ald.student_loan_interest.cap.JOINT From 60bf6c66868294c14491f66b3c4b49703be282cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:02:54 +0000 Subject: [PATCH 3/4] Close review gaps on the claimed-spouse student loan interest change The property test now draws whether the claimed filer is the head or the spouse, so a bar that reached only a claimed spouse fails it, and pins phase-out examples where the claimed filer's income moves joint MAGI in 2019 and 2025. The candidates register splits the student loan interest half off the education-credit item, since PE-US now follows 221(c). Hawaii and Kentucky each get a joint case with a claimed spouse, the states where this eligibility reaches state tax. The eligibility reference now cites 221(e)(2) as well as 221(c). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FN4kh1QukzuAxXmrD --- ...loan_interest_claimed_spouse_invariants.py | 142 +++++++++++------- .../hi_student_loan_interest_adjustment.yaml | 41 +++++ .../states/ky/tax/income/ky_federal_agi.yaml | 36 +++++ .../student_loan_interest_ald_eligible.py | 11 +- source_conflicts/candidates.yaml | 47 ++++-- 5 files changed, 213 insertions(+), 64 deletions(-) diff --git a/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py b/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py index adf5f0a67da..46300826ea6 100644 --- a/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py +++ b/policyengine_us/tests/core/test_student_loan_interest_claimed_spouse_invariants.py @@ -6,26 +6,33 @@ under the single return-level cap of IRC 221(b)(1) and the joint phase-out of IRC 221(b)(2), whose MAGI includes both spouses. -Hypothesis draws batches of joint couples whose head is never claimed. Each -couple runs in one vectorized simulation in four variants: +Hypothesis draws batches of joint couples with one unclaimed filer and one +filer who may be claimed. Each couple draws whether the claimable filer is the +head or the spouse, so the checks hold for either claimed filer. Each couple +runs in one vectorized simulation in four variants: -- A: the spouse is claimed and has the drawn interest; -- B: the spouse is claimed and has no interest; -- C: the spouse is not claimed and has no interest; -- D: the spouse is not claimed and has the drawn interest. +- A: the claimable filer is claimed and has the drawn interest; +- B: the claimable filer is claimed and has no interest; +- C: the claimable filer is not claimed and has no interest; +- D: the claimable filer is not claimed and has the drawn interest. The checks: -1. The unclaimed head's deduction is the same in A, B and C: it never depends - on the claimed spouse's own interest, or on whether a spouse with no +1. The unclaimed filer's deduction is the same in A, B and C: it never + depends on the claimed filer's own interest, or on whether a filer with no interest is claimed. -2. A claimed spouse's own interest is never deducted: their deduction is zero - and the return's total in A is no more than the head's own interest. +2. A claimed filer's own interest is never deducted: their deduction is zero + and the return's total in A is no more than the unclaimed filer's own + interest. 3. The return's deduction never exceeds the cap, in every variant. + +Explicit examples put a claimed filer's income inside the joint phase-out in +both years, with the claimed filer as head and as spouse, so a joint MAGI that +dropped the claimed filer would break check 1 by hundreds of dollars. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_us import Simulation @@ -45,38 +52,78 @@ @st.composite def couples(draw): return { - "head_income": draw(money), - "spouse_income": draw(st.one_of(st.just(0.0), money)), - "head_interest": draw(interest), - "spouse_interest": draw(interest), + "claimed_role": draw(st.sampled_from(["spouse", "head"])), + "unclaimed_income": draw(money), + "claimed_income": draw(st.one_of(st.just(0.0), money)), + "unclaimed_interest": draw(interest), + "claimed_interest": draw(interest), } +def couple( + claimed_role, + unclaimed_income, + claimed_income, + unclaimed_interest, + claimed_interest, +): + return dict( + claimed_role=claimed_role, + unclaimed_income=float(unclaimed_income), + claimed_income=float(claimed_income), + unclaimed_interest=float(unclaimed_interest), + claimed_interest=float(claimed_interest), + ) + + +# Couples whose claimed filer's income moves the joint phase-out. 2025: start +# $170,000, range $30,000; 2019: start $140,000, range $30,000. +PHASE_OUT_2025 = [ + # Joint MAGI 150,000 + 30,000 = 180,000: 2,500 x (1 - 10/30) = 1,666.67, + # against 2,500 if the claimed spouse's 30,000 were dropped. + couple("spouse", 150_000, 30_000, 2_500, 1_000), + # The same with the head claimed. + couple("head", 150_000, 30_000, 2_500, 1_000), + # Joint MAGI 165,000 + 20,000 = 185,000: 2,000 x (1 - 15/30) = 1,000, + # against 2,000 x (1 - 0) = 2,000 at 165,000. + couple("spouse", 165_000, 20_000, 2_000, 3_000), + couple("spouse", 2_000, 0, 1_000, 700), +] +PHASE_OUT_2019 = [ + # Joint MAGI 130,000 + 20,000 = 150,000: 2,000 x (1 - 10/30) = 1,333.33, + # against 2,000 at 130,000. + couple("spouse", 130_000, 20_000, 2_000, 500), + couple("head", 130_000, 20_000, 2_000, 500), + couple("head", 0, 0, 0, 0), +] + + def _situation(units, year): people, tax_units, households = {}, {}, {} for i, unit in enumerate(units): + claimed_is_head = unit["claimed_role"] == "head" for key, variant in VARIANTS.items(): - head, spouse = f"head_{i}_{key}", f"spouse_{i}_{key}" - people[head] = { + unclaimed, claimed = f"unclaimed_{i}_{key}", f"claimed_{i}_{key}" + people[unclaimed] = { "age": {year: 30}, - "employment_income": {year: unit["head_income"]}, - "student_loan_interest": {year: unit["head_interest"]}, + "employment_income": {year: unit["unclaimed_income"]}, + "student_loan_interest": {year: unit["unclaimed_interest"]}, "claimed_as_dependent_on_another_return": {year: False}, - "is_tax_unit_head": {year: True}, - "is_tax_unit_spouse": {year: False}, + "is_tax_unit_head": {year: not claimed_is_head}, + "is_tax_unit_spouse": {year: claimed_is_head}, } - spouse_interest = ( - unit["spouse_interest"] if variant["spouse_interest"] else 0.0 + claimed_interest = ( + unit["claimed_interest"] if variant["spouse_interest"] else 0.0 ) - people[spouse] = { + people[claimed] = { "age": {year: 22}, - "employment_income": {year: unit["spouse_income"]}, - "student_loan_interest": {year: spouse_interest}, + "employment_income": {year: unit["claimed_income"]}, + "student_loan_interest": {year: claimed_interest}, "claimed_as_dependent_on_another_return": {year: variant["claimed"]}, - "is_tax_unit_head": {year: False}, - "is_tax_unit_spouse": {year: True}, + "is_tax_unit_head": {year: claimed_is_head}, + "is_tax_unit_spouse": {year: not claimed_is_head}, } - members = [head, spouse] + members = [unclaimed, claimed] tax_units[f"tu_{i}_{key}"] = { "members": members, "filing_status": {year: "JOINT"}, @@ -96,27 +143,28 @@ def _check(units, year): f"{year}-01-01" ).gov.irs.ald.student_loan_interest.cap.JOINT n_variants = len(VARIANTS) - # People are ordered unit by unit, variant by variant, head then spouse. + # People are ordered unit by unit, variant by variant, unclaimed filer + # then claimable filer, whichever of them is the head. ald = ald.reshape(len(units), n_variants, 2) own = own.reshape(len(units), n_variants, 2) a, b, c, d = range(n_variants) - head_ald = ald[:, :, 0] + unclaimed_ald = ald[:, :, 0] - # 1. The unclaimed head's deduction ignores the claimed spouse. - np.testing.assert_allclose(head_ald[:, a], head_ald[:, b], atol=TOLERANCE) - np.testing.assert_allclose(head_ald[:, a], head_ald[:, c], atol=TOLERANCE) + # 1. The unclaimed filer's deduction ignores the claimed filer. + np.testing.assert_allclose(unclaimed_ald[:, a], unclaimed_ald[:, b], atol=TOLERANCE) + np.testing.assert_allclose(unclaimed_ald[:, a], unclaimed_ald[:, c], atol=TOLERANCE) - # 2. The claimed spouse's own interest is never deducted. + # 2. The claimed filer's own interest is never deducted. assert (ald[:, [a, b], 1] == 0).all() assert (ald[:, a].sum(axis=1) <= own[:, a, 0] + TOLERANCE).all() # 3. The return's deduction never exceeds the cap. assert (ald.sum(axis=2) <= cap + TOLERANCE).all() assert (ald >= 0).all() - # The claimed spouse doesn't change the unclaimed spouse's deduction - # through the shared cap: with the spouse's interest, the head's share - # in D is never above the head's deduction in A. - assert (head_ald[:, d] <= head_ald[:, a] + TOLERANCE).all() + # The claimed filer doesn't change the unclaimed filer's deduction + # through the shared cap: with the claimable filer's interest, the + # unclaimed filer's share in D is never above their deduction in A. + assert (unclaimed_ald[:, d] <= unclaimed_ald[:, a] + TOLERANCE).all() SETTINGS = dict( @@ -128,6 +176,8 @@ def _check(units, year): @settings(**SETTINGS) +@example(PHASE_OUT_2025, 2025) +@example(PHASE_OUT_2019, 2019) @given(st.lists(couples(), min_size=10, max_size=30), st.sampled_from([2019, 2025])) def test_claimed_spouse_invariants(units, year): _check(units, year) @@ -137,17 +187,7 @@ def test_crafted_witnesses(): # A claimed spouse with interest beside an unclaimed head below and in # the 2025 joint phase-out ($170,000 start, $30,000 range). units = [ - dict( - head_income=2_000, - spouse_income=0, - head_interest=1_000, - spouse_interest=700, - ), - dict( - head_income=150_000, - spouse_income=30_000, - head_interest=4_000, - spouse_interest=3_000, - ), + couple("spouse", 2_000, 0, 1_000, 700), + couple("spouse", 150_000, 30_000, 4_000, 3_000), ] _check(units, 2025) diff --git a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/hi_student_loan_interest_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/hi_student_loan_interest_adjustment.yaml index a40d25ee3fd..682cd09349e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/hi_student_loan_interest_adjustment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/hi_student_loan_interest_adjustment.yaml @@ -273,3 +273,44 @@ hi_subtractions: 5_000 # $62,500 + $1,666.67 addition - $5,000 pension subtraction. hi_agi: 59_166.67 + +- name: Hawaii deduction keeps the unclaimed head's interest on a joint return with a claimed spouse + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 2_000 + student_loan_interest: 1_000 + is_tax_unit_head: true + spouse: + age: 22 + is_full_time_student: true + is_tax_unit_spouse: true + claimable_as_dependent_on_another_return: true + claimed_as_dependent_on_another_return: true + student_loan_interest: 700 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + spm_units: + spm_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: HI + output: + # IRC 221(c) bars only the claimed spouse, so Hawaii counts the head's + # interest only: min(1,000, 2,500 cap) = 1,000. Federal AGI is + # 2,000 - 1,000 = 1,000; Hawaii MAGI adds back the 1,000 federal + # deduction, giving 2,000, below the 100,000 joint phase-out start. + student_loan_interest_ald_eligible: [true, false] + hi_modified_agi: 2_000 + hi_student_loan_interest_deduction: 1_000 + # Hawaii deduction 1,000 - federal deduction 1,000 = 0, so Hawaii AGI + # equals federal AGI. + hi_student_loan_interest_adjustment: 0 + hi_agi: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml index faebdf8b0fd..e7526a24cc6 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml @@ -361,3 +361,39 @@ output: adjusted_gross_income: 40_000 ky_federal_agi: [0, 40_000] + +# IRC 221(c) bars only the claimed spouse from the student loan interest +# deduction; Kentucky starts each column from the filer's own federal AGI. +- name: Unclaimed head's student loan interest deduction lowers Kentucky AGI on a joint return with a claimed spouse + period: 2025 + input: + people: + head: + age: 30 + employment_income: 50_000 + student_loan_interest: 2_000 + is_tax_unit_head: true + spouse: + age: 22 + is_tax_unit_spouse: true + claimed_as_dependent_on_another_return: true + student_loan_interest: 1_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: KY + output: + # Federal: only the head's 2,000 is eligible; joint MAGI 50,000 is below + # the 170,000 phase-out start. Head AGI 50,000 - 2,000 = 48,000. + student_loan_interest_ald: [2_000, 0] + ky_federal_agi: [48_000, 0] + ky_agi: [48_000, 0] + # Joint: (48,000 - 3,270 standard deduction) x 4% = 1,789.20. The + # separate path gives the same (the spouse's column is 0), so the joint + # path stands. Family size credit: Kentucky modified AGI 48,000 is above + # its range for two; no personal or refundable credits apply. + ky_income_tax: 1_789.20 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py index 5ba456d3ce1..ae5de35368c 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py @@ -6,13 +6,16 @@ class student_loan_interest_ald_eligible(Variable): entity = Person label = "Eligible for the Student loan interest ALD" definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/221#c" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/221#c", + "https://www.law.cornell.edu/uscode/text/26/221#e_2", + ) documentation = ( "Eligibility for the student loan interest above-the-line deduction. " "Per IRC § 221, the taxpayer must not file as Married Filing Separately " - "(§ 221(e)(2)) and must not be an individual whom another taxpayer claims as a dependent " - "(§ 221(c)); on a joint return that bar applies to the claimed spouse " - "only. " + "(§ 221(e)(2)) and must not be an individual whom another taxpayer " + "claims as a dependent (§ 221(c)); on a joint return that bar " + "applies to the claimed spouse only. " "The MAGI phase-out is handled separately in the deduction calculation. " "Note: While § 221(d)(3) references § 25A(b)(3) for the definition of " "'eligible student', this refers to the student's status when the loan " diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 1cea80fc061..7b7bca4a8db 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10215,30 +10215,29 @@ candidates: years: from: 2018 type: tax - question: Does claiming one spouse as a dependent bar only that spouse's education credits and student loan interest - deduction, or the whole joint return? + question: Does claiming one spouse as a dependent bar only that spouse's education credits, or the whole joint + return? sources: - kind: statute - citation: 26 U.S.C. 25A(g)(3); 26 U.S.C. 221(c) + citation: 26 U.S.C. 25A(g)(3) url: https://www.law.cornell.edu/uscode/text/26/25A#g_3 - kind: instructions - citation: Form 1040 instructions (education credits and student loan interest) + citation: Form 1040 instructions (education credits) url: https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=98 readings: - statute: The claimed spouse loses the benefit; the other spouse keeps their own credit or deduction. + statute: The claimed spouse loses the credit; the other spouse keeps their own credit. other: The whole joint return is barred. pe_us: follows: conflicting-source code: - policyengine_us/variables/gov/irs/credits/education/is_barred_from_education_credits_by_dependency.py - - policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py prs: - https://github.com/PolicyEngine/policyengine-us/pull/10087 state: ruled note: 'PE-US follows the Form 1040 instructions: a claimed head or spouse bars the whole joint return from the - education credits and the student loan interest deduction (is_barred_from_education_credits_by_dependency.py, - student_loan_interest_ald_eligible.py). Ruled October 2026; the statute-first rule now requires changing the - whole-return bar; verify the education-credit page anchor because the supplied URL is for student loan interest.' + education credits (is_barred_from_education_credits_by_dependency.py). Ruled October 2026; the statute-first + rule now requires changing the whole-return bar; verify the education-credit page anchor because the supplied + URL is for student loan interest.' - id: us:eitc-trust-royalties-investment-income program: income-tax years: @@ -10723,6 +10722,36 @@ candidates: state: unreviewed note: 'The open PR follows POMS rather than the broader regulatory reading; no statutory comparison or specific ruling is supplied. Code not yet on main: ssi_spousal_deeming_child_allocations, ssi_ineligible_child_allocation.' +- id: us:student-loan-interest-joint-claimed-spouse + program: income-tax + years: + from: 2018 + type: tax + question: Does claiming one spouse as a dependent bar only that spouse's student loan interest deduction, or the + whole joint return? + sources: + - kind: statute + citation: 26 U.S.C. 221(c) + url: https://www.law.cornell.edu/uscode/text/26/221#c + - kind: instructions + citation: 2025 Form 1040 instructions, Schedule 1 line 21 (student loan interest deduction) + url: https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=98 + readings: + statute: Section 221(c) bars only the individual whom another taxpayer claims; the other spouse keeps the deduction + for their own interest. + other: The instructions bar the return if the filer, or the spouse on a joint return, is claimed as a dependent. + example: On a 2025 refund-only joint return, a head with $2,000 wages and $1,000 interest, beside a spouse claimed + elsewhere with $700 interest, deducts $1,000 under the statute and nothing under the instructions. + pe_us: + follows: statute + code: + - policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_eligible.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10577 + state: ruled + note: 'Ruled 2026-10-20 for the statute by the project lead. PE-US bars only the claimed head or spouse + (student_loan_interest_ald_eligible.py); joint MAGI for the section 221(b)(2) phase-out still counts both spouses. + Split from us:education-benefits-joint-claimed-spouse, which keeps the education credits.' - id: us:surviving-spouse-child-gross-income-test program: income-tax years: From 63fde0b1afbe50c7a68cf42887713c9b23b2dfac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:16:18 +0000 Subject: [PATCH 4/4] Label the Kentucky claimed-spouse case as mechanics only Its head earns enough that the joint return couldn't be filed only for a refund, so the claim is an input that pins the column arithmetic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FN4kh1QukzuAxXmrD --- .../baseline/gov/states/ky/tax/income/ky_federal_agi.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml index e7526a24cc6..24d98364314 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml @@ -364,6 +364,8 @@ # IRC 221(c) bars only the claimed spouse from the student loan interest # deduction; Kentucky starts each column from the filer's own federal AGI. +# Mechanics only: the claim is set as an input on a return that couldn't be +# refund-only at this income. - name: Unclaimed head's student loan interest deduction lowers Kentucky AGI on a joint return with a claimed spouse period: 2025 input: