Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The married-filing-separately 33% bracket in the additional tax bracket contrib reform now ends at half the joint threshold from 2026 (270,775 in 2026), as the baseline schedule does, instead of a copy of the joint threshold that inverted it and taxed separate filers' income from 270,775 to 541,550 at 33%. Tests now check that federal-style bracket thresholds strictly increase and that no parameter value holds NaN, including inside list values.
Original file line number Diff line number Diff line change
Expand Up @@ -680,16 +680,16 @@ thresholds:
2024-01-01: 243_725
2025-01-01: 250_525
# CBO forecast.
2026-01-01: 541_550
2027-01-01: 553_050
2028-01-01: 564_200
2029-01-01: 575_400
2030-01-01: 586_750
2031-01-01: 598_350
2032-01-01: 610_200
2033-01-01: 622_300
2034-01-01: 634_650
2035-01-01: 647_250
2026-01-01: 270_775
2027-01-01: 276_525
2028-01-01: 282_100
2029-01-01: 287_700
2030-01-01: 293_375
2031-01-01: 299_175
2032-01-01: 305_100
2033-01-01: 311_150
2034-01-01: 317_325
2035-01-01: 323_625
metadata:
uprating:
parameter: gov.irs.uprating
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,30 @@
taxable_income_taxed_below_25_percent: 201_775
dwks19: 200_000
capital_gains_excluded_from_taxable_income: 100_000

- name: A separate filer's 33% bracket ends at half the joint threshold under the reform.
period: 2026
absolute_error_margin: 0.01
reforms: policyengine_us.reforms.additional_tax_bracket.additional_tax_bracket_reform.additional_tax_bracket
input:
gov.contrib.additional_tax_bracket.in_effect: true
taxable_income: 300_000
filing_status: SEPARATE
output:
# 2026 separate thresholds 12,150 / 49,300 / 99,500 / 151,625 / 270,775 /
# 305,875 at 10 / 15 / 25 / 28 / 33 / 35%:
# 1,215 + 5,572.50 + 12,550 + 14,595 + 39,319.50 + 0.35 * 29,225.
income_tax_main_rates: 83_480.75

- name: A separate filer above the 35% bracket pays 39.6% under the reform.
period: 2026
absolute_error_margin: 0.01
reforms: policyengine_us.reforms.additional_tax_bracket.additional_tax_bracket_reform.additional_tax_bracket
input:
gov.contrib.additional_tax_bracket.in_effect: true
taxable_income: 400_000
filing_status: SEPARATE
output:
# 83,480.75 on the first 300,000 (case above), plus 35% of 5,875 up to
# 305,875, plus 39.6% of the remaining 94,125.
income_tax_main_rates: 122_810.50
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,18 @@ def four_status_situation():


