Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/remove-addmed-from-fiitax.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stop adding the Additional Medicare Tax (Form 8959) to `fiitax` so PE's output aligns with NBER TAXSIM-35 (`taxsimtest`), which reports AddMed in the separate `addmed` column per Form 1040 Line 23 / Schedule 2 Line 11. The prior behavior caused PE to overshoot TAXSIM by ~$412K (95% of the remaining federal mismatch) on the eCPS n=2000 TY 2025 sample. AddMed continues to flow through the `v44` output (`employee_medicare_tax + additional_medicare_tax`).
42 changes: 23 additions & 19 deletions policyengine_taxsim/runners/policyengine_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,11 +1267,14 @@ def _compute_marginal_rates(self, sim, year_str, year_data):
delta = (
100.0 # $100: large enough for float32 precision, small for bracket safety
)
# Get base tax values from the main simulation
# frate must match fiitax definition: income_tax + additional_medicare_tax
base_federal = self._calc_tax_unit(
sim, "income_tax", year_str
) + self._calc_tax_unit(sim, "additional_medicare_tax", year_str)
# Get base tax values from the main simulation.
# frate must match fiitax definition. NBER TAXSIM-35
# (`taxsimtest`) reports fiitax as income_tax only —
# Additional Medicare Tax (Form 8959) flows out in the
# separate `addmed` column per Form 1040 Line 23 /
# Schedule 2 Line 11. Mirror that here so the marginal rate
# doesn't pick up the 0.9% AddMed step above threshold.
base_federal = self._calc_tax_unit(sim, "income_tax", year_str)
base_state = self._calc_tax_unit(sim, "state_income_tax", year_str)

# Get current employment_income at person level
Expand Down Expand Up @@ -1314,10 +1317,8 @@ def _compute_marginal_rates(self, sim, year_str, year_data):
# Set perturbed employment income
branch.set_input("employment_income", year_str, emp_income + perturbation)

# Compute perturbed tax values
new_federal = self._calc_tax_unit(
branch, "income_tax", year_str
) + self._calc_tax_unit(branch, "additional_medicare_tax", year_str)
# Compute perturbed tax values (match base_federal: no AddMed)
new_federal = self._calc_tax_unit(branch, "income_tax", year_str)
new_state = self._calc_tax_unit(branch, "state_income_tax", year_str)
# Compute rates as percentages: 100 * (new - base) / delta
frate = 100.0 * (new_federal - base_federal) / delta
Expand Down Expand Up @@ -1567,16 +1568,19 @@ def _extract_vectorized_results(
f"Error calculating {pe_var} for {taxsim_var}: {e}"
) from e

# Apply fiitax special calculation (income_tax + additional_medicare_tax)
# TAXSIM includes Additional Medicare Tax (0.9% on wages above
# $200K/$250K) in fiitax. PE's income_tax does not include it,
# so we add it here.
addl_med = self._calc_tax_unit(sim, "additional_medicare_tax", year_str)
if "fiitax" in columns:
columns["fiitax"] = np.round(columns["fiitax"] + addl_med, 2)
else:
fiitax_arr = self._calc_tax_unit(sim, "income_tax", year_str) + addl_med
columns["fiitax"] = np.round(fiitax_arr, 2)
# fiitax = income_tax only. NBER TAXSIM-35 (`taxsimtest`)
# reports the Additional Medicare Tax (Form 8959,
# IRC § 3101(b)(2) / § 1401(b)(2)) separately in the
# `addmed` column rather than rolling it into fiitax —
# matching Form 1040 Line 23 / Schedule 2 Line 11.
# PE's `income_tax` (which already includes NIIT via
# `income_tax_before_refundable_credits`) is the correct
# match. AddMed continues to flow through the `v44`
# output column (employee_medicare_tax + additional_medicare_tax).
if "fiitax" not in columns:
columns["fiitax"] = np.round(
self._calc_tax_unit(sim, "income_tax", year_str), 2
)

# Apply v22 CTC split: TAXSIM v22 reports only the
# non-refundable CTC (capped at tax liability) for years
Expand Down
112 changes: 112 additions & 0 deletions tests/test_addmed_excluded_from_fiitax.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""
Test that PE's `fiitax` output does NOT include the Additional
Medicare Tax (Form 8959, IRC § 3101(b)(2) / § 1401(b)(2)).

NBER TAXSIM-35 (`taxsimtest`) reports AddMed in a separate `addmed`
column rather than rolling it into `fiitax`. This matches Form 1040
Line 23 / Schedule 2 Line 11: Additional Medicare Tax is "Other
Taxes", distinct from regular income tax on Line 16.

Reference:
- IRC § 3101(b)(2): 0.9% Additional Medicare Tax on wages above
threshold.
- IRC § 1401(b)(2): 0.9% Additional Medicare Tax on self-employment
earnings above threshold.
- Form 8959: Additional Medicare Tax computation.
- Form 1040 Line 23 / Schedule 2 Line 11: AddMed reported under
"Other Taxes", not regular income tax.

NBER `taxsimtest` smoke test (single $500K wages, no other income,
year 2024):
fiitax = $140,264.75 (= income_tax only)
addmed = $2,355.75 (separate column)
v28 = $140,264.75 (income tax before NIIT / AddMed)

