From 86e9d4b45e91b89877326b83c06c0cc19171df20 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 20:35:16 -0400 Subject: [PATCH 1/2] Exclude section 68(c) deductions from the 80 percent ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before P.L. 119-21, 26 USC 68(a)(2) capped the overall limitation on itemized deductions at 80 percent of "the itemized deductions otherwise allowable", and 68(c) excluded medical expenses, investment interest, and casualty, theft and wagering losses from that term. The Schedule A Itemized Deductions Worksheet—Line 29 (2013-2017) implements this on line 2. PolicyEngine applied the 80 percent to all itemized deductions, so filers with medical expenses, investment interest or casualty losses got too large a reduction whenever the ceiling bound. Add gov.irs.deductions.itemized.limitation.excluded_deductions and subtract it from the base of the 80 percent ceiling in the non-OBBB branch. The P.L. 119-21 branch (2026+) is unchanged: sec. 70111(a) rewrote section 68 without subsection (c). Co-Authored-By: Claude Opus 5.5 --- .../fix-68-80pct-cap-exclusions.fixed.md | 1 + .../limitation/excluded_deductions.yaml | 24 ++ ...temized_deduction_limitation_properties.py | 284 ++++++++++++++++++ ...d_taxable_income_deductions_reduction.yaml | 138 +++++++++ ...zed_taxable_income_deductions_reduction.py | 18 +- 5 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fix-68-80pct-cap-exclusions.fixed.md create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml create mode 100644 policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py diff --git a/changelog.d/fix-68-80pct-cap-exclusions.fixed.md b/changelog.d/fix-68-80pct-cap-exclusions.fixed.md new file mode 100644 index 00000000000..dc80a7d6787 --- /dev/null +++ b/changelog.d/fix-68-80pct-cap-exclusions.fixed.md @@ -0,0 +1 @@ +Exclude medical expenses, investment interest, and casualty and theft losses from the itemized deductions that the pre-2026 section 68 80 percent ceiling applies to, per section 68(c) and the Schedule A Itemized Deductions Worksheet—Line 29. diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml new file mode 100644 index 00000000000..40ce1629db0 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml @@ -0,0 +1,24 @@ +description: The US excludes these deductions from the itemized deductions that the 80 percent ceiling on the itemized deductions limitation applies to, under the section 68 structure in effect before the One Big Beautiful Bill Act. +values: + # Section 68(c) excludes the medical expense deduction (section 213), any + # deduction for investment interest (section 163(d)), and casualty, theft + # and wagering losses (section 165(c)(2), (c)(3) and (d)). + # investment_interest_expense is the investment interest that + # interest_deduction includes through non_mortgage_interest. + # wagering_losses_deduction is deducted outside + # total_itemized_taxable_income_deductions, so it never enters the base. + 2009-01-01: + - medical_expense_deduction + - investment_interest_expense + - casualty_loss_deduction +metadata: + unit: list + period: year + label: Itemized deductions excluded from the limitation ceiling + reference: + - title: 26 U.S. Code § 68(c) (2016 edition) - Exception for certain itemized deductions + href: https://www.govinfo.gov/content/pkg/USCODE-2016-title26/html/USCODE-2016-title26-subtitleA-chap1-subchapB-partI-sec68.htm + - title: 2016 Instructions for Schedule A (Form 1040), Itemized Deductions Worksheet—Line 29, line 2 + href: https://www.irs.gov/pub/irs-prior/i1040sca--2016.pdf#page=13 + - title: 2017 Instructions for Schedule A (Form 1040), Itemized Deductions Worksheet—Line 29, line 2 + href: https://www.irs.gov/pub/irs-prior/i1040sca--2017.pdf#page=14 diff --git a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py new file mode 100644 index 00000000000..4b83c7593d6 --- /dev/null +++ b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py @@ -0,0 +1,284 @@ +"""Invariants of the overall limitation on itemized deductions (26 U.S.C. 68). + +Before P.L. 119-21, section 68(a) reduced itemized deductions by the lesser of +3 percent of AGI over the applicable amount or 80 percent of "the itemized +deductions otherwise allowable", and section 68(c) excluded from that term the +deduction for medical expenses (section 213), any deduction for investment +interest (section 163(d)), and casualty, theft and wagering losses (section +165(c)(2), (c)(3) and (d)). The Itemized Deductions Worksheet—Line 29 in the +Instructions for Schedule A (Form 1040) implements this. P.L. 119-21, sec. +70111(a), rewrote section 68 for taxable years beginning after 2025 without +subsection (c). + +Tax units with seeded random incomes, filing statuses and deduction mixes share +one simulation per case. The tests check: + +1. PolicyEngine matches an independent restatement of worksheet lines 1-10 for + 2015-2017, with the line 6 applicable amounts restated from the worksheets. + (The limitation's rate parameters do not resolve before 2015.) +2. The reduction lies between 0 and both the 3 percent amount and 80 percent of + the deductions that section 68(c) does not exclude. +3. Excluded deductions are never reduced: allowed deductions are at least the + excluded amount plus 20 percent of the rest. +4. Raising an excluded deduction leaves the reduction unchanged, so allowed + deductions rise one for one. +5. Allowed deductions never fall when any component rises, and the reduction + never falls when AGI rises. +6. From 2026 the reduction depends only on total itemized deductions, not on + how they split between formerly excluded and other categories. +7. With the OBBB structure switched off in 2026 (the prior-law counterfactual), + the pre-2026 worksheet logic applies with the 2026 applicable amounts. + +Gambling losses are deducted outside total_itemized_taxable_income_deductions +(wagering_losses_deduction), so they never enter the limited base; they are +not generated here. +""" + +import numpy as np +import pytest + +from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_core.reforms import Reform + +N = 600 +STATUSES = ("SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE") + +# Worksheet line 6: "Enter $X if married filing jointly or qualifying +# widow(er); $Y if head of household; $Z if single; or $W if married filing +# separately" (Instructions for Schedule A, Itemized Deductions Worksheet—Line +# 29, 2015-2017 editions). +APPLICABLE_AMOUNTS = { + 2015: {"JOINT": 309_900, "HEAD_OF_HOUSEHOLD": 284_050, "SINGLE": 258_250}, + 2016: {"JOINT": 311_300, "HEAD_OF_HOUSEHOLD": 285_350, "SINGLE": 259_400}, + 2017: {"JOINT": 313_800, "HEAD_OF_HOUSEHOLD": 287_650, "SINGLE": 261_500}, +} +SEPARATE_AMOUNTS = {2015: 154_950, 2016: 155_650, 2017: 156_900} + +# Components of total_itemized_taxable_income_deductions, split by whether +# section 68(c) excludes them. Mortgage and investment interest are person +# inputs that interest_deduction adds up. +EXCLUDED = ("medical_expense_deduction", "casualty_loss_deduction") +EXCLUDED_PERSON = ("investment_interest_expense",) +OTHER = ("charitable_deduction", "salt_deduction", "misc_deduction") +OTHER_PERSON = ("deductible_mortgage_interest",) +COMPONENTS = EXCLUDED + OTHER +PERSON_COMPONENTS = EXCLUDED_PERSON + OTHER_PERSON +ALL_COMPONENTS = COMPONENTS + PERSON_COMPONENTS +# PolicyEngine computes in float32, whose spacing near $1 million is 6 cents, +# so tolerances scale with the size of the operands (not of the result: the +# 80 percent base is a difference of two such sums). +RTOL = 1e-6 +ATOL = 0.01 + + +def _slack(scale): + return ATOL + RTOL * np.abs(scale) + + +def _close(actual, desired, scale, message=""): + gap = np.abs(np.asarray(actual, dtype=float) - desired) + worst = int(np.argmax(gap - _slack(scale))) + assert np.all(gap <= _slack(scale)), ( + f"{message} unit {worst}: {actual[worst]} vs {desired[worst]}" + ) + + +OUTPUTS = ( + "itemized_taxable_income_deductions_reduction", + "itemized_taxable_income_deductions", + "total_itemized_taxable_income_deductions", +) + + +def _draw(seed): + """Seeded tax units: filing status, AGI and each deduction component.""" + rng = np.random.default_rng(seed) + units = { + "filing_status": rng.choice(STATUSES, N), + "adjusted_gross_income": np.round(rng.uniform(0, 1_500_000, N), 2), + } + for name in ALL_COMPONENTS: + amount = np.round(rng.uniform(0, 150_000, N), 2) + units[name] = np.where(rng.random(N) < 0.5, 0, amount) + # Every fifth unit has only excluded deductions (worksheet line 3 "No"). + only_excluded = np.arange(N) % 5 == 0 + for name in OTHER + OTHER_PERSON: + units[name] = np.where(only_excluded, 0, units[name]) + return units + + +def _simulate(year, units, reform=None): + period = str(year) + people, tax_units, households = {}, {}, {} + for i in range(N): + person = f"p{i}" + people[person] = {"age": {period: 50}} + for name in PERSON_COMPONENTS: + people[person][name] = {period: float(units[name][i])} + tax_units[f"t{i}"] = { + "members": [person], + "filing_status": {period: str(units["filing_status"][i])}, + "adjusted_gross_income": {period: float(units["adjusted_gross_income"][i])}, + **{name: {period: float(units[name][i])} for name in COMPONENTS}, + } + households[f"h{i}"] = {"members": [person]} + situation = {"people": people, "tax_units": tax_units, "households": households} + simulation = Simulation(situation=situation, reform=reform) + return {name: simulation.calculate(name, period) for name in OUTPUTS} + + +def _split(units): + excluded = sum(units[name] for name in EXCLUDED + EXCLUDED_PERSON) + other = sum(units[name] for name in OTHER + OTHER_PERSON) + return excluded, other + + +def _worksheet_line_9(units, applicable_amount): + """Itemized Deductions Worksheet—Line 29, lines 1-9, restated.""" + excluded, other = _split(units) + line_1 = excluded + other + line_2 = excluded + line_3 = np.where(line_2 < line_1, line_1 - line_2, 0) + line_4 = line_3 * 0.8 + line_5 = units["adjusted_gross_income"] + line_6 = applicable_amount + line_7 = np.where(line_6 < line_5, line_5 - line_6, 0) + line_8 = line_7 * 0.03 + return np.minimum(line_4, line_8) + + +def _worksheet_amounts(year, statuses): + amounts = APPLICABLE_AMOUNTS[year] + return np.array( + [ + SEPARATE_AMOUNTS[year] + if status == "SEPARATE" + else amounts["JOINT" if status == "SURVIVING_SPOUSE" else status] + for status in statuses + ] + ) + + +def _parameter_amounts(year, statuses): + p = ( + CountryTaxBenefitSystem() + .parameters(f"{year}-01-01") + .gov.irs.deductions.itemized.limitation + ) + return np.asarray(p.applicable_amount[np.asarray(statuses)]) + + +def _check_pre_2026_structure(units, results, applicable_amount): + reduction = results["itemized_taxable_income_deductions_reduction"] + allowed = results["itemized_taxable_income_deductions"] + excluded, other = _split(units) + scale = excluded + other + units["adjusted_gross_income"] + + # PolicyEngine's total is the sum of the generated components. + _close( + results["total_itemized_taxable_income_deductions"], + excluded + other, + scale, + "total", + ) + + # 1. Worksheet line 9 and line 10. + line_9 = _worksheet_line_9(units, applicable_amount) + _close(reduction, line_9, scale, "line 9") + _close(allowed, excluded + other - line_9, scale, "line 10") + + # 2. Bounds on the reduction. + agi_excess = np.maximum(0, units["adjusted_gross_income"] - applicable_amount) + assert np.all(reduction >= 0) + assert np.all(reduction <= 0.03 * agi_excess + _slack(scale)) + assert np.all(reduction <= 0.8 * other + _slack(scale)) + + # 3. Excluded deductions are never reduced. + assert np.all(allowed >= excluded + 0.2 * other - _slack(scale)) + + +@pytest.mark.parametrize("year", sorted(APPLICABLE_AMOUNTS)) +def test_reduction_matches_the_line_29_worksheet(year): + units = _draw(seed=year) + results = _simulate(year, units) + amounts = _worksheet_amounts(year, units["filing_status"]) + # The parameter file carries the same applicable amounts. + np.testing.assert_array_equal( + _parameter_amounts(year, units["filing_status"]), amounts + ) + _check_pre_2026_structure(units, results, amounts) + + +@pytest.mark.parametrize("year", sorted(APPLICABLE_AMOUNTS)) +def test_reduction_responds_to_components_and_income(year): + units = _draw(seed=year + 1) + base = _simulate(year, units) + base_reduction = base["itemized_taxable_income_deductions_reduction"] + base_allowed = base["itemized_taxable_income_deductions"] + excluded, other = _split(units) + scale = excluded + other + units["adjusted_gross_income"] + 10_000 + + for name in ALL_COMPONENTS: + raised = dict(units, **{name: units[name] + 1_000}) + results = _simulate(year, raised) + allowed = results["itemized_taxable_income_deductions"] + # 5. More of any deduction never lowers allowed deductions. + assert np.all(allowed >= base_allowed - _slack(scale)), name + if name in EXCLUDED + EXCLUDED_PERSON: + # 4. Excluded deductions pass through without reduction. + _close( + results["itemized_taxable_income_deductions_reduction"], + base_reduction, + scale, + name, + ) + _close(allowed, base_allowed + 1_000, scale, name) + + # 5. More AGI never lowers the reduction. + richer = dict(units, adjusted_gross_income=units["adjusted_gross_income"] + 10_000) + richer_reduction = _simulate(year, richer)[ + "itemized_taxable_income_deductions_reduction" + ] + assert np.all(richer_reduction >= base_reduction - _slack(scale)) + + +def test_2026_reduction_ignores_the_mix_of_deductions(): + # 6. Section 68 as amended by P.L. 119-21 has no subsection (c), so moving + # amounts from formerly excluded deductions into charity changes nothing. + units = _draw(seed=2026) + excluded, other = _split(units) + moved = dict(units) + for name in ALL_COMPONENTS: + moved[name] = np.zeros(N) + moved["charitable_deduction"] = excluded + other + base = _simulate(2026, units) + results = _simulate(2026, moved) + total = excluded + other + scale = total + units["adjusted_gross_income"] + reduction = base["itemized_taxable_income_deductions_reduction"] + _close( + results["itemized_taxable_income_deductions_reduction"], + reduction, + scale, + ) + assert np.all(reduction >= 0) + assert np.all(reduction <= total * 2 / 37 + _slack(scale)) + assert np.any(reduction > 0) + + +def test_2026_prior_law_counterfactual_uses_the_worksheet(): + # 7. Without the OBBB structure, 2026 reverts to the pre-2026 section 68, + # including subsection (c), at the 2026 applicable amounts. + reform = Reform.from_dict( + { + "gov.irs.deductions.itemized.limitation.obbb.applies": { + "2026-01-01.2100-12-31": False + } + }, + country_id="us", + ) + units = _draw(seed=2025) + results = _simulate(2026, units, reform=reform) + amounts = _parameter_amounts(2026, units["filing_status"]) + _check_pre_2026_structure(units, results, amounts) + assert np.any(results["itemized_taxable_income_deductions_reduction"] > 0) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml index 4014b22ef63..965f6ed655c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml @@ -26,3 +26,141 @@ total_itemized_taxable_income_deductions: 1_000 output: itemized_taxable_income_deductions_reduction: 54 + +# 26 U.S.C. 68(c) before P.L. 119-21: medical expenses, investment interest, +# and casualty, theft and wagering losses are not "itemized deductions" for +# the 80 percent ceiling. Expected values follow the Itemized Deductions +# Worksheet—Line 29 in the Instructions for Schedule A (Form 1040): line 2 is +# "the amount from Schedule A, lines 4, 14, and 20" (medical, investment +# interest, casualty or theft), and line 4 is 80% of line 1 minus line 2. +- name: 2016 single filer with medical expenses - the 80 percent ceiling binds + period: 2016 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 400_000 + filing_status: SINGLE + medical_expense_deduction: 60_000 + charitable_deduction: 5_000 + interest_deduction: 0 + salt_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # 2016 worksheet: + # Line 1: 60_000 + 5_000 = 65_000 + # Line 2: 60_000 (Schedule A line 4, medical) + # Line 3: 65_000 - 60_000 = 5_000 + # Line 4: 5_000 * 80% = 4_000 + # Line 7: 400_000 - 259_400 = 140_600 + # Line 8: 140_600 * 3% = 4_218 + # Line 9: smaller of 4_000 or 4_218 = 4_000 + # Line 10: 65_000 - 4_000 = 61_000 + itemized_taxable_income_deductions_reduction: 4_000 + itemized_taxable_income_deductions: 61_000 + +- name: 2017 joint filers with investment interest and a casualty loss - the 80 percent ceiling binds + period: 2017 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + investment_interest_expense: 30_000 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, spouse] + adjusted_gross_income: 600_000 + filing_status: JOINT + casualty_loss_deduction: 20_000 + charitable_deduction: 10_000 + medical_expense_deduction: 0 + salt_deduction: 0 + misc_deduction: 0 + households: + household: + members: [head, spouse] + output: + # 2017 worksheet: + # Line 1: 30_000 + 20_000 + 10_000 = 60_000 + # Line 2: 30_000 (line 14, investment interest) + 20_000 (line 20) = 50_000 + # Line 3: 60_000 - 50_000 = 10_000 + # Line 4: 10_000 * 80% = 8_000 + # Line 7: 600_000 - 313_800 = 286_200 + # Line 8: 286_200 * 3% = 8_586 + # Line 9: smaller of 8_000 or 8_586 = 8_000 + # Line 10: 60_000 - 8_000 = 52_000 + interest_deduction: 30_000 + total_itemized_taxable_income_deductions: 60_000 + itemized_taxable_income_deductions_reduction: 8_000 + itemized_taxable_income_deductions: 52_000 + +- name: 2015 head of household with only medical expenses - deduction not limited + period: 2015 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 500_000 + filing_status: HEAD_OF_HOUSEHOLD + medical_expense_deduction: 40_000 + charitable_deduction: 0 + interest_deduction: 0 + salt_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # 2015 worksheet: + # Line 1: 40_000 + # Line 2: 40_000 (Schedule A line 4, medical) + # Line 3: line 2 is not less than line 1, so "Your deduction isn't + # limited" and line 29 is line 1. + itemized_taxable_income_deductions_reduction: 0 + itemized_taxable_income_deductions: 40_000 + +- name: 2016 separate filer - the 3 percent amount binds despite medical expenses + period: 2016 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 200_000 + filing_status: SEPARATE + medical_expense_deduction: 10_000 + salt_deduction: 40_000 + charitable_deduction: 0 + interest_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # 2016 worksheet: + # Line 1: 10_000 + 40_000 = 50_000 + # Line 2: 10_000 + # Line 3: 40_000 + # Line 4: 40_000 * 80% = 32_000 + # Line 7: 200_000 - 155_650 = 44_350 + # Line 8: 44_350 * 3% = 1_330.50 + # Line 9: smaller of 32_000 or 1_330.50 = 1_330.50 + # Line 10: 50_000 - 1_330.50 = 48_669.50 + itemized_taxable_income_deductions_reduction: 1_330.50 + itemized_taxable_income_deductions: 48_669.50 + +# P.L. 119-21, sec. 70111(a), rewrote section 68 for taxable years beginning +# after December 31, 2025, without the former subsection (c). Medical +# expenses therefore count toward "such amount of itemized deductions". +# The 37 percent bracket for single filers begins at $640,600 in 2026 +# (Rev. Proc. 2025-32, sec. 4.01, Table 3). +- name: 2026 single filer with medical expenses - no section 68(c) exclusion + period: 2026 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 900_000 + filing_status: SINGLE + medical_expense_deduction: 150_000 + charitable_deduction: 50_000 + interest_deduction: 0 + salt_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # Itemized deductions: 150_000 + 50_000 = 200_000 + # Excess over the 37 percent bracket: 900_000 - 640_600 = 259_400 + # Lesser: 200_000; reduction: 200_000 * 2 / 37 = 10_810.81 + itemized_taxable_income_deductions_reduction: 10_810.81 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py index 285d459f6b0..05d2de5a2d1 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py @@ -7,6 +7,13 @@ class itemized_taxable_income_deductions_reduction(Variable): label = "Itemized taxable income deductions reduction" unit = USD definition_period = YEAR + reference = [ + # Section 68 before P.L. 119-21, including subsection (c). + "https://www.govinfo.gov/content/pkg/USCODE-2016-title26/html/USCODE-2016-title26-subtitleA-chap1-subchapB-partI-sec68.htm", + "https://www.irs.gov/pub/irs-prior/i1040sca--2016.pdf#page=13", + # Section 68 as amended by P.L. 119-21, sec. 70111, from 2026. + "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section68&num=0&edition=prelim", + ] def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.deductions.itemized.limitation @@ -20,9 +27,6 @@ def formula(tax_unit, period, parameters): maximum_deductions = tax_unit( "total_itemized_taxable_income_deductions", period ) - maximum_deductions_reduction = ( - maximum_deductions * p.itemized_deduction_rate - ) if p.obbb.applies: top_rate_threshold = parameters( period @@ -37,5 +41,13 @@ def formula(tax_unit, period, parameters): total_itemized_deductions, taxable_income_excess ) return p.obbb.rate * lesser_of_deductions_or_excess + # Section 68(c) excludes medical expenses, investment interest, + # and casualty, theft and wagering losses from the itemized + # deductions the 80 percent ceiling applies to. + excluded_deductions = add(tax_unit, period, p.excluded_deductions) + limited_deductions = max_(0, maximum_deductions - excluded_deductions) + maximum_deductions_reduction = ( + limited_deductions * p.itemized_deduction_rate + ) return min_(agi_excess_reduction, maximum_deductions_reduction) return 0 From 066a85ed8e8b410bb1d68cdc56767dae1846b862 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 20:49:02 -0400 Subject: [PATCH 2/2] Note unmodeled 2017 contributions; reuse one tax system in tests Addresses the independent review of #9695: the excluded_deductions comment now says the 2017 worksheet's qualified disaster-relief contributions are not modeled, and the property test builds the parameter tree once instead of per call. Co-Authored-By: Claude Opus 5.5 --- .../itemized/limitation/excluded_deductions.yaml | 3 +++ ...test_itemized_deduction_limitation_properties.py | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml index 40ce1629db0..96e642fd5db 100644 --- a/policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/limitation/excluded_deductions.yaml @@ -7,6 +7,9 @@ values: # interest_deduction includes through non_mortgage_interest. # wagering_losses_deduction is deducted outside # total_itemized_taxable_income_deductions, so it never enters the base. + # The 2017 worksheet also removes qualified disaster-relief contributions + # (P.L. 115-63 sec. 504(a)(3), P.L. 115-123 sec. 20104(a)(3)), which are + # not modeled. 2009-01-01: - medical_expense_deduction - investment_interest_expense diff --git a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py index 4b83c7593d6..36c428c7094 100644 --- a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py +++ b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py @@ -34,6 +34,8 @@ not generated here. """ +from functools import cache + import numpy as np import pytest @@ -159,12 +161,13 @@ def _worksheet_amounts(year, statuses): ) +@cache +def _system(): + return CountryTaxBenefitSystem() + + def _parameter_amounts(year, statuses): - p = ( - CountryTaxBenefitSystem() - .parameters(f"{year}-01-01") - .gov.irs.deductions.itemized.limitation - ) + p = _system().parameters(f"{year}-01-01").gov.irs.deductions.itemized.limitation return np.asarray(p.applicable_amount[np.asarray(statuses)])