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
1 change: 1 addition & 0 deletions changelog.d/cms-long-range-glide.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Glide the CMS out-of-pocket spending and employer premium upraters from their last projected growth rates to the 2026 Medicare Trustees' long-range per capita health spending rates after 2034, instead of compounding the last projected rate to 2100.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ values:
2032-01-01: 11_986
2033-01-01: 12_559
2034-01-01: 13_124
# 2035 to 2100 grow at the 2033-2034 rate (13_124 / 12_559, 4.5% a year)
# and later years hold the 2100 value; see set_all_uprating_parameters in
# uprating_extensions.py.
# From 2035, growth glides from the 2033-2034 rate (13_124 / 12_559, 4.5%
# a year) to the Medicare Trustees' long-range per capita health spending
# rates (4.3% in 2050, 4.1% in 2100), and later years hold the 2100 value;
# see set_all_uprating_parameters in uprating_extensions.py.
114 changes: 111 additions & 3 deletions policyengine_us/parameters/uprating_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import math
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional, Tuple
from typing import Dict, Optional, Tuple

from policyengine_us.model_api import *
from policyengine_core.periods import Instant, instant
Expand All @@ -21,6 +21,62 @@
"above_the_line_deductions",
)

# Long-range annual growth rates from the 2026 Medicare Trustees Report
# (transmitted 2026-06-09, sha256
# ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8), as
# {year: rate} knots for extend_parameter_values(long_range_growth=...). The
# Trustees set the growth rates for years 11 through 25 of their projection
# (2036-2050) by interpolating between the rate at the end of the short-range
# period and the rate at the start of the last 50 years (file pages 24 and
# 180), and the long-range rates then decline gradually from 2050 to 2100
# (file pages 23 and 173). They publish only the 2050 and 2100 rates, so the
# path between those two knots is linear here.
#
# Per capita overall health spending grows at GDP plus 0.7 percent (4.3
# percent) in 2050, declining gradually to GDP plus 0.4 percent (4.1 percent)
# by 2100 (file page 173). The Trustees publish no long-range rate for
# out-of-pocket spending or private insurance (Table V.B5, file page 200,
# stops private health insurance in 2035), so this rate serves those series:
# the factors model behind it holds insurance coverage unchanged (file page
# 175), and the Trustees see only a small likelihood that one part of the
# health sector grows indefinitely at significantly faster rates than the
# others (file page 177).
HEALTH_SPENDING_PER_CAPITA_LONG_RANGE_GROWTH = {2050: 0.043, 2100: 0.041}

# The Trustees' Medicare cost growth rates exclude the effects of the aging of
# the population and of changes in the sex and time-to-death mix of
# enrollees (file page 172, footnote 81). Spending per enrollee includes
# them: Table II.D1 (file page 29) puts the beneficiary demographic mix at
# -0.1 percent a year in 2051-2100 for each of Parts A, B and D.
MEDICARE_LONG_RANGE_DEMOGRAPHIC_MIX = -0.001


def with_medicare_demographic_mix(cost_growth: Dict[int, float]) -> Dict[int, float]:
"""Medicare spending per enrollee growth: the Trustees' cost growth rate
(excluding demographic impacts) compounded with the long-range
demographic mix."""
return {
year: (1 + rate) * (1 + MEDICARE_LONG_RANGE_DEMOGRAPHIC_MIX) - 1
for year, rate in cost_growth.items()
}


# Parts A, B and D combined: 3.8 percent in 2050, declining to 3.7 percent by
# 2100 (file pages 23 and 180).
MEDICARE_PER_ENROLLEE_LONG_RANGE_GROWTH = with_medicare_demographic_mix(
{2050: 0.038, 2100: 0.037}
)
# Part B: 3.8 percent in 2050, remaining at 3.8 percent by 2100 (file pages 23
# and 180).
MEDICARE_PART_B_PER_ENROLLEE_LONG_RANGE_GROWTH = with_medicare_demographic_mix(
{2050: 0.038, 2100: 0.038}
)
# Part D: 4.1 percent in 2050, declining to 3.9 percent by 2100 (file pages 23
# and 179).
MEDICARE_PART_D_PER_ENROLLEE_LONG_RANGE_GROWTH = with_medicare_demographic_mix(
{2050: 0.041, 2100: 0.039}
)


def get_irs_cpi(parameters: ParameterNode, year: int) -> float:
"""Calculate IRS CPI based on Chained CPI-U average from Sep to Aug."""
Expand Down Expand Up @@ -498,13 +554,47 @@ def extend_wa_millionaires_standard_deduction(
)


def long_range_growth_rate(
year: int,
last_projected_year: int,
short_range_growth: float,
long_range_growth: Dict[int, float],
) -> float:
"""Annual growth rate for a year after the last projected year, on a path
that glides from the short-range rate to long-range rates.

The knots are (last_projected_year, short_range_growth) and each
(year, rate) in long_range_growth. The rate moves linearly between
adjacent knots, as the Medicare Trustees interpolate between their
short-range and long-range rates, and holds the last knot's rate after
it.
"""
knots = sorted(long_range_growth.items())
if not knots or knots[0][0] <= last_projected_year:
raise ValueError("Long-range growth knots must follow the last projected year.")
if year <= last_projected_year:
raise ValueError(
"Long-range growth applies only after the last projected year."
)
previous_year, previous_rate = last_projected_year, short_range_growth
for knot_year, knot_rate in knots:
if year == knot_year:
return knot_rate
if year < knot_year:
share = (year - previous_year) / (knot_year - previous_year)
return previous_rate + share * (knot_rate - previous_rate)
previous_year, previous_rate = knot_year, knot_rate
return previous_rate


