From 667b6cd35c345485085eb2f3f28604a82af59519 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 14:09:41 -0400 Subject: [PATCH 1/2] Test that Schedule A medical expenses exclude pre-tax payroll premiums A premium paid by salary reduction under a section 125 cafeteria plan is paid by the employer and excluded from wages under IRC 106(a) (Rev. Rul. 2002-3), so it is not an expense paid by the taxpayer under IRC 213(a). pre_tax_health_insurance_premiums holds those premiums and is disjoint from the other premium inputs, so the Schedule A formulas already leave it out. Nothing tested that. Add eight YAML cases (worker who itemizes, both payment methods, direct and decomposed premium inputs, a working Medicare enrollee, joint filers, an itemizer end to end and a non-itemizer) and a Hypothesis property test of five invariants. Both fail if the pre-tax input is added to, or subtracted from, the Schedule A premium base. No formula changes. Co-Authored-By: Claude Opus 5.5 --- ...schedule-a-pretax-premium-tests.changed.md | 1 + ...t_schedule_a_pre_tax_premium_invariants.py | 267 ++++++++++++++++++ .../itemizing/itemized_medical_expenses.yaml | 160 +++++++++++ 3 files changed, 428 insertions(+) create mode 100644 changelog.d/schedule-a-pretax-premium-tests.changed.md create mode 100644 policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py diff --git a/changelog.d/schedule-a-pretax-premium-tests.changed.md b/changelog.d/schedule-a-pretax-premium-tests.changed.md new file mode 100644 index 00000000000..8d0235f6187 --- /dev/null +++ b/changelog.d/schedule-a-pretax-premium-tests.changed.md @@ -0,0 +1 @@ +Add tests confirming that Schedule A medical expenses exclude health insurance premiums paid through pre-tax payroll deductions. diff --git a/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py new file mode 100644 index 00000000000..7418b3ec01b --- /dev/null +++ b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py @@ -0,0 +1,267 @@ +"""Invariants for pre-tax payroll premiums and Schedule A medical expenses. + +IRC 213(a) allows "the expenses paid during the taxable year, not compensated +for by insurance or otherwise, for medical care", above 7.5% of adjusted gross +income. A premium paid by salary reduction under a section 125 cafeteria plan +is paid by the employer, not the employee, and is excluded from wages under +IRC 106(a) (Rev. Rul. 2002-3), so it is not an expense the taxpayer paid. + +pre_tax_health_insurance_premiums holds those premiums. The other premium +inputs hold premiums not paid that way, and the two are disjoint: a premium is +reported in one or the other, never both. + +Hypothesis draws batches of tax units (single or joint, with up to two +dependents), each person with wages, pre-tax premiums, after-tax premiums and +other medical expenses. The after-tax premiums are supplied either directly or +as non-Medicare premiums. Each batch runs as one vectorized simulation, again +with every pre-tax premium removed, and again with every pre-tax premium paid +after tax instead. For every batch: + +1. Exclusion: Schedule A medical expenses equal the tax unit's after-tax + premiums plus other medical expenses, whatever the pre-tax premiums are. +2. One tax benefit per dollar: each person's wage exclusion is their pre-tax + premiums, up to their wages, and their Schedule A premiums are their + after-tax premiums. The two never cover the same dollar, and together they + never exceed the premiums paid. +3. Differential: the deduction equals an independent numpy calculation, with + the 7.5% floor taken from the statute rather than the parameters. +4. Bounds: 0 <= deduction <= Schedule A medical expenses. +5. Payment method: paying the pre-tax premiums after tax instead raises + Schedule A medical expenses by exactly those premiums and wages by exactly + the wage exclusion, and never lowers the deduction. Removing the pre-tax + premiums leaves Schedule A medical expenses unchanged and never raises the + deduction. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.1 # dollars; incomes are float32 + +# IRC 213(a): "to the extent that such expenses exceed 7.5 percent of +# adjusted gross income". The rate has applied since 2017. +STATUTORY_FLOOR = 0.075 +YEARS = [2024, 2026] + +amounts = st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) +wages = st.one_of(st.just(0.0), st.integers(1, 200_000).map(float)) + + +@st.composite +def people(draw): + return { + "wages": draw(wages), + "pre_tax": draw(amounts), + "after_tax": draw(amounts), + "other": draw(st.one_of(st.just(0.0), st.integers(1, 40_000).map(float))), + # Which input carries the after-tax premiums. + "direct": draw(st.booleans()), + } + + +@st.composite +def tax_units(draw): + return { + "head": draw(people()), + "spouse": draw(st.none() | people()), + "dependents": draw(st.lists(people(), max_size=2)), + } + + +def _person(wages=0.0, pre_tax=0.0, after_tax=0.0, other=0.0, direct=False): + return { + "wages": wages, + "pre_tax": pre_tax, + "after_tax": after_tax, + "other": other, + "direct": direct, + } + + +def _situation(units, year, treatment): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + + def add(name, values, role): + pre_tax = values["pre_tax"] if treatment == "drawn" else 0 + after_tax = values["after_tax"] + if treatment == "paid_after_tax": + after_tax += values["pre_tax"] + people[name] = { + "age": {"head": 45, "spouse": 43, "dependent": 17}[role], + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "dependent", + "employment_income": values["wages"], + "pre_tax_health_insurance_premiums": pre_tax, + "health_insurance_premiums": after_tax * values["direct"], + "health_insurance_premiums_without_medicare_part_b": after_tax + * (not values["direct"]), + "other_medical_expenses": values["other"], + # No modeled Medicare Part B premium, so the premiums are only + # the drawn ones. + "medicare_enrolled": False, + } + + for i, unit in enumerate(units): + head = f"head_{i}" + add(head, unit["head"], "head") + members, couple = [head], [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + add(spouse, unit["spouse"], "spouse") + 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") + 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} + + +PERSON = [ + "employment_income", + "irs_employment_income", + "medical_expense_health_insurance_premiums", +] +TAX_UNIT = [ + "adjusted_gross_income", + "itemized_medical_expenses", + "medical_expense_deduction", +] + + +def _run(units, year, treatment): + sim = Simulation(situation=_situation(units, year, treatment)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in PERSON + TAX_UNIT + } + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _members(units): + return [ + person + for unit in units + for person in [unit["head"], unit["spouse"], *unit["dependents"]] + if person is not None + ] + + +def _check(units, year): + drawn = _run(units, year, "drawn") + unit = drawn["unit"] + members = _members(units) + pre_tax = np.array([person["pre_tax"] for person in members]) + after_tax = np.array([person["after_tax"] for person in members]) + other = np.array([person["other"] for person in members]) + earned = np.array([person["wages"] for person in members]) + np.testing.assert_array_equal(drawn["employment_income"], earned) + + def unit_sum(values): + return np.bincount(unit, weights=values, minlength=len(units)) + + expenses = drawn["itemized_medical_expenses"] + deduction = drawn["medical_expense_deduction"] + + # 1. Exclusion, against the drawn amounts. + np.testing.assert_allclose(expenses, unit_sum(after_tax + other), atol=TOLERANCE) + + # 2. One tax benefit per dollar. + wage_exclusion = earned - drawn["irs_employment_income"] + schedule_a_premiums = drawn["medical_expense_health_insurance_premiums"] + np.testing.assert_allclose( + wage_exclusion, np.minimum(pre_tax, earned), atol=TOLERANCE + ) + np.testing.assert_allclose(schedule_a_premiums, after_tax, atol=TOLERANCE) + assert ( + wage_exclusion + schedule_a_premiums <= pre_tax + after_tax + TOLERANCE + ).all() + + # 3. Differential against an independent calculation. + agi = drawn["adjusted_gross_income"] + expected = np.maximum( + unit_sum(after_tax + other) - STATUTORY_FLOOR * np.maximum(agi, 0), 0 + ) + np.testing.assert_allclose(deduction, expected, atol=TOLERANCE) + + # 4. Bounds. + assert (deduction >= 0).all() + assert (deduction <= expenses + TOLERANCE).all() + + # 5. Payment method. + removed = _run(units, year, "removed") + np.testing.assert_allclose( + removed["itemized_medical_expenses"], expenses, atol=TOLERANCE + ) + np.testing.assert_allclose(removed["irs_employment_income"], earned, atol=TOLERANCE) + assert (removed["medical_expense_deduction"] <= deduction + TOLERANCE).all() + + paid_after_tax = _run(units, year, "paid_after_tax") + np.testing.assert_allclose( + paid_after_tax["itemized_medical_expenses"] - expenses, + unit_sum(pre_tax), + atol=TOLERANCE, + ) + np.testing.assert_allclose( + paid_after_tax["irs_employment_income"], earned, atol=TOLERANCE + ) + assert (paid_after_tax["medical_expense_deduction"] >= deduction - TOLERANCE).all() + + +# 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=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +EDGE_CASES = [ + # Nothing paid. + {"head": _person(wages=50_000), "spouse": None, "dependents": []}, + # Pre-tax premiums only. + { + "head": _person(wages=60_000, pre_tax=2_000, other=9_000), + "spouse": None, + "dependents": [], + }, + # Pre-tax premiums above wages: the wage exclusion stops at wages. + { + "head": _person(wages=1_000, pre_tax=2_500, after_tax=400, direct=True), + "spouse": None, + "dependents": [], + }, + # Each spouse pays a different way, and a working dependent pays both. + { + "head": _person(wages=50_000, pre_tax=2_400, other=6_000), + "spouse": _person(wages=30_000, after_tax=1_800, other=1_000, direct=True), + "dependents": [_person(wages=8_000, pre_tax=600, after_tax=300, other=250)], + }, +] + + +@settings(**SETTINGS) +@example(year=2025, units=EDGE_CASES) +@given( + year=st.sampled_from(YEARS), + units=st.lists(tax_units(), min_size=5, max_size=30), +) +def test_schedule_a_pre_tax_premium_invariants(year, units): + _check(units, year) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml index e9dfb1c6311..580d1f7e672 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml @@ -11,3 +11,163 @@ members: [person1, person2] output: itemized_medical_expenses: 1_500 + +# Premiums paid through pre-tax payroll deductions. +# +# IRC 213(a) allows "the expenses paid during the taxable year, not +# compensated for by insurance or otherwise, for medical care". A premium paid +# by salary reduction under a section 125 cafeteria plan is paid by the +# employer, not the employee, and is excluded from wages under IRC 106(a) +# (Rev. Rul. 2002-3). It is therefore not an expense the taxpayer paid. +# IRS Publication 502 (2025), "Employer-Sponsored Health Insurance Plan": +# "Don't include in your medical and dental expenses any insurance premiums +# paid by an employer-sponsored health insurance plan unless the premiums are +# included on your Form W-2, Wage and Tax Statement." +# +# pre_tax_health_insurance_premiums and the other premium inputs are disjoint: +# a premium is reported in one or the other, never both. Schedule A therefore +# counts the other premium inputs in full and never the pre-tax input. + +- name: Pre-tax payroll premiums reduce wages and are not Schedule A medical expenses. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + other_medical_expenses: 9_000 + output: + # 60,000 - 2,000, for income tax and for FICA. + irs_employment_income: 58_000 + payroll_tax_gross_wages: 58_000 + adjusted_gross_income: 58_000 + medical_expense_health_insurance_premiums: 0 + # Counting the premium again would give 11,000. + itemized_medical_expenses: 9_000 + # 9,000 - 7.5% of 58,000. + medical_expense_deduction: 4_650 + +- name: A worker who pays premiums both ways counts only the after-tax premiums. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + health_insurance_premiums_without_medicare_part_b: 3_000 + other_medical_expenses: 9_000 + output: + irs_employment_income: 58_000 + adjusted_gross_income: 58_000 + # The 3,000 is not reduced by the 2,000: the inputs do not overlap. + medical_expense_health_insurance_premiums: 3_000 + itemized_medical_expenses: 12_000 + # 12,000 - 7.5% of 58,000. + medical_expense_deduction: 7_650 + +- name: The directly supplied premium input likewise excludes pre-tax premiums. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + health_insurance_premiums: 3_000 + other_medical_expenses: 9_000 + output: + medical_expense_health_insurance_premiums: 3_000 + itemized_medical_expenses: 12_000 + medical_expense_deduction: 7_650 + +- name: The same premiums paid after tax are medical expenses and do not reduce wages. + period: 2025 + input: + age: 40 + employment_income: 60_000 + health_insurance_premiums_without_medicare_part_b: 5_000 + other_medical_expenses: 9_000 + output: + irs_employment_income: 60_000 + payroll_tax_gross_wages: 60_000 + adjusted_gross_income: 60_000 + medical_expense_health_insurance_premiums: 5_000 + itemized_medical_expenses: 14_000 + # 14,000 - 7.5% of 60,000. + medical_expense_deduction: 9_500 + +- name: A working Medicare enrollee counts Part B premiums but not pre-tax payroll premiums. + period: 2025 + input: + age: 67 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + medicare_enrolled: true + medicare_part_b_premium: 2_220 + other_medical_expenses: 9_000 + output: + irs_employment_income: 58_000 + medical_expense_health_insurance_premiums: 2_220 + itemized_medical_expenses: 11_220 + # 11,220 - 7.5% of 58,000. + medical_expense_deduction: 6_870 + +- name: Joint filers count one spouse's after-tax premiums and not the other's pre-tax premiums. + period: 2025 + input: + people: + person1: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + pre_tax_health_insurance_premiums: 2_400 + other_medical_expenses: 6_000 + person2: + age: 40 + is_tax_unit_spouse: true + employment_income: 30_000 + health_insurance_premiums_without_medicare_part_b: 1_800 + other_medical_expenses: 1_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + output: + irs_employment_income: [47_600, 30_000] + adjusted_gross_income: 77_600 + medical_expense_health_insurance_premiums: [0, 1_800] + # 6,000 + 1,000 + 1,800. + itemized_medical_expenses: 8_800 + # 8,800 - 7.5% of 77,600. + medical_expense_deduction: 2_980 + +- name: An itemizer with pre-tax premiums deducts only the other medical expenses. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + other_medical_expenses: 30_000 + # Isolate the medical deduction from modeled state and local taxes. + salt_deduction: 0 + output: + adjusted_gross_income: 58_000 + itemized_medical_expenses: 30_000 + # 30,000 - 7.5% of 58,000. + medical_expense_deduction: 25_650 + tax_unit_itemizes: true + # 58,000 - 25,650. Counting the premium again would give 30,350. + taxable_income: 32_350 + +- name: A non-itemizer with pre-tax premiums gets the wage exclusion and the standard deduction. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + output: + adjusted_gross_income: 58_000 + itemized_medical_expenses: 0 + medical_expense_deduction: 0 + tax_unit_itemizes: false + standard_deduction: 15_750 + # 58,000 - 15,750. + taxable_income: 42_250 From f96668004935c895b12b2ff3974035338df3e7d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 14:54:20 -0400 Subject: [PATCH 2/2] Limit the Schedule A pre-tax premium property test to filers Review r1: the property test gave dependents their own premiums and medical expenses and asserted they reach the filers' Schedule A. Whether a dependent's own payments belong there is a separate question, so leave dependents out and state the invariants for single and joint wage earners with no self-employment, Medicare premiums or reimbursed expenses. Say what the file adds beyond the YAML cases (relations between paired runs of the same generated households, in one vectorized batch). Each example takes about 0.1 seconds, so run 20 instead of 4. Co-Authored-By: Claude Opus 5.5 --- ...t_schedule_a_pre_tax_premium_invariants.py | 69 +++++++++---------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py index 7418b3ec01b..2d158a813f6 100644 --- a/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py +++ b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py @@ -10,14 +10,24 @@ inputs hold premiums not paid that way, and the two are disjoint: a premium is reported in one or the other, never both. -Hypothesis draws batches of tax units (single or joint, with up to two -dependents), each person with wages, pre-tax premiums, after-tax premiums and -other medical expenses. The after-tax premiums are supplied either directly or -as non-Medicare premiums. Each batch runs as one vectorized simulation, again -with every pre-tax premium removed, and again with every pre-tax premium paid -after tax instead. For every batch: - -1. Exclusion: Schedule A medical expenses equal the tax unit's after-tax +Hypothesis draws batches of single and joint filers, each person with wages, +pre-tax premiums, after-tax premiums and other medical expenses. The after-tax +premiums are supplied either directly or as non-Medicare premiums. Each batch +runs as one vectorized simulation, again with every pre-tax premium removed, +and again with every pre-tax premium paid after tax instead. + +The invariants are stated for these filers only: wage earners under 65 who pay +their own costs, with no self-employment (so no self-employed health insurance +deduction), no Medicare premiums and no reimbursed expenses. Dependents are +left out, because whether a dependent's own payments belong on the filer's +Schedule A is a separate question. + +The YAML cases in itemized_medical_expenses.yaml pin hand-computed households. This +file adds what YAML cannot express: relations between paired runs of the same +generated households (invariant 5), and the same checks across a mixed batch +in one vectorized simulation. For every batch: + +1. Exclusion: Schedule A medical expenses equal the filers' after-tax premiums plus other medical expenses, whatever the pre-tax premiums are. 2. One tax benefit per dollar: each person's wage exclusion is their pre-tax premiums, up to their wages, and their Schedule A premiums are their @@ -64,11 +74,7 @@ def people(draw): @st.composite def tax_units(draw): - return { - "head": draw(people()), - "spouse": draw(st.none() | people()), - "dependents": draw(st.lists(people(), max_size=2)), - } + return {"head": draw(people()), "spouse": draw(st.none() | people())} def _person(wages=0.0, pre_tax=0.0, after_tax=0.0, other=0.0, direct=False): @@ -90,10 +96,9 @@ def add(name, values, role): if treatment == "paid_after_tax": after_tax += values["pre_tax"] people[name] = { - "age": {"head": 45, "spouse": 43, "dependent": 17}[role], + "age": {"head": 45, "spouse": 43}[role], "is_tax_unit_head": role == "head", "is_tax_unit_spouse": role == "spouse", - "is_tax_unit_dependent": role == "dependent", "employment_income": values["wages"], "pre_tax_health_insurance_premiums": pre_tax, "health_insurance_premiums": after_tax * values["direct"], @@ -108,18 +113,12 @@ def add(name, values, role): for i, unit in enumerate(units): head = f"head_{i}" add(head, unit["head"], "head") - members, couple = [head], [head] + members = [head] if unit["spouse"] is not None: spouse = f"spouse_{i}" add(spouse, unit["spouse"], "spouse") 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") - members.append(name) - groups["marital_units"][f"single_{i}_{j}"] = {"members": [name]} + groups["marital_units"][f"couple_{i}"] = {"members": members} groups["tax_units"][f"tax_unit_{i}"] = {"members": members} groups["households"][f"household_{i}"] = { "members": members, @@ -158,7 +157,7 @@ def _members(units): return [ person for unit in units - for person in [unit["head"], unit["spouse"], *unit["dependents"]] + for person in [unit["head"], unit["spouse"]] if person is not None ] @@ -224,10 +223,9 @@ def unit_sum(values): assert (paid_after_tax["medical_expense_deduction"] >= deduction - TOLERANCE).all() -# A batch's cost is mostly per-variable overhead, so each example is a large -# batch and there are few examples. +# Each example is a batch of filers run three ways, at about 0.1 seconds. SETTINGS = dict( - max_examples=4, + max_examples=20, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], @@ -235,24 +233,23 @@ def unit_sum(values): EDGE_CASES = [ # Nothing paid. - {"head": _person(wages=50_000), "spouse": None, "dependents": []}, + {"head": _person(wages=50_000), "spouse": None}, # Pre-tax premiums only. - { - "head": _person(wages=60_000, pre_tax=2_000, other=9_000), - "spouse": None, - "dependents": [], - }, + {"head": _person(wages=60_000, pre_tax=2_000, other=9_000), "spouse": None}, # Pre-tax premiums above wages: the wage exclusion stops at wages. { "head": _person(wages=1_000, pre_tax=2_500, after_tax=400, direct=True), "spouse": None, - "dependents": [], }, - # Each spouse pays a different way, and a working dependent pays both. + # Each spouse pays a different way. { "head": _person(wages=50_000, pre_tax=2_400, other=6_000), "spouse": _person(wages=30_000, after_tax=1_800, other=1_000, direct=True), - "dependents": [_person(wages=8_000, pre_tax=600, after_tax=300, other=250)], + }, + # One spouse pays both ways. + { + "head": _person(wages=40_000, pre_tax=600, after_tax=300, other=250), + "spouse": _person(), }, ]