From 57b7f1f64a8565c2798f0591137da7bc7468726a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:15:01 -0400 Subject: [PATCH 1/9] Glide CMS health spending upraters to Trustees long-range growth extend_parameter_values gains long_range_growth knots: after the last projected year, growth moves linearly from the measured short-range rate to the 2026 Medicare Trustees' 2050 and 2100 rates, as the Trustees interpolate years 11-25 of their projection. moop_per_capita and esi_premium_per_enrollee now glide to per capita health spending growth (4.3% in 2050, 4.1% in 2100); Medicare, Part B and Part D per-enrollee knots include the -0.1%/yr demographic mix from Table II.D1. Co-Authored-By: Claude Opus 5.5 --- changelog.d/cms-long-range-glide.changed.md | 1 + .../gov/hhs/cms/esi_premium_per_enrollee.yaml | 7 +- .../parameters/uprating_extensions.py | 112 +++++++++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 changelog.d/cms-long-range-glide.changed.md diff --git a/changelog.d/cms-long-range-glide.changed.md b/changelog.d/cms-long-range-glide.changed.md new file mode 100644 index 00000000000..f33182da0dc --- /dev/null +++ b/changelog.d/cms-long-range-glide.changed.md @@ -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. diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml index 36951af5b5a..9302c17c389 100644 --- a/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml @@ -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. diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 23ec45e9904..02713657faf 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -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 @@ -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.""" @@ -498,6 +554,37 @@ 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, @@ -505,6 +592,7 @@ def extend_parameter_values( 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 @@ -520,6 +608,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}" @@ -533,7 +626,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 @@ -841,23 +942,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 From fda47f3939ecfffc377b52b010f7841a9c5cdddd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:15:20 -0400 Subject: [PATCH 2/9] Format uprating_extensions.py Co-Authored-By: Claude Opus 5.5 --- policyengine_us/parameters/uprating_extensions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 02713657faf..299eb2f4188 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -573,7 +573,9 @@ def long_range_growth_rate( 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.") + 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: From 4b749bdc08cb8f190c9873997f4f66d494c30863 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:17:31 -0400 Subject: [PATCH 3/9] Test the long-range glide and update ESI extension tests Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_esi_premium_uprating.py | 21 +- .../core/test_long_range_health_growth.py | 353 ++++++++++++++++++ .../parameters/test_uprating_extensions.py | 19 +- 3 files changed, 380 insertions(+), 13 deletions(-) create mode 100644 policyengine_us/tests/core/test_long_range_health_growth.py diff --git a/policyengine_us/tests/core/test_esi_premium_uprating.py b/policyengine_us/tests/core/test_esi_premium_uprating.py index 88db168c410..cbadf456e6a 100644 --- a/policyengine_us/tests/core/test_esi_premium_uprating.py +++ b/policyengine_us/tests/core/test_esi_premium_uprating.py @@ -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 @@ -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 @@ -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) diff --git a/policyengine_us/tests/core/test_long_range_health_growth.py b/policyengine_us/tests/core/test_long_range_health_growth.py new file mode 100644 index 00000000000..32beca7e648 --- /dev/null +++ b/policyengine_us/tests/core/test_long_range_health_growth.py @@ -0,0 +1,353 @@ +"""Long-range growth for CMS health spending upraters. + +After a series' last projected year, ``extend_parameter_values`` with +``long_range_growth`` knots moves each year's growth linearly from the +measured short-range rate, placed at the last projected year, to the 2026 +Medicare Trustees' 2050 and 2100 rates. The Trustees interpolate years 11 +through 25 of their projection the same way (2026 Medicare Trustees Report, +file pages 24 and 180). + +Invariants: + +1. Knots: the rate in a knot year is that knot's rate exactly. +2. Betweenness: every rate lies between the two knots around it, so the path + never overshoots either end; after the last knot the rate holds. +3. Closed form: the rate in year y between knots (y0, g0) and (y1, g1) is + g0 + (g1 - g0)(y - y0) / (y1 - y0), and the value is the last projected + value times the product of (1 + rate). Checked against an independent + numpy implementation, for synthetic series and for the wired CMS series. +4. Constant knots: knots equal to the short-range rate reproduce the + compound path V_L (1 + g)^(y - L), and no knots leave the old rule intact. +5. Monotonicity: raising any knot's rate never lowers any extended value. +6. Homogeneity: scaling the projected values by c > 0 scales every extended + value by c, so the glide depends only on growth, not on the level. +7. Projected years are untouched, and years after the end year hold the end + year's value. +8. Trustees consistency: starting from the Trustees' 2035 per beneficiary + growth (Table V.D1), the Medicare per-enrollee knots reproduce the growth + in spending per enrollee implied by the Trustees' incurred expenditure + growth (Table V.B5) and enrollment (Table V.B3) in each long-range period, + and the Part B and Part D knots reproduce Table II.D1's total increase net + of beneficiary growth, within the tables' rounding. +""" + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode + +from policyengine_us.parameters.uprating_extensions import ( + HEALTH_SPENDING_PER_CAPITA_LONG_RANGE_GROWTH, + MEDICARE_LONG_RANGE_DEMOGRAPHIC_MIX, + MEDICARE_PART_B_PER_ENROLLEE_LONG_RANGE_GROWTH, + MEDICARE_PART_D_PER_ENROLLEE_LONG_RANGE_GROWTH, + MEDICARE_PER_ENROLLEE_LONG_RANGE_GROWTH, + extend_parameter_values, + long_range_growth_rate, + with_medicare_demographic_mix, +) +from policyengine_us.system import system + +END_YEAR = 2100 + +rates = st.floats(-0.05, 0.15, allow_nan=False, allow_infinity=False) + + +@st.composite +def glide_cases(draw): + last = draw(st.integers(2020, 2060)) + knot_years = draw( + st.lists( + st.integers(last + 1, END_YEAR + 20), min_size=1, max_size=4, unique=True + ) + ) + knots = {year: draw(rates) for year in knot_years} + return last, draw(rates), knots + + +def _series(values): + # A node, so Parameter.update can reach a parent. + node = ParameterNode( + "node", + data={ + "series": { + "values": {f"{year}-01-01": value for year, value in values.items()} + } + }, + ) + return node.series + + +def _extend(last, short, knots, level=100.0, end_year=END_YEAR): + """Extend a two-point series whose last projected year grows at + ``short``.""" + series = _series({last - 1: level, last: level * (1 + short)}) + extend_parameter_values( + series, last, end_year, long_range_growth=knots if knots else None + ) + return series + + +def _value(series, year): + return series(f"{year}-01-01") + + +def _reference_path(last_value, last, short, knots, end_year=END_YEAR): + """Independent implementation: numpy interpolation and a cumulative + product.""" + years = np.arange(last + 1, end_year + 1) + knot_years = [last, *sorted(knots)] + knot_rates = [short, *(knots[year] for year in sorted(knots))] + growth = np.interp(years, knot_years, knot_rates) + return dict(zip(years.tolist(), last_value * np.cumprod(1 + growth))) + + +def _average_growth(series, start, end): + """Average annual growth from the end of ``start - 1`` to ``end``.""" + return (_value(series, end) / _value(series, start - 1)) ** ( + 1 / (end - start + 1) + ) - 1 + + +# 1-2. Knots and betweenness. + + +@given(glide_cases()) +def test_rate_in_a_knot_year_is_the_knot_rate(case): + last, short, knots = case + for year, rate in knots.items(): + assert long_range_growth_rate(year, last, short, knots) == rate + + +@given(glide_cases(), st.integers(1, 80)) +def test_rate_lies_between_the_surrounding_knots(case, offset): + last, short, knots = case + year = last + offset + points = [(last, short), *sorted(knots.items())] + rate = long_range_growth_rate(year, last, short, knots) + if year >= points[-1][0]: + assert rate == points[-1][1] + return + (_, before), (_, after) = next( + (left, right) + for left, right in zip(points, points[1:]) + if left[0] < year <= right[0] + ) + assert min(before, after) - 1e-15 <= rate <= max(before, after) + 1e-15 + + +@pytest.mark.parametrize("year", [2034, 2020]) +def test_rate_is_undefined_up_to_the_last_projected_year(year): + with pytest.raises(ValueError): + long_range_growth_rate(year, 2034, 0.05, {2050: 0.04}) + + +@pytest.mark.parametrize("knots", [{}, {2034: 0.04}, {2030: 0.04, 2050: 0.04}]) +def test_knots_must_follow_the_last_projected_year(knots): + with pytest.raises(ValueError): + long_range_growth_rate(2040, 2034, 0.05, knots) + + +# 3. Closed form. + + +def test_rate_on_the_first_segment_matches_the_closed_form(): + # 2034 at 5%, 2050 at 3%: each year moves 2 / 16 = 0.125 point. + for offset in range(1, 17): + rate = long_range_growth_rate(2034 + offset, 2034, 0.05, {2050: 0.03}) + assert rate == pytest.approx(0.05 - 0.00125 * offset, abs=1e-15) + + +@given(glide_cases(), st.floats(1, 1e6)) +@settings(max_examples=60, deadline=None) +def test_extension_matches_an_independent_implementation(case, level): + last, short, knots = case + series = _extend(last, short, knots, level) + reference = _reference_path(level * (1 + short), last, short, knots) + for year, expected in reference.items(): + assert _value(series, year) == pytest.approx(expected, rel=1e-12) + + +# 4. Constant knots and the old rule. + + +@given(st.integers(2020, 2060), rates) +@settings(max_examples=40, deadline=None) +def test_constant_knots_reproduce_compound_growth(last, short): + glided = _extend(last, short, {2050 if last < 2050 else last + 1: short}) + compounded = _extend(last, short, None) + for year in range(last + 1, END_YEAR + 1): + expected = 100 * (1 + short) ** (year - last + 1) + assert _value(glided, year) == pytest.approx(expected, rel=1e-12) + assert _value(compounded, year) == pytest.approx(expected, rel=1e-12) + + +# 5. Monotonicity. + + +@given(glide_cases(), st.data(), st.floats(1e-4, 0.05)) +@settings(max_examples=60, deadline=None) +def test_raising_a_knot_never_lowers_a_value(case, data, increase): + last, short, knots = case + raised_year = data.draw(st.sampled_from(sorted(knots))) + raised = {**knots, raised_year: knots[raised_year] + increase} + base = _extend(last, short, knots) + higher = _extend(last, short, raised) + for year in range(last + 1, END_YEAR + 1): + assert _value(higher, year) >= _value(base, year) * (1 - 1e-12) + if raised_year <= END_YEAR: + assert _value(higher, END_YEAR) > _value(base, END_YEAR) + + +# 6. Homogeneity. + + +@given(glide_cases(), st.floats(1e-3, 1e3)) +@settings(max_examples=40, deadline=None) +def test_scaling_the_level_scales_every_value(case, scale): + last, short, knots = case + base = _extend(last, short, knots) + scaled = _extend(last, short, knots, level=100 * scale) + for year in range(last - 1, END_YEAR + 30): + assert _value(scaled, year) == pytest.approx( + scale * _value(base, year), rel=1e-12 + ) + + +# 7. Projected years and the hold after the end year. + + +@given(glide_cases(), st.integers(1, 40)) +@settings(max_examples=40, deadline=None) +def test_projected_years_hold_and_later_years_freeze(case, later): + last, short, knots = case + end_year = min(last + later, END_YEAR) + series = _extend(last, short, knots, end_year=end_year) + assert _value(series, last - 1) == 100 + assert _value(series, last) == pytest.approx(100 * (1 + short), rel=1e-15) + for year in (end_year + 1, end_year + 25): + assert _value(series, year) == _value(series, end_year) + + +# 8. Trustees consistency. + +# Table V.D1 (file page 210): 2035 average percent change in per beneficiary +# costs, intermediate estimates. +TRUSTEES_2035_PER_BENEFICIARY_GROWTH = { + "total": 0.054, + "part_b": 0.067, + "part_d": 0.031, +} +# Table V.B5 (file page 200): average annual growth in incurred Medicare +# expenditures. +TRUSTEES_INCURRED_EXPENDITURE_GROWTH = { + (2036, 2050): 0.049, + (2051, 2075): 0.043, + (2076, 2100): 0.038, +} +# Table V.B3 (file page 196): beneficiaries with HI and/or SMI, thousands. +TRUSTEES_TOTAL_ENROLLMENT = {2035: 80_632, 2050: 85_413, 2075: 99_585, 2100: 103_133} +# Table II.D1 (file page 29): average annual percentage change in total +# incurred expenditures and in the number of beneficiaries, by Part. +TRUSTEES_COMPONENTS = { + "part_b": {(2036, 2050): (0.056, 0.004), (2051, 2100): (0.041, 0.004)}, + "part_d": {(2036, 2050): (0.039, 0.004), (2051, 2100): (0.043, 0.004)}, +} +# Every published rate is rounded to a tenth of a point. A period's implied +# per-enrollee growth carries two such roundings, and the glide's start rate +# and knots carry more; 0.1 point covers them for the long periods. +V_B5_TOLERANCE = 0.001 +# Table II.D1's per-beneficiary growth nets two rounded components. +II_D1_TOLERANCE = 0.0015 + + +def test_medicare_knots_compound_cost_growth_with_the_demographic_mix(): + assert MEDICARE_LONG_RANGE_DEMOGRAPHIC_MIX == -0.001 + assert MEDICARE_PER_ENROLLEE_LONG_RANGE_GROWTH == pytest.approx( + {2050: 1.038 * 0.999 - 1, 2100: 1.037 * 0.999 - 1}, abs=1e-15 + ) + assert with_medicare_demographic_mix({2050: 0.0}) == {2050: -0.001} + # The mix lowers growth by about a tenth of a point in every Part. + for knots in ( + MEDICARE_PER_ENROLLEE_LONG_RANGE_GROWTH, + MEDICARE_PART_B_PER_ENROLLEE_LONG_RANGE_GROWTH, + MEDICARE_PART_D_PER_ENROLLEE_LONG_RANGE_GROWTH, + ): + for rate in knots.values(): + assert 0.035 < rate < 0.041 + + +@pytest.mark.parametrize("period", TRUSTEES_INCURRED_EXPENDITURE_GROWTH) +def test_medicare_knots_reproduce_trustees_spending_per_enrollee(period): + start, end = period + enrollment_growth = ( + TRUSTEES_TOTAL_ENROLLMENT[end] / TRUSTEES_TOTAL_ENROLLMENT[start - 1] + ) ** (1 / (end - start + 1)) - 1 + implied = (1 + TRUSTEES_INCURRED_EXPENDITURE_GROWTH[period]) / ( + 1 + enrollment_growth + ) - 1 + series = _extend( + 2035, + TRUSTEES_2035_PER_BENEFICIARY_GROWTH["total"], + MEDICARE_PER_ENROLLEE_LONG_RANGE_GROWTH, + ) + assert _average_growth(series, start, end) == pytest.approx( + implied, abs=V_B5_TOLERANCE + ) + + +@pytest.mark.parametrize( + "part, knots", + [ + ("part_b", MEDICARE_PART_B_PER_ENROLLEE_LONG_RANGE_GROWTH), + ("part_d", MEDICARE_PART_D_PER_ENROLLEE_LONG_RANGE_GROWTH), + ], +) +def test_part_knots_reproduce_table_ii_d1(part, knots): + series = _extend(2035, TRUSTEES_2035_PER_BENEFICIARY_GROWTH[part], knots) + for (start, end), (total, beneficiaries) in TRUSTEES_COMPONENTS[part].items(): + implied = (1 + total) / (1 + beneficiaries) - 1 + assert _average_growth(series, start, end) == pytest.approx( + implied, abs=II_D1_TOLERANCE + ) + + +def test_repeating_the_2035_rate_misses_the_trustees_path(): + """The rule this replaces: 5.4 percent a year from 2036 runs a point and + more above every period's implied growth.""" + series = _extend(2035, TRUSTEES_2035_PER_BENEFICIARY_GROWTH["total"], None) + for (start, end), growth in TRUSTEES_INCURRED_EXPENDITURE_GROWTH.items(): + enrollment_growth = ( + TRUSTEES_TOTAL_ENROLLMENT[end] / TRUSTEES_TOTAL_ENROLLMENT[start - 1] + ) ** (1 / (end - start + 1)) - 1 + implied = (1 + growth) / (1 + enrollment_growth) - 1 + assert _average_growth(series, start, end) - implied > 0.009 + + +# Wiring of the CMS series in the tax-benefit system. + +PARAMETERS = system.parameters +WIRED_SERIES = [ + (PARAMETERS.calibration.gov.hhs.cms.moop_per_capita, 2035), + (PARAMETERS.calibration.gov.hhs.cms.esi_premium_per_enrollee, 2034), +] + + +@pytest.mark.parametrize("parameter, last", WIRED_SERIES) +def test_cms_series_glide_to_per_capita_health_spending(parameter, last): + short = _value(parameter, last) / _value(parameter, last - 1) - 1 + reference = _reference_path( + _value(parameter, last), + last, + short, + HEALTH_SPENDING_PER_CAPITA_LONG_RANGE_GROWTH, + ) + for year, expected in reference.items(): + assert _value(parameter, year) == pytest.approx(expected, rel=1e-12) + assert _value(parameter, 2150) == _value(parameter, END_YEAR) + # From 2050 the series grows at the Trustees' 4.3 to 4.1 percent. + for year in (2051, 2075, END_YEAR): + growth = _value(parameter, year) / _value(parameter, year - 1) - 1 + assert 0.041 - 1e-12 <= growth <= 0.043 + 1e-12 + assert _average_growth(parameter, 2051, END_YEAR) == pytest.approx(0.042, abs=1e-4) diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index e50fffb6cf2..286eea4355c 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -138,16 +138,23 @@ def test_cms_moop_per_capita_extends_to_2100(): assert parameter("2100-01-01") > parameter("2036-01-01") -def test_cms_esi_premium_per_enrollee_holds_last_projected_growth_rate(): +def test_cms_esi_premium_per_enrollee_glides_to_long_range_growth(): """Employer plan premium inputs keep growing after CMS's 2034 projection, - at the 2033-2034 growth rate.""" + gliding from the 2033-2034 growth rate to the Medicare Trustees' + long-range per capita health spending rates.""" parameter = PARAMETERS.calibration.gov.hhs.cms.esi_premium_per_enrollee - last_growth = parameter("2034-01-01") / parameter("2033-01-01") + last_growth = parameter("2034-01-01") / parameter("2033-01-01") - 1 + + def growth(year): + return parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") - 1 assert parameter("2034-01-01") == 13_124 - for year in [2035, 2036, 2050, 2100]: - growth = parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") - assert growth == pytest.approx(last_growth, rel=1e-12) + # 2035 is one sixteenth of the way from the 2034 rate to 4.3% in 2050. + assert growth(2035) == pytest.approx( + last_growth + (0.043 - last_growth) / 16, rel=1e-12 + ) + assert growth(2050) == pytest.approx(0.043, rel=1e-12) + assert growth(2100) == pytest.approx(0.041, rel=1e-12) assert parameter("2150-01-01") == parameter("2100-01-01") From 5ea0535333f3f6511c9ccab2634150344f11a85c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:19:51 -0400 Subject: [PATCH 4/9] Compare the demographic mix knot approximately Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_long_range_health_growth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_long_range_health_growth.py b/policyengine_us/tests/core/test_long_range_health_growth.py index 32beca7e648..c63c2e52562 100644 --- a/policyengine_us/tests/core/test_long_range_health_growth.py +++ b/policyengine_us/tests/core/test_long_range_health_growth.py @@ -267,7 +267,9 @@ def test_medicare_knots_compound_cost_growth_with_the_demographic_mix(): assert MEDICARE_PER_ENROLLEE_LONG_RANGE_GROWTH == pytest.approx( {2050: 1.038 * 0.999 - 1, 2100: 1.037 * 0.999 - 1}, abs=1e-15 ) - assert with_medicare_demographic_mix({2050: 0.0}) == {2050: -0.001} + assert with_medicare_demographic_mix({2050: 0.0}) == pytest.approx( + {2050: -0.001}, abs=1e-15 + ) # The mix lowers growth by about a tenth of a point in every Part. for knots in ( MEDICARE_PER_ENROLLEE_LONG_RANGE_GROWTH, From 7b680126034f62beb1ad53955662f7051a0a4575 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:44:05 +0000 Subject: [PATCH 5/9] Test the out-of-pocket glide from main's 2034 anchor Main's #10084 moved the out-of-pocket series' last projected year to 2034 and added two tests of the constant 2,512 / 2,412 extension, which fail once the series glides. Assert the glide instead, as this branch already does for ESI: 2035 grows by g + (0.043 - g) / 16, 2050 by 4.3 percent and 2100 by 4.1 percent, and every value matches an independent np.interp and np.cumprod closed form. The wiring test now anchors the series at 2034; at 2035 it passed only because the straight line from 2034 runs through 2035. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_cms_moop_uprating.py | 45 ++++++++++++++----- .../core/test_long_range_health_growth.py | 6 +-- .../parameters/test_uprating_extensions.py | 22 ++++++--- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/policyengine_us/tests/core/test_cms_moop_uprating.py b/policyengine_us/tests/core/test_cms_moop_uprating.py index 014139b9bba..c22b31850bf 100644 --- a/policyengine_us/tests/core/test_cms_moop_uprating.py +++ b/policyengine_us/tests/core/test_cms_moop_uprating.py @@ -5,8 +5,10 @@ release) report out-of-pocket spending per capita for 2018 to 2034: historical estimates through 2024 and projections from 2025. The parameter holds that column. 2015 to 2017 come from CMS's historical accounts by the rule that -reproduces Table 3's 2018-2024 values, and 2035 to 2100 grow at the 2033-2034 -rate. +reproduces Table 3's 2018-2024 values. From 2035, growth glides from the +2033-2034 rate to the 2026 Medicare Trustees' long-range per capita health +spending rates: 4.3 percent in 2050 and 4.1 percent in 2100 (Trustees Report, +file page 173). These tests read only the parameter tree and the parameter's YAML file. @@ -27,14 +29,18 @@ 4. Bounds and order: the series is positive in every year. It is strictly increasing over 2015-2019 and 2020-2100; its one decline, 2019 to 2020 (1,269 to 1,239), is CMS's and is intended. -5. Extension rule: each year from 2035 to 2100 grows by 2,512 / 2,412, so the - value equals the closed form 2,512 * (2,512 / 2,412) ** (year - 2034); later - years hold the 2100 value. +5. Extension rule: with g = 2,512 / 2,412 - 1, the 2034-2035 growth rate, + growth moves in a straight line from g at 2034 to 4.3 percent in 2050, then + to 4.1 percent in 2100, so 2035 grows by g + (0.043 - g) / 16. Each value + is 2,512 times the product of one plus each year's rate since 2034, checked + against an independent np.interp and np.cumprod. Later years hold the 2100 + value. """ import math from pathlib import Path +import numpy as np import pytest import yaml from hypothesis import given @@ -142,7 +148,10 @@ ), ) ) -EXTENSION_GROWTH = 2_512 / 2_412 +# 2033-2034 growth, from which the extension glides to the Trustees' +# long-range per capita health spending rates (file page 173). +LAST_PROJECTED_GROWTH = 2_512 / 2_412 - 1 +TRUSTEES_KNOTS = {2050: 0.043, END_YEAR: 0.041} def _index(year): @@ -242,11 +251,27 @@ def test_the_only_decline_is_cms_2019_to_2020(): assert (_index(2019), _index(2020)) == (1_269, 1_239) +def _growth(year): + return _index(year) / _index(year - 1) - 1 + + +def test_extension_glides_from_the_last_projected_rate_to_the_trustees_rates(): + g = LAST_PROJECTED_GROWTH + # 2035 is one sixteenth of the way from the 2034 rate to 4.3% in 2050. + assert _growth(2035) == pytest.approx(g + (0.043 - g) / 16, rel=1e-12) + assert _growth(2050) == pytest.approx(0.043, rel=1e-12) + assert _growth(END_YEAR) == pytest.approx(0.041, rel=1e-12) + + @given(st.integers(LAST_PROJECTED_YEAR + 1, END_YEAR)) -def test_extension_holds_the_last_projected_growth_rate(year): - growth = _index(year) / _index(year - 1) - assert growth == pytest.approx(EXTENSION_GROWTH, rel=1e-12) - closed_form = 2_512 * EXTENSION_GROWTH ** (year - LAST_PROJECTED_YEAR) +def test_extension_matches_an_independent_closed_form(year): + years = np.arange(LAST_PROJECTED_YEAR + 1, year + 1) + rates = np.interp( + years, + [LAST_PROJECTED_YEAR, *TRUSTEES_KNOTS], + [LAST_PROJECTED_GROWTH, *TRUSTEES_KNOTS.values()], + ) + closed_form = 2_512 * np.prod(1 + rates) assert _index(year) == pytest.approx(closed_form, rel=1e-12) diff --git a/policyengine_us/tests/core/test_long_range_health_growth.py b/policyengine_us/tests/core/test_long_range_health_growth.py index c63c2e52562..8efad20c945 100644 --- a/policyengine_us/tests/core/test_long_range_health_growth.py +++ b/policyengine_us/tests/core/test_long_range_health_growth.py @@ -4,8 +4,8 @@ ``long_range_growth`` knots moves each year's growth linearly from the measured short-range rate, placed at the last projected year, to the 2026 Medicare Trustees' 2050 and 2100 rates. The Trustees interpolate years 11 -through 25 of their projection the same way (2026 Medicare Trustees Report, -file pages 24 and 180). +through 25 of their projection between the short-range and long-range rates +(2026 Medicare Trustees Report, file pages 24 and 180); this path is linear. Invariants: @@ -331,7 +331,7 @@ def test_repeating_the_2035_rate_misses_the_trustees_path(): PARAMETERS = system.parameters WIRED_SERIES = [ - (PARAMETERS.calibration.gov.hhs.cms.moop_per_capita, 2035), + (PARAMETERS.calibration.gov.hhs.cms.moop_per_capita, 2034), (PARAMETERS.calibration.gov.hhs.cms.esi_premium_per_enrollee, 2034), ] diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index a961a3e5ee6..b932d7c914f 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -140,17 +140,25 @@ def test_social_security_benefit_inputs_use_gross_benefit_uprater(): assert system.variables[variable_name].uprating is None -def test_cms_moop_per_capita_extends_to_2100(): +def test_cms_moop_per_capita_glides_to_long_range_growth(): """Health expense input upraters keep growing after CMS's 2034 - projection, at the 2033-2034 growth rate, then hold the 2100 value.""" + projection, gliding from the 2033-2034 growth rate to the Medicare + Trustees' long-range per capita health spending rates, then hold the 2100 + value.""" parameter = PARAMETERS.calibration.gov.hhs.cms.moop_per_capita - last_growth = parameter("2034-01-01") / parameter("2033-01-01") + last_growth = parameter("2034-01-01") / parameter("2033-01-01") - 1 + + def growth(year): + return parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") - 1 assert parameter("2034-01-01") == 2_512 - assert last_growth == pytest.approx(2_512 / 2_412, rel=1e-12) - for year in [2035, 2036, 2050, 2100]: - growth = parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") - assert growth == pytest.approx(last_growth, rel=1e-12) + assert last_growth == pytest.approx(2_512 / 2_412 - 1, rel=1e-12) + # 2035 is one sixteenth of the way from the 2034 rate to 4.3% in 2050. + assert growth(2035) == pytest.approx( + last_growth + (0.043 - last_growth) / 16, rel=1e-12 + ) + assert growth(2050) == pytest.approx(0.043, rel=1e-12) + assert growth(2100) == pytest.approx(0.041, rel=1e-12) assert parameter("2150-01-01") == parameter("2100-01-01") From d79d0cb1cd21137fca32ffcf4824da89ad35c5a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:44:05 +0000 Subject: [PATCH 6/9] Describe the glide wherever the constant extension was described The out-of-pocket YAML comment, the premium-input invariant and the SPM methodology page still said these series grow at their last published rate. The long-range constants now also cite Table V.B5's note that the Trustees extend CMS's health expenditure projections by the same methods, and say their 2050-2100 path is not a straight line (file page 175), and the helper's docstring no longer implies the Trustees say "linearly". Co-Authored-By: Claude Opus 5.5 --- docs-quarto/methodology/spm-poverty.qmd | 2 +- .../calibration/gov/hhs/cms/moop_per_capita.yaml | 7 ++++--- .../parameters/uprating_extensions.py | 16 ++++++++++------ .../tests/core/test_premium_input_uprating.py | 7 +++++-- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index f0ea3e3acb4..2f23b91a039 100644 --- a/docs-quarto/methodology/spm-poverty.qmd +++ b/docs-quarto/methodology/spm-poverty.qmd @@ -50,7 +50,7 @@ The [MOOP decomposition page](moop-decomposition.qmd) records the design and the Population results use the Microcosm US dataset. The current default is Build P: 57,240 Current Population Survey Annual Social and Economic Supplement households with 166,321 people, whose weights are fitted to 5,659 administrative and demographic targets (IRS Statistics of Income by state and by income, Census population estimates by age, sex and state, CMS Medicaid and Marketplace enrollment, USDA SNAP participation and benefits, SSA SSI, TANF, state tax collections and BEA national accounts). Poverty rates are never a target. That is a rule of the calibration, not an accident: a model whose weights were fitted to survey-based poverty rates could not be checked against them. -2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses and over-the-counter health expenses that enter SPM resources; and physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses. CMS counts premiums outside out-of-pocket spending, so premium inputs follow premium series instead, each a per-person series that grows at its last published one-year rate to 2100 and holds the 2100 value after that. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums, pre-tax health insurance premiums and the premiums people report paying (health insurance premiums excluding Medicare Part B, and the other health insurance premiums that enter SPM resources) follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), which CMS projects to 2034. These are the employer's share of employer plans, the employee's share paid through payroll, and the premiums survey respondents report, most of which people with employer coverage pay; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August of the preceding year (26 U.S.C. 213(d)(10)(B)), which the CBO and CMS projections do not provide. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. The series is a growth proxy, not a forecast of each person's premium: hold-harmless limits and Medicare Advantage premium reductions move some premiums differently. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography, and federal student loan balances, which `default_uprating.py` exempts from the default) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. +2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses and over-the-counter health expenses that enter SPM resources; and physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses. After CMS's last projected year, the out-of-pocket series glides from its last projected growth rate to the Medicare Trustees' long-range growth in per capita health spending, 4.3 percent in 2050 and 4.1 percent in 2100, and holds the 2100 value after that. CMS counts premiums outside out-of-pocket spending, so premium inputs follow premium series instead, each a per-person series that holds its 2100 value after 2100. The employer-sponsored insurance premium series glides to the same Trustees rates after its last projected year; the other premium series grow at their last published one-year rates to 2100. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums, pre-tax health insurance premiums and the premiums people report paying (health insurance premiums excluding Medicare Part B, and the other health insurance premiums that enter SPM resources) follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), which CMS projects to 2034. These are the employer's share of employer plans, the employee's share paid through payroll, and the premiums survey respondents report, most of which people with employer coverage pay; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August of the preceding year (26 U.S.C. 213(d)(10)(B)), which the CBO and CMS projections do not provide. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. The series is a growth proxy, not a forecast of each person's premium: hold-harmless limits and Medicare Advantage premium reductions move some premiums differently. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography, and federal student loan balances, which `default_uprating.py` exempts from the default) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. ## What the model does not do diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml index 0b9aaac29c2..887dac50c9d 100644 --- a/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml @@ -46,6 +46,7 @@ values: 2032-01-01: 2_314 2033-01-01: 2_412 2034-01-01: 2_512 - # 2035 to 2100 grow at the 2033-2034 rate (2_512 / 2_412, 4.1% 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 (2_512 / 2_412, 4.1% 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. diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index f7186f59e44..179d147b74d 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -30,7 +30,10 @@ # 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. +# path between those two knots is linear here. That is an approximation: the +# Trustees' own path is not a straight line, as their income-technology +# elasticity declines at a slowing pace and their price elasticity follows a +# non-linear path between 2050 and 2100 (file page 175). # # 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) @@ -40,7 +43,9 @@ # 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). +# others (file page 177). The Trustees extend CMS's national health +# expenditure projections past 2033 by the same long-range methods (Table +# V.B5, note 1, file page 200). 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 @@ -782,10 +787,9 @@ def long_range_growth_rate( 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. + (year, rate) in long_range_growth. The Medicare Trustees interpolate + between their short-range and long-range rates; this path is linear + between adjacent knots, 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: diff --git a/policyengine_us/tests/core/test_premium_input_uprating.py b/policyengine_us/tests/core/test_premium_input_uprating.py index 6431e6684fd..58d425a9f4d 100644 --- a/policyengine_us/tests/core/test_premium_input_uprating.py +++ b/policyengine_us/tests/core/test_premium_input_uprating.py @@ -55,8 +55,11 @@ 5. Bounds and order: each index is positive in every year, and strictly increasing from its last published dip (direct purchase from 2020, Part B from 2023, medical care CPI throughout) to 2100. -6. Extension rule: past its source's last year, each index grows at its last - published one-year rate to 2100 and holds its 2100 value after. +6. Extension rule: past its source's last year, the direct purchase, medical + care CPI and Part B indexes grow at their last published one-year rates to + 2100. The ESI index instead glides to the Medicare Trustees' long-range per + capita health spending rates (tested in test_long_range_health_growth.py + and test_esi_premium_uprating.py). Every index holds its 2100 value after. 7. No premium input follows out-of-pocket spending. """ From 8f619d390d416c40ce270776c551de3bf973d1e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:00:21 +0000 Subject: [PATCH 7/9] Call 2,512 / 2,412 the 2033-2034 rate in the glide invariant 2,412 is the 2033 value and 2,512 the 2034 value, and the closed-form test multiplies with np.prod, not np.cumprod. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_cms_moop_uprating.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/core/test_cms_moop_uprating.py b/policyengine_us/tests/core/test_cms_moop_uprating.py index c22b31850bf..e1a211a71d2 100644 --- a/policyengine_us/tests/core/test_cms_moop_uprating.py +++ b/policyengine_us/tests/core/test_cms_moop_uprating.py @@ -29,11 +29,11 @@ 4. Bounds and order: the series is positive in every year. It is strictly increasing over 2015-2019 and 2020-2100; its one decline, 2019 to 2020 (1,269 to 1,239), is CMS's and is intended. -5. Extension rule: with g = 2,512 / 2,412 - 1, the 2034-2035 growth rate, +5. Extension rule: with g = 2,512 / 2,412 - 1, the 2033-2034 growth rate, growth moves in a straight line from g at 2034 to 4.3 percent in 2050, then to 4.1 percent in 2100, so 2035 grows by g + (0.043 - g) / 16. Each value is 2,512 times the product of one plus each year's rate since 2034, checked - against an independent np.interp and np.cumprod. Later years hold the 2100 + against an independent np.interp and np.prod. Later years hold the 2100 value. """ From 6e0594f89ca05f1c73feafadbd189ddbdea474d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:30:48 +0000 Subject: [PATCH 8/9] Glide the direct purchase premium uprater with the ESI premium The review found the two private premium series disagreeing after this PR: ESI premium inputs glided to the Trustees' per capita health spending rates while directly bought premiums kept compounding CMS's 2033-2034 rate (4.71 percent). The Trustees publish no long-range rate for either kind of private premium, so the reason this PR gives for ESI applies to direct purchase unchanged. Its PR (#10085) has merged, so the reason for deferring it is gone. The 2035 value, hand computed, is 9,589 * (1 + g + (0.043 - g) / 16) = 10,037.85 with g = 9,589 / 9,158 - 1; 2050 falls 3.2 percent and 2100 24.1 percent against the constant rule. The Medicare Part B premium and Medicare per capita cost stay on the constant rule: each needs its own anchor choice, left to a follow-up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KtJoHdqUJtpTYx4Z5 --- changelog.d/cms-long-range-glide.changed.md | 2 +- docs-quarto/methodology/spm-poverty.qmd | 2 +- .../direct_purchase_premium_per_enrollee.yaml | 7 +++--- .../parameters/uprating_extensions.py | 5 +++- .../core/test_long_range_health_growth.py | 1 + .../tests/core/test_premium_input_uprating.py | 17 ++++++++----- .../parameters/test_uprating_extensions.py | 24 ++++++++++++++----- 7 files changed, 40 insertions(+), 18 deletions(-) diff --git a/changelog.d/cms-long-range-glide.changed.md b/changelog.d/cms-long-range-glide.changed.md index f33182da0dc..1c3e5bb823e 100644 --- a/changelog.d/cms-long-range-glide.changed.md +++ b/changelog.d/cms-long-range-glide.changed.md @@ -1 +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. +Glide the CMS out-of-pocket spending, employer premium and direct purchase 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. diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index 2f23b91a039..e94c8b1e91a 100644 --- a/docs-quarto/methodology/spm-poverty.qmd +++ b/docs-quarto/methodology/spm-poverty.qmd @@ -50,7 +50,7 @@ The [MOOP decomposition page](moop-decomposition.qmd) records the design and the Population results use the Microcosm US dataset. The current default is Build P: 57,240 Current Population Survey Annual Social and Economic Supplement households with 166,321 people, whose weights are fitted to 5,659 administrative and demographic targets (IRS Statistics of Income by state and by income, Census population estimates by age, sex and state, CMS Medicaid and Marketplace enrollment, USDA SNAP participation and benefits, SSA SSI, TANF, state tax collections and BEA national accounts). Poverty rates are never a target. That is a rule of the calibration, not an accident: a model whose weights were fitted to survey-based poverty rates could not be checked against them. -2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses and over-the-counter health expenses that enter SPM resources; and physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses. After CMS's last projected year, the out-of-pocket series glides from its last projected growth rate to the Medicare Trustees' long-range growth in per capita health spending, 4.3 percent in 2050 and 4.1 percent in 2100, and holds the 2100 value after that. CMS counts premiums outside out-of-pocket spending, so premium inputs follow premium series instead, each a per-person series that holds its 2100 value after 2100. The employer-sponsored insurance premium series glides to the same Trustees rates after its last projected year; the other premium series grow at their last published one-year rates to 2100. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums, pre-tax health insurance premiums and the premiums people report paying (health insurance premiums excluding Medicare Part B, and the other health insurance premiums that enter SPM resources) follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), which CMS projects to 2034. These are the employer's share of employer plans, the employee's share paid through payroll, and the premiums survey respondents report, most of which people with employer coverage pay; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August of the preceding year (26 U.S.C. 213(d)(10)(B)), which the CBO and CMS projections do not provide. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. The series is a growth proxy, not a forecast of each person's premium: hold-harmless limits and Medicare Advantage premium reductions move some premiums differently. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography, and federal student loan balances, which `default_uprating.py` exempts from the default) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. +2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses and over-the-counter health expenses that enter SPM resources; and physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses. After CMS's last projected year, the out-of-pocket series glides from its last projected growth rate to the Medicare Trustees' long-range growth in per capita health spending, 4.3 percent in 2050 and 4.1 percent in 2100, and holds the 2100 value after that. CMS counts premiums outside out-of-pocket spending, so premium inputs follow premium series instead, each a per-person series that holds its 2100 value after 2100. The employer-sponsored insurance and direct purchase premium series glide to the same Trustees rates after their last projected year; the other premium series grow at their last published one-year rates to 2100. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums, pre-tax health insurance premiums and the premiums people report paying (health insurance premiums excluding Medicare Part B, and the other health insurance premiums that enter SPM resources) follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), which CMS projects to 2034. These are the employer's share of employer plans, the employee's share paid through payroll, and the premiums survey respondents report, most of which people with employer coverage pay; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August of the preceding year (26 U.S.C. 213(d)(10)(B)), which the CBO and CMS projections do not provide. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. The series is a growth proxy, not a forecast of each person's premium: hold-harmless limits and Medicare Advantage premium reductions move some premiums differently. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography, and federal student loan balances, which `default_uprating.py` exempts from the default) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. ## What the model does not do diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml index 7e6b0bf1902..599892097df 100644 --- a/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml @@ -45,6 +45,7 @@ values: 2032-01-01: 8_743 2033-01-01: 9_158 2034-01-01: 9_589 - # 2035 to 2100 grow at the 2033-2034 rate (9_589 / 9_158, 4.7% 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 (9_589 / 9_158, 4.7% 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. diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index b591d32b89e..827b9a65359 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -1236,13 +1236,16 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: # CMS direct purchase insurance premium per enrollee (January values, last # projection year 2034). Premiums for plans bought directly use this as - # their uprater. + # their uprater. Like the ESI premium, growth glides from CMS's 2033-2034 + # rate to the Medicare Trustees' long-range per capita health spending + # rates, and later years hold the 2100 value. extend_parameter_values( parameters.calibration.gov.hhs.cms.direct_purchase_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, ) # Medicare Part B standard monthly premium (January values, last Trustees diff --git a/policyengine_us/tests/core/test_long_range_health_growth.py b/policyengine_us/tests/core/test_long_range_health_growth.py index 8efad20c945..2be3c546b1b 100644 --- a/policyengine_us/tests/core/test_long_range_health_growth.py +++ b/policyengine_us/tests/core/test_long_range_health_growth.py @@ -333,6 +333,7 @@ def test_repeating_the_2035_rate_misses_the_trustees_path(): WIRED_SERIES = [ (PARAMETERS.calibration.gov.hhs.cms.moop_per_capita, 2034), (PARAMETERS.calibration.gov.hhs.cms.esi_premium_per_enrollee, 2034), + (PARAMETERS.calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee, 2034), ] diff --git a/policyengine_us/tests/core/test_premium_input_uprating.py b/policyengine_us/tests/core/test_premium_input_uprating.py index 58d425a9f4d..58ab42f1de5 100644 --- a/policyengine_us/tests/core/test_premium_input_uprating.py +++ b/policyengine_us/tests/core/test_premium_input_uprating.py @@ -55,11 +55,12 @@ 5. Bounds and order: each index is positive in every year, and strictly increasing from its last published dip (direct purchase from 2020, Part B from 2023, medical care CPI throughout) to 2100. -6. Extension rule: past its source's last year, the direct purchase, medical - care CPI and Part B indexes grow at their last published one-year rates to - 2100. The ESI index instead glides to the Medicare Trustees' long-range per - capita health spending rates (tested in test_long_range_health_growth.py - and test_esi_premium_uprating.py). Every index holds its 2100 value after. +6. Extension rule: past its source's last year, the medical care CPI and + Part B indexes grow at their last published one-year rates to 2100. The + ESI and direct purchase indexes instead glide to the Medicare Trustees' + long-range per capita health spending rates (tested in + test_long_range_health_growth.py, test_esi_premium_uprating.py and + test_uprating_extensions.py). Every index holds its 2100 value after. 7. No premium input follows out-of-pocket spending. """ @@ -397,7 +398,11 @@ def test_each_index_strictly_increases_after_its_last_dip(path, offset, gap): assert _index(path, year) < _index(path, later) -@given(st.sampled_from(sorted(SPANS)), st.integers(0, END_YEAR)) +# Indexes that repeat their last published growth rate after their last year. +CONSTANT_EXTENSIONS = sorted({MEDICAL_CPI, PART_B}) + + +@given(st.sampled_from(CONSTANT_EXTENSIONS), st.integers(0, END_YEAR)) def test_each_extension_holds_the_last_published_growth_rate(path, offset): _, last, _ = SPANS[path] year = last + 1 + offset % (END_YEAR - last) diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index b932d7c914f..aab6438e4ca 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -182,16 +182,28 @@ def growth(year): assert parameter("2150-01-01") == parameter("2100-01-01") -def test_cms_direct_purchase_premium_per_enrollee_holds_last_projected_growth_rate(): +def test_cms_direct_purchase_premium_per_enrollee_glides_to_long_range_growth(): """Directly bought premium inputs keep growing after CMS's 2034 - projection, at the 2033-2034 growth rate.""" + projection, gliding from the 2033-2034 growth rate to the Medicare + Trustees' long-range per capita health spending rates, as the ESI + premium does.""" parameter = PARAMETERS.calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee - last_growth = parameter("2034-01-01") / parameter("2033-01-01") + last_growth = parameter("2034-01-01") / parameter("2033-01-01") - 1 + + def growth(year): + return parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") - 1 assert parameter("2034-01-01") == 9_589 - for year in [2035, 2036, 2050, 2100]: - growth = parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") - assert growth == pytest.approx(last_growth, rel=1e-12) + assert last_growth == pytest.approx(9_589 / 9_158 - 1, rel=1e-12) + # 2035 is one sixteenth of the way from the 2034 rate to 4.3% in 2050: + # 0.0470627 + (0.043 - 0.0470627) / 16 = 0.0468088, so 2035 is + # 9,589 * 1.0468088 = 10,037.85 (hand computed). + assert growth(2035) == pytest.approx( + last_growth + (0.043 - last_growth) / 16, rel=1e-12 + ) + assert parameter("2035-01-01") == pytest.approx(10_037.85, abs=0.01) + assert growth(2050) == pytest.approx(0.043, rel=1e-12) + assert growth(2100) == pytest.approx(0.041, rel=1e-12) assert parameter("2150-01-01") == parameter("2100-01-01") From 98575457848f1bdf868c70cd02c57bd264d49d10 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 08:28:26 -0500 Subject: [PATCH 9/9] Describe the short-range rate and the glide in extend_parameter_values Co-Authored-By: Claude Opus 5.5 --- policyengine_us/parameters/uprating_extensions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 827b9a65359..bc2d910824c 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -819,8 +819,11 @@ def extend_parameter_values( long_range_growth: Optional[Dict[int, float]] = None, ) -> None: """ - Extend a parameter's values from last_projected_year to end_year using - the growth rate from the last two years of projections. + Extend a parameter's values from last_projected_year to end_year. The + short-range rate is the growth between growth_years (by default the last + two projected years). It repeats in every year or, given + long_range_growth, glides through the knots. Years after end_year hold + its value. Args: parameter: The parameter to extend @@ -838,7 +841,7 @@ def extend_parameter_values( 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 + # Measure the short-range growth rate between growth_years date_format = f"-{period_month:02d}-{period_day:02d}" if growth_years is None: growth_years = (last_projected_year - 1, last_projected_year)