def extend_parameter_values(
parameter: Parameter,
last_projected_year: int,
end_year: int,
period_month: int = 1,
period_day: int = 1,
growth_years: Optional[Tuple[int, int]] = None,
long_range_growth: Optional[Dict[int, float]] = None,
) -> None:
"""
Extend a parameter's values from last_projected_year to end_year using
Expand All @@ -520,6 +610,11 @@ def extend_parameter_values(
rate between, instead of the last two projected years — used
when the final actual embeds a one-off level shift that should
not compound in the long-run extension.
long_range_growth: Optional {year: annual growth rate} knots. When
given, growth moves from the measured short-range rate, placed
at last_projected_year, through the knots (see
long_range_growth_rate) instead of repeating the short-range
rate in every year.
"""
# Calculate the growth rate from the last two years of projections
date_format = f"-{period_month:02d}-{period_day:02d}"
Expand All @@ -533,7 +628,15 @@ def extend_parameter_values(
# Apply growth rate for years beyond projections
for year in range(last_projected_year + 1, end_year + 1):
previous_value = parameter(f"{year - 1}{date_format}")
new_value = previous_value * growth_rate
if long_range_growth is None:
new_value = previous_value * growth_rate
else:
new_value = previous_value * (
1
+ long_range_growth_rate(
year, last_projected_year, growth_rate - 1, long_range_growth
)
)
parameter.update(period=f"year:{year}{date_format}:1", value=new_value)

# Set the final value for periods after the last year
Expand Down Expand Up @@ -841,23 +944,28 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode:

# CMS per-capita out-of-pocket medical spending (January values, last
# projection year 2035). Health expense inputs use this as their uprater.
# Growth glides from the 2034-2035 rate to the Medicare Trustees'
# long-range per capita health spending rates.
extend_parameter_values(
parameters.calibration.gov.hhs.cms.moop_per_capita,
last_projected_year=2035,
end_year=END_YEAR,
period_month=1,
period_day=1,
long_range_growth=HEALTH_SPENDING_PER_CAPITA_LONG_RANGE_GROWTH,
)

# CMS employer-sponsored insurance premium per enrollee (January values,
# last projection year 2034). Employer and pre-tax employee premium
# inputs use this as their uprater.
# inputs use this as their uprater. Growth glides from the 2033-2034 rate
# to the Medicare Trustees' long-range per capita health spending rates.
extend_parameter_values(
parameters.calibration.gov.hhs.cms.esi_premium_per_enrollee,
last_projected_year=2034,
end_year=END_YEAR,
period_month=1,
period_day=1,
long_range_growth=HEALTH_SPENDING_PER_CAPITA_LONG_RANGE_GROWTH,
)

# Gross Social Security benefit inputs should age with gross Social
Expand Down
21 changes: 14 additions & 7 deletions policyengine_us/tests/core/test_esi_premium_uprating.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
report ESI spending, ESI enrollment and ESI spending per enrollee for 2013 to
2034. ESI spending is premiums paid by employers and employees (NHEA
methodology paper, file page 29), so spending per enrollee is the premium per
covered person. The parameter holds that row; 2035 to 2100 grow at its
2033-2034 rate.
covered person. The parameter holds that row; from 2035 its growth glides
from the 2033-2034 rate to the Medicare Trustees' long-range per capita health
spending rates (``tests/core/test_long_range_health_growth.py``).

These tests read only the parameter tree. Engine uprating of the inputs (a
household simulation and dataset extension) is covered in
Expand All @@ -23,7 +24,8 @@
3. Bounds and order: the index is positive in every year and strictly
increasing, as CMS's series is in every year from 2013 to 2034; the
extension keeps both through 2100.
4. Extension rule: each year from 2035 to 2100 grows by 13,124 / 12,559.
4. Extension rule: growth in 2035 starts from 13,124 / 12,559 and reaches
the Trustees' 4.3 percent in 2050 and 4.1 percent in 2100.
"""

import pytest
Expand Down Expand Up @@ -130,10 +132,15 @@ def test_index_is_positive_and_strictly_increasing(year, gap):


@given(st.integers(LAST_PROJECTED_YEAR + 1, END_YEAR))
def test_extension_holds_the_last_projected_growth_rate(year):
last_growth = _index(LAST_PROJECTED_YEAR) / _index(LAST_PROJECTED_YEAR - 1)
assert last_growth == pytest.approx(13_124 / 12_559, rel=1e-12)
assert _index(year) / _index(year - 1) == pytest.approx(last_growth, rel=1e-12)
def test_extension_glides_from_the_last_projected_growth_rate(year):
last_growth = _index(LAST_PROJECTED_YEAR) / _index(LAST_PROJECTED_YEAR - 1) - 1
assert last_growth == pytest.approx(13_124 / 12_559 - 1, rel=1e-12)
if year <= 2050:
share = (year - LAST_PROJECTED_YEAR) / (2050 - LAST_PROJECTED_YEAR)
expected = last_growth + share * (0.043 - last_growth)
else:
expected = 0.043 + (year - 2050) / 50 * (0.041 - 0.043)
assert _index(year) / _index(year - 1) - 1 == pytest.approx(expected, rel=1e-12)


@pytest.mark.parametrize("variable", PREMIUM_INPUTS)
Expand Down
Loading
Loading