Skip to content

Test that Schedule A medical expenses exclude pre-tax payroll premiums - #10067

Merged
MaxGhenis merged 2 commits into
mainfrom
schedule-a-pretax-premium-tests
Oct 11, 2026
Merged

MaxGhenis merged 2 commits into
mainfrom
schedule-a-pretax-premium-tests

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Tests only. No formula or parameter changes, so no model output moves.

Why

PolicyEngine/microcosm#1189 set out to populate pre_tax_health_insurance_premiums (about $181B before calibration). Its body noted that the engine "counts the whole reported premium in the itemized medical-expense base, so a premium marked pre-tax here is also there." This PR was opened to remove that double count from the federal Schedule A path.

The Schedule A formulas turn out to be right already, and a subtraction would break them:

The double count came from the data side. microcosm#1189 as opened wrote the reported premium (PHIP_VAL) to pre_tax_health_insurance_premiums and left the same dollars in health_insurance_premiums_without_medicare_part_b. I raised it there. Its author agrees, plans to ship only the employer premium in that PR, and will add the pre-tax column, netted out of the other premium inputs, in a follow-up that waits for an engine with #10046. #10064, which asked for the engine subtraction, is closed as not planned.

Nothing in this repo tested that Schedule A leaves the pre-tax input out. This PR adds that guard, so that neither a later "fix" by subtraction nor adding the input to the medical base can land unnoticed.

The disjoint contract is the US hub's ruling of 2026-10-10 under the leaf-input rule. The alternative is to define the existing premium inputs as totals that contain a separately identified pre-tax subset; these tests would then need to change with the formulas.

Law

  • 26 U.S.C. 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.
  • 26 U.S.C. 106(a): "gross income of an employee does not include employer-provided coverage under an accident or health plan."
  • 26 U.S.C. 125(a): "no amount shall be included in the gross income of a participant in a cafeteria plan solely because, under the plan, the participant may choose among the benefits of the plan."
  • Rev. Rul. 2002-3: when an employer applies salary reduction amounts to health insurance premiums, "the premium payments are paid by M, not the employees, and are excludable from the employees' gross income under §106".
  • Treas. Reg. 1.213-1(a)(1) limits the deduction to expenses actually paid during the year.
  • 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."

A salary-reduction premium is paid by the employer and never enters the employee's wages, so it is not an expense the taxpayer paid under section 213(a).

Invariants

