From fbb459026a65fa78ed6dd1d6d90866ccb7aca2d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:56:14 +0000 Subject: [PATCH 1/4] Give separate filers the $3,750 Schedule R initial amount section_22_income's select checked one qualified individual before the separate-return condition, so a married individual filing separately got $5,000 instead of the $3,750 that 22(c)(2)(A)(iii) sets. Check the separate return first. Also allow the credit on a separate return only when the spouses lived apart all year (22(e)(1)), using the existing cohabitating_spouses input. Co-Authored-By: Claude Opus 5.5 --- ...lderly-credit-separate-filer-base.fixed.md | 1 + .../section_22_income.yaml | 166 ++++++++++++++++++ .../elderly_disabled_credit_potential.py | 13 +- .../elderly_and_disabled/section_22_income.py | 11 +- 4 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 changelog.d/elderly-credit-separate-filer-base.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml diff --git a/changelog.d/elderly-credit-separate-filer-base.fixed.md b/changelog.d/elderly-credit-separate-filer-base.fixed.md new file mode 100644 index 00000000000..a88628c029a --- /dev/null +++ b/changelog.d/elderly-credit-separate-filer-base.fixed.md @@ -0,0 +1 @@ +Give a married individual filing separately the $3,750 Schedule R initial amount, and allow the elderly or disabled credit on a separate return only when the spouses lived apart all year (26 U.S.C. 22(c)(2)(A), 22(e)(1)). diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml new file mode 100644 index 00000000000..06905e14ac6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml @@ -0,0 +1,166 @@ +# Section 22 amount (Schedule R line 22) by filing status, 26 U.S.C. 22(c) +# and (d). Every case has wages only (no pensions or social security) unless +# noted, so AGI equals wages. + +- name: A separate filer aged 67 who lived apart all year starts from $3,750. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # 22(c)(2)(A)(iii): $3,750. AGI $3,000 is under the $5,000 separate + # threshold, so no phase-out: 3,750. + section_22_income: 3_750 + # 22(a): 15% x 3,750 = 562.50. + elderly_disabled_credit_potential: 562.5 + +- name: A separate filer who lived with their spouse has the same amount but no credit. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: true + households: + household: + members: [head] + state_code: TX + output: + section_22_income: 3_750 + # 22(e)(1): married spouses who did not live apart all year must file + # jointly to take the credit. + elderly_disabled_credit_potential: 0 + elderly_disabled_credit: 0 + +- name: A separate filer's $3,750 is phased out and reduced by nontaxable social security. + period: 2024 + input: + people: + head: + age: 70 + employment_income: 6_000 + social_security_retirement: 1_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # Provisional income 6,000 + 500 = 6,500 is under the $25,000 base for + # a separate filer who lived apart, so all $1,000 of benefits is + # nontaxable and AGI is 6,000. + tax_unit_taxable_social_security: 0 + adjusted_gross_income: 6_000 + # 3,750 - 1,000 nontaxable benefits - 0.5 x (6,000 - 5,000) = 2,250. + section_22_income: 2_250 + # 15% x 2,250 = 337.50. + elderly_disabled_credit_potential: 337.5 + +- name: A separate filer under 65 is capped at disability income. + period: 2024 + input: + people: + head: + age: 60 + retired_on_total_disability: true + employment_income: 2_000 + total_disability_payments: 2_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # 22(c)(2)(B): min(3,750, 2,000 disability income) = 2,000; AGI 2,000 + # is under the threshold. + section_22_income: 2_000 + elderly_disabled_credit_potential: 300 + +- name: A single filer aged 67 starts from $5,000. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: TX + output: + # 22(c)(2)(A)(i): $5,000; AGI 3,000 < 7,500. + section_22_income: 5_000 + elderly_disabled_credit_potential: 750 + +- name: A joint return with one qualified spouse starts from $5,000. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: TX + output: + # 22(c)(2)(A)(i): $5,000; AGI 3,000 < 10,000. + section_22_income: 5_000 + elderly_disabled_credit_potential: 750 + +- name: A joint return with two qualified spouses starts from $7,500. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + spouse: + age: 66 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: TX + output: + # 22(c)(2)(A)(ii): $7,500; AGI 3,000 < 10,000. + section_22_income: 7_500 + # 15% x 7,500 = 1,125. + elderly_disabled_credit_potential: 1_125 diff --git a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py index 8fc9f1143b2..df21419aa7c 100644 --- a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py +++ b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py @@ -8,8 +8,17 @@ class elderly_disabled_credit_potential(Variable): label = "Potential value of the Elderly or disabled credit" documentation = "Schedule R credit for the elderly and the disabled" unit = USD - reference = "https://www.law.cornell.edu/uscode/text/26/22" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/22#a", + "https://www.law.cornell.edu/uscode/text/26/22#e_1", + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.credits.elderly_or_disabled - return p.rate * tax_unit("section_22_income", period) + # Section 22(e)(1): a married individual filing a separate return + # gets the credit only if the spouses lived apart all year. + filing_status = tax_unit("filing_status", period) + separate = filing_status == filing_status.possible_values.SEPARATE + lived_together = tax_unit("cohabitating_spouses", period) + allowed = ~(separate & lived_together) + return allowed * p.rate * tax_unit("section_22_income", period) diff --git a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py index 07760c79d44..496f8909314 100644 --- a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py +++ b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py @@ -8,7 +8,7 @@ class section_22_income(Variable): unit = USD documentation = "Income upon which the elderly or disabled credit is applied" definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/22" + reference = "https://www.law.cornell.edu/uscode/text/26/22#c" def formula(tax_unit, period, parameters): elderly_disabled = parameters(period).gov.irs.credits.elderly_or_disabled @@ -18,16 +18,21 @@ def formula(tax_unit, period, parameters): num_qualifying_individuals = tax_unit.sum( person("qualifies_for_elderly_or_disabled_credit", period) ) + separate = filing_status == filing_status.possible_values.SEPARATE + # Section 22(c)(2)(A)(iii) sets a separate filer's initial amount, + # so the separate-return condition comes first: select takes the + # first true condition, and a separate filer has one qualified + # individual. initial_amount = select( [ + separate & (num_qualifying_individuals >= 1), num_qualifying_individuals == 1, num_qualifying_individuals == 2, - filing_status == filing_status.possible_values.SEPARATE, ], [ + elderly_disabled.amount.separate, elderly_disabled.amount.one_qualified, elderly_disabled.amount.two_qualified, - elderly_disabled.amount.separate, ], default=0, ) From 5fd88537b0a735b36001d1b3c945b38f88218575 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:19:15 +0000 Subject: [PATCH 2/4] Add properties for the separate filer's section 22 amount and the credit limit A Hypothesis test runs batches of mixed tax units and checks that a separate filer's section 22 amount never exceeds $3,750 and that the credit never exceeds the Schedule R credit limit or tax before credits. Restoring main's select order makes the first property fail. Co-Authored-By: Claude Opus 5.5 --- ...test_elderly_disabled_credit_invariants.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py diff --git a/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py new file mode 100644 index 00000000000..48dcb12fb1f --- /dev/null +++ b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py @@ -0,0 +1,92 @@ +"""Properties of the elderly or disabled credit (26 U.S.C. 22, Schedule R). + +1. A married individual filing a separate return starts from $3,750 + (22(c)(2)(A)(iii)), and the later steps only reduce it, so the section 22 + amount for a separate filer never exceeds $3,750. +2. The credit never exceeds the Schedule R credit limit: tax before credits + less the credits that precede it. + +Each Hypothesis example is a batch of tax units run as one vectorized +simulation, so the test checks the formulas on mixed arrays as well. +""" + +from hypothesis import HealthCheck, given, settings, strategies as st + +from policyengine_us import Simulation + +YEAR = 2024 +SEPARATE_INITIAL_AMOUNT = 3_750 + +FILING_STATUSES = ["SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD"] + +money = st.one_of(st.just(0.0), st.floats(0, 60_000).map(round)) + +case = st.fixed_dictionaries( + { + "filing_status": st.sampled_from(FILING_STATUSES), + "cohabitating": st.booleans(), + "head_age": st.integers(40, 90), + "spouse_age": st.integers(40, 90), + "head_disabled": st.booleans(), + "wages": money, + "pension": money, + "taxable_share": st.floats(0, 1), + "social_security": money, + "disability_payments": st.floats(0, 10_000).map(round), + } +) + + +def build_situation(cases): + people, tax_units, households = {}, {}, {} + for i, c in enumerate(cases): + head = f"head_{i}" + pension = c["pension"] + people[head] = { + "age": {YEAR: c["head_age"]}, + "retired_on_total_disability": {YEAR: c["head_disabled"]}, + "employment_income": {YEAR: c["wages"]}, + "total_disability_payments": { + YEAR: min(c["disability_payments"], c["wages"]) + }, + "pension_income": {YEAR: pension}, + "taxable_pension_income": {YEAR: round(pension * c["taxable_share"])}, + "social_security_retirement": {YEAR: c["social_security"]}, + } + members = [head] + if c["filing_status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = {"age": {YEAR: c["spouse_age"]}} + members.append(spouse) + tax_units[f"tax_unit_{i}"] = { + "members": members, + "filing_status": {YEAR: c["filing_status"]}, + "cohabitating_spouses": {YEAR: c["cohabitating"]}, + } + households[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +@settings( + max_examples=10, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(case, min_size=8, max_size=8)) +def test_separate_amount_and_credit_limit(cases): + sim = Simulation(situation=build_situation(cases)) + filing_status = sim.calculate("filing_status", YEAR).decode_to_str() + section_22 = sim.calculate("section_22_income", YEAR) + credit = sim.calculate("elderly_disabled_credit", YEAR) + credit_limit = sim.calculate("elderly_disabled_credit_credit_limit", YEAR) + tax_before_credits = sim.calculate("income_tax_before_credits", YEAR) + + separate = filing_status == "SEPARATE" + assert (section_22[separate] <= SEPARATE_INITIAL_AMOUNT).all() + assert (section_22 >= 0).all() + assert (credit >= 0).all() + assert (credit <= credit_limit + 1e-6).all() + assert (credit <= tax_before_credits + 1e-6).all() From 137446fdafc39c56eb97a88d7f4983f65048e601 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:23:29 +0000 Subject: [PATCH 3/4] Apply the 22(e)(1) separate-filer bar in section_22_income Michigan's subtraction reads section_22_income, the Schedule R line 19 amount. A separate filer who lived with the spouse can check neither box 8 nor box 9 and has no line 19 amount, so the bar belongs on the amount itself, not on the credit. The potential credit is again rate x section_22_income; federal results are unchanged. Also: explain why the separate condition tests n >= 1 (dependents can qualify), point the phase-out references at 22(d), fix the Schedule R line in the YAML header, add YAML cases for a qualifying dependent, a cohabiting joint return and a Michigan separate filer, and replace the credit-limit property, which could not fail, with a differential test against main's formula and the statute's closed form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GnTUicNF8teuGcift --- ...lderly-credit-separate-filer-base.fixed.md | 2 +- .../elderly_or_disabled/phase_out.yaml | 8 +- ...test_elderly_disabled_credit_invariants.py | 135 ++++++++++++++++-- .../section_22_income.yaml | 62 +++++++- .../states/mi/tax/income/mi_subtractions.yaml | 29 ++++ .../elderly_disabled_credit_potential.py | 14 +- .../elderly_and_disabled/section_22_income.py | 23 ++- 7 files changed, 239 insertions(+), 34 deletions(-) diff --git a/changelog.d/elderly-credit-separate-filer-base.fixed.md b/changelog.d/elderly-credit-separate-filer-base.fixed.md index a88628c029a..5c5745be5f6 100644 --- a/changelog.d/elderly-credit-separate-filer-base.fixed.md +++ b/changelog.d/elderly-credit-separate-filer-base.fixed.md @@ -1 +1 @@ -Give a married individual filing separately the $3,750 Schedule R initial amount, and allow the elderly or disabled credit on a separate return only when the spouses lived apart all year (26 U.S.C. 22(c)(2)(A), 22(e)(1)). +Give a married individual filing separately the $3,750 Schedule R initial amount, and set the section 22 amount to zero for a separate filer who lived with their spouse at any time in the year, so the elderly or disabled credit and Michigan's section 22 subtraction both follow 26 U.S.C. 22(c)(2)(A) and 22(e)(1). diff --git a/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml b/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml index c9a99f259cd..be4d8fb69a9 100644 --- a/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml +++ b/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml @@ -23,13 +23,13 @@ threshold: - filing_status propagate_metadata_to_children: true reference: - - title: 26 U.S. Code § 22(c)(3)(D) - href: https://www.law.cornell.edu/uscode/text/26/22#c_3_D + - title: 26 U.S. Code § 22(d) + href: https://www.law.cornell.edu/uscode/text/26/22#d rate: values: 2010-01-01: 0.5 metadata: unit: /1 reference: - - title: 26 U.S. Code § 22(c)(3)(D) - href: https://www.law.cornell.edu/uscode/text/26/22#c_3_D + - title: 26 U.S. Code § 22(d) + href: https://www.law.cornell.edu/uscode/text/26/22#d diff --git a/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py index 48dcb12fb1f..d71aae0c1b2 100644 --- a/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py +++ b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py @@ -3,19 +3,38 @@ 1. A married individual filing a separate return starts from $3,750 (22(c)(2)(A)(iii)), and the later steps only reduce it, so the section 22 amount for a separate filer never exceeds $3,750. -2. The credit never exceeds the Schedule R credit limit: tax before credits - less the credits that precede it. +2. For every status but separate, the section 22 amount equals the formula + before the separate-filer change, frozen below as a reference function. +3. For a separate filer it equals the statute's closed form: zero if the + spouses lived together at any time in the year (22(e)(1)); otherwise + min($3,750 if anyone qualifies, the 22(c)(2)(B) cap), less nontaxable + pensions and social security (22(c)(3)), less half of AGI over $5,000 + (22(d)), floored at zero. + +Units may carry a dependent, who can qualify by age, so a separate filer +can count two qualified individuals. Each Hypothesis example is a batch of tax units run as one vectorized simulation, so the test checks the formulas on mixed arrays as well. """ -from hypothesis import HealthCheck, given, settings, strategies as st +from hypothesis import HealthCheck, example, given, settings, strategies as st +import numpy as np from policyengine_us import Simulation YEAR = 2024 SEPARATE_INITIAL_AMOUNT = 3_750 +ONE_QUALIFIED = 5_000 +TWO_QUALIFIED = 7_500 +ELDERLY_AGE = 65 +PHASE_OUT_RATE = 0.5 +PHASE_OUT_THRESHOLD = { + "SINGLE": 7_500, + "JOINT": 10_000, + "SEPARATE": 5_000, + "HEAD_OF_HOUSEHOLD": 7_500, +} FILING_STATUSES = ["SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD"] @@ -33,6 +52,9 @@ "taxable_share": st.floats(0, 1), "social_security": money, "disability_payments": st.floats(0, 10_000).map(round), + # Two in three units have a dependent, half of them 65 or older, so + # separate filers with two qualified members come up often. + "dependent_age": st.one_of(st.none(), st.integers(65, 90), st.integers(10, 64)), } ) @@ -58,6 +80,13 @@ def build_situation(cases): spouse = f"spouse_{i}" people[spouse] = {"age": {YEAR: c["spouse_age"]}} members.append(spouse) + if c["dependent_age"] is not None: + dependent = f"dependent_{i}" + people[dependent] = { + "age": {YEAR: c["dependent_age"]}, + "is_tax_unit_dependent": {YEAR: True}, + } + members.append(dependent) tax_units[f"tax_unit_{i}"] = { "members": members, "filing_status": {YEAR: c["filing_status"]}, @@ -70,23 +99,109 @@ def build_situation(cases): return {"people": people, "tax_units": tax_units, "households": households} +def reduction_and_cap_inputs(sim): + """Per-unit inputs shared by both reference formulas, read from the + simulation so that only the section 22 step itself is reimplemented.""" + age = sim.calculate("age", YEAR) + disabled = sim.calculate("retired_on_total_disability", YEAR) + dependent = sim.calculate("is_tax_unit_dependent", YEAR) + disability_pay = sim.calculate("total_disability_payments", YEAR) + elderly = age >= ELDERLY_AGE + n = sim.map_result(elderly | disabled, "person", "tax_unit") + num_elderly = sim.map_result(elderly & ~dependent, "person", "tax_unit") + young_disability = sim.map_result(disability_pay * ~elderly, "person", "tax_unit") + cap = num_elderly * ONE_QUALIFIED + young_disability + pensions = sim.map_result( + sim.calculate("pension_income", YEAR) + - sim.calculate("taxable_pension_income", YEAR), + "person", + "tax_unit", + ) + social_security = sim.calculate("tax_unit_social_security", YEAR) - sim.calculate( + "tax_unit_taxable_social_security", YEAR + ) + agi = sim.calculate("adjusted_gross_income", YEAR) + return n, cap, pensions + social_security, agi + + +def pre_change_section_22(n, filing_status, cap, nontaxable, agi): + """The section 22 formula before the separate-filer change: select took + n == 1 before the separate-return condition.""" + initial = np.select( + [n == 1, n == 2, filing_status == "SEPARATE"], + [ONE_QUALIFIED, TWO_QUALIFIED, SEPARATE_INITIAL_AMOUNT], + default=0, + ) + threshold = np.array([PHASE_OUT_THRESHOLD[f] for f in filing_status]) + reduction = PHASE_OUT_RATE * np.maximum(0, agi - threshold) + return np.maximum(0, np.minimum(initial, cap) - nontaxable - reduction) + + +def statute_separate_section_22(n, lived_together, cap, nontaxable, agi): + """26 U.S.C. 22(c)-(e) for a married individual filing separately.""" + initial = np.where(n >= 1, SEPARATE_INITIAL_AMOUNT, 0) + reduction = PHASE_OUT_RATE * np.maximum(0, agi - PHASE_OUT_THRESHOLD["SEPARATE"]) + amount = np.maximum(0, np.minimum(initial, cap) - nontaxable - reduction) + return np.where(lived_together, 0, amount) + + +def fixed_case(filing_status, cohabitating, head_age=67, dependent_age=None, **kw): + c = { + "filing_status": filing_status, + "cohabitating": cohabitating, + "head_age": head_age, + "spouse_age": 50, + "head_disabled": False, + "wages": 3_000.0, + "pension": 0.0, + "taxable_share": 0.0, + "social_security": 0.0, + "disability_payments": 0.0, + "dependent_age": dependent_age, + } + c.update(kw) + return c + + +# A batch that always runs, beside the random ones, so each separate-filer +# branch is reached whatever Hypothesis draws. +FIXED_BATCH = [ + # Separate, lived apart, two qualified members (head and dependent). + fixed_case("SEPARATE", False, dependent_age=80), + # Separate, lived together: 22(e)(1) bars the amount. + fixed_case("SEPARATE", True), + # Separate, lived together, two qualified members. + fixed_case("SEPARATE", True, dependent_age=80), + # Separate, nobody qualifies, with disability pay. + fixed_case("SEPARATE", False, head_age=60, disability_payments=2_000.0), + # Joint and single returns whose spouses live together keep the amount. + fixed_case("JOINT", True), + fixed_case("SINGLE", True), + fixed_case("HEAD_OF_HOUSEHOLD", True, dependent_age=70), + fixed_case("JOINT", False, spouse_age=70, dependent_age=80), +] + + @settings( max_examples=10, deadline=None, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) @given(st.lists(case, min_size=8, max_size=8)) -def test_separate_amount_and_credit_limit(cases): +@example(FIXED_BATCH) +def test_section_22_amount_against_reference_formulas(cases): sim = Simulation(situation=build_situation(cases)) filing_status = sim.calculate("filing_status", YEAR).decode_to_str() + lived_together = sim.calculate("cohabitating_spouses", YEAR) section_22 = sim.calculate("section_22_income", YEAR) - credit = sim.calculate("elderly_disabled_credit", YEAR) - credit_limit = sim.calculate("elderly_disabled_credit_credit_limit", YEAR) - tax_before_credits = sim.calculate("income_tax_before_credits", YEAR) + n, cap, nontaxable, agi = reduction_and_cap_inputs(sim) separate = filing_status == "SEPARATE" assert (section_22[separate] <= SEPARATE_INITIAL_AMOUNT).all() assert (section_22 >= 0).all() - assert (credit >= 0).all() - assert (credit <= credit_limit + 1e-6).all() - assert (credit <= tax_before_credits + 1e-6).all() + + pre_change = pre_change_section_22(n, filing_status, cap, nontaxable, agi) + np.testing.assert_allclose(section_22[~separate], pre_change[~separate]) + + statute = statute_separate_section_22(n, lived_together, cap, nontaxable, agi) + np.testing.assert_allclose(section_22[separate], statute[separate]) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml index 06905e14ac6..83fb26531e0 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml @@ -1,4 +1,4 @@ -# Section 22 amount (Schedule R line 22) by filing status, 26 U.S.C. 22(c) +# Section 22 amount (Schedule R line 19) by filing status, 26 U.S.C. 22(c) # and (d). Every case has wages only (no pensions or social security) unless # noted, so AGI equals wages. @@ -25,7 +25,7 @@ # 22(a): 15% x 3,750 = 562.50. elderly_disabled_credit_potential: 562.5 -- name: A separate filer who lived with their spouse has the same amount but no credit. +- name: A separate filer who lived with their spouse has no section 22 amount and no credit. period: 2024 input: people: @@ -42,9 +42,11 @@ members: [head] state_code: TX output: - section_22_income: 3_750 # 22(e)(1): married spouses who did not live apart all year must file - # jointly to take the credit. + # jointly to take the credit. Schedule R boxes 8 and 9, the only boxes + # for a separate filer, require living apart all year, so there is no + # line 19 amount. + section_22_income: 0 elderly_disabled_credit_potential: 0 elderly_disabled_credit: 0 @@ -164,3 +166,55 @@ section_22_income: 7_500 # 15% x 7,500 = 1,125. elderly_disabled_credit_potential: 1_125 + +- name: A separate filer with a qualified dependent still starts from $3,750. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + dependent: + age: 80 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head, dependent] + state_code: TX + output: + # Two members qualify (both are 65 or older), but 22(c)(2)(A)(iii) + # gives a separate filer $3,750 whatever the count. The under-65 cap + # counts only the elderly head (dependents excluded): 1 x 5,000, above + # 3,750. AGI 3,000 < 5,000: 3,750. + section_22_income: 3_750 + elderly_disabled_credit_potential: 562.5 + +- name: Living together does not bar a joint return. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + cohabitating_spouses: true + households: + household: + members: [head, spouse] + state_code: TX + output: + # 22(e)(1) reaches only separate returns. 22(c)(2)(A)(i): $5,000 with + # one qualified spouse; AGI 3,000 < 10,000. + section_22_income: 5_000 + # 15% x 5,000 = 750. + elderly_disabled_credit_potential: 750 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml index 046978ff58e..8ae2f31b6ef 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml @@ -170,3 +170,32 @@ mi_subtractions: 49_422.75 # 110,000 - 49,422.75 - 11,600 mi_taxable_income: 48_977.25 + +- name: A separate filer who lived with their spouse has no section 22 amount to subtract. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: true + households: + household: + members: [head] + state_code: MI + output: + # MCL 206.30(1)(f)(v) subtracts the section 22 amount eligible for the + # federal credit, which the MI-1040 instructions take from Schedule R + # line 19. A separate filer who lived with their spouse can't check + # Schedule R box 8 or 9 and has no line 19 amount (26 U.S.C. 22(e)(1)). + section_22_income: 0 + # Born 1957, so the tier three standard deduction: 20,000 base for a + # separate filer less one 5,600 personal exemption = 14,400. No other + # subtraction applies to $3,000 of wages. + mi_standard_deduction: 14_400 + # 0 + 14,400 = 14,400. + mi_subtractions: 14_400 diff --git a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py index df21419aa7c..fbaac9aab66 100644 --- a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py +++ b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py @@ -8,17 +8,9 @@ class elderly_disabled_credit_potential(Variable): label = "Potential value of the Elderly or disabled credit" documentation = "Schedule R credit for the elderly and the disabled" unit = USD - reference = ( - "https://www.law.cornell.edu/uscode/text/26/22#a", - "https://www.law.cornell.edu/uscode/text/26/22#e_1", - ) + reference = "https://www.law.cornell.edu/uscode/text/26/22#a" def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.credits.elderly_or_disabled - # Section 22(e)(1): a married individual filing a separate return - # gets the credit only if the spouses lived apart all year. - filing_status = tax_unit("filing_status", period) - separate = filing_status == filing_status.possible_values.SEPARATE - lived_together = tax_unit("cohabitating_spouses", period) - allowed = ~(separate & lived_together) - return allowed * p.rate * tax_unit("section_22_income", period) + # The 22(e)(1) separate-return bar is applied in section_22_income. + return p.rate * tax_unit("section_22_income", period) diff --git a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py index 496f8909314..7f37ba286b1 100644 --- a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py +++ b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py @@ -8,7 +8,10 @@ class section_22_income(Variable): unit = USD documentation = "Income upon which the elderly or disabled credit is applied" definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/22#c" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/22#c", + "https://www.law.cornell.edu/uscode/text/26/22#e_1", + ) def formula(tax_unit, period, parameters): elderly_disabled = parameters(period).gov.irs.credits.elderly_or_disabled @@ -21,8 +24,10 @@ def formula(tax_unit, period, parameters): separate = filing_status == filing_status.possible_values.SEPARATE # Section 22(c)(2)(A)(iii) sets a separate filer's initial amount, # so the separate-return condition comes first: select takes the - # first true condition, and a separate filer has one qualified - # individual. + # first true condition. It tests n >= 1, not n == 1, because + # qualifies_for_elderly_or_disabled_credit is not limited to the + # head and spouse: a separate filer with a qualifying dependent + # counts two, and would otherwise fall through to $7,500. initial_amount = select( [ separate & (num_qualifying_individuals >= 1), @@ -67,4 +72,14 @@ def formula(tax_unit, period, parameters): ) phase_out_reduction = elderly_disabled.phase_out.rate * amount_over_phase_out - return max_(0, capped_reduced_amount - phase_out_reduction) + amount = max_(0, capped_reduced_amount - phase_out_reduction) + # Section 22(e)(1): a married individual filing a separate return + # takes the credit only if the spouses lived apart for the whole + # year. Schedule R gives a separate filer only boxes 8 and 9, both + # conditioned on "you lived apart from your spouse for all of" the + # year, so a separate filer who lived with the spouse has no line 19 + # section 22 amount. The bar sits here, not on the credit, so that + # readers of the line 19 amount, such as Michigan's subtraction, + # see it too. + lived_together = tax_unit("cohabitating_spouses", period) + return where(separate & lived_together, 0, amount) From ac9f82a13e10f3e18d2f3fb4861d338c59be049f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 19:07:55 -0500 Subject: [PATCH 4/4] Pin the separate filer with no qualifying member Only the Python test's fixed batch killed the mutant that drops the n >= 1 guard, so a YAML case now does too. The separate-filer reference also counts qualifying members as the model does, dependents included, so it can't be "the statute's closed form" on that point; the docstrings now say so. Co-Authored-By: Claude Opus 5.5 --- ...test_elderly_disabled_credit_invariants.py | 14 ++++++----- .../section_22_income.yaml | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py index d71aae0c1b2..6bbf0f26bff 100644 --- a/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py +++ b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py @@ -5,11 +5,12 @@ amount for a separate filer never exceeds $3,750. 2. For every status but separate, the section 22 amount equals the formula before the separate-filer change, frozen below as a reference function. -3. For a separate filer it equals the statute's closed form: zero if the - spouses lived together at any time in the year (22(e)(1)); otherwise - min($3,750 if anyone qualifies, the 22(c)(2)(B) cap), less nontaxable - pensions and social security (22(c)(3)), less half of AGI over $5,000 - (22(d)), floored at zero. +3. For a separate filer it equals the closed form of 22(c)-(e) with the + model's count of qualifying members (which, like the model, includes + dependents): zero if the spouses lived together at any time in the + year (22(e)(1)); otherwise min($3,750 if any member qualifies, the + 22(c)(2)(B) cap), less nontaxable pensions and social security + (22(c)(3)), less half of AGI over $5,000 (22(d)), floored at zero. Units may carry a dependent, who can qualify by age, so a separate filer can count two qualified individuals. @@ -138,7 +139,8 @@ def pre_change_section_22(n, filing_status, cap, nontaxable, agi): def statute_separate_section_22(n, lived_together, cap, nontaxable, agi): - """26 U.S.C. 22(c)-(e) for a married individual filing separately.""" + """26 U.S.C. 22(c)-(e) for a married individual filing separately, counting + qualifying members as the model does (dependents included).""" initial = np.where(n >= 1, SEPARATE_INITIAL_AMOUNT, 0) reduction = PHASE_OUT_RATE * np.maximum(0, agi - PHASE_OUT_THRESHOLD["SEPARATE"]) amount = np.maximum(0, np.minimum(initial, cap) - nontaxable - reduction) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml index 83fb26531e0..0bb6c6ba595 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml @@ -218,3 +218,26 @@ section_22_income: 5_000 # 15% x 5,000 = 750. elderly_disabled_credit_potential: 750 + +- name: A separate filer with no qualifying member has no initial amount. + period: 2024 + input: + people: + head: + age: 60 + employment_income: 2_000 + total_disability_payments: 2_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # Under 65 and not retired on total disability, so 22(b) doesn't reach + # this filer: no initial amount, whatever the disability pay. + section_22_income: 0 + elderly_disabled_credit_potential: 0