From 018b28cdc44a91966f66fdf9d42f9d64126fa40b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 16:05:49 -0400 Subject: [PATCH 1/2] Apply New York's pre-TCJA 10% medical expense floor (IT-196 line 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NY Tax Law § 615(a) defines NY itemized deductions as federal deductions "as such deductions existed immediately prior to the enactment of Public Law 115-97". Pre-TCJA 26 U.S.C. 213(a) allowed medical expenses above 10% of AGI; TCJA § 11027 set the federal 7.5% floor, which NY does not follow. Form IT-196 line 3 multiplies federal AGI by 10% on every form from 2018 through 2025. ny_itemized_deductions_max and the Line 40 limitation used the federal medical_expense_deduction (7.5% floor since 2017). Add ny_medical_expense_deduction (10% of positive AGI, from a new NY floor parameter) and use it in both, so the medical deduction still cancels between worksheet lines 1 and 2. Fix the pre-TCJA components test (medical 2,500 -> 0, max 26,500 -> 24,000), move the phase-out tests' medical input to the NY variable, and add boundary, year, non-NY and integration cases plus Hypothesis invariants (bound, differential against the published 10%, order against the federal deduction, monotonicity, composition, Line 40 invariance). Co-Authored-By: Claude Opus 5.5 --- changelog.d/ny-medical-expense-floor.fixed.md | 1 + .../deductions/itemized/medical/floor.yaml | 20 ++ ...ny_medical_expense_deduction_invariants.py | 225 ++++++++++++++++++ .../ny_itemized_deductions_phase_out.yaml | 14 +- .../ny_medical_expense_deduction.yaml | 132 ++++++++++ .../itemized/ny_pre_tcja_deductions.yaml | 7 +- .../itemized/ny_itemized_deductions_max.py | 3 +- .../ny_itemized_deductions_phase_out.py | 4 +- .../pre_tcja/ny_medical_expense_deduction.py | 31 +++ 9 files changed, 424 insertions(+), 13 deletions(-) create mode 100644 changelog.d/ny-medical-expense-floor.fixed.md create mode 100644 policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml create mode 100644 policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml create mode 100644 policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py diff --git a/changelog.d/ny-medical-expense-floor.fixed.md b/changelog.d/ny-medical-expense-floor.fixed.md new file mode 100644 index 00000000000..8d20abc37a9 --- /dev/null +++ b/changelog.d/ny-medical-expense-floor.fixed.md @@ -0,0 +1 @@ +New York itemized deductions now allow medical and dental expenses only above 10% of federal AGI, the pre-TCJA 26 U.S.C. 213(a) floor that NY Tax Law § 615(a) keeps and Form IT-196 line 3 applies, through a new ny_medical_expense_deduction used by ny_itemized_deductions_max and the Line 40 limitation. They previously used the federal 7.5% floor, which overstated the deduction by up to 2.5% of federal AGI. diff --git a/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml new file mode 100644 index 00000000000..038db7746a5 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml @@ -0,0 +1,20 @@ +description: New York allows an itemized deduction for medical and dental expenses exceeding this share of federal adjusted gross income. +values: + 2018-01-01: 0.1 +metadata: + unit: /1 + period: year + label: New York itemized medical expense deduction floor + reference: + # NY Tax Law § 615(a) computes NY itemized deductions as federal + # deductions existed immediately prior to Public Law 115-97 (TCJA). + # Pre-TCJA 26 U.S.C. 213(a) set a 10% floor; TCJA § 11027 lowered the + # federal floor to 7.5%, which NY does not follow. + - title: NY Tax Law § 615(a) + href: https://www.nysenate.gov/legislation/laws/TAX/615 + - title: 26 U.S. Code § 213(a) (2016 edition, before Public Law 115-97) + href: https://www.govinfo.gov/content/pkg/USCODE-2016-title26/html/USCODE-2016-title26-subtitleA-chap1-subchapB-partVII-sec213.htm + - title: 2018 NY Form IT-196, line 3 (multiply line 2 by 10%) + href: https://www.tax.ny.gov/pdf/2018/inc/it196_2018_fill_in.pdf#page=1 + - title: 2025 NY Form IT-196, line 3 (multiply line 2 by 10%) + href: https://www.tax.ny.gov/pdf/2025/inc/it196_2025_fill_in.pdf#page=1 diff --git a/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py new file mode 100644 index 00000000000..c6c817acf10 --- /dev/null +++ b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py @@ -0,0 +1,225 @@ +"""Invariants for the New York itemized medical expense deduction. + +NY Tax Law 615(a) computes New York itemized deductions as federal deductions +existed immediately before Public Law 115-97, so Form IT-196 lines 1-4 keep +the 10% floor of pre-TCJA 26 USC 213(a): line 4 is medical and dental +expenses less 10% of federal AGI. The federal floor has been 7.5% since 2017. + +Hypothesis draws batches of tax units (single or joint, in New York or +another state, with AGI from negative to several million, medical expenses, +real estate taxes and charitable gifts), and a seeded population of 200 units +adds breadth. Each batch runs as one vectorized simulation, and again with +every head's medical expenses raised by a drawn non-negative amount. For +every tax unit: + +1. Bound: 0 <= ny_medical_expense_deduction <= itemized_medical_expenses, + and it is zero outside New York. +2. Differential: in New York, ny_medical_expense_deduction equals an + independent numpy max(0, expenses - floor * max(AGI, 0)), with the floor + read from Form IT-196 line 3 rather than from the model's parameters. +3. Order: it never exceeds the federal medical_expense_deduction. +4. Monotone: raising expenses by d raises it by at least 0 and at most d. +5. Composition: ny_itemized_deductions_max less ny_medical_expense_deduction + does not change when medical expenses change. +6. Line 40: ny_itemized_deductions_phase_out does not change when medical + expenses change, because the medical deduction enters worksheet line 1 + (deductions before the limitation) and line 2 (deductions not subject to + it) alike. +""" + +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 +# The model stores float32, about 7 significant digits, so amounts in the +# hundreds of thousands also get a relative tolerance of a few float32 ulps. +RTOL = 1e-6 + +# Form IT-196 line 3, "Multiply line 2 by 10% (0.10)", on the 2018, 2021, +# 2024 and 2025 forms (tax.ny.gov/pdf//inc/it196__fill_in.pdf). +PUBLISHED_FLOOR = {2018: 0.1, 2021: 0.1, 2024: 0.1, 2025: 0.1} +YEARS = sorted(PUBLISHED_FLOOR) +STATES = ["NY", "NY", "NY", "CA", "TX"] + +agis = st.one_of( + st.integers(-200_000, 0).map(float), + st.integers(0, 500_000).map(float), + st.integers(500_000, 6_000_000).map(float), +) +amounts = st.one_of( + st.just(0.0), + st.integers(1, 60_000).map(float), + st.integers(60_000, 800_000).map(float), +) +increases = st.one_of(st.just(0.0), st.integers(1, 100_000).map(float)) + + +@st.composite +def tax_units(draw): + return { + "joint": draw(st.booleans()), + "state": draw(st.sampled_from(STATES)), + "agi": draw(agis), + "medical": draw(amounts), + "real_estate_taxes": draw(amounts), + "charity": draw(amounts), + "increase": draw(increases), + } + + +SEED = 20261006 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def amount(): + return float( + [0, rng.integers(1, 60_001), rng.integers(60_000, 800_001)][ + rng.choice([0, 1, 2]) + ] + ) + + return [ + { + "joint": bool(rng.random() < 0.5), + "state": str(rng.choice(STATES)), + "agi": float( + [ + rng.integers(-200_000, 1), + rng.integers(0, 500_001), + rng.integers(500_000, 6_000_001), + ][rng.choice([0, 1, 2])] + ), + "medical": amount(), + "real_estate_taxes": amount(), + "charity": amount(), + "increase": float(rng.integers(0, 100_001)) if rng.random() < 0.7 else 0.0, + } + for _ in range(n) + ] + + +def _situation(units, year, *, raised): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, unit in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 45, + "is_tax_unit_head": True, + "other_medical_expenses": unit["medical"] + raised * unit["increase"], + "real_estate_taxes": unit["real_estate_taxes"], + "charitable_cash_donations": unit["charity"], + } + members = [head] + if unit["joint"]: + spouse = f"spouse_{i}" + people[spouse] = {"age": 43, "is_tax_unit_spouse": True} + members.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": members} + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "adjusted_gross_income": {year: unit["agi"]}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: unit["state"]}, + } + 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, *, raised=False): + sim = Simulation(situation=_situation(units, year, raised=raised)) + return { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + "itemized_medical_expenses", + "adjusted_gross_income", + "medical_expense_deduction", + "ny_medical_expense_deduction", + "ny_itemized_deductions_max", + "ny_itemized_deductions_phase_out", + ] + } + + +def _slack(values): + return TOLERANCE + RTOL * np.abs(values) + + +def _check(units, year): + in_ny = np.array([unit["state"] == "NY" for unit in units]) + base = _run(units, year) + expenses = base["itemized_medical_expenses"] + deduction = base["ny_medical_expense_deduction"] + + # 1. Bounds, and zero outside New York. + assert (deduction >= 0).all() + assert (deduction <= expenses + _slack(expenses)).all() + assert (deduction[~in_ny] == 0).all() + + # 2. Differential against numpy and the published floor. + floor = PUBLISHED_FLOOR[year] * np.maximum(base["adjusted_gross_income"], 0) + np.testing.assert_allclose( + deduction[in_ny], + np.maximum(expenses - floor, 0)[in_ny], + atol=TOLERANCE, + rtol=RTOL, + ) + + # 3. Never more than the federal deduction, whose floor is lower. + federal = base["medical_expense_deduction"] + assert (deduction <= federal + _slack(federal)).all() + + # 4. Monotone, and at most dollar for dollar, in expenses. + raised = _run(units, year, raised=True) + increase = raised["itemized_medical_expenses"] - expenses + gain = raised["ny_medical_expense_deduction"] - deduction + assert (gain >= -_slack(deduction)).all() + assert (gain <= increase + _slack(raised["ny_medical_expense_deduction"])).all() + + # 5. Medical expenses reach the NY itemized total only through the NY + # medical deduction. + np.testing.assert_allclose( + raised["ny_itemized_deductions_max"] - raised["ny_medical_expense_deduction"], + base["ny_itemized_deductions_max"] - deduction, + atol=TOLERANCE, + rtol=RTOL, + ) + + # 6. The Line 40 limitation is unaffected by medical expenses. + np.testing.assert_allclose( + raised["ny_itemized_deductions_phase_out"], + base["ny_itemized_deductions_phase_out"], + atol=TOLERANCE, + rtol=RTOL, + ) + + +# 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_ny_medical_expense_deduction_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/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml index 07de472c2ad..25f730d5927 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml @@ -10,7 +10,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 340_000 @@ -26,7 +26,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 350_000 @@ -45,7 +45,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 408_000 @@ -61,7 +61,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 500_000 @@ -80,7 +80,7 @@ input: ny_itemized_deductions_max: 20_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 5_000_000 @@ -102,7 +102,7 @@ # remaining 60,000 is subject to the limitation. ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 25_000 + ny_medical_expense_deduction: 25_000 investment_interest_expense: 10_000 ny_casualty_loss_deduction: 5_000 adjusted_gross_income: 5_000_000 @@ -123,7 +123,7 @@ # the federal Line 40 worksheet; the Pease base should ignore it. ny_itemized_deductions_max: 130_000 ny_college_tuition_deduction: 30_000 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 500_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml new file mode 100644 index 00000000000..92ce6374023 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml @@ -0,0 +1,132 @@ +# NY Form IT-196 lines 1-4: medical and dental expenses (line 1) less 10% of +# federal AGI (line 2, Form IT-201 or IT-203 line 19; line 3 multiplies by +# 10% on every IT-196 from 2018 through 2025). NY Tax Law § 615(a) computes +# NY itemized deductions as federal deductions existed immediately before +# Public Law 115-97, so NY keeps pre-TCJA 26 U.S.C. 213(a)'s 10% floor, not +# the federal 7.5% floor. + +- name: Expenses above 10% of AGI are deductible; the federal floor is lower. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 12_000 + output: + # Line 1 = 12,000; line 3 = 100,000 * 0.10 = 10,000; line 4 = 2,000. + ny_medical_expense_deduction: 2_000 + # Federal Schedule A: 12,000 - 100,000 * 0.075 = 4,500. + medical_expense_deduction: 4_500 + +- name: Expenses between 7.5% and 10% of AGI give no NY deduction. + period: 2025 + input: + state_code: NY + employment_income: 80_000 + other_medical_expenses: 7_000 + output: + # Line 3 = 80,000 * 0.10 = 8,000 is more than line 1 (7,000), so line 4 + # is blank. + ny_medical_expense_deduction: 0 + # Federal: 7,000 - 80,000 * 0.075 = 1,000. + medical_expense_deduction: 1_000 + +- name: Expenses exactly at 10% of AGI give no NY deduction. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 10_000 + output: + # Line 4 = 10,000 - 10,000 = 0. + ny_medical_expense_deduction: 0 + +- name: One dollar above the 10% floor is deductible. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 10_001 + output: + ny_medical_expense_deduction: 1 + +- name: Negative AGI gives a zero floor, so all expenses are deductible. + period: 2025 + input: + state_code: NY + adjusted_gross_income: -20_000 + other_medical_expenses: 3_000 + output: + # positive_agi = max(-20,000, 0) = 0, so line 3 = 0 and line 4 = 3,000. + ny_medical_expense_deduction: 3_000 + +- name: First IT-196 year (2018) uses the 10% floor; federal 2018 used 7.5%. + period: 2018 + input: + state_code: NY + adjusted_gross_income: 50_000 + other_medical_expenses: 8_000 + output: + # 2018 Form IT-196 line 3: 50,000 * 0.10 = 5,000; line 4 = 3,000. + ny_medical_expense_deduction: 3_000 + # TCJA section 11027 floor for 2018: 8,000 - 50,000 * 0.075 = 4,250. + medical_expense_deduction: 4_250 + +- name: 2026 keeps the 10% floor. + period: 2026 + input: + state_code: NY + adjusted_gross_income: 200_000 + other_medical_expenses: 25_000 + output: + # 25,000 - 200,000 * 0.10 = 5,000 (federal: 25,000 - 15,000 = 10,000). + ny_medical_expense_deduction: 5_000 + medical_expense_deduction: 10_000 + +- name: Filers outside New York get no NY medical expense deduction. + period: 2025 + input: + state_code: CA + employment_income: 100_000 + other_medical_expenses: 12_000 + output: + ny_medical_expense_deduction: 0 + +- name: NY itemized deductions use the 10% medical floor. + period: 2025 + input: + state_code: NY + employment_income: 80_000 + real_estate_taxes: 12_000 + other_medical_expenses: 9_000 + output: + # NY medical: 9,000 - 80,000 * 0.10 = 1,000 (federal floor would give + # 9,000 - 6,000 = 3,000). + ny_medical_expense_deduction: 1_000 + # Real estate taxes 12,000 + NY medical 1,000. Federal AGI is below the + # Line 40 applicable amount and NY AGI is not over 100,000, so neither + # reduction applies. + ny_itemized_deductions_max: 13_000 + ny_itemized_deductions_phase_out: 0 + ny_itemized_deductions_reduction: 0 + ny_itemized_deductions: 13_000 + +- name: Line 40 worksheet excludes the same NY medical deduction it includes. + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: NY + filing_status: SINGLE + adjusted_gross_income: 5_000_000 + real_estate_taxes: 150_000 + other_medical_expenses: 600_000 + output: + # NY medical: 600,000 - 5,000,000 * 0.10 = 100,000. + ny_medical_expense_deduction: 100_000 + # Worksheet line 1 = 150,000 + 100,000 = 250,000. + ny_itemized_deductions_max: 250_000 + # Line 2 = NY medical 100,000; line 3 = 150,000; line 4 = 0.80 * 150,000 + # = 120,000. Line 8 = 0.03 * (5,000,000 - 340,700) = 139,779. + # Reduction = min(120,000, 139,779) = 120,000. Excluding the federal + # medical deduction (225,000) instead would leave line 3 = 25,000 and a + # 20,000 reduction. + ny_itemized_deductions_phase_out: 120_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml index 502aab68f83..c37afdd3437 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml @@ -78,9 +78,10 @@ casualty_loss: 15_000 output: # Charitable: 5,000 - # Medical: 10,000 - (100,000 * 0.075) = 2,500 + # NY Medical (Form IT-196 lines 1-4): 10,000 - (100,000 * 0.10) = 0 + # (the federal 7.5% floor would give 2,500) # NY SALT: 12,000 real estate only (sales tax 3,000 added back per s.615(c)(1)) # NY Misc: 4,000 - (100,000 * 0.02) = 2,000 # NY Casualty: 15,000 - (100,000 * 0.10) = 5,000 - # Total: 5,000 + 2,500 + 12,000 + 2,000 + 5,000 = 26,500 - ny_itemized_deductions_max: 26_500 + # Total: 5,000 + 0 + 12,000 + 2,000 + 5,000 = 24,000 + ny_itemized_deductions_max: 24_000 diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py index 69366a1932e..c6b5d9dc9d8 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py @@ -18,6 +18,7 @@ class ny_itemized_deductions_max(Variable): Public Law 115-97 (TCJA). This means: - No SALT cap ($10,000 limit does not apply) - State/local income taxes are NOT deductible (only sales + property) + - Medical expenses deductible above 10% of AGI (not the 7.5% floor) - Miscellaneous deductions still allowed with 2% AGI floor - Casualty losses not limited to federally declared disasters - NY-specific college tuition deduction addition per § 615(d) @@ -26,8 +27,8 @@ class ny_itemized_deductions_max(Variable): # Federal deductions that don't differ from pre-TCJA "charitable_deduction", "interest_deduction", - "medical_expense_deduction", # NY-specific pre-TCJA versions + "ny_medical_expense_deduction", "ny_salt_deduction", "ny_misc_deduction", "ny_casualty_loss_deduction", diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py index 27565cc41af..9e160b62047 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py @@ -35,7 +35,7 @@ class ny_itemized_deductions_phase_out(Variable): Worksheet line 2 (L2) = form lines 4, 14, 16a, 20, 29, 30, 37, i.e. the deductions NOT subject to the limitation (26 U.S.C. 68(c)): - - line 4 medical/dental -> medical_expense_deduction + - line 4 medical/dental -> ny_medical_expense_deduction - line 14 investment interest -> investment_interest_expense - line 20 casualty/theft loss -> ny_casualty_loss_deduction - lines 16a/29/30/37 (qualified contributions, gambling losses, @@ -62,7 +62,7 @@ def formula(tax_unit, period, parameters): # Worksheet line 2 (L2): deductions not subject to the limitation # (26 U.S.C. 68(c)) - medical, investment interest, casualty/theft. - medical = tax_unit("medical_expense_deduction", period) + medical = tax_unit("ny_medical_expense_deduction", period) investment_interest = add(tax_unit, period, ["investment_interest_expense"]) casualty = tax_unit("ny_casualty_loss_deduction", period) l2 = medical + investment_interest + casualty diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py new file mode 100644 index 00000000000..ad6ba05f2a2 --- /dev/null +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class ny_medical_expense_deduction(Variable): + value_type = float + entity = TaxUnit + label = "NY medical expense deduction" + unit = USD + definition_period = YEAR + reference = ( + "https://www.nysenate.gov/legislation/laws/TAX/615", + "https://www.tax.ny.gov/pdf/2025/inc/it196_2025_fill_in.pdf#page=1", + ) + defined_for = StateCode.NY + documentation = """ + NY Tax Law § 615 requires itemized deductions to be computed using + pre-TCJA federal rules. Pre-TCJA 26 U.S.C. 213(a) allowed medical and + dental expenses only above 10% of federal AGI; NY does not follow the + 7.5% floor that Public Law 115-97 § 11027 set for federal returns. + Form IT-196 lines 1-4 apply this floor to federal AGI (Form IT-201 or + IT-203 line 19; line 19a, recomputed federal AGI, in 2020-2022, which + PolicyEngine does not distinguish from federal AGI). + """ + + def formula(tax_unit, period, parameters): + # NY uses pre-TCJA rules: medical expenses are deductible above 10% + # of AGI (not the federal 7.5% floor after 2017) + p = parameters(period).gov.states.ny.tax.income.deductions.itemized.medical + expenses = tax_unit("itemized_medical_expenses", period) + medical_floor = p.floor * tax_unit("positive_agi", period) + return max_(0, expenses - medical_floor) From b1cad7c17e661ce048642e5aad4a445e4bb65765 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 18:56:38 -0400 Subject: [PATCH 2/2] Keep 2017 on the federal 7.5% floor; scale invariant tolerances by operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #9931: the parameter backdated 10% to 2017, when Form IT-201-D line 1 still took federal Schedule A line 4 (7.5% under Pub. L. 115-97 § 11027). The floor is now 0.1 from 2013, 0.075 for 2017, and 0.1 from 2018. Added 2016 and 2017 cases. The invariants test scaled float32 slack by the result, so expenses within rounding of a large floor failed. Slack now scales with each comparison's largest operand, with the reviewer's counterexamples pinned as @example and 2017 added to the published-floor years. Co-Authored-By: Claude Opus 5.5 --- .../deductions/itemized/medical/floor.yaml | 19 ++++- ...ny_medical_expense_deduction_invariants.py | 82 +++++++++++++------ .../ny_medical_expense_deduction.yaml | 36 ++++++-- .../pre_tcja/ny_medical_expense_deduction.py | 18 ++-- 4 files changed, 112 insertions(+), 43 deletions(-) diff --git a/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml index 038db7746a5..d17693a2977 100644 --- a/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml +++ b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml @@ -1,19 +1,30 @@ description: New York allows an itemized deduction for medical and dental expenses exceeding this share of federal adjusted gross income. values: + # Through 2017, Form IT-201-D line 1 took the medical deduction from federal + # Schedule A line 4, after the federal floor: 10% from 2013 (7.5% for filers + # 65 and over through 2016, not modeled), then 7.5% for 2017 under Public + # Law 115-97 § 11027. + 2013-01-01: 0.1 + 2017-01-01: 0.075 + # From 2018, NY Tax Law § 615(a) computes NY itemized deductions as federal + # deductions existed immediately prior to Public Law 115-97, so NY keeps the + # pre-TCJA 26 U.S.C. 213(a) 10% floor (Form IT-196 line 3). 2018-01-01: 0.1 metadata: unit: /1 period: year label: New York itemized medical expense deduction floor reference: - # NY Tax Law § 615(a) computes NY itemized deductions as federal - # deductions existed immediately prior to Public Law 115-97 (TCJA). - # Pre-TCJA 26 U.S.C. 213(a) set a 10% floor; TCJA § 11027 lowered the - # federal floor to 7.5%, which NY does not follow. - title: NY Tax Law § 615(a) href: https://www.nysenate.gov/legislation/laws/TAX/615 - title: 26 U.S. Code § 213(a) (2016 edition, before Public Law 115-97) href: https://www.govinfo.gov/content/pkg/USCODE-2016-title26/html/USCODE-2016-title26-subtitleA-chap1-subchapB-partVII-sec213.htm + - title: 2016 NY Form IT-201-D, line 1 (federal Schedule A, line 4) + href: https://www.tax.ny.gov/pdf/2016/inc/it201d_2016_fill_in.pdf#page=1 + - title: 2017 NY Form IT-201-D, line 1 (federal Schedule A, line 4) + href: https://www.tax.ny.gov/pdf/2017/inc/it201d_2017_fill_in.pdf#page=1 + - title: 2017 federal Schedule A, line 3 (multiply line 2 by 7.5%) + href: https://www.irs.gov/pub/irs-prior/f1040sa--2017.pdf#page=1 - title: 2018 NY Form IT-196, line 3 (multiply line 2 by 10%) href: https://www.tax.ny.gov/pdf/2018/inc/it196_2018_fill_in.pdf#page=1 - title: 2025 NY Form IT-196, line 3 (multiply line 2 by 10%) diff --git a/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py index c6c817acf10..01c54bad117 100644 --- a/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py +++ b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py @@ -4,6 +4,8 @@ existed immediately before Public Law 115-97, so Form IT-196 lines 1-4 keep the 10% floor of pre-TCJA 26 USC 213(a): line 4 is medical and dental expenses less 10% of federal AGI. The federal floor has been 7.5% since 2017. +Through 2017, Form IT-201-D line 1 took federal Schedule A line 4, so New +York followed the federal 7.5% floor in 2017. Hypothesis draws batches of tax units (single or joint, in New York or another state, with AGI from negative to several million, medical expenses, @@ -16,7 +18,9 @@ and it is zero outside New York. 2. Differential: in New York, ny_medical_expense_deduction equals an independent numpy max(0, expenses - floor * max(AGI, 0)), with the floor - read from Form IT-196 line 3 rather than from the model's parameters. + read from the forms (Form IT-196 line 3 from 2018; for 2017, the federal + Schedule A line 3 that IT-201-D line 1 used) rather than from the model's + parameters. 3. Order: it never exceeds the federal medical_expense_deduction. 4. Monotone: raising expenses by d raises it by at least 0 and at most d. 5. Composition: ny_itemized_deductions_max less ny_medical_expense_deduction @@ -29,19 +33,23 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_us import Simulation TOLERANCE = 0.01 # dollars -# The model stores float32, about 7 significant digits, so amounts in the -# hundreds of thousands also get a relative tolerance of a few float32 ulps. +# The model computes in float32, about 7 significant digits. A difference of +# float32 amounts can be off by a few ulps of its largest operand (half an +# ulp is about 0.03 near 600,000) even when the result is near zero, so each +# comparison also allows RTOL times the largest amount that went into it. RTOL = 1e-6 # Form IT-196 line 3, "Multiply line 2 by 10% (0.10)", on the 2018, 2021, # 2024 and 2025 forms (tax.ny.gov/pdf//inc/it196__fill_in.pdf). -PUBLISHED_FLOOR = {2018: 0.1, 2021: 0.1, 2024: 0.1, 2025: 0.1} +# For 2017, IT-201-D line 1 took federal Schedule A line 4, and the 2017 +# Schedule A line 3 reads "Multiply line 2 by 7.5% (0.075)". +PUBLISHED_FLOOR = {2017: 0.075, 2018: 0.1, 2021: 0.1, 2024: 0.1, 2025: 0.1} YEARS = sorted(PUBLISHED_FLOOR) STATES = ["NY", "NY", "NY", "CA", "TX"] @@ -151,8 +159,13 @@ def _run(units, year, *, raised=False): } -def _slack(values): - return TOLERANCE + RTOL * np.abs(values) +def _slack(*operands): + return TOLERANCE + RTOL * np.max(np.abs(np.stack(operands)), axis=0) + + +def _assert_close(actual, desired, slack, what): + off = np.abs(actual - desired) > slack + assert not off.any(), f"{what}: {actual[off]} != {desired[off]}" def _check(units, year): @@ -161,46 +174,47 @@ def _check(units, year): expenses = base["itemized_medical_expenses"] deduction = base["ny_medical_expense_deduction"] + agi = np.maximum(base["adjusted_gross_income"], 0) + slack = _slack(expenses, agi) + # 1. Bounds, and zero outside New York. assert (deduction >= 0).all() - assert (deduction <= expenses + _slack(expenses)).all() + assert (deduction <= expenses + slack).all() assert (deduction[~in_ny] == 0).all() # 2. Differential against numpy and the published floor. - floor = PUBLISHED_FLOOR[year] * np.maximum(base["adjusted_gross_income"], 0) - np.testing.assert_allclose( - deduction[in_ny], - np.maximum(expenses - floor, 0)[in_ny], - atol=TOLERANCE, - rtol=RTOL, - ) + expected = np.maximum(expenses - PUBLISHED_FLOOR[year] * agi, 0) + _assert_close(deduction[in_ny], expected[in_ny], slack[in_ny], "differential") - # 3. Never more than the federal deduction, whose floor is lower. - federal = base["medical_expense_deduction"] - assert (deduction <= federal + _slack(federal)).all() + # 3. Never more than the federal deduction, whose floor is no higher. + assert (deduction <= base["medical_expense_deduction"] + slack).all() # 4. Monotone, and at most dollar for dollar, in expenses. raised = _run(units, year, raised=True) + raised_slack = _slack(raised["itemized_medical_expenses"], agi) increase = raised["itemized_medical_expenses"] - expenses gain = raised["ny_medical_expense_deduction"] - deduction - assert (gain >= -_slack(deduction)).all() - assert (gain <= increase + _slack(raised["ny_medical_expense_deduction"])).all() + assert (gain >= -raised_slack).all() + assert (gain <= increase + raised_slack).all() # 5. Medical expenses reach the NY itemized total only through the NY # medical deduction. - np.testing.assert_allclose( + total_slack = _slack( + raised["ny_itemized_deductions_max"], base["ny_itemized_deductions_max"] + ) + _assert_close( raised["ny_itemized_deductions_max"] - raised["ny_medical_expense_deduction"], base["ny_itemized_deductions_max"] - deduction, - atol=TOLERANCE, - rtol=RTOL, + total_slack, + "composition", ) # 6. The Line 40 limitation is unaffected by medical expenses. - np.testing.assert_allclose( + _assert_close( raised["ny_itemized_deductions_phase_out"], base["ny_itemized_deductions_phase_out"], - atol=TOLERANCE, - rtol=RTOL, + total_slack, + "Line 40", ) @@ -214,8 +228,24 @@ def _check(units, year): ) +def _near_floor(agi, medical): + return { + "joint": False, + "state": "NY", + "agi": float(agi), + "medical": float(medical), + "real_estate_taxes": 0.0, + "charity": 0.0, + "increase": 0.0, + } + + @settings(**SETTINGS) @given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +# Expenses within float32 rounding of a large floor, where a tolerance scaled +# by the result rather than the operands fails (review of #9931). +@example(2025, [_near_floor(5_999_999, 600_000)] * 5) +@example(2025, [_near_floor(5_999_997, 600_000), _near_floor(5_242_881, 524_290)]) def test_ny_medical_expense_deduction_invariants(year, units): _check(units, year) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml index 92ce6374023..1d13a25a085 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml @@ -1,9 +1,11 @@ # NY Form IT-196 lines 1-4: medical and dental expenses (line 1) less 10% of -# federal AGI (line 2, Form IT-201 or IT-203 line 19; line 3 multiplies by -# 10% on every IT-196 from 2018 through 2025). NY Tax Law § 615(a) computes -# NY itemized deductions as federal deductions existed immediately before -# Public Law 115-97, so NY keeps pre-TCJA 26 U.S.C. 213(a)'s 10% floor, not -# the federal 7.5% floor. +# federal AGI (line 2, Form IT-201 or IT-203 line 19, or line 19a, recomputed +# federal AGI, in 2020-2022; line 3 multiplies by 10% on every IT-196 from +# 2018 through 2025). NY Tax Law § 615(a) computes NY itemized deductions as +# federal deductions existed immediately before Public Law 115-97, so NY +# keeps pre-TCJA 26 U.S.C. 213(a)'s 10% floor, not the federal 7.5% floor. +# Through 2017, Form IT-201-D line 1 took the federal Schedule A line 4 +# amount, so NY followed the federal floor. - name: Expenses above 10% of AGI are deductible; the federal floor is lower. period: 2025 @@ -71,6 +73,30 @@ # TCJA section 11027 floor for 2018: 8,000 - 50,000 * 0.075 = 4,250. medical_expense_deduction: 4_250 +- name: 2017 follows the federal 7.5% floor (IT-201-D line 1 used federal Schedule A). + period: 2017 + input: + state_code: NY + adjusted_gross_income: 100_000 + other_medical_expenses: 9_000 + output: + # 2017 IT-201-D line 1 = federal Schedule A line 4; 2017 Schedule A + # line 3 multiplies AGI by 7.5%: 9,000 - 7,500 = 1,500. + ny_medical_expense_deduction: 1_500 + medical_expense_deduction: 1_500 + +- name: 2016 follows the federal 10% floor. + period: 2016 + input: + state_code: NY + adjusted_gross_income: 100_000 + other_medical_expenses: 12_000 + output: + # 2016 Schedule A line 3 multiplies AGI by 10% for filers under 65: + # 12,000 - 10,000 = 2,000. + ny_medical_expense_deduction: 2_000 + medical_expense_deduction: 2_000 + - name: 2026 keeps the 10% floor. period: 2026 input: diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py index ad6ba05f2a2..eb74491e9f4 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py @@ -14,17 +14,19 @@ class ny_medical_expense_deduction(Variable): defined_for = StateCode.NY documentation = """ NY Tax Law § 615 requires itemized deductions to be computed using - pre-TCJA federal rules. Pre-TCJA 26 U.S.C. 213(a) allowed medical and - dental expenses only above 10% of federal AGI; NY does not follow the - 7.5% floor that Public Law 115-97 § 11027 set for federal returns. - Form IT-196 lines 1-4 apply this floor to federal AGI (Form IT-201 or - IT-203 line 19; line 19a, recomputed federal AGI, in 2020-2022, which - PolicyEngine does not distinguish from federal AGI). + pre-TCJA federal rules from 2018. Pre-TCJA 26 U.S.C. 213(a) allowed + medical and dental expenses only above 10% of federal AGI; NY does not + follow the federal 7.5% floor in place since 2017 (Public Law 115-97 + § 11027 for 2017-2018, later law after that). Form IT-196 lines 1-4 apply + this floor to federal AGI (Form IT-201 or IT-203 line 19; line 19a, + recomputed federal AGI, in 2020-2022, which PolicyEngine does not + distinguish from federal AGI). Through 2017, NY took the federal + Schedule A amount, so the floor parameter follows the federal floor then. """ def formula(tax_unit, period, parameters): - # NY uses pre-TCJA rules: medical expenses are deductible above 10% - # of AGI (not the federal 7.5% floor after 2017) + # From 2018 NY uses pre-TCJA rules: medical expenses are deductible + # above 10% of AGI, not the federal 7.5% floor in place since 2017 p = parameters(period).gov.states.ny.tax.income.deductions.itemized.medical expenses = tax_unit("itemized_medical_expenses", period) medical_floor = p.floor * tax_unit("positive_agi", period)