Skip to content

Commit e3a3c59

Browse files
committed
Add Trustees core threshold reform
1 parent 37024b3 commit e3a3c59

4 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added an explicit Trustees core threshold reform for long-run TOB analysis scenarios.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .trustees_core_thresholds import (
2+
TRUSTEES_CORE_THRESHOLD_ASSUMPTION,
3+
create_trustees_core_thresholds_reform,
4+
trustees_core_thresholds_reform,
5+
)
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from __future__ import annotations
2+
3+
import math
4+
from typing import Any
5+
6+
from policyengine_us.model_api import Reform
7+
8+
9+
TRUSTEES_CORE_THRESHOLD_ASSUMPTION: dict[str, Any] = {
10+
"name": "trustees-2025-core-thresholds-v1",
11+
"description": (
12+
"Best-public Trustees tax-side approximation: keep Social Security "
13+
"benefit-tax thresholds fixed, but wage-index core ordinary federal "
14+
"tax thresholds after 2034 using the active NAWI path."
15+
),
16+
"source": "SSA 2025 Trustees Report V.C.7",
17+
"start_year": 2035,
18+
"projection_base_year": 2026,
19+
"parameter_groups": [
20+
"ordinary_income_brackets",
21+
"standard_deduction",
22+
"aged_blind_standard_deduction",
23+
"capital_gains_thresholds",
24+
"amt_thresholds",
25+
],
26+
"not_default_current_law": True,
27+
}
28+
29+
30+
def _round_amount(amount: float, rounding: dict | None) -> float:
31+
if not rounding:
32+
return amount
33+
34+
interval = float(rounding["interval"])
35+
rounding_type = rounding["type"]
36+
37+
if rounding_type == "downwards":
38+
return math.floor(amount / interval) * interval
39+
if rounding_type == "nearest":
40+
return math.floor(amount / interval + 0.5) * interval
41+
42+
raise ValueError(f"Unsupported rounding type: {rounding_type}")
43+
44+
45+
def _uprating_parameter_name(parameter) -> str | None:
46+
metadata = getattr(parameter, "metadata", {})
47+
uprating = metadata.get("uprating")
48+
if isinstance(uprating, dict):
49+
return uprating.get("parameter")
50+
return uprating
51+
52+
53+
def _get_parameter_by_name(parameters, name: str):
54+
current = parameters
55+
for part in name.split("."):
56+
current = getattr(current, part)
57+
return current
58+
59+
60+
def _nawi_growth_for_tax_year(parameters, year: int) -> float:
61+
nawi = parameters.gov.ssa.nawi
62+
return float(nawi(f"{year - 1}-01-01")) / float(nawi(f"{year - 2}-01-01"))
63+
64+
65+
def _iter_updatable_parameters(
66+
root,
67+
*,
68+
uprating_parameter: str | None = None,
69+
) -> list:
70+
candidates = [root]
71+
if hasattr(root, "get_descendants"):
72+
candidates.extend(root.get_descendants())
73+
74+
result = []
75+
for candidate in candidates:
76+
if candidate.__class__.__name__ != "Parameter":
77+
continue
78+
uprating_name = _uprating_parameter_name(candidate)
79+
if uprating_name is None:
80+
continue
81+
if uprating_parameter is not None and uprating_name != uprating_parameter:
82+
continue
83+
result.append(candidate)
84+
return result
85+
86+
87+
def _apply_wage_growth_to_parameter(
88+
parameter,
89+
*,
90+
parameters,
91+
start_year: int,
92+
end_year: int,
93+
projection_base_year: int,
94+
) -> None:
95+
metadata = getattr(parameter, "metadata", {})
96+
uprating = metadata.get("uprating")
97+
rounding = uprating.get("rounding") if isinstance(uprating, dict) else None
98+
uprating_name = _uprating_parameter_name(parameter)
99+
if uprating_name is None:
100+
return
101+
102+
# Validate that the referenced default uprating parameter exists.
103+
_get_parameter_by_name(parameters, uprating_name)
104+
values_by_year = {}
105+
106+
for year in range(projection_base_year + 1, start_year):
107+
values_by_year[year] = float(parameter(f"{year}-01-01"))
108+
109+
for year in range(start_year, end_year + 1):
110+
if year - 1 in values_by_year:
111+
previous_value = values_by_year[year - 1]
112+
else:
113+
previous_value = float(parameter(f"{year - 1}-01-01"))
114+
updated_value = _round_amount(
115+
previous_value * _nawi_growth_for_tax_year(parameters, year),
116+
rounding,
117+
)
118+
values_by_year[year] = updated_value
119+
120+
for year, value in values_by_year.items():
121+
parameter.update(
122+
period=f"year:{year}-01-01:1",
123+
value=value,
124+
)
125+
126+
127+
def _core_threshold_roots(parameters) -> list:
128+
return [
129+
parameters.gov.irs.income.bracket.thresholds,
130+
parameters.gov.irs.deductions.standard.amount,
131+
parameters.gov.irs.deductions.standard.aged_or_blind.amount,
132+
parameters.gov.irs.capital_gains.thresholds,
133+
parameters.gov.irs.income.amt.brackets,
134+
parameters.gov.irs.income.amt.exemption.amount,
135+
parameters.gov.irs.income.amt.exemption.phase_out.start,
136+
parameters.gov.irs.income.amt.exemption.separate_limit,
137+
]
138+
139+
140+
def _parameters_have_long_run_projection(parameters, end_year: int) -> bool:
141+
parameter = parameters.gov.irs.income.bracket.thresholds.children["1"].SINGLE
142+
return any(
143+
value.instant_str == f"{end_year}-01-01" for value in parameter.values_list
144+
)
145+
146+
147+
def create_trustees_core_thresholds_reform(
148+
*,
149+
start_year: int = 2035,
150+
end_year: int = 2100,
151+
projection_base_year: int = 2026,
152+
) -> Reform:
153+
"""Return a Trustees-style long-run tax-threshold assumption reform.
154+
155+
This is an explicit scenario assumption, not default current law. It leaves
156+
Social Security benefit-tax thresholds unchanged and wage-indexes the core
157+
IRS thresholds used by the CRFB long-run TOB analysis from ``start_year``.
158+
"""
159+
160+
def modify_parameters(parameters):
161+
if not _parameters_have_long_run_projection(parameters, end_year):
162+
return parameters
163+
164+
seen = set()
165+
for root in _core_threshold_roots(parameters):
166+
for parameter in _iter_updatable_parameters(root):
167+
if parameter.name in seen:
168+
continue
169+
seen.add(parameter.name)
170+
_apply_wage_growth_to_parameter(
171+
parameter,
172+
parameters=parameters,
173+
start_year=start_year,
174+
end_year=end_year,
175+
projection_base_year=projection_base_year,
176+
)
177+
return parameters
178+
179+
class reform(Reform):
180+
def apply(self):
181+
self.modify_parameters(modify_parameters)
182+
183+
return reform
184+
185+
186+
trustees_core_thresholds_reform = create_trustees_core_thresholds_reform()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import math
2+
3+
from policyengine_us import CountryTaxBenefitSystem
4+
from policyengine_us.reforms.ssa.trustees_core_thresholds import (
5+
TRUSTEES_CORE_THRESHOLD_ASSUMPTION,
6+
create_trustees_core_thresholds_reform,
7+
)
8+
9+
10+
def _parameters(reform=None):
11+
return CountryTaxBenefitSystem(reform=reform).parameters
12+
13+
14+
def test_metadata_marks_trustees_core_thresholds_as_explicit_assumption():
15+
assert (
16+
TRUSTEES_CORE_THRESHOLD_ASSUMPTION["name"] == "trustees-2025-core-thresholds-v1"
17+
)
18+
assert TRUSTEES_CORE_THRESHOLD_ASSUMPTION["not_default_current_law"] is True
19+
assert TRUSTEES_CORE_THRESHOLD_ASSUMPTION["start_year"] == 2035
20+
assert "amt_thresholds" in TRUSTEES_CORE_THRESHOLD_ASSUMPTION["parameter_groups"]
21+
22+
23+
def test_reform_wage_indexes_core_tax_threshold_from_2035():
24+
baseline = _parameters()
25+
reformed = _parameters((create_trustees_core_thresholds_reform(),))
26+
27+
baseline_threshold = baseline.gov.irs.income.bracket.thresholds.children["1"].SINGLE
28+
reformed_threshold = reformed.gov.irs.income.bracket.thresholds.children["1"].SINGLE
29+
nawi = reformed.gov.ssa.nawi
30+
31+
assert reformed_threshold("2034-01-01") == baseline_threshold("2034-01-01")
32+
nawi_growth = float(nawi("2034-01-01")) / float(nawi("2033-01-01"))
33+
expected_2035 = (
34+
math.floor(float(reformed_threshold("2034-01-01")) * nawi_growth / 25) * 25
35+
)
36+
assert reformed_threshold("2035-01-01") == expected_2035
37+
assert reformed_threshold("2035-01-01") != baseline_threshold("2035-01-01")
38+
39+
40+
def test_reform_updates_standard_deduction_and_amt_thresholds():
41+
baseline = _parameters()
42+
reformed = _parameters((create_trustees_core_thresholds_reform(),))
43+
44+
standard_deduction = reformed.gov.irs.deductions.standard.amount.SINGLE
45+
amt_bracket = reformed.gov.irs.income.amt.brackets[1].threshold
46+
47+
assert standard_deduction(
48+
"2034-01-01"
49+
) == baseline.gov.irs.deductions.standard.amount.SINGLE("2034-01-01")
50+
assert amt_bracket("2034-01-01") == baseline.gov.irs.income.amt.brackets[
51+
1
52+
].threshold("2034-01-01")
53+
assert standard_deduction(
54+
"2035-01-01"
55+
) != baseline.gov.irs.deductions.standard.amount.SINGLE("2035-01-01")
56+
assert amt_bracket("2035-01-01") != baseline.gov.irs.income.amt.brackets[
57+
1
58+
].threshold("2035-01-01")
59+
60+
61+
def test_reform_does_not_change_social_security_benefit_tax_thresholds():
62+
baseline = _parameters()
63+
reformed = _parameters((create_trustees_core_thresholds_reform(),))
64+
65+
baseline_threshold = (
66+
baseline.gov.irs.social_security.taxability.threshold.base.main.SINGLE
67+
)
68+
reformed_threshold = (
69+
reformed.gov.irs.social_security.taxability.threshold.base.main.SINGLE
70+
)
71+
72+
for year in [2034, 2035, 2100]:
73+
instant = f"{year}-01-01"
74+
assert reformed_threshold(instant) == baseline_threshold(instant)

0 commit comments

Comments
 (0)