def test_shipped_additional_tax_bracket_reform_is_clamped():
# The contrib reform replaces both bracket loops and its parameter
# tree still carries the expiration projection's inverted SEPARATE
# thresholds (bracket 5 top 541,550 above bracket 6 top 305,875),
# so it must clamp too: before the fix a zero-income 2026 MFS filer
# owed a flat −$82,486 under this shipped reform. The reform's
# user-supplied extra bracket is filled as in its own yaml tests;
# brackets 1–6 stay at the shipped (inverted) values.
# The contrib reform replaces both bracket loops, so it must clamp too.
# Its parameter tree used to carry the expiration projection's inverted
# SEPARATE thresholds (bracket 5 top 541,550 above bracket 6 top
# 305,875), and before the clamp a zero-income 2026 MFS filer owed a flat
# −$82,486 under the shipped reform. The file now has the correct
# 270,775, so this reform recreates that inversion. The reform's
# user-supplied extra bracket is filled as in its own yaml tests.
fill_extra_bracket = Reform.from_dict(
{
"gov.contrib.additional_tax_bracket.bracket.thresholds.5.SEPARATE": {
"2026-01-01.2026-12-31": 541_550
},
"gov.contrib.additional_tax_bracket.bracket.thresholds.7.SEPARATE": {
"2026-01-01.2026-12-31": 800_000
},
Expand All @@ -220,3 +223,17 @@ def test_shipped_additional_tax_bracket_reform_is_clamped():
before_credits = sim.calculate("income_tax_before_credits", 2026)[0]
assert main == 0
assert before_credits == 0

# A zero-income filer owes nothing under any non-negative loop, so also
# check a filer inside the inverted band. Clamped, bracket 6 is empty and
# 151,625-400,000 is all taxed at bracket 5's 33%: 115,896.25. Without
# the inversion the tax is 122,810.50, and an unclamped loop gives yet
# another figure.
situation = separate_filer(0)
situation["tax_units"]["tu"]["taxable_income"] = {"2026": 400_000}
sim = Simulation(
reform=(fill_extra_bracket, additional_tax_bracket), situation=situation
)
assert sim.calculate("income_tax_main_rates", 2026)[0] == pytest.approx(
115_896.25, abs=0.01
)
202 changes: 201 additions & 1 deletion policyengine_us/tests/test_parameter_scale_thresholds.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@
Duplicate `+inf` thresholds are exempt: tables whose length changes over
time park unused brackets there, and no finite value reaches them. `-inf` is
a reachable bottom row, so duplicates there are flagged.

Federal-style rate schedules such as `gov.irs.income.bracket` are not scales:
their `thresholds` node maps bracket numbers 1..N to a value per filing status,
and `tax_at_main_rates` reads bracket i's threshold as its top. A repeated
threshold empties a bracket and an inverted one is clamped (#9084), so either
silently taxes a band at the wrong rate. Each filing status's thresholds must
therefore strictly increase at every instant, again exempting `+inf`.

YAML's `.nan` loads as a float in a scalar or inside a list value, and every
comparison with it is false, so no parameter value may hold NaN at any depth.
"""

import math
from collections import defaultdict
from datetime import date

import pytest
from policyengine_core.parameters import ParameterNode, ParameterScale
from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale
from policyengine_core.parameters.operations.uprate_parameters import (
uprate_parameters,
)
Expand Down Expand Up @@ -260,3 +270,193 @@ def test_parameter_scales_have_no_duplicate_thresholds():

assert scales
assert not errors, "\n".join(errors)


def _mapped_threshold_tables():
# A rate schedule's `thresholds` node sits beside the `rates` node that
# `tax_at_main_rates` reads with it, and maps bracket numbers 1..N.
return [
node.children["thresholds"]
for node in system.parameters.get_descendants()
if isinstance(node, ParameterNode)
and "rates" in node.children
and isinstance(node.children.get("thresholds"), ParameterNode)
and node.children["thresholds"].children
and sorted(node.children["thresholds"].children)
== sorted(
str(i) for i in range(1, len(node.children["thresholds"].children) + 1)
)
]


def _mapped_threshold_errors(node):
brackets = sorted(node.children, key=int)
columns = defaultdict(dict)
for bracket in brackets:
child = node.children[bracket]
if isinstance(child, ParameterNode):
for column, leaf in child.children.items():
columns[column][bracket] = leaf
else:
columns[None][bracket] = child
instants_by_error = defaultdict(list)
for column, leaves in columns.items():
instants = sorted(
{
value_at_instant.instant_str
for leaf in leaves.values()
for value_at_instant in leaf.values_list
}
)
for instant_str in instants:
present = [
(bracket, value)
for bracket in brackets
if bracket in leaves
and (value := leaves[bracket]._get_at_instant(instant_str)) is not None
]
for (lower, low), (upper, high) in zip(present, present[1:]):
if not low < high and not low == high == math.inf:
instants_by_error[(column, lower, upper)].append(
(instant_str, low, high)
)
errors = []
for (column, lower, upper), cases in instants_by_error.items():
instant_str, low, high = cases[0]
when = instant_str
if len(cases) > 1:
when = (
f"{instant_str} and {len(cases) - 1} later instants to {cases[-1][0]}"
)
name = node.name if column is None else f"{node.name}.*.{column}"
errors.append(
f"{name}: bracket {upper} threshold {high:,.15g} does not exceed "
f"bracket {lower} threshold {low:,.15g} at {when}"
)
return errors


def _mapped_table(thresholds):
return ParameterNode("root", data={"thresholds": thresholds}).thresholds


def test_mapped_threshold_guard_flags_repeats_and_inversions():
table = _mapped_table(
{
1: {"SINGLE": {"2024-01-01": 100}, "JOINT": {"2024-01-01": 200}},
2: {
"SINGLE": {"2024-01-01": 100},
"JOINT": {"2024-01-01": 400, "2025-01-01": 150},
},
3: {"SINGLE": {"2024-01-01": 300}, "JOINT": {"2024-01-01": 600}},
}
)

assert _mapped_threshold_errors(table) == [
"root.thresholds.*.SINGLE: bracket 2 threshold 100 does not exceed "
"bracket 1 threshold 100 at 2024-01-01",
"root.thresholds.*.JOINT: bracket 2 threshold 150 does not exceed "
"bracket 1 threshold 200 at 2025-01-01",
]


def test_mapped_threshold_guard_flags_negative_infinity_repeats_and_nan():
table = _mapped_table(
{
1: {"SINGLE": {"2024-01-01": -math.inf}, "JOINT": {"2024-01-01": 100}},
2: {
"SINGLE": {"2024-01-01": -math.inf},
"JOINT": {"2024-01-01": math.nan},
},
3: {"SINGLE": {"2024-01-01": 300}, "JOINT": {"2024-01-01": 600}},
}
)

assert _mapped_threshold_errors(table) == [
"root.thresholds.*.SINGLE: bracket 2 threshold -inf does not exceed "
"bracket 1 threshold -inf at 2024-01-01",
"root.thresholds.*.JOINT: bracket 2 threshold nan does not exceed "
"bracket 1 threshold 100 at 2024-01-01",
"root.thresholds.*.JOINT: bracket 3 threshold 600 does not exceed "
"bracket 2 threshold nan at 2024-01-01",
]


def test_mapped_threshold_guard_ignores_only_parked_positive_infinity():
def table_with_top(top):
return _mapped_table(
{
1: {"SINGLE": {"2024-01-01": 100}},
2: {"SINGLE": {"2024-01-01": math.inf}},
3: {"SINGLE": {"2024-01-01": top}},
}
)

assert _mapped_threshold_errors(table_with_top(math.inf)) == []
assert _mapped_threshold_errors(table_with_top(500)) == [
"root.thresholds.*.SINGLE: bracket 3 threshold 500 does not exceed "
"bracket 2 threshold inf at 2024-01-01"
]


def test_mapped_bracket_thresholds_strictly_increase():
tables = _mapped_threshold_tables()
errors = [error for table in tables for error in _mapped_threshold_errors(table)]

assert {
"gov.irs.income.bracket.thresholds",
"gov.irs.capital_gains.thresholds",
} <= {table.name for table in tables}
assert not errors, "\n".join(errors)


def _nan_paths(value, path=""):
if isinstance(value, float) and math.isnan(value):
yield path or "value"
elif isinstance(value, (list, tuple)):
for index, item in enumerate(value):
yield from _nan_paths(item, f"{path}[{index}]")


def _nan_values(parameter):
return [
f"{value_at_instant.instant_str} {path}"
for value_at_instant in parameter.values_list
for path in _nan_paths(value_at_instant.value)
]


def test_nan_guard_flags_nan_in_scalars_and_nested_lists():
parameter = Parameter(
"test_parameter",
{
"values": {
"2023-01-01": [1.0, [2.0, 3.0]],
"2024-01-01": [1.0, [2.0, math.nan]],
"2025-01-01": math.nan,
"2026-01-01": [math.nan],
}
},
)

assert sorted(_nan_values(parameter)) == [
"2024-01-01 [1][1]",
"2025-01-01 value",
"2026-01-01 [0]",
]


def test_parameter_values_are_not_nan():
parameters = [
parameter
for parameter in system.parameters.get_descendants()
if isinstance(parameter, Parameter)
]
errors = [
f"{parameter.name}: {error}"
for parameter in parameters
for error in _nan_values(parameter)
]

assert parameters
assert not errors, "\n".join(errors)
Loading