From 4fa892e775b8bde0f316b560a35020431f8b57c4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:30:49 -0400 Subject: [PATCH 1/7] Add tests for the per-educator cap on the educator expense deduction YAML cases at, below and above the cap for 2021 ($250), 2022 and 2025 ($300) and 2026 ($350); joint returns with one and two educators; a dependent's expenses. A Hypothesis property checks the bound, a numpy differential, monotonicity and the AGI identity. Co-Authored-By: Claude Opus 5.5 --- .../test_educator_expense_cap_invariants.py | 238 +++++++++++++ .../educator_expense_ald.yaml | 315 ++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 policyengine_us/tests/core/test_educator_expense_cap_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml diff --git a/policyengine_us/tests/core/test_educator_expense_cap_invariants.py b/policyengine_us/tests/core/test_educator_expense_cap_invariants.py new file mode 100644 index 00000000000..5eaf4248739 --- /dev/null +++ b/policyengine_us/tests/core/test_educator_expense_cap_invariants.py @@ -0,0 +1,238 @@ +"""Invariants for the educator expense deduction, 26 USC 62(a)(2)(D) and (d). + +Each eligible educator deducts their own qualified expenses up to the cap. On +a joint return the head's and the spouse's capped amounts add up; neither +spouse can use the other's unused room. A tax unit dependent's expenses are +on the dependent's own return, not the filer's. + +Hypothesis draws batches of tax units (single, joint with zero, one or two +educators, and head of household or joint with dependents who may report +educator expenses), and a seeded population of 200 units adds breadth. Each +batch runs as one vectorized simulation, and again with every member's +expenses raised by a drawn non-negative amount. For every tax unit: + +1. Bound: 0 <= educator_expense_ald <= the cap times the number of head and + spouse with expenses, and never more than their expenses. +2. Differential: educator_expense_ald equals an independent numpy sum over + the head and spouse of min(expenses, cap), with the cap taken from the + Rev. Procs. rather than from the model's parameters. Each person's own + amount, dependents included, is min(expenses, cap). +3. Monotone: raising expenses never lowers educator_expense_ald and never + raises AGI. Raising only dependents' expenses changes neither. +4. Accounting: with wages as the only income and no other deductions, AGI is + wages less educator_expense_ald. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# Per-educator caps from the Rev. Procs. for each taxable year: 2020-45 +# sec. 3.14 (2021), 2021-45 sec. 3.13 (2022), 2024-40 sec. 2.13 (2025) and +# 2025-32 sec. 4.12 (2026). +PUBLISHED_CAP = {2021: 250, 2022: 300, 2025: 300, 2026: 350} +YEARS = sorted(PUBLISHED_CAP) + +# Mostly amounts near the caps, plus zeros and large amounts. +expenses = st.one_of( + st.just(0.0), + st.integers(1, 600).map(float), + st.integers(600, 5_000).map(float), +) +increases = st.one_of(st.just(0.0), st.integers(1, 500).map(float)) +wages = st.integers(0, 150_000).map(float) + + +@st.composite +def people(draw): + return { + "employment_income": draw(wages), + "educator_expense": draw(expenses), + "increase": draw(increases), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint", "hoh", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": draw(people()), + "spouse": draw(people()) if kind.startswith("joint") else None, + "dependents": [draw(people()) for _ in range(n_dependents)], + } + + +SEED = 20261005 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def person(): + expense = rng.choice([0, 1, 2]) + return { + "employment_income": float(rng.integers(0, 150_001)), + "educator_expense": float( + [0, rng.integers(1, 601), rng.integers(600, 5_001)][expense] + ), + "increase": float(rng.integers(0, 501)) if rng.random() < 0.7 else 0.0, + } + + units = [] + for _ in range(n): + kind = rng.choice(["single", "joint", "hoh", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head": person(), + "spouse": person() if kind.startswith("joint") else None, + "dependents": [person() for _ in range(n_dependents)], + } + ) + return units + + +def _situation(units, year, *, raise_filers, raise_dependents): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + + def add(name, values, role, raised): + expense = values["educator_expense"] + raised * values["increase"] + people[name] = { + "age": {"head": 45, "spouse": 43, "dependent": 20}[role], + "is_full_time_student": role == "dependent", + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "dependent", + "employment_income": values["employment_income"], + "educator_expense": expense, + } + + for i, unit in enumerate(units): + head = f"head_{i}" + add(head, unit["head"], "head", raise_filers) + members, couple = [head], [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + add(spouse, unit["spouse"], "spouse", raise_filers) + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, dependent in enumerate(unit["dependents"]): + name = f"dependent_{i}_{j}" + add(name, dependent, "dependent", raise_dependents) + members.append(name) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [name]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, raise_filers=False, raise_dependents=False): + sim = Simulation( + situation=_situation( + units, + year, + raise_filers=raise_filers, + raise_dependents=raise_dependents, + ) + ) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + "educator_expense", + "educator_expense_ald_person", + "employment_income", + "educator_expense_ald", + "above_the_line_deductions", + "adjusted_gross_income", + ] + } + out["filer"] = np.asarray(sim.calculate("is_tax_unit_head_or_spouse", year)) + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], weights=values, minlength=len(run["educator_expense_ald"]) + ) + + +def _check(units, year): + cap = PUBLISHED_CAP[year] + base = _run(units, year) + filer = base["filer"] + expense = base["educator_expense"] + + # 1. Bounds. + deduction = base["educator_expense_ald"] + educators = _unit_sum(base, (filer & (expense > 0)).astype(float)) + assert (deduction >= 0).all() + assert (deduction <= cap * educators + TOLERANCE).all() + assert (deduction <= _unit_sum(base, filer * expense) + TOLERANCE).all() + + # 2. Differential against numpy. + own = np.minimum(expense, cap) + np.testing.assert_allclose(base["educator_expense_ald_person"], own, atol=TOLERANCE) + np.testing.assert_allclose( + deduction, _unit_sum(base, filer * own), atol=TOLERANCE + ) + np.testing.assert_allclose( + base["above_the_line_deductions"], deduction, atol=TOLERANCE + ) + + # 3. Monotone in expenses. + raised = _run(units, year, raise_filers=True, raise_dependents=True) + assert (raised["educator_expense_ald"] >= deduction - TOLERANCE).all() + assert ( + raised["adjusted_gross_income"] + <= base["adjusted_gross_income"] + TOLERANCE + ).all() + dependents_only = _run(units, year, raise_dependents=True) + for name in ["educator_expense_ald", "adjusted_gross_income"]: + np.testing.assert_allclose( + dependents_only[name], base[name], atol=TOLERANCE, err_msg=name + ) + + # 4. Accounting identity. + np.testing.assert_allclose( + base["adjusted_gross_income"], + _unit_sum(base, filer * base["employment_income"]) - deduction, + atol=TOLERANCE, + ) + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +def test_educator_expense_cap_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + _check(_seeded_units(), year) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml new file mode 100644 index 00000000000..3ee820f1215 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml @@ -0,0 +1,315 @@ +# Educator expense deduction, 26 USC 62(a)(2)(D) and (d): each eligible +# educator deducts their qualified expenses up to the cap, $250 through 2021 +# and $300 for 2022 through 2025 (Rev. Procs. 2020-45 sec. 3.14, 2021-45 +# sec. 3.13). The Form 1040 instructions for Schedule 1, line 11 (2021, p. 88; +# 2022, p. 87) give the joint return rule: "If you and your spouse are filing +# jointly and both of you were eligible educators, the maximum deduction is +# $600. However, neither spouse can deduct more than $300 of their qualified +# expenses" ($500 and $250 for 2021). + +- name: 2021, expenses below the $250 cap are deducted in full + period: 2021 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 200 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 200 + educator_expense_ald: 200 + above_the_line_deductions: 200 + adjusted_gross_income: 49_800 + +- name: 2021, expenses at the $250 cap are deducted in full + period: 2021 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 250 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 250 + educator_expense_ald: 250 + above_the_line_deductions: 250 + adjusted_gross_income: 49_750 + +- name: 2021, expenses above the $250 cap are deducted up to the cap + period: 2021 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 250 + educator_expense_ald: 250 + above_the_line_deductions: 250 + adjusted_gross_income: 49_750 + +- name: 2022, expenses below the $300 cap are deducted in full + period: 2022 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 299 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 299 + educator_expense_ald: 299 + above_the_line_deductions: 299 + adjusted_gross_income: 49_701 + +- name: 2022, expenses at the $300 cap are deducted in full + period: 2022 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 300 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 300 + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 49_700 + +- name: 2022, expenses above the $300 cap are deducted up to the cap + period: 2022 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 1_000 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 300 + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 49_700 + +- name: 2025, expenses above the $300 cap are deducted up to the cap + period: 2025 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 301 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + # Rev. Proc. 2024-40 sec. 2.13: $300 for 2025. + educator_expense_ald_person: 300 + educator_expense_ald: 300 + adjusted_gross_income: 49_700 + +- name: 2026, the cap rises to $350 + period: 2026 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + # Rev. Proc. 2025-32 sec. 4.12: $350 for 2026. + educator_expense_ald_person: 350 + educator_expense_ald: 350 + adjusted_gross_income: 49_650 + +- name: 2022, joint return with two eligible educators, each capped at $300 + period: 2022 + input: + people: + head: + age: 40 + employment_income: 50_000 + educator_expense: 450 + spouse: + age: 38 + employment_income: 40_000 + educator_expense: 250 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # The head's $450 is capped at $300; the spouse's $250 is below the cap. + # The head's unused room does not carry to the spouse: 300 + 250 = 550. + educator_expense_ald_person: [300, 250] + educator_expense_ald: 550 + above_the_line_deductions: 550 + adjusted_gross_income: 89_450 + +- name: 2022, joint return with two eligible educators both above the cap reaches the $600 maximum + period: 2022 + input: + people: + head: + age: 40 + employment_income: 50_000 + educator_expense: 800 + spouse: + age: 38 + employment_income: 40_000 + educator_expense: 700 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + educator_expense_ald_person: [300, 300] + educator_expense_ald: 600 + adjusted_gross_income: 89_400 + +- name: 2022, joint return with one eligible educator is capped at $300, not $600 + period: 2022 + input: + people: + head: + age: 40 + employment_income: 50_000 + educator_expense: 700 + spouse: + age: 38 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + educator_expense_ald_person: [300, 0] + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 89_700 + +- name: 2021, joint return with one eligible educator is capped at $250, not $500 + period: 2021 + input: + people: + head: + age: 40 + employment_income: 50_000 + spouse: + age: 38 + employment_income: 40_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + educator_expense_ald_person: [0, 250] + educator_expense_ald: 250 + adjusted_gross_income: 89_750 + +- name: 2022, a dependent's educator expenses stay off the filer's return + period: 2022 + input: + people: + parent: + age: 50 + employment_income: 60_000 + educator_expense: 100 + student: + age: 22 + is_full_time_student: true + is_tax_unit_dependent: true + employment_income: 3_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # The student's own amount is capped for their own return; only the + # head's $100 is on this one. + educator_expense_ald_person: [100, 300] + educator_expense_ald: 100 + above_the_line_deductions: 100 + adjusted_gross_income: 59_900 From c7312e9e89150257f25d730e3589acfaec472e8a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:40:00 -0400 Subject: [PATCH 2/7] Cap the educator expense deduction per eligible educator (26 USC 62(a)(2)(D)) gov.irs.ald.deductions listed the raw educator_expense input, so the federal deduction was uncapped. Add gov.irs.ald.educator_expense.cap with each year's IRS value ($250 for 2002-2021, $300 for 2022-2025, $350 for 2026 per Rev. Proc. 2025-32 sec. 4.12), uprated after 2026 to the nearest $50 as 62(d)(3) rounds. educator_expense_ald_person caps each person's own expenses; educator_expense_ald adds the head's and spouse's amounts and replaces the raw input in the ALD list. A dependent's own capped amount feeds dependent_taxable_ss_magi and the dependent's Medicaid AGI. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-educator-expense-cap.fixed.md | 1 + .../parameters/gov/irs/ald/deductions.yaml | 8 +-- .../gov/irs/ald/educator_expense/cap.yaml | 54 +++++++++++++++++++ .../test_educator_expense_cap_invariants.py | 7 +-- .../medicaid_adjusted_gross_income_person.py | 4 ++ .../above_the_line_deductions/README.md | 1 + .../educator_expense_ald.py | 28 ++++++++++ .../educator_expense_ald_person.py | 24 +++++++++ .../dependent_taxable_ss_magi.py | 2 +- .../oh_educator_expense_deduction_person.py | 5 +- .../expense/person/educator_expense.py | 12 ++++- 11 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fix-educator-expense-cap.fixed.md create mode 100644 policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py diff --git a/changelog.d/fix-educator-expense-cap.fixed.md b/changelog.d/fix-educator-expense-cap.fixed.md new file mode 100644 index 00000000000..346e546903a --- /dev/null +++ b/changelog.d/fix-educator-expense-cap.fixed.md @@ -0,0 +1 @@ +Cap the federal educator expense deduction at each eligible educator's limit under 26 U.S.C. 62(a)(2)(D) and (d)(3): $250 through 2021, $300 for 2022 through 2025 and $350 for 2026, indexed after that. On a joint return each spouse is capped separately, and a tax unit dependent's expenses stay off the filer's return. Previously the raw expense input was deducted in full. diff --git a/policyengine_us/parameters/gov/irs/ald/deductions.yaml b/policyengine_us/parameters/gov/irs/ald/deductions.yaml index 9657a3ccb81..4a0f5b4740b 100644 --- a/policyengine_us/parameters/gov/irs/ald/deductions.yaml +++ b/policyengine_us/parameters/gov/irs/ald/deductions.yaml @@ -6,7 +6,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - capped_qualified_tuition_expenses_ald - domestic_production_ald - health_savings_account_ald @@ -23,7 +23,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - capped_qualified_tuition_expenses_ald - health_savings_account_ald - self_employed_health_insurance_ald @@ -39,7 +39,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald @@ -54,7 +54,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald diff --git a/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml b/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml new file mode 100644 index 00000000000..61bfb1cf852 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml @@ -0,0 +1,54 @@ +description: The IRS caps the educator expense deduction at this amount for each eligible educator. +values: + # 26 USC 62(a)(2)(D), added for 2002 by Pub. L. 107-147 and extended + # retroactively through 2015, when Pub. L. 114-113 made it permanent and + # indexed it from 2016 (26 USC 62(d)(3)). + 2002-01-01: 250 + 2016-01-01: 250 + 2017-01-01: 250 + 2018-01-01: 250 + 2019-01-01: 250 + 2020-01-01: 250 + 2021-01-01: 250 + 2022-01-01: 300 + 2023-01-01: 300 + 2024-01-01: 300 + 2025-01-01: 300 + 2026-01-01: 350 +metadata: + unit: currency-USD + period: year + label: Educator expense deduction cap + uprating: + parameter: gov.irs.uprating + # 26 USC 62(d)(3): any increase is rounded to the nearest multiple of $50. + rounding: + type: nearest + interval: 50 + reference: + - title: 26 U.S. Code § 62(a)(2)(D) - Certain expenses of elementary and secondary school teachers + href: https://www.law.cornell.edu/uscode/text/26/62#a_2_D + - title: 26 U.S. Code § 62(d)(3) - Inflation adjustment + href: https://www.law.cornell.edu/uscode/text/26/62#d_3 + - title: 2016 Form 1040 instructions, line 23, educator expenses (2016) + href: https://www.irs.gov/pub/irs-prior/i1040gi--2016.pdf#page=31 + - title: Rev. Proc. 2016-55, section 3.12 (2017) + href: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf#page=14 + - title: Rev. Proc. 2017-58, section 3.12 (2018) + href: https://www.irs.gov/pub/irs-drop/rp-17-58.pdf#page=12 + - title: Rev. Proc. 2018-57, section 3.14 (2019) + href: https://www.irs.gov/pub/irs-drop/rp-18-57.pdf#page=15 + - title: Rev. Proc. 2019-44, section 3.14 (2020) + href: https://www.irs.gov/pub/irs-drop/rp-19-44.pdf#page=12 + - title: Rev. Proc. 2020-45, section 3.14 (2021) + href: https://www.irs.gov/pub/irs-drop/rp-20-45.pdf#page=12 + - title: Rev. Proc. 2021-45, section 3.13 (2022) + href: https://www.irs.gov/pub/irs-drop/rp-21-45.pdf#page=13 + - title: Rev. Proc. 2022-38, section 3.13 (2023) + href: https://www.irs.gov/pub/irs-drop/rp-22-38.pdf#page=12 + - title: Rev. Proc. 2023-34, section 3.13 (2024) + href: https://www.irs.gov/pub/irs-drop/rp-23-34.pdf#page=11 + - title: Rev. Proc. 2024-40, section 2.13 (2025) + href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf#page=11 + - title: Rev. Proc. 2025-32, section 4.12 (2026) + href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=17 diff --git a/policyengine_us/tests/core/test_educator_expense_cap_invariants.py b/policyengine_us/tests/core/test_educator_expense_cap_invariants.py index 5eaf4248739..9883e61dc71 100644 --- a/policyengine_us/tests/core/test_educator_expense_cap_invariants.py +++ b/policyengine_us/tests/core/test_educator_expense_cap_invariants.py @@ -189,9 +189,7 @@ def _check(units, year): # 2. Differential against numpy. own = np.minimum(expense, cap) np.testing.assert_allclose(base["educator_expense_ald_person"], own, atol=TOLERANCE) - np.testing.assert_allclose( - deduction, _unit_sum(base, filer * own), atol=TOLERANCE - ) + np.testing.assert_allclose(deduction, _unit_sum(base, filer * own), atol=TOLERANCE) np.testing.assert_allclose( base["above_the_line_deductions"], deduction, atol=TOLERANCE ) @@ -200,8 +198,7 @@ def _check(units, year): raised = _run(units, year, raise_filers=True, raise_dependents=True) assert (raised["educator_expense_ald"] >= deduction - TOLERANCE).all() assert ( - raised["adjusted_gross_income"] - <= base["adjusted_gross_income"] + TOLERANCE + raised["adjusted_gross_income"] <= base["adjusted_gross_income"] + TOLERANCE ).all() dependents_only = _run(units, year, raise_dependents=True) for name in ["educator_expense_ald", "adjusted_gross_income"]: diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index 4b83de2ba8b..1dc59df282d 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -5,12 +5,16 @@ "self_employment_tax_ald_person", "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald_person", + # Each educator's own capped expenses, a tax unit dependent's included, + # since the dependent deducts them on their own return. + "educator_expense_ald_person", ] TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS = ( "self_employment_tax_ald", "self_employed_health_insurance_ald", "self_employed_pension_contribution_ald", + "educator_expense_ald", ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 8ee985038a9..90be8e0597e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -4,6 +4,7 @@ Above-the-line deductions are deductions made from gross income to arrive at adj | Section | Deduction | | --- | --- | +| [62(a)(2)(D)](https://www.law.cornell.edu/uscode/text/26/62#a_2_D) | Educator expenses, capped per eligible educator | | [85](https://www.law.cornell.edu/uscode/text/26/85) | Unemployment compensation | | [86](https://www.law.cornell.edu/uscode/text/26/86) | Social security | | [135](https://www.law.cornell.edu/uscode/text/26/135) | Tuition fees | diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py new file mode 100644 index 00000000000..1325647df36 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class educator_expense_ald(Variable): + value_type = float + entity = TaxUnit + label = "Educator expense deduction" + unit = USD + documentation = ( + "Above-the-line deduction for the head's and spouse's educator " + "expenses (Schedule 1, line 11). Each eligible educator deducts up to " + "the cap; on a joint return neither spouse can use the other's unused " + "amount. A tax unit dependent deducts their own expenses on their own " + "return." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_2_D", + "https://www.law.cornell.edu/uscode/text/26/62#d", + # Form 1040 instructions (2025), Schedule 1, line 11. + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=93", + ) + + def formula(tax_unit, period, parameters): + person = tax_unit.members + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + deduction = person("educator_expense_ald_person", period) + return tax_unit.sum(head_or_spouse * deduction) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py new file mode 100644 index 00000000000..953b3dd656d --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class educator_expense_ald_person(Variable): + value_type = float + entity = Person + label = "Educator expense deduction for each person" + unit = USD + documentation = ( + "Each person's educator expenses, capped at the per-educator limit of " + "26 USC 62(a)(2)(D). This is the amount for the person's own return; " + "educator_expense_ald adds the head's and spouse's amounts for the " + "tax unit's return." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_2_D", + "https://www.law.cornell.edu/uscode/text/26/62#d", + ) + + def formula(person, period, parameters): + expenses = max_(0, person("educator_expense", period)) + cap = parameters(period).gov.irs.ald.educator_expense.cap + return min_(expenses, cap) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py index 932e2011bb4..e5ae2b3fa10 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py @@ -19,7 +19,7 @@ "self_employment_tax_ald": "self_employment_tax_ald_person", "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", - "educator_expense": "educator_expense", + "educator_expense_ald": "educator_expense_ald_person", "early_withdrawal_penalty": "early_withdrawal_penalty", "traditional_ira_contributions": "traditional_ira_contributions", } diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py index 2d5ac005513..b2ad7b79e3e 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py @@ -10,8 +10,9 @@ class oh_educator_expense_deduction_person(Variable): default_value = 0 defined_for = StateCode.OH # ORC § 5747.01(A)(31): Ohio allows a deduction ONLY for educator - # expenses in EXCESS of the federal IRC § 62(a)(2)(D) cap (currently - # $300). The federal-cap portion is already deducted via federal AGI; + # expenses in EXCESS of the federal IRC § 62(a)(2)(D) cap + # (gov.irs.ald.educator_expense.cap). The federal-cap portion is already + # deducted via federal AGI (educator_expense_ald); # adding the federal `educator_expense` here would double-count. This # stub variable therefore replaces (not supplements) the federal # educator deduction in oh/.../deductions.yaml. It remains an explicit diff --git a/policyengine_us/variables/household/expense/person/educator_expense.py b/policyengine_us/variables/household/expense/person/educator_expense.py index 68edbcb7bcd..43266719142 100644 --- a/policyengine_us/variables/household/expense/person/educator_expense.py +++ b/policyengine_us/variables/household/expense/person/educator_expense.py @@ -6,5 +6,15 @@ class educator_expense(Variable): entity = Person label = "Educator expenses" unit = USD - documentation = "Expenses necessary for carrying out educator-related duties." + documentation = ( + "Unreimbursed qualified expenses an eligible educator paid: a " + "kindergarten through grade 12 teacher, instructor, counselor, " + "principal or aide in a school for at least 900 hours in the school " + "year. Enter the full amount; the deduction applies the " + "per-educator cap." + ) definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_2_D", + "https://www.law.cornell.edu/uscode/text/26/62#d_1", + ) From 9cec818e95854c8b9c65a3df55164efa9fe4fdc9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:43:58 -0400 Subject: [PATCH 3/7] Test the capped educator amount on a dependent's MAGI and in Medicaid AGI Co-Authored-By: Claude Opus 5.5 --- ..._gross_income_person_educator_expense.yaml | 25 ++++++++++++++++ .../dependent_taxable_ss_magi.yaml | 29 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml new file mode 100644 index 00000000000..9dcc4afd8af --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml @@ -0,0 +1,25 @@ +- name: Each person subtracts their own capped educator expenses. + period: 2022 + input: + people: + person1: + is_tax_unit_head: true + medicaid_irs_gross_income: 60_000 + educator_expense: 500 + person2: + is_tax_unit_spouse: true + medicaid_irs_gross_income: 40_000 + person3: + is_tax_unit_dependent: true + medicaid_irs_gross_income: 5_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [person1, person2, person3] + filing_status: JOINT + output: + # 26 USC 62(a)(2)(D): $300 per eligible educator for 2022 (Rev. Proc. + # 2021-45 sec. 3.13). The head deducts 300 of 500; the spouse has none + # to share; the dependent deducts 300 of 400 on their own return. + medicaid_adjusted_gross_income_person: [59_700, 40_000, 4_700] + absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml index c9c384d913c..f5dfb884933 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml @@ -54,6 +54,35 @@ # Taxable = min(0.5 * 20,000, 0.5 * (26,000 - 25,000)) = 500. dependent_taxable_social_security: [0, 500] +- name: A dependent's educator expenses reduce their modified AGI up to the cap. + period: 2022 + input: + people: + head: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + parent: + age: 70 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + social_security_retirement: 20_000 + taxable_interest_income: 16_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [head, parent] + households: + household: + members: [head, parent] + output: + # Modified AGI = 16,000 - 300, the 2022 cap of 26 USC 62(a)(2)(D) + # (Rev. Proc. 2021-45 sec. 3.13) on the 500 of expenses = 15,700. + dependent_taxable_ss_magi: [0, 15_700] + # Provisional income = 15,700 + 10,000 = 25,700. + # Taxable = min(0.5 * 20,000, 0.5 * (25,700 - 25,000)) = 350. + dependent_taxable_social_security: [0, 350] + - name: A married dependent who lives with their spouse has the separate-return capital loss limit. period: 2022 input: From f25432710ea8b5fe2d1d7ffe0f2a1d7fce0c372c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:29:38 -0400 Subject: [PATCH 4/7] Sum the educator deduction over non-dependents, as irs_gross_income does A member flagged as a tax unit dependent has their income left off the filer's return by irs_gross_income, which masks on is_tax_unit_dependent; mask their deduction the same way (tax_unit_non_dep_sum, as #9801 does for the other person-level deductions). Make the dependent test case's roles explicit. Co-Authored-By: Claude Opus 5.5 --- .../above_the_line_deductions/educator_expense_ald.yaml | 3 +++ .../above_the_line_deductions/educator_expense_ald.py | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml index 3ee820f1215..bc930da42ce 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml @@ -291,11 +291,14 @@ people: parent: age: 50 + is_tax_unit_head: true employment_income: 60_000 educator_expense: 100 student: age: 22 is_full_time_student: true + is_tax_unit_head: false + is_tax_unit_spouse: false is_tax_unit_dependent: true employment_income: 3_000 educator_expense: 400 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py index 1325647df36..865ae60ca8a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py @@ -11,7 +11,7 @@ class educator_expense_ald(Variable): "expenses (Schedule 1, line 11). Each eligible educator deducts up to " "the cap; on a joint return neither spouse can use the other's unused " "amount. A tax unit dependent deducts their own expenses on their own " - "return." + "return, as irs_gross_income leaves their income off this one." ) definition_period = YEAR reference = ( @@ -22,7 +22,4 @@ class educator_expense_ald(Variable): ) def formula(tax_unit, period, parameters): - person = tax_unit.members - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - deduction = person("educator_expense_ald_person", period) - return tax_unit.sum(head_or_spouse * deduction) + return tax_unit_non_dep_sum("educator_expense_ald_person", tax_unit, period) From 636b0be64eb68c0fe71b0c443416c3c65169127b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 23:12:20 -0400 Subject: [PATCH 5/7] Project the educator expense cap from the statutory $250 base (26 USC 62(d)(3)) Core's metadata uprating chained from the rounded 2026 value ($350), which put the projected cap at $400 in 2030-2031. 62(d)(3) indexes the $250 amount with a 2014 base year and rounds the increase to the nearest $50, so on the repo's CPI the cap stays $350 through 2031 and reaches $400 in 2032. extend_educator_expense_cap computes each year after the last published value from the statute, with get_irs_cola(parameters, year, 2014), as the dependent standard deduction extension does. The published 2002-2026 values stay explicit and take precedence. Tests: the 62(d)(3) computation reproduces every published cap from 2016 to 2026; projected years 2027-2100 equal it, are multiples of $50 and never fall; a synthetic index pins nearest rounding in both directions; and a 2030 YAML case expects $350. Co-Authored-By: Claude Opus 5.5 --- .../gov/irs/ald/educator_expense/cap.yaml | 11 +- .../parameters/uprating_extensions.py | 37 ++++++ .../educator_expense_ald.yaml | 23 ++++ .../parameters/test_uprating_extensions.py | 107 ++++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml b/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml index 61bfb1cf852..e3023b207cf 100644 --- a/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml +++ b/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml @@ -19,14 +19,21 @@ metadata: unit: currency-USD period: year label: Educator expense deduction cap + # Values after the last IRS publication are computed in + # parameters/uprating_extensions.py per 26 U.S.C. 62(d)(3): the statutory + # $250 increased by the 1(f)(3) cost-of-living adjustment with calendar + # year 2014 as the base year, each increase rounded to the nearest multiple + # of $50. Computing from the $250 base rather than chaining from the + # rounded $350 puts each projected $50 step in the year the statute does on + # the model's CPI projection; those values supersede this uprating rule, + # which describes the index and rounding. uprating: parameter: gov.irs.uprating - # 26 USC 62(d)(3): any increase is rounded to the nearest multiple of $50. rounding: type: nearest interval: 50 reference: - - title: 26 U.S. Code § 62(a)(2)(D) - Certain expenses of elementary and secondary school teachers + - title: 26 U.S. Code § 62(a)(2)(D) - Educator expenses href: https://www.law.cornell.edu/uscode/text/26/62#a_2_D - title: 26 U.S. Code § 62(d)(3) - Inflation adjustment href: https://www.law.cornell.edu/uscode/text/26/62#d_3 diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 1e047d86f67..204e7876c86 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -170,6 +170,38 @@ def extend_dependent_standard_deduction_parameters( ) +# 26 U.S.C. 62(d)(3) indexing of the 62(a)(2)(D) educator expense cap: +# statutory dollar amount and 1(f)(3) base year. +EDUCATOR_EXPENSE_CAP_STATUTORY_BASE = (250, 2014) + + +def extend_educator_expense_cap(parameters: ParameterNode, end_year: int) -> None: + """Project the per-educator cap on the educator expense deduction. + + 26 U.S.C. 62(d)(3) increases the $250 amount in 62(a)(2)(D) by the + 1(f)(3) cost-of-living adjustment, substituting calendar year 2014 for + 2016, and rounds the increase to the nearest multiple of $50. Each year + is computed from the statutory base rather than chained from the last + rounded value, which can put a $50 step a year or more early or late; + IRS values encoded in the YAML take precedence for their own years. + """ + ROUNDING_INTERVAL = 50 + base, base_year = EDUCATOR_EXPENSE_CAP_STATUTORY_BASE + cap = parameters.gov.irs.ald.educator_expense.cap + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in cap.values_list + ) + for year in range(first_projected_year, end_year + 1): + cola = get_irs_cola(parameters, year, base_year) + # Nearest multiple of $50; an exact half rounds up. + increase = math.floor(base * cola / ROUNDING_INTERVAL + 0.5) * ROUNDING_INTERVAL + cap.update(period=f"year:{year}-01-01:1", value=float(base + increase)) + cap.update( + start=instant(f"{end_year}-01-01"), + value=cap(f"{end_year}-01-01"), + ) + + def extend_or_ctc_parameters(parameters: ParameterNode, end_year: int) -> None: """Project the Oregon Kids' Credit amount and phase-out start. @@ -639,4 +671,9 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: # projected windows are available. extend_dependent_standard_deduction_parameters(parameters, end_year=END_YEAR) + # The educator expense cap follows 26 U.S.C. 62(d)(3)'s chained CPI-U + # schedule, computed from the statutory $250 and 2014 base year. Must run + # after the Chained CPI-U extension above. + extend_educator_expense_cap(parameters, end_year=END_YEAR) + return parameters diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml index bc930da42ce..a7efcc9e1bc 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml @@ -175,6 +175,29 @@ educator_expense_ald: 350 adjusted_gross_income: 49_650 +- name: 2030, the projected cap is still $350 + period: 2030 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + # 26 USC 62(d)(3) on the model's CPI projection: the increase on the $250 + # base is $250 x the 2030 COLA (0.451) = $112.80, which rounds to the + # nearest $50 at $100. Chaining from the rounded 2026 $350 gave $400. + educator_expense_ald_person: 350 + educator_expense_ald: 350 + adjusted_gross_income: 49_650 + - name: 2022, joint return with two eligible educators, each capped at $300 period: 2022 input: diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index 6da33a15d0c..9049852d5a0 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -12,8 +12,10 @@ from policyengine_us.tools.per_capita_uprating import per_capita_path from policyengine_us.parameters.uprating_extensions import ( DEPENDENT_STANDARD_DEDUCTION_STATUTORY_BASES, + EDUCATOR_EXPENSE_CAP_STATUTORY_BASE, LONG_RUN_CBO_INCOME_BY_SOURCE_PARAMETERS, extend_dependent_standard_deduction_parameters, + extend_educator_expense_cap, get_irs_cola, get_irs_cola_denominator, round_social_security_amount, @@ -670,3 +672,108 @@ def test_dependent_standard_deduction_projections_follow_statute(): assert dependent.additional_earned_income("2032-01-01") == 550 assert dependent.additional_earned_income("2042-01-01") == 650 assert dependent.amount("2042-01-01") == 1_900 + + +def statutory_educator_expense_cap(year): + """26 U.S.C. 62(d)(3): $250 plus its increase, to the nearest $50.""" + base, base_year = EDUCATOR_EXPENSE_CAP_STATUTORY_BASE + increase = base * get_irs_cola(PARAMETERS, year, base_year) + lower = math.floor(increase / 50) * 50 + return base + (lower if increase - lower < 25 else lower + 50) + + +def encoded_educator_expense_caps(): + """The year: value pairs written in the educator expense cap YAML.""" + path = ( + Path(__file__).parents[4] / "parameters/gov/irs/ald/educator_expense/cap.yaml" + ) + values = yaml.safe_load(path.read_text())["values"] + return {int(str(date)[:4]): value for date, value in values.items()} + + +def test_irs_cola_reproduces_published_educator_expense_caps(): + """The 62(d)(3) computation matches every IRS-published cap, 2016-2026.""" + encoded = encoded_educator_expense_caps() + for year in range(2016, 2027): + assert statutory_educator_expense_cap(year) == encoded[year], year + + +def test_educator_expense_cap_encoded_values_take_precedence(): + """The published caps in the YAML survive the statutory extension.""" + cap = PARAMETERS.gov.irs.ald.educator_expense.cap + for year, value in encoded_educator_expense_caps().items(): + assert cap(f"{year}-01-01") == value, year + # The 2002 value covers 2002-2015, before 62(d)(3) indexing. + for year in range(2002, 2016): + assert cap(f"{year}-01-01") == 250, year + + +def test_educator_expense_cap_projections_follow_statute(): + """Projected caps come from the $250 base, not the rounded 2026 $350.""" + cap = PARAMETERS.gov.irs.ald.educator_expense.cap + last_encoded_year = max(encoded_educator_expense_caps()) + previous = cap(f"{last_encoded_year}-01-01") + for year in range(last_encoded_year + 1, 2101): + value = cap(f"{year}-01-01") + assert value == statutory_educator_expense_cap(year), year + assert value % 50 == 0, year + assert value >= previous, year + previous = value + assert cap("2150-01-01") == cap("2100-01-01") + + # Hand anchors. They rest on the model's CPI projection, so a CBO refresh + # can move them; 2031 and 2032 sit within $5 of the $125 step. The 2014 + # denominator is 134.30. The increase, $250 x the COLA, rounds to $100 + # while under $125: $89.57 in 2027, $112.80 in 2030 and $120.06 in 2031. + # It rounds to $150 from $127.32 in 2032 to $150.03 in 2035. Chaining + # from the rounded 2026 $350 reached $400 in 2030. + for year in range(2027, 2032): + assert cap(f"{year}-01-01") == 350, year + for year in range(2032, 2036): + assert cap(f"{year}-01-01") == 400, year + + +def test_educator_expense_cap_extension_rounds_the_base_increase_to_nearest_50(): + """Encoded years are kept; later years round the $250 base's increase.""" + + def months(start_year, level): + return { + **{f"{start_year}-{month:02d}-01": level for month in range(9, 13)}, + **{f"{start_year + 1}-{month:02d}-01": level for month in range(1, 9)}, + } + + cpi = SimpleNamespace( + cpi_u=Parameter("cpi_u", data={**months(2013, 100), **months(2015, 200)}), + # Monthly observations end in August 2030; 2032 holds a projection. + c_cpi_u=Parameter( + "c_cpi_u", + data={**months(2015, 150), **months(2029, 102), "2032-02-01": 126}, + ), + ) + # A node, so Parameter.update can reach a parent. + educator_expense = ParameterNode( + "educator_expense", + data={"cap": {"values": {"2002-01-01": 250, "2030-01-01": 999}}}, + ) + parameters = SimpleNamespace( + gov=SimpleNamespace( + bls=SimpleNamespace(cpi=cpi), + irs=SimpleNamespace(ald=SimpleNamespace(educator_expense=educator_expense)), + ) + ) + extend_educator_expense_cap(parameters, 2033) + + cap = educator_expense.cap + # Encoded years keep their values, statutory or not. + assert cap("2029-01-01") == 250 + assert cap("2030-01-01") == 999 + # Denominator: 2014 base 100 x 150 / 200 = 75. 2031 reads the observed + # Sep 2029-Aug 2030 window (102); 2032 has no observed month and no + # projection point of its own, so it reads 102 too. + # $250 x (102 / 75 - 1) = $90, nearest to $100 (rounding down gives $50). + for year in (2031, 2032): + assert cap(f"{year}-01-01") == 350, year + # 2033 reads the 2032 projection point (126). + # $250 x (126 / 75 - 1) = $170, nearest to $150 (rounding up gives $200). + assert cap("2033-01-01") == 400 + assert cap("2040-01-01") == 400 From f38732ad3e03da97a6765b54eb1ee28ce4073eb3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 23:12:43 -0400 Subject: [PATCH 6/7] Document early childhood educators' eligibility from 2026 (Pub. L. 119-111) Pub. L. 119-111, division B, section 1, adds early childhood educators to the 62(d)(1) eligible educator definition for expenses paid or incurred in taxable years beginning after 2025. The model has no educator-status input and takes any positive educator_expense as an eligible educator's, so the input's documentation now gives the effective-year split, and both groups share the same cap. Co-Authored-By: Claude Opus 5.5 --- .../educator_expense_ald_person.py | 4 +++- .../expense/person/educator_expense.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py index 953b3dd656d..6f57a28f1fe 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py @@ -10,7 +10,9 @@ class educator_expense_ald_person(Variable): "Each person's educator expenses, capped at the per-educator limit of " "26 USC 62(a)(2)(D). This is the amount for the person's own return; " "educator_expense_ald adds the head's and spouse's amounts for the " - "tax unit's return." + "tax unit's return. Any positive educator_expense is taken to be an " + "eligible educator's: kindergarten through grade 12 in every year, " + "and early childhood from 2026. Both groups have the same cap." ) definition_period = YEAR reference = ( diff --git a/policyengine_us/variables/household/expense/person/educator_expense.py b/policyengine_us/variables/household/expense/person/educator_expense.py index 43266719142..7e6a582eba2 100644 --- a/policyengine_us/variables/household/expense/person/educator_expense.py +++ b/policyengine_us/variables/household/expense/person/educator_expense.py @@ -7,14 +7,23 @@ class educator_expense(Variable): label = "Educator expenses" unit = USD documentation = ( - "Unreimbursed qualified expenses an eligible educator paid: a " - "kindergarten through grade 12 teacher, instructor, counselor, " + "Unreimbursed qualified expenses an eligible educator paid or " + "incurred. An eligible educator is a teacher, instructor, counselor, " "principal or aide in a school for at least 900 hours in the school " - "year. Enter the full amount; the deduction applies the " - "per-educator cap." + "year. Kindergarten through grade 12 educators qualify in every year. " + "Early childhood educators qualify for tax years beginning after " + "2025 (Pub. L. 119-111, division B, section 1), in a school or " + "childcare facility that serves more than two non-resident children " + "under age 6 and is publicly funded or paid for those services. The " + "model has no input for educator status and treats any positive " + "amount as an eligible educator's, so enter an early childhood " + "educator's expenses only for 2026 and later. Enter the full amount; " + "the deduction applies the per-educator cap." ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/62#a_2_D", "https://www.law.cornell.edu/uscode/text/26/62#d_1", + # Pub. L. 119-111, division B, section 1 (140 Stat. 1049). + "https://www.govinfo.gov/content/pkg/PLAW-119publ111/html/PLAW-119publ111.htm", ) From 7f13ef6cd3fe040b2a2d82044caddef5109481b2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 05:03:55 -0400 Subject: [PATCH 7/7] Check the educator deduction in #9801's dependent ALD harness and Medicaid split test_dependent_above_the_line_deductions_invariants.py now lists educator_expense_ald among its AGGREGATES, so the harness checks that a dependent's educator expenses never change the filer's educator_expense_ald and that it equals the numpy sum of the head's and spouse's educator_expense_ald_person. A Medicaid AGI case supplies educator_expense_ald directly, at 250 against own amounts of 300 and 200, so person_share_of_tax_unit_amount scales the head's and spouse's shares to 150 and 100 while the dependent keeps their own 300. Without the educator entry in the Medicaid map this case and the existing one both fail. Co-Authored-By: Claude Opus 5.5 --- ...nt_above_the_line_deductions_invariants.py | 1 + ..._gross_income_person_educator_expense.yaml | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index 3783006a6be..70b816980e6 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -82,6 +82,7 @@ "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", "alimony_expense_ald": "alimony_expense_ald_person", + "educator_expense_ald": "educator_expense_ald_person", } TAX_UNIT_OUTPUTS = [ "adjusted_gross_income", diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml index 9dcc4afd8af..c3d448c99f9 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml @@ -23,3 +23,34 @@ # to share; the dependent deducts 300 of 400 on their own return. medicaid_adjusted_gross_income_person: [59_700, 40_000, 4_700] absolute_error_margin: 0.01 + +- name: A tax-unit educator deduction that differs from the own amounts is shared in proportion to them. + period: 2022 + input: + people: + person1: + is_tax_unit_head: true + medicaid_irs_gross_income: 60_000 + educator_expense: 500 + person2: + is_tax_unit_spouse: true + medicaid_irs_gross_income: 40_000 + educator_expense: 200 + person3: + is_tax_unit_dependent: true + medicaid_irs_gross_income: 5_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [person1, person2, person3] + filing_status: JOINT + # Supplied directly, as an input or a reform would set it. + educator_expense_ald: 250 + output: + # Own capped amounts for 2022: head 300 of 500, spouse 200, dependent + # 300 of 400. The head's and spouse's 500 is scaled to the 250 deduction, + # so the head subtracts 150 and the spouse 100. The dependent keeps their + # own 300, which is on their own return. + educator_expense_ald_person: [300, 200, 300] + medicaid_adjusted_gross_income_person: [59_850, 39_900, 4_700] + absolute_error_margin: 0.01