If we incorrectly add `additional_medicare_tax` to `fiitax`, PE
overshoots TAXSIM by exactly the AddMed amount. This was the source
of ~$412K (95%) of the residual federal mismatch in the eCPS n=2000
TY 2025 comparison before this fix.
"""

import pandas as pd
import pytest

from policyengine_taxsim.runners.policyengine_runner import PolicyEngineRunner


def _high_wage_single():
"""Single filer, $500K wages — clearly above the $200K AddMed
threshold but with no investment income (NIIT=0) so the only
difference between including / excluding AddMed in fiitax is
the AddMed amount itself."""
return pd.DataFrame(
{
"taxsimid": [1],
"year": 2024,
"state": [5], # CA
"mstat": 1,
"depx": 0,
"page": 45,
"sage": 0,
"pwages": [500_000.0],
"swages": 0.0,
"idtl": 2,
}
)


def _high_wage_mfj():
"""MFJ filer, $1M total wages — above the $250K AddMed
threshold so we expect a meaningful AddMed component."""
return pd.DataFrame(
{
"taxsimid": [2],
"year": 2024,
"state": [5],
"mstat": 2,
"depx": 0,
"page": 45,
"sage": 45,
"pwages": [500_000.0],
"swages": [500_000.0],
"idtl": 2,
}
)


class TestAddMedExcludedFromFiitax:
def test_single_500k_fiitax_excludes_addmed(self):
"""
Single $500K wages 2024: NBER TAXSIM-35 `taxsimtest` reports
fiitax = $140,264.75. If PE's fiitax incorrectly added the
$2,700 AddMed, the result would round to ~$142,965 — well
outside any sane tolerance.
"""
records = _high_wage_single()
runner = PolicyEngineRunner(records.copy(), logs=False)
result = runner.run(show_progress=False)
fiitax = float(result["fiitax"].iloc[0])
assert fiitax == pytest.approx(140_265, abs=200), (
f"Expected fiitax≈$140,265 (matching NBER TAXSIM-35 `taxsimtest`), "
f"got ${fiitax:.2f}. A spread of ~$2,700 over target indicates "
f"PE is incorrectly adding Additional Medicare Tax to fiitax. "
f"Per Form 1040 Line 23 / Schedule 2 Line 11, AddMed is an "
f"`Other Tax` and must flow through the `addmed` / `v44` "
f"output columns, not fiitax."
)

def test_mfj_1m_fiitax_excludes_addmed(self):
"""
MFJ $1M total wages 2024: NBER TAXSIM-35 `taxsimtest` reports
fiitax = $285,321.50. If AddMed were incorrectly added to
PE's fiitax, it would land several thousand above target.
"""
records = _high_wage_mfj()
runner = PolicyEngineRunner(records.copy(), logs=False)
result = runner.run(show_progress=False)
fiitax = float(result["fiitax"].iloc[0])
assert fiitax == pytest.approx(285_321, abs=300), (
f"Expected fiitax≈$285,321 for MFJ $1M wages 2024, got "
f"${fiitax:.2f}. Several thousand over target means AddMed "
f"has been re-added to fiitax."
)
24 changes: 18 additions & 6 deletions tests/test_performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ def test_deterministic_output(self, golden_output):


class TestFederalOutputAdjustments:
def test_fiitax_includes_additional_medicare_tax_when_precomputed(self):
def test_fiitax_uses_income_tax_only_excludes_additional_medicare_tax(self):
"""
fiitax is normally populated from the income_tax mapping before the
special post-processing step runs. That post-processing still needs to
add additional_medicare_tax instead of skipping fiitax entirely.
fiitax is populated from the income_tax mapping and must NOT be
further adjusted by additional_medicare_tax. NBER TAXSIM-35
(`taxsimtest`) reports the Additional Medicare Tax (Form 8959)
in a separate `addmed` column per Form 1040 Line 23 /
Schedule 2 Line 11 — so the AddMed value never gets added to
the fiitax output, and `_calc_tax_unit` is never called for
`additional_medicare_tax` during fiitax assembly.
"""
records = pd.DataFrame(
{
Expand Down Expand Up @@ -138,15 +142,23 @@ def fake_calc_tu(self_runner, sim, var_name, period):
if var_name == "income_tax":
return np.array([1000.0])
if var_name == "additional_medicare_tax":
# We assert below that this branch is never taken,
# but return a sentinel so a future regression that
# re-introduces the call surfaces obviously rather
# than silently using a zero.
return np.array([900.0])
raise AssertionError(f"Unexpected variable: {var_name}")

runner._calc_tax_unit = types.MethodType(fake_calc_tu, runner)

result = runner._extract_vectorized_results(fake_sim, runner.input_df)

assert result["fiitax"].iloc[0] == pytest.approx(1900.0)
assert calc_calls == ["income_tax", "additional_medicare_tax"]
assert result["fiitax"].iloc[0] == pytest.approx(1000.0)
assert "additional_medicare_tax" not in calc_calls, (
"fiitax must not call additional_medicare_tax — AddMed flows "
"through the separate `addmed` / `v44` output per NBER "
"TAXSIM-35 (`taxsimtest`) and Form 1040 Line 23."
)


class TestGeneratePhaseEfficiency:
Expand Down
Loading