|
| 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() |
0 commit comments