These hold for the filers the tests cover: single and joint wage earners under 65 who pay their own costs, with no self-employment (so no self-employed health insurance deduction, which #10055 nets out of Schedule A), no Medicare premiums and no reimbursed expenses. The premium inputs are disjoint.

  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 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 that takes the 7.5% floor from the statute, not from 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.

Tests

  • itemized_medical_expenses.yaml: eight new cases.
    • A worker with pre-tax premiums and $9,000 of other medical expenses: wages $58,000 for income tax and FICA, Schedule A medical expenses $9,000, deduction $4,650.
    • Both payment methods together, with the after-tax premiums supplied as the decomposed input and as the direct input.
    • The same premiums paid after tax.
    • A working Medicare enrollee: Part B counts, the pre-tax premium does not.
    • Joint filers who each pay a different way.
    • An itemizer end to end (taxable income $32,350; counting the premium again would give $30,350).
    • A non-itemizer, who gets the wage exclusion and the standard deduction.
  • tests/core/test_schedule_a_pre_tax_premium_invariants.py: a Hypothesis property test of the five invariants. It draws 20 batches of 5 to 30 single and joint filers for 2024 and 2026, runs each batch three ways (as drawn, pre-tax premiums removed, pre-tax premiums paid after tax), and adds five explicit edge cases, including pre-tax premiums above wages. Dependents are left out: whether a dependent's own payments belong on the filer's Schedule A is a separate question.

No counterexamples.

Why a Python test, and what it costs

The YAML cases pin hand-computed households. The Python file adds what YAML cannot express: relations between paired runs of the same generated households (invariant 5), and the same checks over a mixed batch in one vectorized simulation. It compares the model with an independent numpy calculation; it does not copy a formula.

It rebuilds no policy. Each simulation shares the reference parameter tree and variable objects and gets only its own variable registry and entities (share_spm_policy in policyengine_us/spm.py). Each batch computes six variables.

Local measurements (macOS, Python 3.13, policyengine-core 3.32.15, machine load average about 60, so these are upper-range):

Run Wall clock Peak RSS
Model construction only (from policyengine_us import Simulation) 13.2 s 965 MiB
This file alone, including model construction 23.0 s 1,104 MiB

In that run pytest reported 6.2 seconds and the test call took 2.7 seconds (20 examples at 0.07 to 0.5 seconds each). Under heavier contention the same file took up to 176 seconds by pytest's clock, so local times are not a CI estimate.

CI group: Rest/core (make test-other-python-rest, the core group). No job, matrix entry or process is added. Linux CI figures, from each run's rest-test-timings artifact:

Rest/core run Tests Wall clock Peak RSS This test
main, Push run 38070802756 at 0590757714 831 12:11.57 6,857,212 kB n/a
This PR at 667b6cd35c, run 38074706313 (4 examples) 832 10:03.96 6,862,832 kB 1.04 s

The two runs are on different runners, so the group totals differ by more than this test costs; the per-test time is the comparable figure. At the current head (20 examples) the Quick Feedback job runs the file alone on Linux: the test call takes 9.30 seconds in a 19.39-second pytest session (job 114288089193). The Rest/core report for f966680049 will be in run 38077671135's artifact.

Validation

At f966680049:

CI: this PR's first run failed "Python tests / Full Suite - Microsimulation" in a test it does not touch (test_spm_system.py::test_subsampling_never_changes_which_policy_a_holder_belongs_to, KeyError: 'reform_only_income'). The run installed policyengine-core 3.33.1, released at 17:41Z on 2026-10-10. An unrelated branch's run with the same core version fails the same way (run 38074181952). The US hub is fixing that test in its own PR.

Review

Independent review r1 (GPT-6.1 Sol) at 667b6cd35c: REQUEST_CHANGES. It agreed that no formula change is right under the disjoint contract, recomputed all eight YAML cases by hand and reproduced the mutation check. Its findings, fixed in f966680049 and in this description:

  • The property test gave dependents their own payments and asserted they reach the filers' Schedule A. Dependents are now left out.
  • encoded-correct needs an Axiom companion case for the same situation, and there is none. The line below is now n/a.
  • The Python test's purpose and cost were not documented. See the section above.
  • The invariants were stated for every tax unit. They are now stated for the filers the tests cover.

Delta review r2 at f966680049: APPROVE, all five r1 findings resolved or addressed, no P1 or P2. Its three optional wording points are applied in this description and in the Microcosm comment.

axiom: n/a: tests only; no policy formula or parameter changes

Related Axiom coverage: us:statutes/26/213#medical_care_deduction (rulespec-us main 16a6677190) takes medical_care_expenses_paid_not_compensated_by_insurance_or_otherwise as a single input and has no premium components or payment-method facts, so it has no case for a pre-tax premium. rulespec-us has no section 106 or 125 module; the payroll health exclusion for earned income is queued in TheAxiomFoundation/rulespec-us#1554.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hand-off to the US + core hub

Head: f96668004935c895b12b2ff3974035338df3e7d8 (base main at 0590757714). Tests only: itemized_medical_expenses.yaml (8 new cases), tests/core/test_schedule_a_pre_tax_premium_invariants.py, and a changelog fragment. Nothing uncommitted or unpushed.

Independent review (GPT-6.1 Sol):

  • r1 at 667b6cd35c: REQUEST_CHANGES (3 P2, 2 P3). ~/reviews/schedA-pretax-premiums-2026-10-10/review-r1.md
  • r2 at f966680049: APPROVE, no P1 or P2; its three optional wording points are applied in the description and in the Microcosm comment. ~/reviews/schedA-pretax-premiums-2026-10-10/review-r2.md
  • Both rounds agree that no formula change is right under the disjoint premium-input contract.

Passed:

Pending:

  • CI at this head: six checks still running (four Contrib shards, Rest, Microsimulation).
  • The Microsimulation check is expected to fail until the test_spm_system.py fix for policyengine-core 3.33.1 is on main. After that, this PR needs a fresh run on a merge commit that includes the fix.
  • Required approving review (REVIEW_REQUIRED).

Depends on: nothing for content. It shares no file with #10046, #10055 or #10023 and can land in any order relative to them.

Holds or decisions: none queued. The description names the alternative contract (premium inputs as totals with a pre-tax subset) as a choice Max may overrule; if he does, these tests change with the formulas.

Follow-ups owed: none by this session. If #10055's head moves, re-run the two files against it: none of the cases has a self-employed health insurance deduction, so they should be unaffected. The Microcosm side (ship only the employer premium in microcosm#1189; pre-tax column later, netted, gated on an engine with #10046) is with that PR's session.

@MaxGhenis MaxGhenis closed this Oct 10, 2026
@MaxGhenis MaxGhenis reopened this Oct 10, 2026
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Reopened by the US + core hub to start a fresh CI run at the same head. The previous run failed on test_spm_system.py, which broke on every pull request when policyengine-core 3.33.1 was released; #10069 fixed it on main at 20:42Z on 2026-10-10. Nothing in this pull request changed.

@MaxGhenis
MaxGhenis merged commit 1ea2725 into main Oct 11, 2026
51 of 52 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, us#10067 at f96668004935c895b12b2ff3974035338df3e7d8 (squash): test that Schedule A medical expenses exclude pre-tax payroll premiums.

  • Handed off to the hub on 2026-10-10 by its owner session, waiting on CI.
  • Review: independent GPT-6.1 Sol review r1 requested changes (three P2, two P3); r2 approves this exact head, with no P1 or P2. Both rounds agree that no formula change is right under the disjoint premium-input contract (the premium inputs and pre_tax_health_insurance_premiums do not overlap).
  • Scope: tests only. The diff changes one YAML test file, one Python property test and a changelog fragment. No variable, parameter or reform changes, so no simulation output changes and no microsimulation was run.
  • Evidence from the owner: both files pass at this head and unchanged on Exclude SE health insurance deductions from Schedule A medical expenses #10055, Document disjoint health premium inputs and correct consumer treatment #10046 and Fix Idaho health insurance premium subtraction #10023's heads; a mutation that adds or subtracts the pre-tax input in medical_expense_health_insurance_premiums fails the YAML cases and the property test.
  • Gates checked live at merge:
    • gh pr checks exits 0;
    • the latest Pull request run at the head concluded success (a fresh run after the core 3.33.1 test fix, us#10069);
    • MERGEABLE, not a draft, no CHANGES_REQUESTED;
    • the semantic-overlap guard;
    • head pinned.
  • Axiom: n/a: tests only; no policy formula or parameter changes.
  • --admin: used only because a required approving review is the sole block.

@MaxGhenis
MaxGhenis deleted the schedule-a-pretax-premium-tests branch October 11, 2026 00:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant