From b076456914b903aac108912d8951bbf84d021cdd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 06:48:12 -0400 Subject: [PATCH 01/12] Uprate ESI premium inputs by CMS ESI premium per enrollee employer_sponsored_insurance_premiums uprated by CBO employment income (per capita after the per-capita conversion), and pre_tax_health_insurance_premiums by CMS out-of-pocket spending per capita, which by CMS's own definition excludes premiums. Both are shares of employer plan premiums, so both now follow CMS NHE Table 17's employer-sponsored insurance spending per enrollee (2013-2034, 2025-2034 projections release), extended past 2034 at the 2033-2034 growth rate. The per-capita classifier now treats per_enrollee paths as per-person rates, like per_capita ones, so they are neither divided by population nor rejected as unclassified. Co-Authored-By: Claude Opus 5.5 --- .../esi-premium-nhe-uprating.changed.md | 1 + docs-quarto/methodology/spm-poverty.qmd | 2 +- .../gov/hhs/cms/esi_premium_per_enrollee.yaml | 46 ++++ .../parameters/uprating_extensions.py | 11 + .../tests/core/test_esi_premium_uprating.py | 235 ++++++++++++++++++ .../tests/core/test_per_capita_uprating.py | 12 + .../tests/core/test_uprating_series.py | 8 +- .../parameters/test_uprating_extensions.py | 13 + policyengine_us/tools/per_capita_uprating.py | 20 +- .../pre_tax_health_insurance_premiums.py | 6 +- .../employer_sponsored_insurance_premiums.py | 7 +- 11 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 changelog.d/esi-premium-nhe-uprating.changed.md create mode 100644 policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml create mode 100644 policyengine_us/tests/core/test_esi_premium_uprating.py diff --git a/changelog.d/esi-premium-nhe-uprating.changed.md b/changelog.d/esi-premium-nhe-uprating.changed.md new file mode 100644 index 00000000000..2a3aa60b0dc --- /dev/null +++ b/changelog.d/esi-premium-nhe-uprating.changed.md @@ -0,0 +1 @@ +Uprate employer-sponsored insurance premiums and pre-tax health insurance premiums by CMS's employer-sponsored insurance premium per enrollee (NHE projections Table 17, 2025-2034 release, extended at its 2033-2034 growth rate), replacing CBO employment income per capita and CMS out-of-pocket spending per capita. diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index 40d24f8a664..1b96ea0a01f 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, over-the-counter health expenses and other health insurance premiums that enter SPM resources; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; health insurance premiums in total, pre-tax, long-term and excluding Medicare Part B, and reported Medicare Part B premiums; and health savings account payroll contributions. 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. Employer-sponsored insurance premiums follow CBO's projection of employment income. 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) 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, over-the-counter health expenses and other health insurance premiums that enter SPM resources; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; health insurance premiums in total, long-term and excluding Medicare Part B, and reported Medicare Part B premiums; and health savings account payroll contributions. 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. Employer-sponsored insurance premiums and pre-tax health insurance premiums, the employer's and the employee's shares of employer plans, 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), also a per-person series, which CMS projects to 2034; later years hold its 2033 to 2034 growth rate. 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) 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/esi_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml new file mode 100644 index 00000000000..814fb0815e7 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml @@ -0,0 +1,46 @@ +description: The Centers for Medicare and Medicaid Services (CMS) reports and projects this employer-sponsored insurance spending per enrollee, which is the premium per covered person, employer and employee shares together. +metadata: + unit: currency-USD + label: CMS employer-sponsored insurance premium per enrollee + reference: + # Data underlying figures: + # https://www.cms.gov/files/zip/nhe-projections-tables.zip + # Last-Modified 2026-09-03; sha256 + # 264f822618a5e1829138aea57cc6c8bf45bbaf072a72898fef5547f7ece6cd70. + # Table17 Health Insurance Enrollment and Enrollment Growth Rates, + # "Spending per Enrollee ($)", "Employer-sponsored Insurance" row: ESI + # spending divided by ESI enrollment. Retrieved 2026-10-11. + - title: CMS National Health Expenditure Projections 2025-2034, Table 17 + href: https://www.cms.gov/data-research/statistics-trends-and-reports/national-health-expenditure-data/projected + # Page 28: ESI "includes premiums paid by employers and/or employees for + # health insurance plans offered by the employer", for active employees, + # COBRA enrollees and retirees. + - title: "CMS National Health Expenditure Accounts: Methodology Paper, 2024, p. 28" + href: https://www.cms.gov/files/document/definitions-sources-and-methods.pdf +values: + # Historical. + 2013-01-01: 4_901 + 2014-01-01: 4_980 + 2015-01-01: 5_113 + 2016-01-01: 5_354 + 2017-01-01: 5_500 + 2018-01-01: 5_783 + 2019-01-01: 5_955 + 2020-01-01: 6_049 + 2021-01-01: 6_456 + 2022-01-01: 6_877 + 2023-01-01: 7_595 + 2024-01-01: 8_000 + # Projections. + 2025-01-01: 8_616 + 2026-01-01: 9_181 + 2027-01-01: 9_627 + 2028-01-01: 10_093 + 2029-01-01: 10_533 + 2030-01-01: 11_001 + 2031-01-01: 11_469 + 2032-01-01: 11_986 + 2033-01-01: 12_559 + 2034-01-01: 13_124 + # Later years hold the 2033-2034 growth rate (13_124 / 12_559, 4.5% a + # year); 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 037fa7898d2..23ec45e9904 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -849,6 +849,17 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: period_day=1, ) + # 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. + 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, + ) + # Gross Social Security benefit inputs should age with gross Social # Security benefits, not taxable Social Security income. extend_parameter_values( diff --git a/policyengine_us/tests/core/test_esi_premium_uprating.py b/policyengine_us/tests/core/test_esi_premium_uprating.py new file mode 100644 index 00000000000..63602eb5d4b --- /dev/null +++ b/policyengine_us/tests/core/test_esi_premium_uprating.py @@ -0,0 +1,235 @@ +"""Employer plan premium inputs age with CMS's employer-sponsored insurance +(ESI) premium per enrollee, not with wages or out-of-pocket spending. + +CMS's National Health Expenditure projections (Table 17, 2025-2034 release) +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, p. 28), so spending per enrollee is the premium per covered +person. The parameter holds that row; later years hold its 2033-2034 growth +rate. + +Invariants: + +1. Transcription: every value equals Table 17's ESI spending over ESI + enrollment, within the table's rounding. +2. Employer share: CMS projects employer contributions (Table 16) as a flat + share of ESI spending, so employer contributions per ESI enrollee grow with + the index. That is why one index serves the employer and employee shares. +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 after 2034 grows by 13,124 / 12,559. +5. Uprating: a premium entered for one year equals that amount times the + index ratio in any later year, for both premium inputs, in a household + simulation and in dataset extension, so uprating is linear in the amount. +6. Weighted totals: in an extended dataset the weighted total grows by the + index ratio times population growth, because weights follow population. +""" + +from functools import lru_cache + +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us import Simulation +from policyengine_us.data.dataset_schema import USSingleYearDataset +from policyengine_us.data.economic_assumptions import extend_single_year_dataset +from policyengine_us.system import system +from policyengine_us.tools.per_capita_uprating import POPULATION_PATH + +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +PREMIUM_INPUTS = ( + "employer_sponsored_insurance_premiums", + "pre_tax_health_insurance_premiums", +) +BASE_YEAR = 2024 +FIRST_YEAR, LAST_PROJECTED_YEAR, END_YEAR = 2013, 2034, 2100 + +# CMS NHE projections tables, Table 17, "Employer-sponsored Insurance" rows +# (https://www.cms.gov/files/zip/nhe-projections-tables.zip, Last-Modified +# 2026-09-03, sha256 264f8226...cd70). +ESI_SPENDING_BILLIONS = dict( + zip( + range(2013, 2035), + ( + *(829.1, 845.4, 880.5, 926.8, 965.7, 1013.1, 1050.3, 1052.7), + *(1120.9, 1203.9, 1333.5, 1429.1, 1549.8, 1650.5, 1732.9), + *(1820.4, 1896.9, 1978.0, 2058.6, 2148.4, 2246.7, 2343.1), + ), + ) +) +ESI_ENROLLMENT_MILLIONS = dict( + zip( + range(2013, 2035), + ( + *(169.2, 169.8, 172.2, 173.1, 175.6, 175.2, 176.4, 174.0), + *(173.6, 175.1, 175.6, 178.6, 179.9, 179.8, 180.0, 180.4), + *(180.1, 179.8, 179.5, 179.2, 178.9, 178.5), + ), + ) +) +# Table 16, employer contributions to private health insurance premiums by +# private business, the federal government and state and local governments. +EMPLOYER_CONTRIBUTIONS_BILLIONS = { + 2024: 753 + 47 + 247, + 2026: 865 + 56 + 288, + 2034: 1_207 + 82 + 429, +} + + +def _index(year): + return get_parameter(system.parameters, ESI)(f"{year}-01-01") + + +def _ratio(year, base_year=BASE_YEAR): + return _index(year) / _index(base_year) + + +@pytest.mark.parametrize("year", sorted(ESI_SPENDING_BILLIONS)) +def test_index_is_table_17_spending_over_enrollment(year): + spending = ESI_SPENDING_BILLIONS[year] + enrollment = ESI_ENROLLMENT_MILLIONS[year] + # Both components are rounded to one decimal and the published value to + # the dollar. + low = 1_000 * (spending - 0.05) / (enrollment + 0.05) - 0.5 + high = 1_000 * (spending + 0.05) / (enrollment - 0.05) + 0.5 + assert low <= _index(year) <= high + + +@pytest.mark.parametrize("start, end", [(2024, 2034), (2026, 2034)]) +def test_employer_contributions_per_enrollee_grow_with_the_index(start, end): + def per_enrollee(year): + return EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_ENROLLMENT_MILLIONS[year] + + years = end - start + employer_growth = (per_enrollee(end) / per_enrollee(start)) ** (1 / years) + index_growth = _ratio(end, start) ** (1 / years) + assert employer_growth == pytest.approx(index_growth, abs=0.001) + # The employer share of ESI premiums stays within 73.2% to 73.4%. + for year in (start, end): + share = EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_SPENDING_BILLIONS[year] + assert 0.732 <= share <= 0.734 + + +@given( + st.integers(FIRST_YEAR, END_YEAR - 1), + st.integers(1, END_YEAR - FIRST_YEAR), +) +def test_index_is_positive_and_strictly_increasing(year, gap): + later = min(year + gap, END_YEAR) + assert 0 < _index(year) < _index(later) + + +@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) + + +@pytest.mark.parametrize("variable", PREMIUM_INPUTS) +def test_premium_inputs_uprate_by_the_esi_index(variable): + assert system.variables[variable].uprating == ESI + + +def _simulation(amounts): + people = { + f"person_{i}": { + "age": {BASE_YEAR: 40}, + PREMIUM_INPUTS[0]: {BASE_YEAR: employer}, + PREMIUM_INPUTS[1]: {BASE_YEAR: employee}, + } + for i, (employer, employee) in enumerate(amounts) + } + return Simulation( + situation={ + "people": people, + "households": {"household": {"members": list(people)}}, + } + ) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + st.lists( + st.tuples( + st.integers(0, 5_000_000).map(lambda cents: cents / 100), + st.integers(0, 2_000_000).map(lambda cents: cents / 100), + ), + min_size=1, + max_size=8, + ), + st.lists(st.integers(BASE_YEAR + 1, END_YEAR), min_size=1, max_size=4), +) +def test_simulation_uprates_premiums_by_the_index_ratio(amounts, years): + simulation = _simulation(amounts) + for year in years: + for column, variable in enumerate(PREMIUM_INPUTS): + values = simulation.calculate(variable, year) + for value, base in zip(values, (pair[column] for pair in amounts)): + assert value == pytest.approx(base * _ratio(year), rel=1e-6, abs=0.01) + + +@pytest.mark.parametrize("year", [2025, 2030, 2034, 2035, 2036]) +def test_simulation_uprates_a_household_premium(year): + simulation = _simulation([(10_000.0, 2_500.0)]) + employer = simulation.calculate(PREMIUM_INPUTS[0], year)[0] + employee = simulation.calculate(PREMIUM_INPUTS[1], year)[0] + assert employer == pytest.approx(10_000 * _ratio(year), rel=1e-6) + assert employee == pytest.approx(2_500 * _ratio(year), rel=1e-6) + + +def _tiny_dataset(): + person = pd.DataFrame( + { + "person_id": [1, 2, 3, 4], + "person_household_id": [1, 1, 2, 3], + "employer_sponsored_insurance_premiums": [14_000.0, 0.0, 6_500.0, 0.0], + "pre_tax_health_insurance_premiums": [4_200.0, 0.0, 1_100.0, 900.0], + "age": [40.0, 8.0, 55.0, 62.0], + } + ) + household = pd.DataFrame( + {"household_id": [1, 2, 3], "household_weight": [1_000.0, 2_500.0, 400.0]} + ) + return USSingleYearDataset( + person=person, + household=household, + tax_unit=pd.DataFrame({"tax_unit_id": [1, 2, 3]}), + spm_unit=pd.DataFrame({"spm_unit_id": [1, 2, 3]}), + family=pd.DataFrame({"family_id": [1, 2, 3]}), + marital_unit=pd.DataFrame({"marital_unit_id": [1, 2, 3, 4]}), + time_period=BASE_YEAR, + ) + + +@lru_cache(maxsize=1) +def _extended(): + return extend_single_year_dataset(_tiny_dataset(), end_year=2036, system=system) + + +def _weighted_total(dataset, column): + weights = dataset.household.set_index("household_id")["household_weight"] + person_weights = dataset.person["person_household_id"].map(weights) + return float((dataset.person[column] * person_weights).sum()) + + +@pytest.mark.parametrize("year", [2025, 2030, 2034, 2036]) +@pytest.mark.parametrize("variable", PREMIUM_INPUTS) +def test_dataset_extension_uprates_by_the_index(variable, year): + base, later = _extended().datasets[BASE_YEAR], _extended().datasets[year] + for before, after in zip(base.person[variable], later.person[variable]): + assert after == pytest.approx(before * _ratio(year), rel=1e-9) + population = get_parameter(system.parameters, POPULATION_PATH) + population_growth = population(f"{year}-01-01") / population(f"{BASE_YEAR}-01-01") + assert _weighted_total(later, variable) / _weighted_total( + base, variable + ) == pytest.approx(_ratio(year) * population_growth, rel=1e-9) diff --git a/policyengine_us/tests/core/test_per_capita_uprating.py b/policyengine_us/tests/core/test_per_capita_uprating.py index 89ed3c4ceb7..f1d2fd3a5fd 100644 --- a/policyengine_us/tests/core/test_per_capita_uprating.py +++ b/policyengine_us/tests/core/test_per_capita_uprating.py @@ -28,6 +28,7 @@ PER_CAPITA_SUFFIX, POPULATION_PATH, _add_per_capita_parameter, + _is_unclassified_calibration_path, add_per_capita_uprating, is_national_total_path, per_capita_path, @@ -36,6 +37,7 @@ SOI_EMPLOYMENT = "calibration.gov.irs.soi.employment_income" CBO_AGI = "calibration.gov.cbo.income_by_source.adjusted_gross_income" CMS_MOOP = "calibration.gov.hhs.cms.moop_per_capita" +CMS_ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" YEARS = (2025, 2030, 2035) BASE_YEAR = 2024 @@ -57,6 +59,7 @@ def _growth(path, year, parameters=None): (CBO_AGI, True), ("calibration.gov.cbo.unemployment_compensation", True), (CMS_MOOP, False), + (CMS_ESI, False), (CPI_U, False), (POPULATION_PATH, False), (per_capita_path(SOI_EMPLOYMENT), False), @@ -88,6 +91,7 @@ def test_no_variable_uprates_by_a_national_total(): ("charitable_cash_donations", per_capita_path(CBO_AGI)), ("miscellaneous_income", per_capita_path(CBO_AGI)), ("other_medical_expenses", CMS_MOOP), + ("employer_sponsored_insurance_premiums", CMS_ESI), ("child_support_expense", CPI_U), ("household_weight", POPULATION_PATH), ], @@ -400,6 +404,14 @@ def test_unclassified_calibration_path_is_rejected(): add_per_capita_uprating(_StubSystem("calibration.gov.aca.spending.state.AL")) +@pytest.mark.parametrize("path", [CMS_MOOP, CMS_ESI]) +def test_per_person_rates_are_classified(path): + """CMS per-capita and per-enrollee series are per-person rates: neither + national totals to divide nor unclassified paths to reject.""" + assert not is_national_total_path(path) + assert not _is_unclassified_calibration_path(path) + + def test_existing_per_capita_series_is_never_overwritten(): """A series someone wrote by hand that happens to share the derived name must stop the build, not be replaced.""" diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index 01b5a7e01b7..fb7d4e7aec6 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -1,6 +1,7 @@ """Each dollar input follows the series closest to what it measures: its own national series, the income source it is part of, prices for consumption -expenses and debts, CMS per-capita spending for health costs.""" +expenses and debts, CMS per-capita spending for health costs and CMS premiums +per enrollee for employer plan premiums.""" from pathlib import Path @@ -18,6 +19,7 @@ UNEMPLOYMENT = per_capita_path("calibration.gov.irs.soi.unemployment_compensation") DEFAULT = per_capita_path("calibration.gov.cbo.income_by_source.adjusted_gross_income") CMS = "calibration.gov.hhs.cms.moop_per_capita" +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" COLA = "gov.ssa.uprating" @@ -60,6 +62,10 @@ "unemployment_compensation": UNEMPLOYMENT, # Health costs. "health_insurance_premiums": CMS, + # Employer plan premiums: the employer's share and the employee's pre-tax + # share. + "employer_sponsored_insurance_premiums": ESI, + "pre_tax_health_insurance_premiums": ESI, # Benefits adjusted by the Social Security cost-of-living index. "veterans_benefits": COLA, # Consumption expenses, and the interest on debts, follow prices. The 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 9049852d5a0..e50fffb6cf2 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -138,6 +138,19 @@ 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(): + """Employer plan premium inputs keep growing after CMS's 2034 projection, + at the 2033-2034 growth rate.""" + parameter = PARAMETERS.calibration.gov.hhs.cms.esi_premium_per_enrollee + last_growth = parameter("2034-01-01") / parameter("2033-01-01") + + 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) + assert parameter("2150-01-01") == parameter("2100-01-01") + + def test_soi_income_upraters_extend_without_trustees_reform(): """SOI income upraters should inherit baseline long-run CBO extensions.""" soi = PARAMETERS.calibration.gov.irs.soi diff --git a/policyengine_us/tools/per_capita_uprating.py b/policyengine_us/tools/per_capita_uprating.py index d14029ef711..8325f5aabd7 100644 --- a/policyengine_us/tools/per_capita_uprating.py +++ b/policyengine_us/tools/per_capita_uprating.py @@ -10,8 +10,9 @@ For every national total that a variable uprates by, this module adds a sibling parameter holding the total divided by population (``_per_capita``) and points the variable at it. Series that are -already per-person rates or price indices (CMS per-capita spending, CPI-U) -are left alone, as is the population series that uprates the weights. +already per-person rates or price indices (CMS per-capita spending, CMS +premiums per enrollee, CPI-U) are left alone, as is the population series +that uprates the weights. Parameters can follow a per-capita series too: a parameter whose ``uprating`` metadata names ``_per_capita`` gets that sibling built before @@ -32,16 +33,23 @@ "calibration.gov.irs.soi.", ) CALIBRATION_PREFIX = "calibration." -PER_CAPITA_MARKER = "per_capita" +# Path fragments that mark a per-person rate: spending per capita, or premiums +# per enrollee. Such a series already divides by the people it covers, so it +# is neither a national total nor divided again. +PER_PERSON_MARKERS = ("per_capita", "per_enrollee") PER_CAPITA_SUFFIX = "_per_capita" DERIVED_FROM = "derived_from" +def _is_per_person_rate(path: str) -> bool: + return any(marker in path for marker in PER_PERSON_MARKERS) + + def is_national_total_path(path: str | None) -> bool: """Whether an uprating parameter path names a national total.""" if not path: return False - return path.startswith(NATIONAL_TOTAL_PREFIXES) and PER_CAPITA_MARKER not in path + return path.startswith(NATIONAL_TOTAL_PREFIXES) and not _is_per_person_rate(path) def per_capita_path(path: str) -> str: @@ -50,12 +58,12 @@ def per_capita_path(path: str) -> str: def _is_unclassified_calibration_path(path: str | None) -> bool: """A ``calibration`` path this module cannot classify: not a national - total, not already per capita, not the population series.""" + total, not already a per-person rate, not the population series.""" if not path or not path.startswith(CALIBRATION_PREFIX): return False return not ( is_national_total_path(path) - or PER_CAPITA_MARKER in path + or _is_per_person_rate(path) or path == POPULATION_PATH ) diff --git a/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py index 57064157628..8d35dda92b6 100644 --- a/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py @@ -9,7 +9,9 @@ class pre_tax_health_insurance_premiums(Variable): documentation = ( "Health insurance premiums paid through pre-tax payroll deductions. " "This excludes Medicare Part B premiums and other post-tax medical " - "out-of-pocket premiums." + "out-of-pocket premiums. These are employees' shares of " + "employer-sponsored insurance premiums, so they are uprated by CMS's " + "employer-sponsored insurance premium per enrollee." ) definition_period = YEAR - uprating = "calibration.gov.hhs.cms.moop_per_capita" + uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" diff --git a/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py b/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py index 15cb910e830..a20003348d7 100644 --- a/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py +++ b/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py @@ -7,8 +7,11 @@ class employer_sponsored_insurance_premiums(Variable): label = "Employer-sponsored insurance premiums" documentation = ( "Annual employer-paid health insurance premiums. CBO treats this " - "as part of household market income." + "as part of household market income. Uprated by CMS's " + "employer-sponsored insurance premium per enrollee, so each " + "policyholder's premium grows with premiums rather than wages; the " + "number of policyholders follows the weights." ) definition_period = YEAR unit = USD - uprating = "calibration.gov.cbo.income_by_source.employment_income" + uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" From 5b30a11f9085470906ca0ac977a8734d2830114c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 07:26:20 -0400 Subject: [PATCH 02/12] Address review: route engine tests to microsimulation, tighten classifier - Split the ESI tests: parameter and property checks stay in tests/core; the Simulation and dataset-extension checks move to tests/microsimulation (fewer Hypothesis examples, one vectorized simulation each). - Assert the 22 published Table 17 values exactly, keeping the spending-over-enrollment consistency check. - Per-person markers count only in a path's last segment, so a marker in an ancestor folder no longer hides a national total. - Cite the methodology paper's file page 29 in the href, the ZIP as the Table 17 source, and the 2035-2100 extension endpoint. - Describe the index as a premium-growth proxy at fixed coverage per policy and roughly constant employer share. Co-Authored-By: Claude Opus 5.5 --- docs-quarto/methodology/spm-poverty.qmd | 2 +- .../gov/hhs/cms/esi_premium_per_enrollee.yaml | 26 +-- .../tests/core/test_esi_premium_uprating.py | 166 ++++-------------- .../tests/core/test_per_capita_uprating.py | 20 +++ .../test_esi_premium_microsim_uprating.py | 130 ++++++++++++++ policyengine_us/tools/per_capita_uprating.py | 11 +- .../pre_tax_health_insurance_premiums.py | 4 +- .../employer_sponsored_insurance_premiums.py | 7 +- 8 files changed, 215 insertions(+), 151 deletions(-) create mode 100644 policyengine_us/tests/microsimulation/test_esi_premium_microsim_uprating.py diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index 1b96ea0a01f..b8562e63b9c 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, over-the-counter health expenses and other health insurance premiums that enter SPM resources; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; health insurance premiums in total, long-term and excluding Medicare Part B, and reported Medicare Part B premiums; and health savings account payroll contributions. 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. Employer-sponsored insurance premiums and pre-tax health insurance premiums, the employer's and the employee's shares of employer plans, 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), also a per-person series, which CMS projects to 2034; later years hold its 2033 to 2034 growth rate. 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) 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, over-the-counter health expenses and other health insurance premiums that enter SPM resources; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; health insurance premiums in total, long-term and excluding Medicare Part B, and reported Medicare Part B premiums; and health savings account payroll contributions. 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. Employer-sponsored insurance premiums and pre-tax health insurance premiums, the employer's and the employee's shares of employer plans, 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), also a per-person series, which CMS projects to 2034; it grows at its 2033 to 2034 rate from 2035 to 2100 and holds the 2100 value after that. 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) 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/esi_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml index 814fb0815e7..36951af5b5a 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 @@ -3,20 +3,19 @@ metadata: unit: currency-USD label: CMS employer-sponsored insurance premium per enrollee reference: - # Data underlying figures: - # https://www.cms.gov/files/zip/nhe-projections-tables.zip - # Last-Modified 2026-09-03; sha256 - # 264f822618a5e1829138aea57cc6c8bf45bbaf072a72898fef5547f7ece6cd70. # Table17 Health Insurance Enrollment and Enrollment Growth Rates, # "Spending per Enrollee ($)", "Employer-sponsored Insurance" row: ESI - # spending divided by ESI enrollment. Retrieved 2026-10-11. + # spending divided by ESI enrollment. ZIP Last-Modified 2026-09-03, sha256 + # 264f822618a5e1829138aea57cc6c8bf45bbaf072a72898fef5547f7ece6cd70, + # retrieved 2026-10-11 from + # https://www.cms.gov/data-research/statistics-trends-and-reports/national-health-expenditure-data/projected - title: CMS National Health Expenditure Projections 2025-2034, Table 17 - href: https://www.cms.gov/data-research/statistics-trends-and-reports/national-health-expenditure-data/projected - # Page 28: ESI "includes premiums paid by employers and/or employees for - # health insurance plans offered by the employer", for active employees, - # COBRA enrollees and retirees. - - title: "CMS National Health Expenditure Accounts: Methodology Paper, 2024, p. 28" - href: https://www.cms.gov/files/document/definitions-sources-and-methods.pdf + href: https://www.cms.gov/files/zip/nhe-projections-tables.zip + # ESI "includes premiums paid by employers and/or employees for health + # insurance plans offered by the employer", for active employees, COBRA + # enrollees and retirees. + - title: "CMS National Health Expenditure Accounts: Methodology Paper, 2024" + href: https://www.cms.gov/files/document/definitions-sources-and-methods.pdf#page=29 values: # Historical. 2013-01-01: 4_901 @@ -42,5 +41,6 @@ values: 2032-01-01: 11_986 2033-01-01: 12_559 2034-01-01: 13_124 - # Later years hold the 2033-2034 growth rate (13_124 / 12_559, 4.5% a - # year); see set_all_uprating_parameters in uprating_extensions.py. + # 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. diff --git a/policyengine_us/tests/core/test_esi_premium_uprating.py b/policyengine_us/tests/core/test_esi_premium_uprating.py index 63602eb5d4b..88db168c410 100644 --- a/policyengine_us/tests/core/test_esi_premium_uprating.py +++ b/policyengine_us/tests/core/test_esi_premium_uprating.py @@ -1,59 +1,62 @@ -"""Employer plan premium inputs age with CMS's employer-sponsored insurance -(ESI) premium per enrollee, not with wages or out-of-pocket spending. +"""The CMS employer-sponsored insurance (ESI) premium per enrollee series that +employer plan premium inputs uprate by. CMS's National Health Expenditure projections (Table 17, 2025-2034 release) 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, p. 28), so spending per enrollee is the premium per covered -person. The parameter holds that row; later years hold its 2033-2034 growth -rate. +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. + +These tests read only the parameter tree. Engine uprating of the inputs (a +household simulation and dataset extension) is covered in +``tests/microsimulation/test_esi_premium_microsim_uprating.py``. Invariants: -1. Transcription: every value equals Table 17's ESI spending over ESI - enrollment, within the table's rounding. +1. Transcription: every value is Table 17's published dollar figure, and is + consistent with the same table's ESI spending over ESI enrollment within + rounding. 2. Employer share: CMS projects employer contributions (Table 16) as a flat share of ESI spending, so employer contributions per ESI enrollee grow with the index. That is why one index serves the employer and employee shares. 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 after 2034 grows by 13,124 / 12,559. -5. Uprating: a premium entered for one year equals that amount times the - index ratio in any later year, for both premium inputs, in a household - simulation and in dataset extension, so uprating is linear in the amount. -6. Weighted totals: in an extended dataset the weighted total grows by the - index ratio times population growth, because weights follow population. +4. Extension rule: each year from 2035 to 2100 grows by 13,124 / 12,559. """ -from functools import lru_cache - -import pandas as pd import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import given from hypothesis import strategies as st from policyengine_core.parameters.operations.get_parameter import get_parameter -from policyengine_us import Simulation -from policyengine_us.data.dataset_schema import USSingleYearDataset -from policyengine_us.data.economic_assumptions import extend_single_year_dataset from policyengine_us.system import system -from policyengine_us.tools.per_capita_uprating import POPULATION_PATH ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" PREMIUM_INPUTS = ( "employer_sponsored_insurance_premiums", "pre_tax_health_insurance_premiums", ) -BASE_YEAR = 2024 FIRST_YEAR, LAST_PROJECTED_YEAR, END_YEAR = 2013, 2034, 2100 +YEARS = range(FIRST_YEAR, LAST_PROJECTED_YEAR + 1) # CMS NHE projections tables, Table 17, "Employer-sponsored Insurance" rows # (https://www.cms.gov/files/zip/nhe-projections-tables.zip, Last-Modified # 2026-09-03, sha256 264f8226...cd70). +ESI_SPENDING_PER_ENROLLEE = dict( + zip( + YEARS, + ( + *(4_901, 4_980, 5_113, 5_354, 5_500, 5_783, 5_955, 6_049, 6_456), + *(6_877, 7_595, 8_000, 8_616, 9_181, 9_627, 10_093, 10_533), + *(11_001, 11_469, 11_986, 12_559, 13_124), + ), + ) +) ESI_SPENDING_BILLIONS = dict( zip( - range(2013, 2035), + YEARS, ( *(829.1, 845.4, 880.5, 926.8, 965.7, 1013.1, 1050.3, 1052.7), *(1120.9, 1203.9, 1333.5, 1429.1, 1549.8, 1650.5, 1732.9), @@ -63,7 +66,7 @@ ) ESI_ENROLLMENT_MILLIONS = dict( zip( - range(2013, 2035), + YEARS, ( *(169.2, 169.8, 172.2, 173.1, 175.6, 175.2, 176.4, 174.0), *(173.6, 175.1, 175.6, 178.6, 179.9, 179.8, 180.0, 180.4), @@ -84,19 +87,22 @@ def _index(year): return get_parameter(system.parameters, ESI)(f"{year}-01-01") -def _ratio(year, base_year=BASE_YEAR): - return _index(year) / _index(base_year) +@pytest.mark.parametrize("year", YEARS) +def test_index_is_table_17_spending_per_enrollee(year): + assert _index(year) == ESI_SPENDING_PER_ENROLLEE[year] -@pytest.mark.parametrize("year", sorted(ESI_SPENDING_BILLIONS)) -def test_index_is_table_17_spending_over_enrollment(year): +@pytest.mark.parametrize("year", YEARS) +def test_published_values_match_spending_over_enrollment(year): + """Checks the transcribed rows against each other, so one mistyped + spending, enrollment or per-enrollee figure fails.""" spending = ESI_SPENDING_BILLIONS[year] enrollment = ESI_ENROLLMENT_MILLIONS[year] # Both components are rounded to one decimal and the published value to # the dollar. low = 1_000 * (spending - 0.05) / (enrollment + 0.05) - 0.5 high = 1_000 * (spending + 0.05) / (enrollment - 0.05) + 0.5 - assert low <= _index(year) <= high + assert low <= ESI_SPENDING_PER_ENROLLEE[year] <= high @pytest.mark.parametrize("start, end", [(2024, 2034), (2026, 2034)]) @@ -106,7 +112,7 @@ def per_enrollee(year): years = end - start employer_growth = (per_enrollee(end) / per_enrollee(start)) ** (1 / years) - index_growth = _ratio(end, start) ** (1 / years) + index_growth = (_index(end) / _index(start)) ** (1 / years) assert employer_growth == pytest.approx(index_growth, abs=0.001) # The employer share of ESI premiums stays within 73.2% to 73.4%. for year in (start, end): @@ -133,103 +139,3 @@ def test_extension_holds_the_last_projected_growth_rate(year): @pytest.mark.parametrize("variable", PREMIUM_INPUTS) def test_premium_inputs_uprate_by_the_esi_index(variable): assert system.variables[variable].uprating == ESI - - -def _simulation(amounts): - people = { - f"person_{i}": { - "age": {BASE_YEAR: 40}, - PREMIUM_INPUTS[0]: {BASE_YEAR: employer}, - PREMIUM_INPUTS[1]: {BASE_YEAR: employee}, - } - for i, (employer, employee) in enumerate(amounts) - } - return Simulation( - situation={ - "people": people, - "households": {"household": {"members": list(people)}}, - } - ) - - -@settings( - max_examples=10, - deadline=None, - derandomize=True, - suppress_health_check=[HealthCheck.too_slow], -) -@given( - st.lists( - st.tuples( - st.integers(0, 5_000_000).map(lambda cents: cents / 100), - st.integers(0, 2_000_000).map(lambda cents: cents / 100), - ), - min_size=1, - max_size=8, - ), - st.lists(st.integers(BASE_YEAR + 1, END_YEAR), min_size=1, max_size=4), -) -def test_simulation_uprates_premiums_by_the_index_ratio(amounts, years): - simulation = _simulation(amounts) - for year in years: - for column, variable in enumerate(PREMIUM_INPUTS): - values = simulation.calculate(variable, year) - for value, base in zip(values, (pair[column] for pair in amounts)): - assert value == pytest.approx(base * _ratio(year), rel=1e-6, abs=0.01) - - -@pytest.mark.parametrize("year", [2025, 2030, 2034, 2035, 2036]) -def test_simulation_uprates_a_household_premium(year): - simulation = _simulation([(10_000.0, 2_500.0)]) - employer = simulation.calculate(PREMIUM_INPUTS[0], year)[0] - employee = simulation.calculate(PREMIUM_INPUTS[1], year)[0] - assert employer == pytest.approx(10_000 * _ratio(year), rel=1e-6) - assert employee == pytest.approx(2_500 * _ratio(year), rel=1e-6) - - -def _tiny_dataset(): - person = pd.DataFrame( - { - "person_id": [1, 2, 3, 4], - "person_household_id": [1, 1, 2, 3], - "employer_sponsored_insurance_premiums": [14_000.0, 0.0, 6_500.0, 0.0], - "pre_tax_health_insurance_premiums": [4_200.0, 0.0, 1_100.0, 900.0], - "age": [40.0, 8.0, 55.0, 62.0], - } - ) - household = pd.DataFrame( - {"household_id": [1, 2, 3], "household_weight": [1_000.0, 2_500.0, 400.0]} - ) - return USSingleYearDataset( - person=person, - household=household, - tax_unit=pd.DataFrame({"tax_unit_id": [1, 2, 3]}), - spm_unit=pd.DataFrame({"spm_unit_id": [1, 2, 3]}), - family=pd.DataFrame({"family_id": [1, 2, 3]}), - marital_unit=pd.DataFrame({"marital_unit_id": [1, 2, 3, 4]}), - time_period=BASE_YEAR, - ) - - -@lru_cache(maxsize=1) -def _extended(): - return extend_single_year_dataset(_tiny_dataset(), end_year=2036, system=system) - - -def _weighted_total(dataset, column): - weights = dataset.household.set_index("household_id")["household_weight"] - person_weights = dataset.person["person_household_id"].map(weights) - return float((dataset.person[column] * person_weights).sum()) - - -@pytest.mark.parametrize("year", [2025, 2030, 2034, 2036]) -@pytest.mark.parametrize("variable", PREMIUM_INPUTS) -def test_dataset_extension_uprates_by_the_index(variable, year): - base, later = _extended().datasets[BASE_YEAR], _extended().datasets[year] - for before, after in zip(base.person[variable], later.person[variable]): - assert after == pytest.approx(before * _ratio(year), rel=1e-9) - population = get_parameter(system.parameters, POPULATION_PATH) - population_growth = population(f"{year}-01-01") / population(f"{BASE_YEAR}-01-01") - assert _weighted_total(later, variable) / _weighted_total( - base, variable - ) == pytest.approx(_ratio(year) * population_growth, rel=1e-9) diff --git a/policyengine_us/tests/core/test_per_capita_uprating.py b/policyengine_us/tests/core/test_per_capita_uprating.py index f1d2fd3a5fd..99a30adf594 100644 --- a/policyengine_us/tests/core/test_per_capita_uprating.py +++ b/policyengine_us/tests/core/test_per_capita_uprating.py @@ -412,6 +412,26 @@ def test_per_person_rates_are_classified(path): assert not _is_unclassified_calibration_path(path) +@pytest.mark.parametrize( + "path, national_total", + [ + # A marker in an ancestor segment does not make a total a rate. + ("calibration.gov.cbo.per_enrollee_estimates.spending_total", True), + ("calibration.gov.irs.soi.per_capita_tables.wages", True), + ("calibration.gov.hhs.per_enrollee_estimates.spending_total", False), + # In the last segment it does, wherever it falls in the name. + ("calibration.gov.cbo.spending_per_enrollee", False), + ("calibration.gov.hhs.medicare.per_capita_cost", False), + ], +) +def test_only_the_last_segment_marks_a_per_person_rate(path, national_total): + assert is_national_total_path(path) is national_total + # Outside the national-total families, an ancestor marker leaves the path + # unclassified, so the build rejects it instead of guessing. + if path.startswith("calibration.gov.hhs.per_enrollee_estimates"): + assert _is_unclassified_calibration_path(path) + + def test_existing_per_capita_series_is_never_overwritten(): """A series someone wrote by hand that happens to share the derived name must stop the build, not be replaced.""" diff --git a/policyengine_us/tests/microsimulation/test_esi_premium_microsim_uprating.py b/policyengine_us/tests/microsimulation/test_esi_premium_microsim_uprating.py new file mode 100644 index 00000000000..d34e1d08c61 --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_esi_premium_microsim_uprating.py @@ -0,0 +1,130 @@ +"""The engine ages employer plan premium inputs by CMS's employer-sponsored +insurance premium per enrollee, in a simulation and in dataset extension. + +``tests/core/test_esi_premium_uprating.py`` checks the series itself and that +both inputs name it. This file checks the distinct failure that a parameter +read cannot: that holder uprating in a ``Simulation`` and +``extend_single_year_dataset`` resolve the per-enrollee path undivided by +population (``tools/per_capita_uprating.py`` divides national totals) and +apply it to these inputs. + +Invariants: + +1. Uprating: a premium entered for 2024 equals that amount times the index + ratio in any later year, for both inputs, so uprating is linear in the + amount (Hypothesis, one vectorized simulation per example). +2. Dataset extension: each person's amount follows the index ratio, and the + weighted total grows by the index ratio times population growth, because + weights follow population. +""" + +from functools import lru_cache + +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us import Simulation +from policyengine_us.data.dataset_schema import USSingleYearDataset +from policyengine_us.data.economic_assumptions import extend_single_year_dataset +from policyengine_us.system import system +from policyengine_us.tools.per_capita_uprating import POPULATION_PATH + +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +EMPLOYER, EMPLOYEE = ( + "employer_sponsored_insurance_premiums", + "pre_tax_health_insurance_premiums", +) +BASE_YEAR, END_YEAR = 2024, 2100 + + +def _ratio(path, year): + parameter = get_parameter(system.parameters, path) + return parameter(f"{year}-01-01") / parameter(f"{BASE_YEAR}-01-01") + + +# Each example builds one simulation holding a batch of people. +@settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + st.lists( + st.tuples( + st.integers(0, 5_000_000).map(lambda cents: cents / 100), + st.integers(0, 2_000_000).map(lambda cents: cents / 100), + ), + min_size=2, + max_size=8, + ), + st.lists(st.integers(BASE_YEAR + 1, END_YEAR), min_size=2, max_size=4), +) +def test_simulation_uprates_premiums_by_the_index_ratio(amounts, years): + people = { + f"person_{i}": { + "age": {BASE_YEAR: 40}, + EMPLOYER: {BASE_YEAR: employer}, + EMPLOYEE: {BASE_YEAR: employee}, + } + for i, (employer, employee) in enumerate(amounts) + } + simulation = Simulation( + situation={ + "people": people, + "households": {"household": {"members": list(people)}}, + } + ) + for year in years: + for column, variable in enumerate((EMPLOYER, EMPLOYEE)): + values = simulation.calculate(variable, year) + for value, pair in zip(values, amounts): + assert value == pytest.approx( + pair[column] * _ratio(ESI, year), rel=1e-6, abs=0.01 + ) + + +@lru_cache(maxsize=1) +def _extended(): + person = pd.DataFrame( + { + "person_id": [1, 2, 3, 4], + "person_household_id": [1, 1, 2, 3], + EMPLOYER: [14_000.0, 0.0, 6_500.0, 0.0], + EMPLOYEE: [4_200.0, 0.0, 1_100.0, 900.0], + "age": [40.0, 8.0, 55.0, 62.0], + } + ) + household = pd.DataFrame( + {"household_id": [1, 2, 3], "household_weight": [1_000.0, 2_500.0, 400.0]} + ) + dataset = USSingleYearDataset( + person=person, + household=household, + tax_unit=pd.DataFrame({"tax_unit_id": [1, 2, 3]}), + spm_unit=pd.DataFrame({"spm_unit_id": [1, 2, 3]}), + family=pd.DataFrame({"family_id": [1, 2, 3]}), + marital_unit=pd.DataFrame({"marital_unit_id": [1, 2, 3, 4]}), + time_period=BASE_YEAR, + ) + return extend_single_year_dataset(dataset, end_year=2036, system=system) + + +def _weighted_total(dataset, column): + weights = dataset.household.set_index("household_id")["household_weight"] + person_weights = dataset.person["person_household_id"].map(weights) + return float((dataset.person[column] * person_weights).sum()) + + +@pytest.mark.parametrize("year", [2025, 2030, 2034, 2036]) +@pytest.mark.parametrize("variable", [EMPLOYER, EMPLOYEE]) +def test_dataset_extension_uprates_by_the_index(variable, year): + base, later = _extended().datasets[BASE_YEAR], _extended().datasets[year] + for before, after in zip(base.person[variable], later.person[variable]): + assert after == pytest.approx(before * _ratio(ESI, year), rel=1e-9) + assert _weighted_total(later, variable) / _weighted_total( + base, variable + ) == pytest.approx(_ratio(ESI, year) * _ratio(POPULATION_PATH, year), rel=1e-9) diff --git a/policyengine_us/tools/per_capita_uprating.py b/policyengine_us/tools/per_capita_uprating.py index 8325f5aabd7..acf5f04a1e4 100644 --- a/policyengine_us/tools/per_capita_uprating.py +++ b/policyengine_us/tools/per_capita_uprating.py @@ -33,16 +33,19 @@ "calibration.gov.irs.soi.", ) CALIBRATION_PREFIX = "calibration." -# Path fragments that mark a per-person rate: spending per capita, or premiums -# per enrollee. Such a series already divides by the people it covers, so it -# is neither a national total nor divided again. +# Fragments that mark a per-person rate when they appear in a path's last +# segment: spending per capita, or premiums per enrollee. Such a series already +# divides by the people it covers, so it is neither a national total nor +# divided again. A marker in an ancestor segment (a folder of estimates) does +# not make the series under it a rate. PER_PERSON_MARKERS = ("per_capita", "per_enrollee") PER_CAPITA_SUFFIX = "_per_capita" DERIVED_FROM = "derived_from" def _is_per_person_rate(path: str) -> bool: - return any(marker in path for marker in PER_PERSON_MARKERS) + name = path.rpartition(".")[2] + return any(marker in name for marker in PER_PERSON_MARKERS) def is_national_total_path(path: str | None) -> bool: diff --git a/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py index 8d35dda92b6..1f1a9e813e4 100644 --- a/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/pre_tax_health_insurance_premiums.py @@ -11,7 +11,9 @@ class pre_tax_health_insurance_premiums(Variable): "This excludes Medicare Part B premiums and other post-tax medical " "out-of-pocket premiums. These are employees' shares of " "employer-sponsored insurance premiums, so they are uprated by CMS's " - "employer-sponsored insurance premium per enrollee." + "employer-sponsored insurance premium per enrollee, holding the " + "number of people covered per policy and the employee's share of the " + "premium at their base-year levels." ) definition_period = YEAR uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" diff --git a/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py b/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py index a20003348d7..bf21c62d1a2 100644 --- a/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py +++ b/policyengine_us/variables/input/employer_sponsored_insurance_premiums.py @@ -9,8 +9,11 @@ class employer_sponsored_insurance_premiums(Variable): "Annual employer-paid health insurance premiums. CBO treats this " "as part of household market income. Uprated by CMS's " "employer-sponsored insurance premium per enrollee, so each " - "policyholder's premium grows with premiums rather than wages; the " - "number of policyholders follows the weights." + "policyholder's premium grows with premiums rather than wages. This " + "is a premium-growth proxy: it holds the number of people covered " + "per policy and the employer's share of the premium, which CMS " + "projects as roughly constant, at their base-year levels. The number " + "of policyholders follows the weights." ) definition_period = YEAR unit = USD From 9e6b734f127a5700f40969927ad3f0191194e72d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 07:46:04 -0400 Subject: [PATCH 03/12] Uprate reported premium inputs by CMS premium series health_insurance_premiums_without_medicare_part_b and other_health_insurance_premiums follow CMS ESI premium per enrollee, like pre_tax_health_insurance_premiums; health_insurance_premiums follows a new CMS direct purchase premium per enrollee series (NHE projections Table 17). CMS out-of-pocket spending excludes premiums by definition. Co-Authored-By: Claude Opus 5.5 --- .../premium-inputs-cms-uprating.changed.md | 1 + .../direct_purchase_premium_per_enrollee.yaml | 50 +++++++++++++++++++ .../parameters/uprating_extensions.py | 15 +++++- .../health/health_insurance_premiums.py | 4 +- ...urance_premiums_without_medicare_part_b.py | 7 ++- .../health/other_health_insurance_premiums.py | 5 +- 6 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 changelog.d/premium-inputs-cms-uprating.changed.md create mode 100644 policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml diff --git a/changelog.d/premium-inputs-cms-uprating.changed.md b/changelog.d/premium-inputs-cms-uprating.changed.md new file mode 100644 index 00000000000..80dc57768c4 --- /dev/null +++ b/changelog.d/premium-inputs-cms-uprating.changed.md @@ -0,0 +1 @@ +Uprate the reported premium inputs by CMS premium series instead of CMS out-of-pocket spending per capita, which excludes premiums: reported and other health insurance premiums follow employer-sponsored insurance premium per enrollee, and directly supplied premiums follow direct purchase insurance premium per enrollee (NHE projections Table 17). 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 new file mode 100644 index 00000000000..7e6b0bf1902 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml @@ -0,0 +1,50 @@ +description: The Centers for Medicare and Medicaid Services (CMS) reports and projects this direct purchase insurance spending per enrollee, which is the premium per covered person for private plans bought outside an employer, before any premium tax credit. +metadata: + unit: currency-USD + label: CMS direct purchase insurance premium per enrollee + reference: + # Table17 Health Insurance Enrollment and Enrollment Growth Rates, + # "Spending per Enrollee ($)", "Direct Purchase" row: direct purchase + # spending divided by direct purchase enrollment. Its footnote 2: direct + # purchase "includes those with Medicare supplemental coverage and + # individually-purchased plans, including coverage purchased through the + # Marketplaces." ZIP Last-Modified 2026-09-03, sha256 + # 264f822618a5e1829138aea57cc6c8bf45bbaf072a72898fef5547f7ece6cd70, + # retrieved 2026-10-11 from + # https://www.cms.gov/data-research/statistics-trends-and-reports/national-health-expenditure-data/projected + - title: CMS National Health Expenditure Projections 2025-2034, Table 17 + href: https://www.cms.gov/files/zip/nhe-projections-tables.zip + # Direct purchase "includes premiums paid by individuals for policies that + # are not available through an employer or a union-based plan. This + # includes Medigap policies, Marketplace plans, and other directly purchased + # health insurance." + - title: "CMS National Health Expenditure Accounts: Methodology Paper, 2024" + href: https://www.cms.gov/files/document/definitions-sources-and-methods.pdf#page=29 +values: + # Historical. + 2013-01-01: 2_263 + 2014-01-01: 3_134 + 2015-01-01: 3_454 + 2016-01-01: 3_695 + 2017-01-01: 4_175 + 2018-01-01: 4_831 + 2019-01-01: 4_812 + 2020-01-01: 4_791 + 2021-01-01: 4_979 + 2022-01-01: 5_149 + 2023-01-01: 5_458 + 2024-01-01: 5_871 + # Projections. + 2025-01-01: 5_994 + 2026-01-01: 6_969 + 2027-01-01: 7_174 + 2028-01-01: 7_456 + 2029-01-01: 7_752 + 2030-01-01: 8_115 + 2031-01-01: 8_364 + 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. diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 23ec45e9904..704b90a6546 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -850,8 +850,8 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: ) # 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. + # last projection year 2034). Employer and employee premium inputs use + # this as their uprater. extend_parameter_values( parameters.calibration.gov.hhs.cms.esi_premium_per_enrollee, last_projected_year=2034, @@ -860,6 +860,17 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: period_day=1, ) + # CMS direct purchase insurance premium per enrollee (January values, last + # projection year 2034). Premiums for plans bought directly use this as + # their uprater. + 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, + ) + # Gross Social Security benefit inputs should age with gross Social # Security benefits, not taxable Social Security income. extend_parameter_values( diff --git a/policyengine_us/variables/household/expense/health/health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/health_insurance_premiums.py index 936031f7fc9..93107c1e06c 100644 --- a/policyengine_us/variables/household/expense/health/health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/health_insurance_premiums.py @@ -7,7 +7,9 @@ class health_insurance_premiums(Variable): label = "Health insurance premiums" unit = USD definition_period = YEAR - uprating = "calibration.gov.hhs.cms.moop_per_capita" + # Survey data put premiums here only for self-employed people under 65 + # without employer-sponsored coverage, who buy plans directly. + uprating = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" documentation = ( "Person-level health insurance premiums supplied directly as an input. " "SPM MOOP and statutory medical expense definitions use decomposed " diff --git a/policyengine_us/variables/household/expense/health/health_insurance_premiums_without_medicare_part_b.py b/policyengine_us/variables/household/expense/health/health_insurance_premiums_without_medicare_part_b.py index 92be6e51a96..e5ea67ae17a 100644 --- a/policyengine_us/variables/household/expense/health/health_insurance_premiums_without_medicare_part_b.py +++ b/policyengine_us/variables/household/expense/health/health_insurance_premiums_without_medicare_part_b.py @@ -7,4 +7,9 @@ class health_insurance_premiums_without_medicare_part_b(Variable): label = "Health insurance premiums without Medicare Part B premiums" unit = USD definition_period = YEAR - uprating = "calibration.gov.hhs.cms.moop_per_capita" + # Survey data carry the person's reported premiums here (CPS ASEC + # PHIP_VAL), most of them paid by people with employer-sponsored + # coverage. They follow the same premium index as + # pre_tax_health_insurance_premiums, so a premium moved between the two + # inputs grows the same way. + uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" diff --git a/policyengine_us/variables/household/expense/health/other_health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/other_health_insurance_premiums.py index 1f90a92ff02..160bf86ed5e 100644 --- a/policyengine_us/variables/household/expense/health/other_health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/other_health_insurance_premiums.py @@ -7,7 +7,10 @@ class other_health_insurance_premiums(Variable): label = "Other health insurance premiums" unit = USD definition_period = YEAR - uprating = "calibration.gov.hhs.cms.moop_per_capita" + # The reported premiums in + # health_insurance_premiums_without_medicare_part_b less modeled CHIP, + # Medicaid and Marketplace premiums, so the same premium index. + uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" documentation = ( "Person-level health insurance premiums not otherwise represented by " "modeled Marketplace, CHIP, Medicaid, Medicare Part A or Part B " From 907d3449004907ba5a34e603c7fc67e55e51e270 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 07:53:52 -0400 Subject: [PATCH 04/12] Test premium input uprating: transcription, employee share, move neutrality Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_per_capita_uprating.py | 5 +- .../tests/core/test_premium_input_uprating.py | 211 ++++++++++++++++++ .../tests/core/test_uprating_series.py | 19 +- .../test_premium_input_microsim_uprating.py | 171 ++++++++++++++ .../parameters/test_uprating_extensions.py | 13 ++ 5 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 policyengine_us/tests/core/test_premium_input_uprating.py create mode 100644 policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py diff --git a/policyengine_us/tests/core/test_per_capita_uprating.py b/policyengine_us/tests/core/test_per_capita_uprating.py index 99a30adf594..a082f1c1cb7 100644 --- a/policyengine_us/tests/core/test_per_capita_uprating.py +++ b/policyengine_us/tests/core/test_per_capita_uprating.py @@ -38,6 +38,7 @@ CBO_AGI = "calibration.gov.cbo.income_by_source.adjusted_gross_income" CMS_MOOP = "calibration.gov.hhs.cms.moop_per_capita" CMS_ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +CMS_DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" YEARS = (2025, 2030, 2035) BASE_YEAR = 2024 @@ -60,6 +61,7 @@ def _growth(path, year, parameters=None): ("calibration.gov.cbo.unemployment_compensation", True), (CMS_MOOP, False), (CMS_ESI, False), + (CMS_DIRECT_PURCHASE, False), (CPI_U, False), (POPULATION_PATH, False), (per_capita_path(SOI_EMPLOYMENT), False), @@ -92,6 +94,7 @@ def test_no_variable_uprates_by_a_national_total(): ("miscellaneous_income", per_capita_path(CBO_AGI)), ("other_medical_expenses", CMS_MOOP), ("employer_sponsored_insurance_premiums", CMS_ESI), + ("health_insurance_premiums", CMS_DIRECT_PURCHASE), ("child_support_expense", CPI_U), ("household_weight", POPULATION_PATH), ], @@ -404,7 +407,7 @@ def test_unclassified_calibration_path_is_rejected(): add_per_capita_uprating(_StubSystem("calibration.gov.aca.spending.state.AL")) -@pytest.mark.parametrize("path", [CMS_MOOP, CMS_ESI]) +@pytest.mark.parametrize("path", [CMS_MOOP, CMS_ESI, CMS_DIRECT_PURCHASE]) def test_per_person_rates_are_classified(path): """CMS per-capita and per-enrollee series are per-person rates: neither national totals to divide nor unclassified paths to reject.""" diff --git a/policyengine_us/tests/core/test_premium_input_uprating.py b/policyengine_us/tests/core/test_premium_input_uprating.py new file mode 100644 index 00000000000..616ec3f4424 --- /dev/null +++ b/policyengine_us/tests/core/test_premium_input_uprating.py @@ -0,0 +1,211 @@ +"""Premium inputs follow CMS premium series, not CMS out-of-pocket spending. + +CMS defines out-of-pocket spending to exclude premiums (NHEA methodology +paper, 2024, file page 14), so no premium input uprates by +``moop_per_capita``. Each follows the CMS premium series closest to what the +data put in it: + +- ``health_insurance_premiums_without_medicare_part_b`` carries the CPS ASEC + premium the person reported (``PHIP_VAL``), and + ``other_health_insurance_premiums`` is that premium less modeled CHIP, + Medicaid and Marketplace premiums. People with employer-sponsored coverage + pay most of these dollars, and Microcosm's pre-tax producer moves employees' + payroll-deducted shares from these inputs to + ``pre_tax_health_insurance_premiums``. All three follow CMS's + employer-sponsored insurance (ESI) premium per enrollee, so that move leaves + the dollars' growth unchanged. +- ``health_insurance_premiums`` holds premiums of self-employed people under 65 + without employer-sponsored coverage, who buy plans directly. It follows + CMS's direct purchase premium per enrollee (Table 17). + +These tests read only the parameter tree. Engine uprating of the inputs is +covered in ``tests/microsimulation/test_premium_input_microsim_uprating.py``. + +Invariants: + +1. Transcription: every direct purchase value is Table 17's published dollar + figure, consistent with the same table's direct purchase spending over + enrollment within rounding. +2. Employee share: ESI spending less employer contributions (Table 16), per + ESI enrollee, grows with the ESI index, so one index serves employees' + premiums whether paid pre-tax or post-tax. +3. Move neutrality: every input that holds employees' shares of ESI premiums + names the same series. +4. Bounds and order: each index is positive in every year, and the direct + purchase index is strictly increasing from 2020 to 2100 (CMS's series + falls in 2019 and 2020). +5. Extension rule: each year from 2035 to 2100 grows by 9,589 / 9,158. +6. No premium input follows out-of-pocket spending. +""" + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us.system import system + +MOOP = "calibration.gov.hhs.cms.moop_per_capita" +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" +SERIES = { + "employer_sponsored_insurance_premiums": ESI, + "pre_tax_health_insurance_premiums": ESI, + "health_insurance_premiums_without_medicare_part_b": ESI, + "other_health_insurance_premiums": ESI, + "health_insurance_premiums": DIRECT_PURCHASE, +} +EMPLOYEE_ESI_INPUTS = ( + "pre_tax_health_insurance_premiums", + "health_insurance_premiums_without_medicare_part_b", + "other_health_insurance_premiums", +) +FIRST_YEAR, LAST_PROJECTED_YEAR, END_YEAR = 2013, 2034, 2100 +YEARS = range(FIRST_YEAR, LAST_PROJECTED_YEAR + 1) + +# CMS NHE projections tables, Table 17, "Direct Purchase" rows +# (https://www.cms.gov/files/zip/nhe-projections-tables.zip, Last-Modified +# 2026-09-03, sha256 264f8226...cd70). +DIRECT_PURCHASE_SPENDING_PER_ENROLLEE = dict( + zip( + YEARS, + ( + *(2_263, 3_134, 3_454, 3_695, 4_175, 4_831, 4_812, 4_791, 4_979), + *(5_149, 5_458, 5_871, 5_994, 6_969, 7_174, 7_456, 7_752, 8_115), + *(8_364, 8_743, 9_158, 9_589), + ), + ) +) +DIRECT_PURCHASE_SPENDING_BILLIONS = dict( + zip( + YEARS, + ( + *(51.1, 81.3, 101.4, 110.2, 115.7, 129.7, 129.0, 131.5, 142.2), + *(154.7, 177.6, 215.5, 229.3, 240.6, 246.2, 246.8, 256.7, 270.3), + *(288.6, 303.5, 319.0, 335.3), + ), + ) +) +DIRECT_PURCHASE_ENROLLMENT_MILLIONS = dict( + zip( + YEARS, + ( + *(22.6, 26.0, 29.4, 29.8, 27.7, 26.8, 26.8, 27.5, 28.6, 30.0), + *(32.5, 36.7, 38.3, 34.5, 34.3, 33.1, 33.1, 33.3, 34.5, 34.7), + *(34.8, 35.0), + ), + ) +) +# Table 17, "Employer-sponsored Insurance" spending and enrollment, and +# Table 16, employer contributions to private health insurance premiums by +# private business, the federal government and state and local governments. +ESI_SPENDING_BILLIONS = {2024: 1_429.1, 2026: 1_650.5, 2034: 2_343.1} +ESI_ENROLLMENT_MILLIONS = {2024: 178.6, 2026: 179.8, 2034: 178.5} +EMPLOYER_CONTRIBUTIONS_BILLIONS = { + 2024: 753 + 47 + 247, + 2026: 865 + 56 + 288, + 2034: 1_207 + 82 + 429, +} + + +def _index(path, year): + return get_parameter(system.parameters, path)(f"{year}-01-01") + + +@pytest.mark.parametrize("year", YEARS) +def test_direct_purchase_index_is_table_17_spending_per_enrollee(year): + assert _index(DIRECT_PURCHASE, year) == DIRECT_PURCHASE_SPENDING_PER_ENROLLEE[year] + + +@pytest.mark.parametrize("year", YEARS) +def test_direct_purchase_values_match_spending_over_enrollment(year): + """Checks the transcribed rows against each other, so one mistyped + spending, enrollment or per-enrollee figure fails.""" + spending = DIRECT_PURCHASE_SPENDING_BILLIONS[year] + enrollment = DIRECT_PURCHASE_ENROLLMENT_MILLIONS[year] + # Both components are rounded to one decimal and the published value to + # the dollar. + low = 1_000 * (spending - 0.05) / (enrollment + 0.05) - 0.5 + high = 1_000 * (spending + 0.05) / (enrollment - 0.05) + 0.5 + assert low <= DIRECT_PURCHASE_SPENDING_PER_ENROLLEE[year] <= high + + +@pytest.mark.parametrize("start, end", [(2024, 2034), (2026, 2034)]) +def test_employee_contributions_per_enrollee_grow_with_the_esi_index(start, end): + """ESI spending is premiums paid by employers and employees (NHEA + methodology paper, file page 29), so spending less employer contributions + is what employees, retirees and COBRA enrollees pay.""" + + def employee_share(year): + return 1 - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_SPENDING_BILLIONS[year] + + def per_enrollee(year): + employee = ESI_SPENDING_BILLIONS[year] - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] + return employee / ESI_ENROLLMENT_MILLIONS[year] + + years = end - start + employee_growth = (per_enrollee(end) / per_enrollee(start)) ** (1 / years) + index_growth = (_index(ESI, end) / _index(ESI, start)) ** (1 / years) + assert employee_growth == pytest.approx(index_growth, abs=0.001) + # Employees pay 26.6% to 26.8% of ESI premiums in each year. + for year in (start, end): + assert 0.266 <= employee_share(year) <= 0.268 + + +def test_inputs_holding_employee_esi_shares_follow_one_series(): + """Microcosm's pre-tax producer moves an employee's payroll-deducted + premium out of the reported premium inputs into the pre-tax input. One + series for all of them keeps that move from changing the dollars' growth.""" + assert {system.variables[name].uprating for name in EMPLOYEE_ESI_INPUTS} == {ESI} + + +@pytest.mark.parametrize("variable, series", sorted(SERIES.items())) +def test_premium_input_follows_its_cms_premium_series(variable, series): + assert system.variables[variable].uprating == series + + +def test_no_premium_input_follows_out_of_pocket_spending(): + """CMS out-of-pocket spending excludes premium payments, so a premium + input on it would follow a series that leaves out what it measures.""" + on_moop = sorted( + name + for name, variable in system.variables.items() + if variable.uprating == MOOP and "premium" in name + ) + assert on_moop == [] + + +@given( + st.sampled_from((ESI, DIRECT_PURCHASE)), + st.integers(FIRST_YEAR, END_YEAR + 50), +) +def test_each_index_is_positive(path, year): + assert _index(path, year) > 0 + + +@given(st.integers(2020, END_YEAR - 1), st.integers(1, END_YEAR - 2020)) +def test_direct_purchase_index_strictly_increases_from_2020(year, gap): + later = min(year + gap, END_YEAR) + assert _index(DIRECT_PURCHASE, year) < _index(DIRECT_PURCHASE, later) + + +def test_direct_purchase_index_dips_where_cms_reports_it(): + """CMS's direct purchase premium per enrollee falls in 2019 and 2020; + the parameter keeps CMS's figures rather than smoothing them.""" + assert _index(DIRECT_PURCHASE, 2019) < _index(DIRECT_PURCHASE, 2018) + assert _index(DIRECT_PURCHASE, 2020) < _index(DIRECT_PURCHASE, 2019) + + +@given(st.integers(LAST_PROJECTED_YEAR + 1, END_YEAR)) +def test_direct_purchase_extension_holds_the_last_projected_growth_rate(year): + last_growth = _index(DIRECT_PURCHASE, LAST_PROJECTED_YEAR) / _index( + DIRECT_PURCHASE, LAST_PROJECTED_YEAR - 1 + ) + assert last_growth == pytest.approx(9_589 / 9_158, rel=1e-12) + growth = _index(DIRECT_PURCHASE, year) / _index(DIRECT_PURCHASE, year - 1) + assert growth == pytest.approx(last_growth, rel=1e-12) + + +@given(st.integers(END_YEAR, END_YEAR + 100)) +def test_direct_purchase_index_holds_its_2100_value_after_2100(year): + assert _index(DIRECT_PURCHASE, year) == _index(DIRECT_PURCHASE, END_YEAR) diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index fb7d4e7aec6..8deaa177d3c 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -1,7 +1,7 @@ """Each dollar input follows the series closest to what it measures: its own national series, the income source it is part of, prices for consumption -expenses and debts, CMS per-capita spending for health costs and CMS premiums -per enrollee for employer plan premiums.""" +expenses and debts, CMS per-capita out-of-pocket spending for health costs and +CMS premiums per enrollee for health insurance premiums.""" from pathlib import Path @@ -20,6 +20,7 @@ DEFAULT = per_capita_path("calibration.gov.cbo.income_by_source.adjusted_gross_income") CMS = "calibration.gov.hhs.cms.moop_per_capita" ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" COLA = "gov.ssa.uprating" @@ -60,12 +61,18 @@ "alimony_income": ALIMONY, "alimony_expense": ALIMONY, "unemployment_compensation": UNEMPLOYMENT, - # Health costs. - "health_insurance_premiums": CMS, - # Employer plan premiums: the employer's share and the employee's pre-tax - # share. + # Health costs paid out of pocket. + "other_medical_expenses": CMS, + "over_the_counter_health_expenses": CMS, + # Premiums for employer plans: the employer's share, the employee's + # pre-tax share, and the premiums people report, most of them paid by + # people with employer coverage. "employer_sponsored_insurance_premiums": ESI, "pre_tax_health_insurance_premiums": ESI, + "health_insurance_premiums_without_medicare_part_b": ESI, + "other_health_insurance_premiums": ESI, + # Premiums for plans bought directly. + "health_insurance_premiums": DIRECT_PURCHASE, # Benefits adjusted by the Social Security cost-of-living index. "veterans_benefits": COLA, # Consumption expenses, and the interest on debts, follow prices. The diff --git a/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py b/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py new file mode 100644 index 00000000000..5233f128ca7 --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py @@ -0,0 +1,171 @@ +"""The engine ages premium inputs by their CMS premium series, in a simulation +and in dataset extension. + +``tests/core/test_premium_input_uprating.py`` checks the series and that each +input names its series. This file checks what a parameter read cannot: that +holder uprating in a ``Simulation`` and ``extend_single_year_dataset`` resolve +the per-enrollee paths undivided by population and apply them to these +inputs. + +Invariants: + +1. Uprating: a premium entered for 2024 equals that amount times its index + ratio in any later year, so uprating is linear in the amount (Hypothesis, + one vectorized simulation per example). +2. Move neutrality: splitting an employee's premium in any proportion between + the pre-tax input and the reported premium inputs leaves the uprated total + unchanged in every later year (Hypothesis). This is what Microcosm's + pre-tax producer does when it routes a reported premium through payroll. +3. Dataset extension: each person's amount follows the index ratio, and the + weighted total grows by the index ratio times population growth, because + weights follow population. +""" + +from functools import lru_cache + +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us import Simulation +from policyengine_us.data.dataset_schema import USSingleYearDataset +from policyengine_us.data.economic_assumptions import extend_single_year_dataset +from policyengine_us.system import system +from policyengine_us.tools.per_capita_uprating import POPULATION_PATH + +ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" +DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" +PRE_TAX = "pre_tax_health_insurance_premiums" +REPORTED = "health_insurance_premiums_without_medicare_part_b" +OTHER = "other_health_insurance_premiums" +DIRECT = "health_insurance_premiums" +SERIES = {PRE_TAX: ESI, REPORTED: ESI, OTHER: ESI, DIRECT: DIRECT_PURCHASE} +BASE_YEAR, END_YEAR = 2024, 2100 + +amounts = st.integers(0, 3_000_000).map(lambda cents: cents / 100) +later_years = st.lists(st.integers(BASE_YEAR + 1, END_YEAR), min_size=2, max_size=4) +SETTINGS = settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + +def _ratio(path, year): + parameter = get_parameter(system.parameters, path) + return parameter(f"{year}-01-01") / parameter(f"{BASE_YEAR}-01-01") + + +def _simulation(people): + return Simulation( + situation={ + "people": people, + "households": {"household": {"members": list(people)}}, + } + ) + + +# Each example builds one simulation holding a batch of people. +@SETTINGS +@given( + st.lists(st.tuples(*(amounts,) * len(SERIES)), min_size=2, max_size=6), + later_years, +) +def test_simulation_uprates_premiums_by_their_index_ratio(rows, years): + people = { + f"person_{i}": { + "age": {BASE_YEAR: 40}, + **{variable: {BASE_YEAR: amount} for variable, amount in zip(SERIES, row)}, + } + for i, row in enumerate(rows) + } + simulation = _simulation(people) + for year in years: + for column, (variable, series) in enumerate(SERIES.items()): + values = simulation.calculate(variable, year) + for value, row in zip(values, rows): + assert value == pytest.approx( + row[column] * _ratio(series, year), rel=1e-6, abs=0.01 + ) + + +@SETTINGS +@given( + st.lists( + st.tuples(amounts, st.integers(0, 100), st.sampled_from((REPORTED, OTHER))), + min_size=2, + max_size=6, + ), + later_years, +) +def test_moving_a_premium_to_pre_tax_keeps_its_growth(rows, years): + """Each person's premium is split: ``percent`` of it pre-tax, the rest in + a reported premium input. Their uprated sum equals the whole premium + uprated, whatever the split.""" + people = {} + for i, (amount, percent, reported) in enumerate(rows): + pre_tax = amount * percent / 100 + people[f"person_{i}"] = { + "age": {BASE_YEAR: 40}, + PRE_TAX: {BASE_YEAR: pre_tax}, + reported: {BASE_YEAR: amount - pre_tax}, + } + simulation = _simulation(people) + for year in years: + totals = ( + simulation.calculate(PRE_TAX, year) + + simulation.calculate(REPORTED, year) + + simulation.calculate(OTHER, year) + ) + for total, (amount, _, _) in zip(totals, rows): + assert total == pytest.approx( + amount * _ratio(ESI, year), rel=1e-6, abs=0.01 + ) + + +@lru_cache(maxsize=1) +def _extended(): + person = pd.DataFrame( + { + "person_id": [1, 2, 3, 4], + "person_household_id": [1, 1, 2, 3], + PRE_TAX: [3_100.0, 0.0, 1_200.0, 0.0], + REPORTED: [1_400.0, 0.0, 6_300.0, 2_250.0], + OTHER: [1_400.0, 0.0, 5_100.0, 2_250.0], + DIRECT: [0.0, 0.0, 6_300.0, 0.0], + "age": [40.0, 8.0, 55.0, 70.0], + } + ) + household = pd.DataFrame( + {"household_id": [1, 2, 3], "household_weight": [1_000.0, 2_500.0, 400.0]} + ) + dataset = USSingleYearDataset( + person=person, + household=household, + tax_unit=pd.DataFrame({"tax_unit_id": [1, 2, 3]}), + spm_unit=pd.DataFrame({"spm_unit_id": [1, 2, 3]}), + family=pd.DataFrame({"family_id": [1, 2, 3]}), + marital_unit=pd.DataFrame({"marital_unit_id": [1, 2, 3, 4]}), + time_period=BASE_YEAR, + ) + return extend_single_year_dataset(dataset, end_year=2036, system=system) + + +def _weighted_total(dataset, column): + weights = dataset.household.set_index("household_id")["household_weight"] + person_weights = dataset.person["person_household_id"].map(weights) + return float((dataset.person[column] * person_weights).sum()) + + +@pytest.mark.parametrize("year", [2025, 2026, 2034, 2036]) +@pytest.mark.parametrize("variable, series", sorted(SERIES.items())) +def test_dataset_extension_uprates_by_the_index(variable, series, year): + base, later = _extended().datasets[BASE_YEAR], _extended().datasets[year] + for before, after in zip(base.person[variable], later.person[variable]): + assert after == pytest.approx(before * _ratio(series, year), rel=1e-9) + assert _weighted_total(later, variable) / _weighted_total( + base, variable + ) == pytest.approx(_ratio(series, year) * _ratio(POPULATION_PATH, year), rel=1e-9) 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..585c258c895 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -151,6 +151,19 @@ def test_cms_esi_premium_per_enrollee_holds_last_projected_growth_rate(): assert parameter("2150-01-01") == parameter("2100-01-01") +def test_cms_direct_purchase_premium_per_enrollee_holds_last_projected_growth_rate(): + """Directly bought premium inputs keep growing after CMS's 2034 + projection, at the 2033-2034 growth rate.""" + parameter = PARAMETERS.calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee + last_growth = parameter("2034-01-01") / parameter("2033-01-01") + + 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 parameter("2150-01-01") == parameter("2100-01-01") + + def test_soi_income_upraters_extend_without_trustees_reform(): """SOI income upraters should inherit baseline long-run CBO extensions.""" soi = PARAMETERS.calibration.gov.irs.soi From b091ef60f6a66e0f27f6c7e7749ef4913f05cf82 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:51:07 -0400 Subject: [PATCH 05/12] Uprate long-term care premiums by CPI-U medical care No dataset fills long_term_health_insurance_premiums and no CMS series measures long-term care insurance premiums; 26 U.S.C. 213(d)(10)(B) indexes the deductible limits on them by the medical care CPI. Adds gov.bls.cpi.cpi_u_medical_care: BLS annual means to 2022, CBO's February 2026 calendar-year series for 2023-2036, extended at its 2035-2036 rate. Co-Authored-By: Claude Opus 5.5 --- .../gov/bls/cpi/cpi_u_medical_care.yaml | 47 +++++++++++++++++++ .../parameters/uprating_extensions.py | 11 +++++ .../long_term_health_insurance_premiums.py | 5 +- 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml diff --git a/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml b/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml new file mode 100644 index 00000000000..965d853f813 --- /dev/null +++ b/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml @@ -0,0 +1,47 @@ +description: The Bureau of Labor Statistics estimates this medical care component of the Consumer Price Index for All Urban Consumers, averaged over each calendar year. +values: + # BLS: the mean of the 12 published monthly indexes, series CUUR0000SAM + # (not seasonally adjusted), rounded to three decimals. + 2015-01-01: 446.752 + 2016-01-01: 463.675 + 2017-01-01: 475.322 + 2018-01-01: 484.707 + 2019-01-01: 498.413 + 2020-01-01: 518.875 + 2021-01-01: 525.276 + 2022-01-01: 546.553 + # CBO February 2026 forecast, calendar years: actual values (BLS data + # released as of December 3, 2025) for 2023 and 2024, projections after. + 2023-01-01: 549.081 + 2024-01-01: 563.841 + 2025-01-01: 581.279 + 2026-01-01: 600.332 + 2027-01-01: 617.675 + 2028-01-01: 636.697 + 2029-01-01: 656.371 + 2030-01-01: 676.782 + 2031-01-01: 697.848 + 2032-01-01: 719.546 + 2033-01-01: 741.904 + 2034-01-01: 764.927 + 2035-01-01: 788.642 + 2036-01-01: 813.07 + # 2037 to 2100 grow at the 2035-2036 rate (813.07 / 788.642, 3.1% a year) + # and later years hold the 2100 value; see set_all_uprating_parameters in + # uprating_extensions.py. +metadata: + unit: /1 + label: CPI-U medical care + economy: false + reference: + - title: BLS | CPI-U, U.S. city average, medical care, not seasonally adjusted (CUUR0000SAM) + href: https://data.bls.gov/timeseries/CUUR0000SAM + - title: CBO | Economic Projections | February 2026 + # Sheet "2. Calendar Year", row 59: "CPI-U, medical care", 1982-84=100. + # cbo.gov refuses automated downloads; the copy read is the Wayback + # capture of 2026-09-22 (sha256 + # ae8f4920702fabf8fb3136bc94a42c53466cff5e890dec22396d8dc49dc2f776), + # whose values match CBO's data repository, variable + # cpiu_medical_price_index in + # https://github.com/US-CBO/cbo-data/blob/284a95665f9f2f74ed1f482feb629b43fce323da/data/economic/economic_projections/calendar_2026-02.csv + href: https://www.cbo.gov/system/files/2026-02/51135-2026-02-Economic-Projections.xlsx diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 704b90a6546..8199a7a3b2b 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -822,6 +822,17 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: period_day=1, ) + # CPI-U medical care (calendar-year values, last CBO projection year + # 2036). Long-term care insurance premium inputs use this as their + # uprater. + extend_parameter_values( + parameters.gov.bls.cpi.cpi_u_medical_care, + last_projected_year=2036, + end_year=END_YEAR, + period_month=1, + period_day=1, + ) + # Washington's millionaires tax deduction uses an odd-year October # adjustment schedule. Must run after the CPI-W extension above so the # determination dates are available through the projection horizon. diff --git a/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py index 71a9825e4ef..5d91d8d3ff2 100644 --- a/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py @@ -7,4 +7,7 @@ class long_term_health_insurance_premiums(Variable): label = "Long-term health insurance premiums" unit = USD definition_period = YEAR - uprating = "calibration.gov.hhs.cms.moop_per_capita" + # No dataset fills this input, and no CMS series measures long-term care + # insurance premiums. 26 U.S.C. 213(d)(10)(B) indexes the deductible + # limits on these premiums by the medical care component of the CPI. + uprating = "gov.bls.cpi.cpi_u_medical_care" From 517726c5b36179161012314f42e5244bd846a6c4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:53:23 -0400 Subject: [PATCH 06/12] Uprate reported Part B premiums by the Part B standard premium Adds calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee: the standard monthly premium for 2013-2026 (2026 Trustees Report Table III.C2) and the Trustees' intermediate estimates for 2027-2035 (Table V.E2), extended at the 2034-2035 rate. A premium with any income-related adjustment is a fixed multiple of the standard premium (42 U.S.C. 1395r(i)(3)). Co-Authored-By: Claude Opus 5.5 --- .../medicare_part_b_premium_per_enrollee.yaml | 47 +++++++++++ .../parameters/uprating_extensions.py | 11 +++ .../tests/core/test_premium_input_uprating.py | 78 +++++++++++++++++++ .../medicare_part_b_premiums_reported.py | 4 +- 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml new file mode 100644 index 00000000000..01ee320a3e4 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml @@ -0,0 +1,47 @@ +description: The Centers for Medicare and Medicaid Services (CMS) sets, and the Medicare Trustees project, this standard monthly Medicare Part B premium per enrollee. +metadata: + unit: currency-USD + period: month + label: Medicare Part B standard monthly premium per enrollee + reference: + # Table III.C2, "Standard Part B Monthly Premium Rates, Actuarial Rates, and + # Premium Rates as a Percentage of Part B Cost" (file pages 89-90), for + # 2013-2026; Table V.E2, "SMI Cost-Sharing and Premium Amounts" (file page + # 213), intermediate estimates for 2027-2035. Report transmitted + # 2026-06-09, retrieved 2026-10-11, sha256 + # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8. + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table V.E2 + href: https://www.cms.gov/oact/tr/2026#page=213 + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table III.C2 + href: https://www.cms.gov/oact/tr/2026#page=89 + - title: 2026 Medicare Parts A & B Premiums and Deductibles + href: https://www.cms.gov/newsroom/fact-sheets/2026-medicare-parts-b-premiums-deductibles +values: + # Historical. + 2013-01-01: 104.9 + 2014-01-01: 104.9 + 2015-01-01: 104.9 + 2016-01-01: 121.8 + 2017-01-01: 134 + 2018-01-01: 134 + 2019-01-01: 135.5 + 2020-01-01: 144.6 + 2021-01-01: 148.5 + 2022-01-01: 170.1 + 2023-01-01: 164.9 + 2024-01-01: 174.7 + 2025-01-01: 185 + 2026-01-01: 202.9 + # Trustees' intermediate estimates. + 2027-01-01: 209.5 + 2028-01-01: 224.5 + 2029-01-01: 238.5 + 2030-01-01: 255.5 + 2031-01-01: 272.1 + 2032-01-01: 290.2 + 2033-01-01: 313.6 + 2034-01-01: 338.5 + 2035-01-01: 360.6 + # 2036 to 2100 grow at the 2034-2035 rate (360.6 / 338.5, 6.5% a year) 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 8199a7a3b2b..8e1142c6419 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -882,6 +882,17 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: period_day=1, ) + # Medicare Part B standard monthly premium (January values, last Trustees + # projection year 2035). Reported Part B premium inputs use this as their + # uprater. + extend_parameter_values( + parameters.calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee, + last_projected_year=2035, + end_year=END_YEAR, + period_month=1, + period_day=1, + ) + # Gross Social Security benefit inputs should age with gross Social # Security benefits, not taxable Social Security income. extend_parameter_values( diff --git a/policyengine_us/tests/core/test_premium_input_uprating.py b/policyengine_us/tests/core/test_premium_input_uprating.py index 616ec3f4424..ebe4ae98b39 100644 --- a/policyengine_us/tests/core/test_premium_input_uprating.py +++ b/policyengine_us/tests/core/test_premium_input_uprating.py @@ -209,3 +209,81 @@ def test_direct_purchase_extension_holds_the_last_projected_growth_rate(year): @given(st.integers(END_YEAR, END_YEAR + 100)) def test_direct_purchase_index_holds_its_2100_value_after_2100(year): assert _index(DIRECT_PURCHASE, year) == _index(DIRECT_PURCHASE, END_YEAR) + + +# Long-term care insurance premiums: no dataset fills the input and no CMS +# series measures these premiums. 26 U.S.C. 213(d)(10)(B) indexes the +# deductible limits on them by the medical care component of the CPI, so the +# input follows CPI-U medical care. +MEDICAL_CPI = "gov.bls.cpi.cpi_u_medical_care" +MEDICAL_CPI_FIRST_YEAR, MEDICAL_CPI_LAST_PROJECTED_YEAR = 2015, 2036 +# CBO, Economic Projections, February 2026, sheet "2. Calendar Year": row 59 +# (level, 1982-84=100) and row 60 (percentage change, annual rate). +CBO_MEDICAL_CPI = dict( + zip( + range(2023, 2037), + ( + *(549.081, 563.841, 581.279, 600.332, 617.675, 636.697, 656.371), + *(676.782, 697.848, 719.546, 741.904, 764.927, 788.642, 813.07), + ), + ) +) +CBO_MEDICAL_CPI_PERCENT_CHANGE = dict( + zip( + range(2023, 2037), + ( + *(0.463, 2.688, 3.092, 3.277, 2.888, 3.079, 3.09, 3.109, 3.112), + *(3.109, 3.107, 3.103, 3.1, 3.097), + ), + ) +) +# BLS series CUUR0000SAM, mean of the 12 published monthly indexes. +BLS_MEDICAL_CPI_ANNUAL_MEAN = {2022: 546.553, 2023: 549.084, 2024: 563.841} + + +def test_long_term_care_premiums_follow_medical_care_prices(): + assert system.variables["long_term_health_insurance_premiums"].uprating == ( + MEDICAL_CPI + ) + + +@pytest.mark.parametrize("year", sorted(CBO_MEDICAL_CPI)) +def test_medical_cpi_is_cbo_calendar_year_series(year): + assert _index(MEDICAL_CPI, year) == CBO_MEDICAL_CPI[year] + + +@pytest.mark.parametrize("year", sorted(CBO_MEDICAL_CPI_PERCENT_CHANGE)) +def test_medical_cpi_levels_match_cbo_percent_changes(year): + """CBO publishes levels to three decimals and changes to three decimals + of a percentage point, so each change agrees with the levels to within + rounding. The 2023 change checks the BLS 2022 value that precedes CBO's + series.""" + change = 100 * (_index(MEDICAL_CPI, year) / _index(MEDICAL_CPI, year - 1) - 1) + assert change == pytest.approx(CBO_MEDICAL_CPI_PERCENT_CHANGE[year], abs=0.001) + + +@pytest.mark.parametrize("year", [2023, 2024]) +def test_cbo_actual_medical_cpi_matches_bls(year): + assert CBO_MEDICAL_CPI[year] == pytest.approx( + BLS_MEDICAL_CPI_ANNUAL_MEAN[year], abs=0.005 + ) + + +@given( + st.integers(MEDICAL_CPI_FIRST_YEAR, END_YEAR - 1), + st.integers(1, END_YEAR - MEDICAL_CPI_FIRST_YEAR), +) +def test_medical_cpi_is_positive_and_strictly_increasing(year, gap): + later = min(year + gap, END_YEAR) + assert 0 < _index(MEDICAL_CPI, year) < _index(MEDICAL_CPI, later) + + +@given(st.integers(MEDICAL_CPI_LAST_PROJECTED_YEAR + 1, END_YEAR)) +def test_medical_cpi_extension_holds_the_last_projected_growth_rate(year): + growth = _index(MEDICAL_CPI, year) / _index(MEDICAL_CPI, year - 1) + assert growth == pytest.approx(813.07 / 788.642, rel=1e-12) + + +@given(st.integers(END_YEAR, END_YEAR + 100)) +def test_medical_cpi_holds_its_2100_value_after_2100(year): + assert _index(MEDICAL_CPI, year) == _index(MEDICAL_CPI, END_YEAR) diff --git a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py index 500970c641d..c332c7bd9c2 100644 --- a/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py +++ b/policyengine_us/variables/household/expense/health/medicare_part_b_premiums_reported.py @@ -7,4 +7,6 @@ class medicare_part_b_premiums_reported(Variable): label = "Medicare Part B premiums (reported)" definition_period = YEAR unit = USD - uprating = "calibration.gov.hhs.cms.moop_per_capita" + # A Part B premium, with any income-related adjustment, is a fixed + # multiple of the standard premium (42 U.S.C. 1395r(i)(3)). + uprating = "calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee" From caf654d7b564ff13963a2977e083d32f88bf0b1d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:57:19 -0400 Subject: [PATCH 07/12] Test and document every premium input's series Core tests cover transcription, the Part B statutory components, agreement with the model's Part B premium, Hypothesis bounds and extension rules for each index; the microsimulation test covers all five inputs. The SPM methodology page says which inputs follow which premium series. Co-Authored-By: Claude Opus 5.5 --- .../premium-inputs-cms-uprating.changed.md | 2 +- docs-quarto/methodology/spm-poverty.qmd | 6 +- .../tests/core/test_per_capita_uprating.py | 5 +- .../tests/core/test_premium_input_uprating.py | 372 +++++++++++------- .../tests/core/test_uprating_series.py | 10 +- .../test_premium_input_microsim_uprating.py | 23 +- .../parameters/test_uprating_extensions.py | 26 ++ 7 files changed, 288 insertions(+), 156 deletions(-) diff --git a/changelog.d/premium-inputs-cms-uprating.changed.md b/changelog.d/premium-inputs-cms-uprating.changed.md index 80dc57768c4..e20ed1c454b 100644 --- a/changelog.d/premium-inputs-cms-uprating.changed.md +++ b/changelog.d/premium-inputs-cms-uprating.changed.md @@ -1 +1 @@ -Uprate the reported premium inputs by CMS premium series instead of CMS out-of-pocket spending per capita, which excludes premiums: reported and other health insurance premiums follow employer-sponsored insurance premium per enrollee, and directly supplied premiums follow direct purchase insurance premium per enrollee (NHE projections Table 17). +Uprate premium inputs by premium series instead of CMS out-of-pocket spending per capita, which excludes premiums: reported and other health insurance premiums follow CMS's employer-sponsored insurance premium per enrollee (the series pre-tax premiums follow), directly supplied premiums follow a new CMS direct purchase premium per enrollee series, long-term care premiums follow a new CPI-U medical care series (BLS and CBO), and reported Medicare Part B premiums follow a new Part B standard premium series (Medicare Trustees Report). diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index b8562e63b9c..da7bc2f33ce 100644 --- a/docs-quarto/methodology/spm-poverty.qmd +++ b/docs-quarto/methodology/spm-poverty.qmd @@ -41,8 +41,8 @@ Benefit amounts are computed from statute for the eligible population. Whether a [`spm_unit_medical_out_of_pocket_expenses`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/variables/household/income/spm_unit/spm_unit_medical_out_of_pocket_expenses.py) is health insurance premiums plus non-premium medical spending. -- Premiums the model computes from rules: Medicare Part A and Part B (including IRMAA), the Part D IRMAA surcharge, CHIP premiums in the states with encoded schedules, and Marketplace net premiums after the premium tax credit. Medicaid expansion premium schedules for Indiana, Michigan and Montana are encoded for reform analysis but are zero in the 2024 and 2025 baselines. Other reported premiums, including the employee share of employer plans, stay as reported in the survey. -- Non-premium spending ([`other_medical_expenses`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/variables/household/expense/health/other_medical_expenses.py) and over-the-counter health expenses) is imported from the survey and uprated with CMS per-capita spending. No rule changes it. +- Premiums the model computes from rules: Medicare Part A and Part B (including IRMAA), the Part D IRMAA surcharge, CHIP premiums in the states with encoded schedules, and Marketplace net premiums after the premium tax credit. Medicaid expansion premium schedules for Indiana, Michigan and Montana are encoded for reform analysis but are zero in the 2024 and 2025 baselines. Other reported premiums, including the employee share of employer plans, stay as reported in the survey and are uprated with CMS's employer-sponsored insurance premium per enrollee. +- Non-premium spending ([`other_medical_expenses`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/variables/household/expense/health/other_medical_expenses.py) and over-the-counter health expenses) is imported from the survey and uprated with CMS per-capita out-of-pocket spending. No rule changes it. The [MOOP decomposition page](moop-decomposition.qmd) records the design and the remaining double-count risks. @@ -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, over-the-counter health expenses and other health insurance premiums that enter SPM resources; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; health insurance premiums in total, long-term and excluding Medicare Part B, and reported Medicare Part B premiums; and health savings account payroll contributions. 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. Employer-sponsored insurance premiums and pre-tax health insurance premiums, the employer's and the employee's shares of employer plans, 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), also a per-person series, which CMS projects to 2034; it grows at its 2033 to 2034 rate from 2035 to 2100 and holds the 2100 value after that. 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) 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; physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses; and health savings account payroll contributions. 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. 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; one series for all three means a premium the data move from the reported inputs to the pre-tax input grows the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow [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), also projected to 2034. Long-term care insurance premiums, which no dataset fills and no CMS series measures, 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), the index for the deductible limits on those premiums (26 U.S.C. 213(d)(10)(B)), which CBO projects to 2036. 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; a premium with any income-related adjustment is a fixed multiple of it. 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) 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/tests/core/test_per_capita_uprating.py b/policyengine_us/tests/core/test_per_capita_uprating.py index a082f1c1cb7..1a4ba4b7e0e 100644 --- a/policyengine_us/tests/core/test_per_capita_uprating.py +++ b/policyengine_us/tests/core/test_per_capita_uprating.py @@ -39,6 +39,7 @@ CMS_MOOP = "calibration.gov.hhs.cms.moop_per_capita" CMS_ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" CMS_DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" +CMS_PART_B = "calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" YEARS = (2025, 2030, 2035) BASE_YEAR = 2024 @@ -62,6 +63,7 @@ def _growth(path, year, parameters=None): (CMS_MOOP, False), (CMS_ESI, False), (CMS_DIRECT_PURCHASE, False), + (CMS_PART_B, False), (CPI_U, False), (POPULATION_PATH, False), (per_capita_path(SOI_EMPLOYMENT), False), @@ -95,6 +97,7 @@ def test_no_variable_uprates_by_a_national_total(): ("other_medical_expenses", CMS_MOOP), ("employer_sponsored_insurance_premiums", CMS_ESI), ("health_insurance_premiums", CMS_DIRECT_PURCHASE), + ("medicare_part_b_premiums_reported", CMS_PART_B), ("child_support_expense", CPI_U), ("household_weight", POPULATION_PATH), ], @@ -407,7 +410,7 @@ def test_unclassified_calibration_path_is_rejected(): add_per_capita_uprating(_StubSystem("calibration.gov.aca.spending.state.AL")) -@pytest.mark.parametrize("path", [CMS_MOOP, CMS_ESI, CMS_DIRECT_PURCHASE]) +@pytest.mark.parametrize("path", [CMS_MOOP, CMS_ESI, CMS_DIRECT_PURCHASE, CMS_PART_B]) def test_per_person_rates_are_classified(path): """CMS per-capita and per-enrollee series are per-person rates: neither national totals to divide nor unclassified paths to reject.""" diff --git a/policyengine_us/tests/core/test_premium_input_uprating.py b/policyengine_us/tests/core/test_premium_input_uprating.py index ebe4ae98b39..3420a5f6d9a 100644 --- a/policyengine_us/tests/core/test_premium_input_uprating.py +++ b/policyengine_us/tests/core/test_premium_input_uprating.py @@ -1,43 +1,57 @@ -"""Premium inputs follow CMS premium series, not CMS out-of-pocket spending. +"""Premium inputs follow premium series, not CMS out-of-pocket spending. CMS defines out-of-pocket spending to exclude premiums (NHEA methodology paper, 2024, file page 14), so no premium input uprates by -``moop_per_capita``. Each follows the CMS premium series closest to what the -data put in it: +``moop_per_capita``. Each follows the series closest to what the data put in +it: - ``health_insurance_premiums_without_medicare_part_b`` carries the CPS ASEC premium the person reported (``PHIP_VAL``), and ``other_health_insurance_premiums`` is that premium less modeled CHIP, Medicaid and Marketplace premiums. People with employer-sponsored coverage pay most of these dollars, and Microcosm's pre-tax producer moves employees' - payroll-deducted shares from these inputs to + payroll-deducted premiums from these inputs to ``pre_tax_health_insurance_premiums``. All three follow CMS's employer-sponsored insurance (ESI) premium per enrollee, so that move leaves the dollars' growth unchanged. - ``health_insurance_premiums`` holds premiums of self-employed people under 65 without employer-sponsored coverage, who buy plans directly. It follows - CMS's direct purchase premium per enrollee (Table 17). + CMS's direct purchase premium per enrollee. +- ``long_term_health_insurance_premiums``: no dataset fills it and no CMS + series measures long-term care insurance premiums. 26 U.S.C. 213(d)(10)(B) + indexes the deductible limits on them by the medical care component of the + CPI, so it follows CPI-U medical care. +- ``medicare_part_b_premiums_reported`` follows the Part B standard premium: + a premium with any income-related adjustment is a fixed multiple of it + (42 U.S.C. 1395r(i)(3)). These tests read only the parameter tree. Engine uprating of the inputs is covered in ``tests/microsimulation/test_premium_input_microsim_uprating.py``. Invariants: -1. Transcription: every direct purchase value is Table 17's published dollar - figure, consistent with the same table's direct purchase spending over - enrollment within rounding. -2. Employee share: ESI spending less employer contributions (Table 16), per - ESI enrollee, grows with the ESI index, so one index serves employees' +1. Transcription: each series equals its published source figure for figure, + and is consistent with the source's own components within rounding: CMS + direct purchase spending over enrollment; CBO's medical care CPI levels + against its percentage changes and against BLS; the Part B premium against + half the aged actuarial rate plus the repayment add-on. +2. Employee share: ESI spending less employer contributions, per ESI + enrollee, grows with the ESI index, so one index serves employees' premiums whether paid pre-tax or post-tax. 3. Move neutrality: every input that holds employees' shares of ESI premiums names the same series. -4. Bounds and order: each index is positive in every year, and the direct - purchase index is strictly increasing from 2020 to 2100 (CMS's series - falls in 2019 and 2020). -5. Extension rule: each year from 2035 to 2100 grows by 9,589 / 9,158. -6. No premium input follows out-of-pocket spending. +4. Agreement: the Part B series equals the model's Part B premium parameter + in every year that parameter holds. +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. +7. No premium input follows out-of-pocket spending. """ +from decimal import ROUND_HALF_UP, Decimal + import pytest from hypothesis import given from hypothesis import strategies as st @@ -48,27 +62,103 @@ MOOP = "calibration.gov.hhs.cms.moop_per_capita" ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" +MEDICAL_CPI = "gov.bls.cpi.cpi_u_medical_care" +PART_B = "calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee" SERIES = { "employer_sponsored_insurance_premiums": ESI, "pre_tax_health_insurance_premiums": ESI, "health_insurance_premiums_without_medicare_part_b": ESI, "other_health_insurance_premiums": ESI, "health_insurance_premiums": DIRECT_PURCHASE, + "long_term_health_insurance_premiums": MEDICAL_CPI, + "medicare_part_b_premiums_reported": PART_B, } EMPLOYEE_ESI_INPUTS = ( "pre_tax_health_insurance_premiums", "health_insurance_premiums_without_medicare_part_b", "other_health_insurance_premiums", ) -FIRST_YEAR, LAST_PROJECTED_YEAR, END_YEAR = 2013, 2034, 2100 -YEARS = range(FIRST_YEAR, LAST_PROJECTED_YEAR + 1) +END_YEAR = 2100 +# First year, last published year and the year from which each index rises +# every year. +SPANS = { + DIRECT_PURCHASE: (2013, 2034, 2020), + MEDICAL_CPI: (2015, 2036, 2015), + PART_B: (2013, 2035, 2023), +} + + +def _index(path, year): + return get_parameter(system.parameters, path)(f"{year}-01-01") + + +# Wiring. + + +@pytest.mark.parametrize("variable, series", sorted(SERIES.items())) +def test_premium_input_follows_its_premium_series(variable, series): + assert system.variables[variable].uprating == series + + +def test_no_premium_input_follows_out_of_pocket_spending(): + """CMS out-of-pocket spending excludes premium payments, so a premium + input on it would follow a series that leaves out what it measures.""" + on_moop = sorted( + name + for name, variable in system.variables.items() + if variable.uprating == MOOP and "premium" in name + ) + assert on_moop == [] + + +def test_inputs_holding_employee_esi_shares_follow_one_series(): + """Microcosm's pre-tax producer moves an employee's payroll-deducted + premium out of the reported premium inputs into the pre-tax input. One + series for all of them keeps that move from changing the dollars' growth.""" + assert {system.variables[name].uprating for name in EMPLOYEE_ESI_INPUTS} == {ESI} + + +# Employer-sponsored insurance: CMS NHE projections tables (2025-2034 release, +# https://www.cms.gov/files/zip/nhe-projections-tables.zip, Last-Modified +# 2026-09-03, sha256 264f8226...cd70), Table 17 ESI spending and enrollment, +# and Table 16 employer contributions to private health insurance premiums by +# private business, the federal government and state and local governments. +ESI_SPENDING_BILLIONS = {2024: 1_429.1, 2026: 1_650.5, 2034: 2_343.1} +ESI_ENROLLMENT_MILLIONS = {2024: 178.6, 2026: 179.8, 2034: 178.5} +EMPLOYER_CONTRIBUTIONS_BILLIONS = { + 2024: 753 + 47 + 247, + 2026: 865 + 56 + 288, + 2034: 1_207 + 82 + 429, +} + + +@pytest.mark.parametrize("start, end", [(2024, 2034), (2026, 2034)]) +def test_employee_contributions_per_enrollee_grow_with_the_esi_index(start, end): + """ESI spending is premiums paid by employers and employees (NHEA + methodology paper, file page 29), so spending less employer contributions + is what employees, retirees and COBRA enrollees pay.""" + + def employee_share(year): + return 1 - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_SPENDING_BILLIONS[year] + + def per_enrollee(year): + employee = ESI_SPENDING_BILLIONS[year] - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] + return employee / ESI_ENROLLMENT_MILLIONS[year] -# CMS NHE projections tables, Table 17, "Direct Purchase" rows -# (https://www.cms.gov/files/zip/nhe-projections-tables.zip, Last-Modified -# 2026-09-03, sha256 264f8226...cd70). + years = end - start + employee_growth = (per_enrollee(end) / per_enrollee(start)) ** (1 / years) + index_growth = (_index(ESI, end) / _index(ESI, start)) ** (1 / years) + assert employee_growth == pytest.approx(index_growth, abs=0.001) + # Employees pay 26.6% to 26.8% of ESI premiums in each year. + for year in (start, end): + assert 0.266 <= employee_share(year) <= 0.268 + + +# Direct purchase: the same tables, Table 17 "Direct Purchase" rows. +DIRECT_PURCHASE_YEARS = range(2013, 2035) DIRECT_PURCHASE_SPENDING_PER_ENROLLEE = dict( zip( - YEARS, + DIRECT_PURCHASE_YEARS, ( *(2_263, 3_134, 3_454, 3_695, 4_175, 4_831, 4_812, 4_791, 4_979), *(5_149, 5_458, 5_871, 5_994, 6_969, 7_174, 7_456, 7_752, 8_115), @@ -78,7 +168,7 @@ ) DIRECT_PURCHASE_SPENDING_BILLIONS = dict( zip( - YEARS, + DIRECT_PURCHASE_YEARS, ( *(51.1, 81.3, 101.4, 110.2, 115.7, 129.7, 129.0, 131.5, 142.2), *(154.7, 177.6, 215.5, 229.3, 240.6, 246.2, 246.8, 256.7, 270.3), @@ -88,7 +178,7 @@ ) DIRECT_PURCHASE_ENROLLMENT_MILLIONS = dict( zip( - YEARS, + DIRECT_PURCHASE_YEARS, ( *(22.6, 26.0, 29.4, 29.8, 27.7, 26.8, 26.8, 27.5, 28.6, 30.0), *(32.5, 36.7, 38.3, 34.5, 34.3, 33.1, 33.1, 33.3, 34.5, 34.7), @@ -96,28 +186,14 @@ ), ) ) -# Table 17, "Employer-sponsored Insurance" spending and enrollment, and -# Table 16, employer contributions to private health insurance premiums by -# private business, the federal government and state and local governments. -ESI_SPENDING_BILLIONS = {2024: 1_429.1, 2026: 1_650.5, 2034: 2_343.1} -ESI_ENROLLMENT_MILLIONS = {2024: 178.6, 2026: 179.8, 2034: 178.5} -EMPLOYER_CONTRIBUTIONS_BILLIONS = { - 2024: 753 + 47 + 247, - 2026: 865 + 56 + 288, - 2034: 1_207 + 82 + 429, -} - - -def _index(path, year): - return get_parameter(system.parameters, path)(f"{year}-01-01") -@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("year", DIRECT_PURCHASE_YEARS) def test_direct_purchase_index_is_table_17_spending_per_enrollee(year): assert _index(DIRECT_PURCHASE, year) == DIRECT_PURCHASE_SPENDING_PER_ENROLLEE[year] -@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("year", DIRECT_PURCHASE_YEARS) def test_direct_purchase_values_match_spending_over_enrollment(year): """Checks the transcribed rows against each other, so one mistyped spending, enrollment or per-enrollee figure fails.""" @@ -130,65 +206,6 @@ def test_direct_purchase_values_match_spending_over_enrollment(year): assert low <= DIRECT_PURCHASE_SPENDING_PER_ENROLLEE[year] <= high -@pytest.mark.parametrize("start, end", [(2024, 2034), (2026, 2034)]) -def test_employee_contributions_per_enrollee_grow_with_the_esi_index(start, end): - """ESI spending is premiums paid by employers and employees (NHEA - methodology paper, file page 29), so spending less employer contributions - is what employees, retirees and COBRA enrollees pay.""" - - def employee_share(year): - return 1 - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_SPENDING_BILLIONS[year] - - def per_enrollee(year): - employee = ESI_SPENDING_BILLIONS[year] - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] - return employee / ESI_ENROLLMENT_MILLIONS[year] - - years = end - start - employee_growth = (per_enrollee(end) / per_enrollee(start)) ** (1 / years) - index_growth = (_index(ESI, end) / _index(ESI, start)) ** (1 / years) - assert employee_growth == pytest.approx(index_growth, abs=0.001) - # Employees pay 26.6% to 26.8% of ESI premiums in each year. - for year in (start, end): - assert 0.266 <= employee_share(year) <= 0.268 - - -def test_inputs_holding_employee_esi_shares_follow_one_series(): - """Microcosm's pre-tax producer moves an employee's payroll-deducted - premium out of the reported premium inputs into the pre-tax input. One - series for all of them keeps that move from changing the dollars' growth.""" - assert {system.variables[name].uprating for name in EMPLOYEE_ESI_INPUTS} == {ESI} - - -@pytest.mark.parametrize("variable, series", sorted(SERIES.items())) -def test_premium_input_follows_its_cms_premium_series(variable, series): - assert system.variables[variable].uprating == series - - -def test_no_premium_input_follows_out_of_pocket_spending(): - """CMS out-of-pocket spending excludes premium payments, so a premium - input on it would follow a series that leaves out what it measures.""" - on_moop = sorted( - name - for name, variable in system.variables.items() - if variable.uprating == MOOP and "premium" in name - ) - assert on_moop == [] - - -@given( - st.sampled_from((ESI, DIRECT_PURCHASE)), - st.integers(FIRST_YEAR, END_YEAR + 50), -) -def test_each_index_is_positive(path, year): - assert _index(path, year) > 0 - - -@given(st.integers(2020, END_YEAR - 1), st.integers(1, END_YEAR - 2020)) -def test_direct_purchase_index_strictly_increases_from_2020(year, gap): - later = min(year + gap, END_YEAR) - assert _index(DIRECT_PURCHASE, year) < _index(DIRECT_PURCHASE, later) - - def test_direct_purchase_index_dips_where_cms_reports_it(): """CMS's direct purchase premium per enrollee falls in 2019 and 2020; the parameter keeps CMS's figures rather than smoothing them.""" @@ -196,29 +213,9 @@ def test_direct_purchase_index_dips_where_cms_reports_it(): assert _index(DIRECT_PURCHASE, 2020) < _index(DIRECT_PURCHASE, 2019) -@given(st.integers(LAST_PROJECTED_YEAR + 1, END_YEAR)) -def test_direct_purchase_extension_holds_the_last_projected_growth_rate(year): - last_growth = _index(DIRECT_PURCHASE, LAST_PROJECTED_YEAR) / _index( - DIRECT_PURCHASE, LAST_PROJECTED_YEAR - 1 - ) - assert last_growth == pytest.approx(9_589 / 9_158, rel=1e-12) - growth = _index(DIRECT_PURCHASE, year) / _index(DIRECT_PURCHASE, year - 1) - assert growth == pytest.approx(last_growth, rel=1e-12) - - -@given(st.integers(END_YEAR, END_YEAR + 100)) -def test_direct_purchase_index_holds_its_2100_value_after_2100(year): - assert _index(DIRECT_PURCHASE, year) == _index(DIRECT_PURCHASE, END_YEAR) - - -# Long-term care insurance premiums: no dataset fills the input and no CMS -# series measures these premiums. 26 U.S.C. 213(d)(10)(B) indexes the -# deductible limits on them by the medical care component of the CPI, so the -# input follows CPI-U medical care. -MEDICAL_CPI = "gov.bls.cpi.cpi_u_medical_care" -MEDICAL_CPI_FIRST_YEAR, MEDICAL_CPI_LAST_PROJECTED_YEAR = 2015, 2036 -# CBO, Economic Projections, February 2026, sheet "2. Calendar Year": row 59 -# (level, 1982-84=100) and row 60 (percentage change, annual rate). +# CPI-U medical care: BLS annual means to 2022 and CBO's February 2026 +# Economic Projections, sheet "2. Calendar Year", row 59 (level, 1982-84=100) +# and row 60 (percentage change, annual rate) from 2023. CBO_MEDICAL_CPI = dict( zip( range(2023, 2037), @@ -241,12 +238,6 @@ def test_direct_purchase_index_holds_its_2100_value_after_2100(year): BLS_MEDICAL_CPI_ANNUAL_MEAN = {2022: 546.553, 2023: 549.084, 2024: 563.841} -def test_long_term_care_premiums_follow_medical_care_prices(): - assert system.variables["long_term_health_insurance_premiums"].uprating == ( - MEDICAL_CPI - ) - - @pytest.mark.parametrize("year", sorted(CBO_MEDICAL_CPI)) def test_medical_cpi_is_cbo_calendar_year_series(year): assert _index(MEDICAL_CPI, year) == CBO_MEDICAL_CPI[year] @@ -254,36 +245,127 @@ def test_medical_cpi_is_cbo_calendar_year_series(year): @pytest.mark.parametrize("year", sorted(CBO_MEDICAL_CPI_PERCENT_CHANGE)) def test_medical_cpi_levels_match_cbo_percent_changes(year): - """CBO publishes levels to three decimals and changes to three decimals - of a percentage point, so each change agrees with the levels to within - rounding. The 2023 change checks the BLS 2022 value that precedes CBO's - series.""" + """CBO publishes levels and percentage changes to three decimals, so each + change agrees with the levels within rounding. The 2023 change also + checks the BLS 2022 value that precedes CBO's series.""" change = 100 * (_index(MEDICAL_CPI, year) / _index(MEDICAL_CPI, year - 1) - 1) assert change == pytest.approx(CBO_MEDICAL_CPI_PERCENT_CHANGE[year], abs=0.001) -@pytest.mark.parametrize("year", [2023, 2024]) -def test_cbo_actual_medical_cpi_matches_bls(year): - assert CBO_MEDICAL_CPI[year] == pytest.approx( +@pytest.mark.parametrize("year", [2022, 2023, 2024]) +def test_medical_cpi_matches_bls_where_bls_has_a_full_year(year): + assert _index(MEDICAL_CPI, year) == pytest.approx( BLS_MEDICAL_CPI_ANNUAL_MEAN[year], abs=0.005 ) +# Part B standard monthly premium: 2026 Medicare Trustees Report, Table III.C2 +# (file pages 89-90, 2013-2026, with the aged monthly actuarial rate) and +# Table V.E2 (file page 213, intermediate estimates for 2027-2035). +PART_B_STANDARD_PREMIUM = dict( + zip( + range(2013, 2036), + ( + *(104.9, 104.9, 104.9, 121.8, 134.0, 134.0, 135.5, 144.6, 148.5), + *(170.1, 164.9, 174.7, 185.0, 202.9, 209.5, 224.5, 238.5, 255.5), + *(272.1, 290.2, 313.6, 338.5, 360.6), + ), + ) +) +PART_B_AGED_ACTUARIAL_RATE = dict( + zip( + range(2013, 2027), + ( + *("209.80", "209.80", "209.80", "237.60", "261.90", "261.90"), + *("264.90", "283.20", "291.00", "334.20", "323.70", "343.40"), + *("368.10", "405.40"), + ), + ) +) +# 42 U.S.C. 1395r(a)(6): the add-on that repays the 2016 premium relief, $3.00 +# through 2024, $0.90 in 2025 and $0.20 in 2026 (2026 Trustees Report, +# footnote on file page 88). +PART_B_REPAYMENT_ADD_ON = { + **{year: "0" for year in range(2013, 2016)}, + **{year: "3.00" for year in range(2016, 2025)}, + 2025: "0.90", + 2026: "0.20", +} + + +@pytest.mark.parametrize("year", sorted(PART_B_STANDARD_PREMIUM)) +def test_part_b_index_is_the_trustees_standard_premium(year): + assert _index(PART_B, year) == PART_B_STANDARD_PREMIUM[year] + + +@pytest.mark.parametrize("year", sorted(PART_B_AGED_ACTUARIAL_RATE)) +def test_part_b_premium_is_half_the_aged_actuarial_rate_plus_the_add_on(year): + """42 U.S.C. 1395r(a)(3) sets the premium at 50% of the aged actuarial + rate, (a)(6) adds the repayment amount, and (c) rounds to the nearest + multiple of 10 cents.""" + premium = ( + Decimal(PART_B_AGED_ACTUARIAL_RATE[year]) / 2 + + Decimal(PART_B_REPAYMENT_ADD_ON[year]) + ).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) + assert float(premium) == _index(PART_B, year) + + +@pytest.mark.parametrize("year", range(2021, 2027)) +def test_part_b_index_agrees_with_the_model_part_b_premium(year): + """``gov.hhs.medicare.part_b.base_premium`` computes modeled Part B + premiums from the same announced amounts.""" + base_premium = get_parameter( + system.parameters, "gov.hhs.medicare.part_b.base_premium" + ) + assert _index(PART_B, year) == base_premium(f"{year}-01-01") + + +# Every index. + + @given( - st.integers(MEDICAL_CPI_FIRST_YEAR, END_YEAR - 1), - st.integers(1, END_YEAR - MEDICAL_CPI_FIRST_YEAR), + st.sampled_from((ESI, DIRECT_PURCHASE, MEDICAL_CPI, PART_B)), + st.integers(2015, END_YEAR + 50), ) -def test_medical_cpi_is_positive_and_strictly_increasing(year, gap): +def test_each_index_is_positive(path, year): + assert _index(path, year) > 0 + + +@given(st.sampled_from(sorted(SPANS)), st.integers(0, END_YEAR), st.integers(1, 99)) +def test_each_index_strictly_increases_after_its_last_dip(path, offset, gap): + _, _, rises_from = SPANS[path] + year = rises_from + offset % (END_YEAR - rises_from) later = min(year + gap, END_YEAR) - assert 0 < _index(MEDICAL_CPI, year) < _index(MEDICAL_CPI, later) + assert _index(path, year) < _index(path, later) + + +@given(st.sampled_from(sorted(SPANS)), 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) + last_growth = _index(path, last) / _index(path, last - 1) + growth = _index(path, year) / _index(path, year - 1) + assert growth == pytest.approx(last_growth, rel=1e-12) -@given(st.integers(MEDICAL_CPI_LAST_PROJECTED_YEAR + 1, END_YEAR)) -def test_medical_cpi_extension_holds_the_last_projected_growth_rate(year): - growth = _index(MEDICAL_CPI, year) / _index(MEDICAL_CPI, year - 1) - assert growth == pytest.approx(813.07 / 788.642, rel=1e-12) +@pytest.mark.parametrize( + "path, last_growth", + [ + (DIRECT_PURCHASE, 9_589 / 9_158), + (MEDICAL_CPI, 813.07 / 788.642), + (PART_B, 360.6 / 338.5), + ], +) +def test_last_published_growth_rates(path, last_growth): + _, last, _ = SPANS[path] + assert _index(path, last) / _index(path, last - 1) == pytest.approx( + last_growth, rel=1e-12 + ) -@given(st.integers(END_YEAR, END_YEAR + 100)) -def test_medical_cpi_holds_its_2100_value_after_2100(year): - assert _index(MEDICAL_CPI, year) == _index(MEDICAL_CPI, END_YEAR) +@given( + st.sampled_from((ESI, DIRECT_PURCHASE, MEDICAL_CPI, PART_B)), + st.integers(END_YEAR, END_YEAR + 100), +) +def test_each_index_holds_its_2100_value_after_2100(path, year): + assert _index(path, year) == _index(path, END_YEAR) diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index 8deaa177d3c..ee243664cbe 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -1,7 +1,8 @@ """Each dollar input follows the series closest to what it measures: its own national series, the income source it is part of, prices for consumption expenses and debts, CMS per-capita out-of-pocket spending for health costs and -CMS premiums per enrollee for health insurance premiums.""" +premiums per enrollee, or the prices that index them, for health insurance +premiums.""" from pathlib import Path @@ -22,6 +23,8 @@ ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" CPI_U = "gov.bls.cpi.cpi_u" +MEDICAL_CPI = "gov.bls.cpi.cpi_u_medical_care" +PART_B = "calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee" COLA = "gov.ssa.uprating" EXPECTED = { @@ -73,6 +76,11 @@ "other_health_insurance_premiums": ESI, # Premiums for plans bought directly. "health_insurance_premiums": DIRECT_PURCHASE, + # Long-term care insurance premiums, whose deductible limits follow + # medical care prices. + "long_term_health_insurance_premiums": MEDICAL_CPI, + # Medicare Part B premiums. + "medicare_part_b_premiums_reported": PART_B, # Benefits adjusted by the Social Security cost-of-living index. "veterans_benefits": COLA, # Consumption expenses, and the interest on debts, follow prices. The diff --git a/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py b/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py index 5233f128ca7..4ccd833ab36 100644 --- a/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py +++ b/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py @@ -1,11 +1,11 @@ -"""The engine ages premium inputs by their CMS premium series, in a simulation -and in dataset extension. +"""The engine ages premium inputs by their premium series, in a simulation and +in dataset extension. ``tests/core/test_premium_input_uprating.py`` checks the series and that each input names its series. This file checks what a parameter read cannot: that holder uprating in a ``Simulation`` and ``extend_single_year_dataset`` resolve -the per-enrollee paths undivided by population and apply them to these -inputs. +the per-enrollee paths and the price index undivided by population and apply +them to these inputs. Invariants: @@ -37,11 +37,22 @@ ESI = "calibration.gov.hhs.cms.esi_premium_per_enrollee" DIRECT_PURCHASE = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" +MEDICAL_CPI = "gov.bls.cpi.cpi_u_medical_care" +PART_B = "calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee" PRE_TAX = "pre_tax_health_insurance_premiums" REPORTED = "health_insurance_premiums_without_medicare_part_b" OTHER = "other_health_insurance_premiums" DIRECT = "health_insurance_premiums" -SERIES = {PRE_TAX: ESI, REPORTED: ESI, OTHER: ESI, DIRECT: DIRECT_PURCHASE} +LONG_TERM_CARE = "long_term_health_insurance_premiums" +PART_B_REPORTED = "medicare_part_b_premiums_reported" +SERIES = { + PRE_TAX: ESI, + REPORTED: ESI, + OTHER: ESI, + DIRECT: DIRECT_PURCHASE, + LONG_TERM_CARE: MEDICAL_CPI, + PART_B_REPORTED: PART_B, +} BASE_YEAR, END_YEAR = 2024, 2100 amounts = st.integers(0, 3_000_000).map(lambda cents: cents / 100) @@ -136,6 +147,8 @@ def _extended(): REPORTED: [1_400.0, 0.0, 6_300.0, 2_250.0], OTHER: [1_400.0, 0.0, 5_100.0, 2_250.0], DIRECT: [0.0, 0.0, 6_300.0, 0.0], + LONG_TERM_CARE: [0.0, 0.0, 0.0, 3_100.0], + PART_B_REPORTED: [0.0, 0.0, 0.0, 2_096.4], "age": [40.0, 8.0, 55.0, 70.0], } ) 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 585c258c895..961a542a5ee 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -164,6 +164,32 @@ def test_cms_direct_purchase_premium_per_enrollee_holds_last_projected_growth_ra assert parameter("2150-01-01") == parameter("2100-01-01") +def test_medicare_part_b_premium_holds_last_projected_growth_rate(): + """Reported Part B premium inputs keep growing after the Trustees' 2035 + projection, at the 2034-2035 growth rate.""" + parameter = PARAMETERS.calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + last_growth = parameter("2035-01-01") / parameter("2034-01-01") + + assert parameter("2035-01-01") == 360.6 + for year in [2036, 2037, 2050, 2100]: + growth = parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") + assert growth == pytest.approx(last_growth, rel=1e-12) + assert parameter("2150-01-01") == parameter("2100-01-01") + + +def test_cpi_u_medical_care_holds_last_projected_growth_rate(): + """Long-term care premium inputs keep growing after CBO's 2036 + projection, at the 2035-2036 growth rate.""" + parameter = PARAMETERS.gov.bls.cpi.cpi_u_medical_care + last_growth = parameter("2036-01-01") / parameter("2035-01-01") + + assert parameter("2036-01-01") == 813.07 + for year in [2037, 2038, 2050, 2100]: + growth = parameter(f"{year}-01-01") / parameter(f"{year - 1}-01-01") + assert growth == pytest.approx(last_growth, rel=1e-12) + assert parameter("2150-01-01") == parameter("2100-01-01") + + def test_soi_income_upraters_extend_without_trustees_reform(): """SOI income upraters should inherit baseline long-run CBO extensions.""" soi = PARAMETERS.calibration.gov.irs.soi From 62fd874555302e9bfc895f5ee2bf84df17397a7e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:31:20 -0400 Subject: [PATCH 08/12] Drop #10083's changelog fragment, already compiled on main Co-Authored-By: Claude Opus 5.5 --- changelog.d/esi-premium-nhe-uprating.changed.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changelog.d/esi-premium-nhe-uprating.changed.md diff --git a/changelog.d/esi-premium-nhe-uprating.changed.md b/changelog.d/esi-premium-nhe-uprating.changed.md deleted file mode 100644 index 2a3aa60b0dc..00000000000 --- a/changelog.d/esi-premium-nhe-uprating.changed.md +++ /dev/null @@ -1 +0,0 @@ -Uprate employer-sponsored insurance premiums and pre-tax health insurance premiums by CMS's employer-sponsored insurance premium per enrollee (NHE projections Table 17, 2025-2034 release, extended at its 2033-2034 growth rate), replacing CBO employment income per capita and CMS out-of-pocket spending per capita. From 432d5ee02529b2f40a9be315f42f160891756277 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:02:47 -0400 Subject: [PATCH 09/12] Project Medicare premiums and IRMAA past the last announced year The Part B standard premium, Part A premiums and deductible, and the Part B and Part D income-related adjustment amounts and thresholds held their 2026 values in every later year. - Part B standard premium and Part B IRMAA amounts uprate by the Trustees' projected standard premium (calibration series from #10085), rounded to 10 cents (42 U.S.C. 1395r(c)). - Part A full and reduced premiums uprate by the Trustees' projected Part A premium (Table V.E1), rounded to $1; the Part A deductible gains its 2026 value ($1,736) and uprates by the Trustees' projected deductible, rounded to $4 (1395e(b)(1)). - IRMAA thresholds (Parts B and D) follow 1395r(i)(5)'s CPI-U schedule, computed from the statutory amounts; the $500,000 tier is indexed from 2028. - Part D IRMAA amounts follow 1395w-113(a)(7)(B) applied to the Trustees' projected base beneficiary premium, with the (a)(9) percent of 20% from 2030; this reproduces Trustees Table V.E4 for 2027-2035. Co-Authored-By: Claude Opus 5.5 --- .../medicare-part-b-premium-uprating.fixed.md | 1 + .../hhs/cms/medicare_part_a_deductible.yaml | 42 +++ .../medicare_part_a_premium_per_enrollee.yaml | 42 +++ ...base_beneficiary_premium_per_enrollee.yaml | 44 ++++ .../gov/hhs/medicare/part_a/deductible.yaml | 13 + .../gov/hhs/medicare/part_a/full_premium.yaml | 10 + .../hhs/medicare/part_a/reduced_premium.yaml | 11 + .../gov/hhs/medicare/part_b/base_premium.yaml | 10 + .../part_b/irmaa/head_of_household.yaml | 73 ++++-- .../gov/hhs/medicare/part_b/irmaa/joint.yaml | 73 ++++-- .../hhs/medicare/part_b/irmaa/separate.yaml | 34 ++- .../gov/hhs/medicare/part_b/irmaa/single.yaml | 74 ++++-- .../part_b/irmaa/surviving_spouse.yaml | 73 ++++-- .../base_beneficiary_premium_percent.yaml | 23 ++ .../part_d/irmaa/head_of_household.yaml | 5 + .../gov/hhs/medicare/part_d/irmaa/joint.yaml | 5 + .../hhs/medicare/part_d/irmaa/separate.yaml | 5 + .../gov/hhs/medicare/part_d/irmaa/single.yaml | 5 + .../part_d/irmaa/surviving_spouse.yaml | 5 + .../parameters/uprating_extensions.py | 239 +++++++++++++++++- 20 files changed, 719 insertions(+), 68 deletions(-) create mode 100644 changelog.d/medicare-part-b-premium-uprating.fixed.md create mode 100644 policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_deductible.yaml create mode 100644 policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_premium_per_enrollee.yaml create mode 100644 policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_d_base_beneficiary_premium_per_enrollee.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/base_beneficiary_premium_percent.yaml diff --git a/changelog.d/medicare-part-b-premium-uprating.fixed.md b/changelog.d/medicare-part-b-premium-uprating.fixed.md new file mode 100644 index 00000000000..9fdda55b809 --- /dev/null +++ b/changelog.d/medicare-part-b-premium-uprating.fixed.md @@ -0,0 +1 @@ +Project Medicare Part A, B and D premiums, the Part A deductible, and the Part B and D income-related adjustment amounts and thresholds past CMS's last announced year, using the 2026 Medicare Trustees Report and the statutory inflation adjustment, instead of holding them at their 2026 values. diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_deductible.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_deductible.yaml new file mode 100644 index 00000000000..9bfc9ee64b6 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_deductible.yaml @@ -0,0 +1,42 @@ +description: The Centers for Medicare and Medicaid Services (CMS) sets, and the Medicare Trustees project, this Medicare Part A inpatient hospital deductible per benefit period. +metadata: + unit: currency-USD + period: year + label: Medicare Part A inpatient hospital deductible + reference: + # Table V.E1, "HI Cost-Sharing and Premium Amounts", "Inpatient hospital + # deductible" column (file page 212): historical amounts through 2026 and + # intermediate estimates for 2027-2035. Report transmitted 2026-06-09, + # retrieved 2026-10-11, sha256 + # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8. + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table V.E1 + href: https://www.cms.gov/oact/tr/2026#page=212 + - title: 2026 Medicare Parts A & B Premiums and Deductibles + href: https://www.cms.gov/newsroom/fact-sheets/2026-medicare-parts-b-premiums-deductibles +values: + # Historical. + 2015-01-01: 1_260 + 2016-01-01: 1_288 + 2017-01-01: 1_316 + 2018-01-01: 1_340 + 2019-01-01: 1_364 + 2020-01-01: 1_408 + 2021-01-01: 1_484 + 2022-01-01: 1_556 + 2023-01-01: 1_600 + 2024-01-01: 1_632 + 2025-01-01: 1_676 + 2026-01-01: 1_736 + # Trustees' intermediate estimates. + 2027-01-01: 1_788 + 2028-01-01: 1_844 + 2029-01-01: 1_900 + 2030-01-01: 1_960 + 2031-01-01: 2_028 + 2032-01-01: 2_092 + 2033-01-01: 2_160 + 2034-01-01: 2_228 + 2035-01-01: 2_296 + # 2036 to 2100 grow at the 2034-2035 rate (2,296 / 2,228, 3.1% a year) and + # later years hold the 2100 value; see set_all_uprating_parameters in + # uprating_extensions.py. diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_premium_per_enrollee.yaml new file mode 100644 index 00000000000..3d9df232ac0 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_a_premium_per_enrollee.yaml @@ -0,0 +1,42 @@ +description: The Centers for Medicare and Medicaid Services (CMS) sets, and the Medicare Trustees project, this standard monthly Medicare Part A premium per enrollee who has fewer than 30 quarters of Medicare-covered employment. +metadata: + unit: currency-USD + period: month + label: Medicare Part A standard monthly premium per enrollee + reference: + # Table V.E1, "HI Cost-Sharing and Premium Amounts", "Monthly premium, + # Standard" column (file page 212): historical amounts through 2026 and + # intermediate estimates for 2027-2035. Report transmitted 2026-06-09, + # retrieved 2026-10-11, sha256 + # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8. + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table V.E1 + href: https://www.cms.gov/oact/tr/2026#page=212 + - title: 2026 Medicare Parts A & B Premiums and Deductibles + href: https://www.cms.gov/newsroom/fact-sheets/2026-medicare-parts-b-premiums-deductibles +values: + # Historical. + 2015-01-01: 407 + 2016-01-01: 411 + 2017-01-01: 413 + 2018-01-01: 422 + 2019-01-01: 437 + 2020-01-01: 458 + 2021-01-01: 471 + 2022-01-01: 499 + 2023-01-01: 506 + 2024-01-01: 505 + 2025-01-01: 518 + 2026-01-01: 565 + # Trustees' intermediate estimates. + 2027-01-01: 600 + 2028-01-01: 629 + 2029-01-01: 660 + 2030-01-01: 694 + 2031-01-01: 730 + 2032-01-01: 767 + 2033-01-01: 812 + 2034-01-01: 862 + 2035-01-01: 900 + # 2036 to 2100 grow at the 2034-2035 rate (900 / 862, 4.4% a year) and later + # years hold the 2100 value; see set_all_uprating_parameters in + # uprating_extensions.py. diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_d_base_beneficiary_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_d_base_beneficiary_premium_per_enrollee.yaml new file mode 100644 index 00000000000..55c7f46e322 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_d_base_beneficiary_premium_per_enrollee.yaml @@ -0,0 +1,44 @@ +description: The Centers for Medicare and Medicaid Services (CMS) sets, and the Medicare Trustees project, this Medicare Part D base beneficiary premium per enrollee. +metadata: + unit: currency-USD + period: month + label: Medicare Part D base beneficiary premium per enrollee + reference: + # Table V.E2, "SMI Cost-Sharing and Premium Amounts", "Part D, Base + # beneficiary premium" column (file page 213): historical amounts through + # 2026 and intermediate estimates for 2027-2035. The 2030 jump is the + # Inflation Reduction Act's reset of the base beneficiary premium percent + # to 20 percent (file page 154). Report transmitted 2026-06-09, retrieved + # 2026-10-11, sha256 + # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8. + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table V.E2 + href: https://www.cms.gov/oact/tr/2026#page=213 + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Part D premiums + href: https://www.cms.gov/oact/tr/2026#page=154 +values: + # Historical. + 2015-01-01: 33.13 + 2016-01-01: 34.10 + 2017-01-01: 35.63 + 2018-01-01: 35.02 + 2019-01-01: 33.19 + 2020-01-01: 32.74 + 2021-01-01: 33.06 + 2022-01-01: 33.37 + 2023-01-01: 32.74 + 2024-01-01: 34.70 + 2025-01-01: 36.78 + 2026-01-01: 38.99 + # Trustees' intermediate estimates. + 2027-01-01: 41.33 + 2028-01-01: 43.81 + 2029-01-01: 46.44 + 2030-01-01: 68.93 + 2031-01-01: 71.27 + 2032-01-01: 71.59 + 2033-01-01: 74.01 + 2034-01-01: 75.48 + 2035-01-01: 77.81 + # 2036 to 2100 grow at the 2034-2035 rate (77.81 / 75.48, 3.1% a year) and + # later years hold the 2100 value; see set_all_uprating_parameters in + # uprating_extensions.py. diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_a/deductible.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_a/deductible.yaml index 89c12e5805c..2ba6e8c3e4f 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_a/deductible.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_a/deductible.yaml @@ -3,11 +3,24 @@ metadata: unit: currency-USD period: year label: Medicare Part A deductible + # After the last announced year, the deductible follows the Medicare + # Trustees' projected deductible, rounded to the nearest multiple of $4 as 42 + # U.S.C. 1395e(b)(1) requires. + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_a_deductible + rounding: + type: nearest + interval: 4 reference: + - title: 42 U.S.C. § 1395e(b)(1) + href: https://www.law.cornell.edu/uscode/text/42/1395e#b_1 - title: 2024 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2024-medicare-parts-b-premiums-and-deductibles - title: 2025 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2025-medicare-parts-b-premiums-and-deductibles + - title: 2026 Medicare Parts A & B Premiums and Deductibles + href: https://www.cms.gov/newsroom/fact-sheets/2026-medicare-parts-b-premiums-deductibles values: 2024-01-01: 1_632 2025-01-01: 1_676 + 2026-01-01: 1_736 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_a/full_premium.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_a/full_premium.yaml index a681c0151e4..fd6d44024c3 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_a/full_premium.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_a/full_premium.yaml @@ -3,7 +3,17 @@ metadata: unit: currency-USD period: month label: Medicare Part A full premium + # After the last announced year, the premium follows the Medicare Trustees' + # projected standard Part A premium, rounded to the nearest dollar as 42 + # U.S.C. 1395i-2(d)(2) requires. + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_a_premium_per_enrollee + rounding: + type: nearest + interval: 1 reference: + - title: 42 U.S.C. § 1395i-2(d) + href: https://www.law.cornell.edu/uscode/text/42/1395i-2#d - title: 2021 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/press-releases/2021-medicare-part-b-premiums-remain-steady - title: 2022 Medicare Parts A & B Premiums and Deductibles diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_a/reduced_premium.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_a/reduced_premium.yaml index c124a354e27..5ea034beb2b 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_a/reduced_premium.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_a/reduced_premium.yaml @@ -3,7 +3,18 @@ metadata: unit: currency-USD period: month label: Medicare Part A reduced premium + # After the last announced year, the premium follows the Medicare Trustees' + # projected standard Part A premium, rounded to the nearest dollar as CMS + # publishes it. 42 U.S.C. 1395i-2(d)(4) sets it 45 percent below the + # standard premium, so the two grow together. + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_a_premium_per_enrollee + rounding: + type: nearest + interval: 1 reference: + - title: 42 U.S.C. § 1395i-2(d) + href: https://www.law.cornell.edu/uscode/text/42/1395i-2#d - title: 2022 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2022-medicare-parts-b-premiums-deductibles-2022-medicare-part-d-income-related-monthly-adjustment - title: 2023 Medicare Parts A & B Premiums and Deductibles diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_b/base_premium.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_b/base_premium.yaml index 3f456752c45..d1b2c27e11f 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_b/base_premium.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_b/base_premium.yaml @@ -3,7 +3,17 @@ metadata: unit: currency-USD period: month label: Medicare Part B base premium + # After the last announced year, the premium follows the Medicare Trustees' + # projected standard premium, rounded to the nearest 10 cents as 42 U.S.C. + # 1395r(c) requires. + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 reference: + - title: 42 U.S.C. § 1395r(a)(3) and (c) + href: https://www.law.cornell.edu/uscode/text/42/1395r#a_3 - title: 2021 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/press-releases/2021-medicare-part-b-premiums-remain-steady - title: 2022 Medicare Parts A & B Premiums and Deductibles diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/head_of_household.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/head_of_household.yaml index 3592b9a0e87..b8db3f41e8a 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/head_of_household.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/head_of_household.yaml @@ -5,7 +5,15 @@ metadata: amount_unit: currency-USD period: month label: Medicare Part B income brackets for head of household filers + # After the last announced year, each income-related adjustment amount + # follows the Medicare Trustees' projected standard Part B premium: 42 + # U.S.C. 1395r(i)(3)(A) makes each a fixed multiple of the same + # unsubsidized premium amount. The thresholds are indexed to CPI-U under + # 1395r(i)(5); see extend_medicare_irmaa_thresholds in + # uprating_extensions.py. reference: + - title: 42 U.S.C. § 1395r(i) + href: https://www.law.cornell.edu/uscode/text/42/1395r#i - title: 2024 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2024-medicare-parts-b-premiums-and-deductibles - title: 2025 Medicare Parts A & B Premiums and Deductibles @@ -26,38 +34,73 @@ brackets: 2025-01-01: 106_001 2026-01-01: 109_001 amount: - 2024-01-01: 69.90 - 2025-01-01: 74 - 2026-01-01: 81.20 + values: + 2024-01-01: 69.90 + 2025-01-01: 74 + 2026-01-01: 81.20 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 129_001 2025-01-01: 133_001 2026-01-01: 137_001 amount: - 2024-01-01: 174.70 - 2025-01-01: 185 - 2026-01-01: 202.90 + values: + 2024-01-01: 174.70 + 2025-01-01: 185 + 2026-01-01: 202.90 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 161_001 2025-01-01: 167_001 2026-01-01: 171_001 amount: - 2024-01-01: 279.50 - 2025-01-01: 295.9 - 2026-01-01: 324.60 + values: + 2024-01-01: 279.50 + 2025-01-01: 295.9 + 2026-01-01: 324.60 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 193_001 2025-01-01: 200_001 2026-01-01: 205_001 amount: - 2024-01-01: 384.30 - 2025-01-01: 406.9 - 2026-01-01: 446.30 + values: + 2024-01-01: 384.30 + 2025-01-01: 406.9 + 2026-01-01: 446.30 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 500_000 2025-01-01: 500_000 2026-01-01: 500_000 amount: - 2024-01-01: 419.30 - 2025-01-01: 443.9 - 2026-01-01: 487 + values: + 2024-01-01: 419.30 + 2025-01-01: 443.9 + 2026-01-01: 487 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/joint.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/joint.yaml index ae22ca67c2e..13f3fef0744 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/joint.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/joint.yaml @@ -5,7 +5,15 @@ metadata: period: month type: single_amount label: Medicare Part B income brackets for joint filers + # After the last announced year, each income-related adjustment amount + # follows the Medicare Trustees' projected standard Part B premium: 42 + # U.S.C. 1395r(i)(3)(A) makes each a fixed multiple of the same + # unsubsidized premium amount. The thresholds are indexed to CPI-U under + # 1395r(i)(5); see extend_medicare_irmaa_thresholds in + # uprating_extensions.py. reference: + - title: 42 U.S.C. § 1395r(i) + href: https://www.law.cornell.edu/uscode/text/42/1395r#i - title: 2024 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2024-medicare-parts-b-premiums-and-deductibles - title: 2025 Medicare Parts A & B Premiums and Deductibles @@ -26,38 +34,73 @@ brackets: 2025-01-01: 212_001 2026-01-01: 218_001 amount: - 2024-01-01: 69.90 - 2025-01-01: 74 - 2026-01-01: 81.20 + values: + 2024-01-01: 69.90 + 2025-01-01: 74 + 2026-01-01: 81.20 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 258_001 2025-01-01: 266_001 2026-01-01: 274_001 amount: - 2024-01-01: 174.70 - 2025-01-01: 185 - 2026-01-01: 202.90 + values: + 2024-01-01: 174.70 + 2025-01-01: 185 + 2026-01-01: 202.90 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 322_001 2025-01-01: 334_001 2026-01-01: 342_001 amount: - 2024-01-01: 279.50 - 2025-01-01: 295.9 - 2026-01-01: 324.60 + values: + 2024-01-01: 279.50 + 2025-01-01: 295.9 + 2026-01-01: 324.60 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 386_001 2025-01-01: 400_001 2026-01-01: 410_001 amount: - 2024-01-01: 384.30 - 2025-01-01: 406.9 - 2026-01-01: 446.30 + values: + 2024-01-01: 384.30 + 2025-01-01: 406.9 + 2026-01-01: 446.30 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 750_000 2025-01-01: 750_000 2026-01-01: 750_000 amount: - 2024-01-01: 419.30 - 2025-01-01: 443.9 - 2026-01-01: 487 + values: + 2024-01-01: 419.30 + 2025-01-01: 443.9 + 2026-01-01: 487 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/separate.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/separate.yaml index 00640fac5b1..0d0b39fc76a 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/separate.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/separate.yaml @@ -5,7 +5,15 @@ metadata: period: month type: single_amount label: Medicare Part B income brackets for married filing separately + # After the last announced year, each income-related adjustment amount + # follows the Medicare Trustees' projected standard Part B premium: 42 + # U.S.C. 1395r(i)(3)(A) makes each a fixed multiple of the same + # unsubsidized premium amount. The thresholds are indexed to CPI-U under + # 1395r(i)(5); see extend_medicare_irmaa_thresholds in + # uprating_extensions.py. reference: + - title: 42 U.S.C. § 1395r(i) + href: https://www.law.cornell.edu/uscode/text/42/1395r#i - title: 2024 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2024-medicare-parts-b-premiums-and-deductibles - title: 2025 Medicare Parts A & B Premiums and Deductibles @@ -26,14 +34,28 @@ brackets: 2025-01-01: 106_001 2026-01-01: 109_001 amount: - 2024-01-01: 384.30 - 2025-01-01: 406.9 - 2026-01-01: 446.30 + values: + 2024-01-01: 384.30 + 2025-01-01: 406.9 + 2026-01-01: 446.30 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 397_000 2025-01-01: 394_000 2026-01-01: 391_000 amount: - 2024-01-01: 419.30 - 2025-01-01: 443.9 - 2026-01-01: 487 + values: + 2024-01-01: 419.30 + 2025-01-01: 443.9 + 2026-01-01: 487 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/single.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/single.yaml index ee4af575421..602a1961220 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/single.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/single.yaml @@ -5,7 +5,15 @@ metadata: period: month type: single_amount label: Medicare Part B income brackets for single filers + # After the last announced year, each income-related adjustment amount + # follows the Medicare Trustees' projected standard Part B premium: 42 + # U.S.C. 1395r(i)(3)(A) makes each a fixed multiple of the same + # unsubsidized premium amount. The thresholds are indexed to CPI-U under + # 1395r(i)(5); see extend_medicare_irmaa_thresholds in + # uprating_extensions.py. reference: + - title: 42 U.S.C. § 1395r(i) + href: https://www.law.cornell.edu/uscode/text/42/1395r#i - title: 2024 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2024-medicare-parts-b-premiums-and-deductibles - title: 2025 Medicare Parts A & B Premiums and Deductibles @@ -26,39 +34,73 @@ brackets: 2025-01-01: 106_001 2026-01-01: 109_001 amount: - 2024-01-01: 69.90 - 2025-01-01: 74 - 2026-01-01: 81.20 + values: + 2024-01-01: 69.90 + 2025-01-01: 74 + 2026-01-01: 81.20 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 129_001 2025-01-01: 133_001 2026-01-01: 137_001 amount: - 2024-01-01: 174.70 - 2025-01-01: 185 - 2026-01-01: 202.90 + values: + 2024-01-01: 174.70 + 2025-01-01: 185 + 2026-01-01: 202.90 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 161_001 2025-01-01: 167_001 2026-01-01: 171_001 amount: - 2024-01-01: 279.50 - 2025-01-01: 295.9 - 2026-01-01: 324.60 + values: + 2024-01-01: 279.50 + 2025-01-01: 295.9 + 2026-01-01: 324.60 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 193_001 2025-01-01: 200_001 2026-01-01: 205_001 amount: - 2024-01-01: 384.30 - 2025-01-01: 406.9 - 2026-01-01: 446.30 + values: + 2024-01-01: 384.30 + 2025-01-01: 406.9 + 2026-01-01: 446.30 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 500_000 2025-01-01: 500_000 2026-01-01: 500_000 amount: - 2024-01-01: 419.30 - 2025-01-01: 443.9 - 2026-01-01: 487 - + values: + 2024-01-01: 419.30 + 2025-01-01: 443.9 + 2026-01-01: 487 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/surviving_spouse.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/surviving_spouse.yaml index 25e1f04734c..e92b60b41c0 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/surviving_spouse.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_b/irmaa/surviving_spouse.yaml @@ -5,7 +5,15 @@ metadata: type: single_amount period: month label: Medicare Part B income brackets for surviving spouse filers + # After the last announced year, each income-related adjustment amount + # follows the Medicare Trustees' projected standard Part B premium: 42 + # U.S.C. 1395r(i)(3)(A) makes each a fixed multiple of the same + # unsubsidized premium amount. The thresholds are indexed to CPI-U under + # 1395r(i)(5); see extend_medicare_irmaa_thresholds in + # uprating_extensions.py. reference: + - title: 42 U.S.C. § 1395r(i) + href: https://www.law.cornell.edu/uscode/text/42/1395r#i - title: 2024 Medicare Parts A & B Premiums and Deductibles href: https://www.cms.gov/newsroom/fact-sheets/2024-medicare-parts-b-premiums-and-deductibles - title: 2025 Medicare Parts A & B Premiums and Deductibles @@ -26,38 +34,73 @@ brackets: 2025-01-01: 106_001 2026-01-01: 109_001 amount: - 2024-01-01: 69.90 - 2025-01-01: 74 - 2026-01-01: 81.20 + values: + 2024-01-01: 69.90 + 2025-01-01: 74 + 2026-01-01: 81.20 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 129_001 2025-01-01: 133_001 2026-01-01: 137_001 amount: - 2024-01-01: 174.70 - 2025-01-01: 185 - 2026-01-01: 202.90 + values: + 2024-01-01: 174.70 + 2025-01-01: 185 + 2026-01-01: 202.90 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 161_001 2025-01-01: 167_001 2026-01-01: 171_001 amount: - 2024-01-01: 279.50 - 2025-01-01: 295.9 - 2026-01-01: 324.60 + values: + 2024-01-01: 279.50 + 2025-01-01: 295.9 + 2026-01-01: 324.60 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 193_001 2025-01-01: 200_001 2026-01-01: 205_001 amount: - 2024-01-01: 384.30 - 2025-01-01: 406.9 - 2026-01-01: 446.30 + values: + 2024-01-01: 384.30 + 2025-01-01: 406.9 + 2026-01-01: 446.30 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 - threshold: 2024-01-01: 500_000 2025-01-01: 500_000 2026-01-01: 500_000 amount: - 2024-01-01: 419.30 - 2025-01-01: 443.9 - 2026-01-01: 487 + values: + 2024-01-01: 419.30 + 2025-01-01: 443.9 + 2026-01-01: 487 + metadata: + uprating: + parameter: calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee + rounding: + type: nearest + interval: 0.1 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/base_beneficiary_premium_percent.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/base_beneficiary_premium_percent.yaml new file mode 100644 index 00000000000..f0bd8731fe3 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/base_beneficiary_premium_percent.yaml @@ -0,0 +1,23 @@ +description: The Centers for Medicare & Medicaid Services (CMS) uses this percent to set the Medicare Part D base beneficiary premium and to scale the Part D income-related monthly adjustment amount. +metadata: + unit: /1 + period: year + label: Medicare Part D base beneficiary premium percent + reference: + # (a)(3)(A) and (a)(7)(B)(i): 25.5 percent, or from 2030 the percent + # specified under (a)(9). (a)(9) sets that percent so the 2030 base + # beneficiary premium equals the lesser of the 2029 premium increased by 6 + # percent or the premium without (a)(9), but not below 20 percent. + - title: 42 U.S.C. § 1395w-113(a)(3), (a)(7)(B) and (a)(9) + href: https://www.law.cornell.edu/uscode/text/42/1395w-113#a + # Footnote 63: "Currently, we expect the percentage in 2030 will be set at + # 20 percent, causing a substantial premium increase." + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, footnote 63 + href: https://www.cms.gov/oact/tr/2026#page=113 + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Part D premiums + href: https://www.cms.gov/oact/tr/2026#page=154 +values: + 2006-01-01: 0.255 + # The Trustees' expectation of the (a)(9) percent, which is also its floor; + # CMS determines it for 2030. + 2030-01-01: 0.2 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/head_of_household.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/head_of_household.yaml index c9c5403a23c..270075ab915 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/head_of_household.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/head_of_household.yaml @@ -5,6 +5,11 @@ metadata: period: month type: single_amount label: Medicare Part D IRMAA brackets for head of household filers + # After the last announced year, the thresholds are indexed to CPI-U under + # 42 U.S.C. 1395r(i)(5) and each adjustment amount is computed from the + # Medicare Trustees' projected base beneficiary premium under 42 U.S.C. + # 1395w-113(a)(7)(B); see extend_medicare_irmaa_thresholds and + # extend_medicare_part_d_irmaa_amounts in uprating_extensions.py. reference: - title: 42 U.S.C. § 1395w-113(a)(7) - Part D income-related premium adjustment. href: https://www.law.cornell.edu/uscode/text/42/1395w-113#a_7 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/joint.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/joint.yaml index 4837285dfdb..3f4c00fdd88 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/joint.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/joint.yaml @@ -5,6 +5,11 @@ metadata: period: month type: single_amount label: Medicare Part D IRMAA brackets for joint filers + # After the last announced year, the thresholds are indexed to CPI-U under + # 42 U.S.C. 1395r(i)(5) and each adjustment amount is computed from the + # Medicare Trustees' projected base beneficiary premium under 42 U.S.C. + # 1395w-113(a)(7)(B); see extend_medicare_irmaa_thresholds and + # extend_medicare_part_d_irmaa_amounts in uprating_extensions.py. reference: - title: 42 U.S.C. § 1395w-113(a)(7) - Part D income-related premium adjustment. href: https://www.law.cornell.edu/uscode/text/42/1395w-113#a_7 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/separate.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/separate.yaml index dfb4259c3c4..9c83356e797 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/separate.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/separate.yaml @@ -5,6 +5,11 @@ metadata: period: month type: single_amount label: Medicare Part D IRMAA brackets for married filing separately + # After the last announced year, the thresholds are indexed to CPI-U under + # 42 U.S.C. 1395r(i)(5) and each adjustment amount is computed from the + # Medicare Trustees' projected base beneficiary premium under 42 U.S.C. + # 1395w-113(a)(7)(B); see extend_medicare_irmaa_thresholds and + # extend_medicare_part_d_irmaa_amounts in uprating_extensions.py. reference: - title: 42 U.S.C. § 1395w-113(a)(7) - Part D income-related premium adjustment. href: https://www.law.cornell.edu/uscode/text/42/1395w-113#a_7 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/single.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/single.yaml index 7c79e99ee12..3aed61d03e2 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/single.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/single.yaml @@ -5,6 +5,11 @@ metadata: period: month type: single_amount label: Medicare Part D IRMAA brackets for single filers + # After the last announced year, the thresholds are indexed to CPI-U under + # 42 U.S.C. 1395r(i)(5) and each adjustment amount is computed from the + # Medicare Trustees' projected base beneficiary premium under 42 U.S.C. + # 1395w-113(a)(7)(B); see extend_medicare_irmaa_thresholds and + # extend_medicare_part_d_irmaa_amounts in uprating_extensions.py. reference: - title: 42 U.S.C. § 1395w-113(a)(7) - Part D income-related premium adjustment. href: https://www.law.cornell.edu/uscode/text/42/1395w-113#a_7 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/surviving_spouse.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/surviving_spouse.yaml index 749583c717b..cacf8ba4190 100644 --- a/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/surviving_spouse.yaml +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/irmaa/surviving_spouse.yaml @@ -5,6 +5,11 @@ metadata: period: month type: single_amount label: Medicare Part D IRMAA brackets for surviving spouse filers + # After the last announced year, the thresholds are indexed to CPI-U under + # 42 U.S.C. 1395r(i)(5) and each adjustment amount is computed from the + # Medicare Trustees' projected base beneficiary premium under 42 U.S.C. + # 1395w-113(a)(7)(B); see extend_medicare_irmaa_thresholds and + # extend_medicare_part_d_irmaa_amounts in uprating_extensions.py. reference: - title: 42 U.S.C. § 1395w-113(a)(7) - Part D income-related premium adjustment. href: https://www.law.cornell.edu/uscode/text/42/1395w-113#a_7 diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 8e1142c6419..fda8c3c53dd 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 @@ -498,6 +498,215 @@ def extend_wa_millionaires_standard_deduction( ) +# 42 U.S.C. 1395r(i)(3)(C)(i)(III): the dollar amounts of the applicable +# percentage table for an unmarried individual, before the (5) inflation +# adjustment. The first is also the (2)(A) threshold amount. +MEDICARE_IRMAA_STATUTORY_AMOUNTS = (85_000, 107_000, 133_500, 160_000) +MEDICARE_IRMAA_STATUTORY_TOP_AMOUNT = 500_000 +# 1395r(i)(5)(A): for 2020 on, CPI-U for the 12 months ending August of the +# preceding year over the 12 months ending August 2018. (5)(C): the $500,000 +# amount is not adjusted before 2028, and from 2028 is adjusted against the 12 +# months ending August 2026. +MEDICARE_IRMAA_CPI_BASE_YEAR = 2018 +MEDICARE_IRMAA_FIRST_INDEXED_YEAR = 2020 +MEDICARE_IRMAA_TOP_CPI_BASE_YEAR = 2026 +MEDICARE_IRMAA_TOP_FIRST_INDEXED_YEAR = 2028 +# 1395r(i)(3)(C)(i)(III): the applicable percentage of each adjusted tier, by +# bracket of each filing status's scale (the first bracket pays no +# adjustment). A married individual filing separately who lives with the +# spouse pays an adjustment only above the unmarried threshold amount +# ((i)(1)), which is above where (C)(iii) starts the 80 percent tier, so that +# scale has only the 80 and 85 percent tiers. +MEDICARE_IRMAA_APPLICABLE_PERCENTAGES = { + "single": (0.35, 0.50, 0.65, 0.80, 0.85), + "joint": (0.35, 0.50, 0.65, 0.80, 0.85), + "head_of_household": (0.35, 0.50, 0.65, 0.80, 0.85), + "surviving_spouse": (0.35, 0.50, 0.65, 0.80, 0.85), + "separate": (0.80, 0.85), +} + + +def round_medicare_irmaa_amount(amount: float) -> float: + """Round an adjusted dollar amount to the nearest multiple of $1,000. + + 42 U.S.C. 1395r(i)(5)(B) names no rule for an amount midway between two + multiples; it rounds up here. + """ + return float( + (Decimal(repr(amount)) / 1_000).quantize(Decimal(1), rounding=ROUND_HALF_UP) + * 1_000 + ) + + +def get_medicare_irmaa_unmarried_amounts( + parameters: ParameterNode, year: int +) -> Tuple[Tuple[float, ...], float]: + """The unmarried dollar amounts of the IRMAA table for ``year``. + + Returns the four amounts above which the 35, 50, 65 and 80 percent tiers + begin, and the amount at which the 85 percent tier begins, each adjusted + under 42 U.S.C. 1395r(i)(5): increased by the percentage (if any) by which + average CPI-U for the 12 months ending August of the preceding year + exceeds its base-period average, then rounded to the nearest $1,000. + """ + if year < MEDICARE_IRMAA_FIRST_INDEXED_YEAR: + raise ValueError( + f"1395r(i)(5) adjusts against August 2018 only from " + f"{MEDICARE_IRMAA_FIRST_INDEXED_YEAR}; got {year}." + ) + window = get_cpi_u_average_for_12_months_ending_august(parameters, year - 1) + + def adjust(amount: int, base_year: int) -> float: + base = get_cpi_u_average_for_12_months_ending_august(parameters, base_year) + return round_medicare_irmaa_amount(amount * (1 + max(window / base - 1, 0))) + + lower = tuple( + adjust(amount, MEDICARE_IRMAA_CPI_BASE_YEAR) + for amount in MEDICARE_IRMAA_STATUTORY_AMOUNTS + ) + if year < MEDICARE_IRMAA_TOP_FIRST_INDEXED_YEAR: + top = float(MEDICARE_IRMAA_STATUTORY_TOP_AMOUNT) + else: + top = adjust( + MEDICARE_IRMAA_STATUTORY_TOP_AMOUNT, MEDICARE_IRMAA_TOP_CPI_BASE_YEAR + ) + return lower, top + + +def get_medicare_irmaa_thresholds( + lower: Tuple[float, ...], top: float +) -> Dict[str, Tuple[float, ...]]: + """Bracket thresholds of each filing status's IRMAA scale. + + The first four tiers cover income "more than" a dollar amount and the top + tier income of "at least" its amount (42 U.S.C. 1395r(i)(3)(C)(i)(III)). + A bracket applies from its threshold, so, as in the published years, the + first four thresholds sit $1 above their amounts. + """ + if lower[3] - lower[0] >= lower[0]: + raise ValueError( + "The married filing separately scale assumes its 80 percent tier " + "starts below the unmarried threshold amount." + ) + unmarried = (0, *(amount + 1 for amount in lower), top) + return { + "single": unmarried, + "head_of_household": unmarried, + "surviving_spouse": unmarried, + # (C)(ii): twice the unmarried amounts, but 150 percent of the + # $500,000 amount. + "joint": (0, *(2 * amount + 1 for amount in lower), 1.5 * top), + # (C)(iii): each amount less the unmarried threshold amount, with no + # adjustment at or below that threshold ((i)(1)). + "separate": (0, lower[0] + 1, top - lower[0]), + } + + +def extend_medicare_irmaa_thresholds(parameters: ParameterNode, end_year: int) -> None: + """Project the Medicare Part B and Part D IRMAA income thresholds. + + Part D uses the Part B thresholds (42 U.S.C. 1395w-113(a)(7)(A)). Each + year after the last CMS-published one is computed from the statutory + amounts (see ``get_medicare_irmaa_unmarried_amounts``) rather than + chained from the prior year's rounded values. CMS values encoded in the + YAML take precedence for their own years. + """ + medicare = parameters.gov.hhs.medicare + thresholds_by_year = {} + for program in (medicare.part_b, medicare.part_d): + for name, scale in program.irmaa.children.items(): + if name not in MEDICARE_IRMAA_APPLICABLE_PERCENTAGES: + raise ValueError(f"Unexpected Medicare IRMAA scale: {name}.") + for index, bracket in enumerate(scale.brackets): + threshold = bracket.threshold + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in threshold.values_list + ) + for year in range(first_projected_year, end_year + 1): + if year not in thresholds_by_year: + thresholds_by_year[year] = get_medicare_irmaa_thresholds( + *get_medicare_irmaa_unmarried_amounts(parameters, year) + ) + year_thresholds = thresholds_by_year[year][name] + if len(year_thresholds) != len(scale.brackets): + raise ValueError( + f"Expected {len(year_thresholds)} brackets in the " + f"{name} Medicare IRMAA scale; found " + f"{len(scale.brackets)}." + ) + threshold.update( + period=f"year:{year}-01-01:1", + value=float(year_thresholds[index]), + ) + threshold.update( + start=instant(f"{end_year}-01-01"), + value=threshold(f"{end_year}-01-01"), + ) + + +def get_medicare_part_d_irmaa_amount( + base_beneficiary_premium: float, + applicable_percentage: float, + base_percent: float, +) -> float: + """One Part D income-related monthly adjustment amount. + + 42 U.S.C. 1395w-113(a)(7)(B): the base beneficiary premium times the + applicable percentage less the base beneficiary premium percent, divided + by that percent. CMS publishes the amounts to the nearest 10 cents. + """ + premium = Decimal(repr(base_beneficiary_premium)) + applicable = Decimal(repr(applicable_percentage)) + base = Decimal(repr(base_percent)) + amount = premium * (applicable - base) / base + return float(amount.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)) + + +def extend_medicare_part_d_irmaa_amounts( + parameters: ParameterNode, end_year: int +) -> None: + """Project the Medicare Part D income-related monthly adjustment amounts. + + Each year after the last CMS-published one applies 42 U.S.C. + 1395w-113(a)(7)(B) to the Medicare Trustees' projected base beneficiary + premium. The base beneficiary premium percent falls from 25.5 percent to + the (a)(9) percent in 2030, which raises every amount; the Trustees + expect 20 percent. CMS values encoded in the YAML take precedence for + their own years. + """ + base_beneficiary_premium = parameters.calibration.gov.hhs.cms.medicare_part_d_base_beneficiary_premium_per_enrollee + part_d = parameters.gov.hhs.medicare.part_d + for name, scale in part_d.irmaa.children.items(): + applicable_percentages = MEDICARE_IRMAA_APPLICABLE_PERCENTAGES.get(name) + if applicable_percentages is None or len(applicable_percentages) != len( + scale.brackets[1:] + ): + raise ValueError( + f"Unexpected Medicare Part D IRMAA scale or bracket count: {name}." + ) + for bracket, applicable_percentage in zip( + scale.brackets[1:], applicable_percentages + ): + amount = bracket.amount + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in amount.values_list + ) + for year in range(first_projected_year, end_year + 1): + period = f"{year}-01-01" + amount.update( + period=f"year:{period}:1", + value=get_medicare_part_d_irmaa_amount( + base_beneficiary_premium(period), + applicable_percentage, + part_d.base_beneficiary_premium_percent(period), + ), + ) + amount.update( + start=instant(f"{end_year}-01-01"), + value=amount(f"{end_year}-01-01"), + ) + + def extend_parameter_values( parameter: Parameter, last_projected_year: int, @@ -893,6 +1102,34 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: period_day=1, ) + # Medicare Part A standard premium and inpatient hospital deductible, and + # Part D base beneficiary premium (January values, last Trustees + # projection year 2035). The Part A premiums and deductible use the first + # two as their upraters; the Part D income-related adjustment amounts are + # computed from the third below. + for medicare_series in ( + parameters.calibration.gov.hhs.cms.medicare_part_a_premium_per_enrollee, + parameters.calibration.gov.hhs.cms.medicare_part_a_deductible, + parameters.calibration.gov.hhs.cms.medicare_part_d_base_beneficiary_premium_per_enrollee, + ): + extend_parameter_values( + medicare_series, + last_projected_year=2035, + end_year=END_YEAR, + period_month=1, + period_day=1, + ) + + # Medicare IRMAA income thresholds follow 42 U.S.C. 1395r(i)(5)'s CPI-U + # schedule (12 months ending August), computed from the statutory + # amounts. Must run after the CPI-U extension above. + extend_medicare_irmaa_thresholds(parameters, end_year=END_YEAR) + + # Medicare Part D income-related adjustment amounts follow 42 U.S.C. + # 1395w-113(a)(7)(B) applied to the base beneficiary premium extended + # above. + extend_medicare_part_d_irmaa_amounts(parameters, end_year=END_YEAR) + # Gross Social Security benefit inputs should age with gross Social # Security benefits, not taxable Social Security income. extend_parameter_values( From a9658c3354d2857726e88f46be96e828abd1182d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:30:39 -0400 Subject: [PATCH 10/12] Test Medicare premium and IRMAA projections Property tests (Hypothesis) for statutory rounding, proportionality, monotonicity and threshold structure in every projected year; differential tests against the 2026 Trustees Report Tables V.E1-V.E4 and CMS's published 2020-2026 IRMAA thresholds; a precedence test showing a CMS amount added to the YAML replaces only its own year; and YAML household cases for 2027-2034. Co-Authored-By: Claude Opus 5.5 --- .../core/test_medicare_premium_projection.py | 572 ++++++++++++++++++ .../eligibility/base_part_a_premium.yaml | 19 + .../eligibility/base_part_b_premium.yaml | 18 + ...ome_adjusted_part_d_premium_surcharge.yaml | 30 + .../eligibility/medicare_part_b_premium.yaml | 61 ++ .../msp_part_b_premium_coverage.yaml | 10 + 6 files changed, 710 insertions(+) create mode 100644 policyengine_us/tests/core/test_medicare_premium_projection.py diff --git a/policyengine_us/tests/core/test_medicare_premium_projection.py b/policyengine_us/tests/core/test_medicare_premium_projection.py new file mode 100644 index 00000000000..bc9d205d074 --- /dev/null +++ b/policyengine_us/tests/core/test_medicare_premium_projection.py @@ -0,0 +1,572 @@ +"""Medicare premiums, deductible and IRMAA after CMS's last announced year. + +CMS announces each year's Part A and B premiums, Part A deductible and +income-related monthly adjustment amounts (IRMAA) in the autumn before. For +later years the model projects them: + +- The Part B standard premium and Part B IRMAA amounts uprate by the + Medicare Trustees' projected standard premium, rounded to 10 cents. +- The Part A premiums and deductible uprate by the Trustees' projections, + rounded to $1 and $4. +- The IRMAA income thresholds follow 42 U.S.C. 1395r(i)(5)'s CPI-U schedule. +- The Part D IRMAA amounts apply 42 U.S.C. 1395w-113(a)(7)(B) to the + Trustees' projected base beneficiary premium. + +These tests read the parameter tree, plus one small parameter tree built for +the precedence tests. Household calculations are in the YAML tests under +``tests/policy/baseline/gov/hhs/medicare``. + +Invariants: + +1. Source agreement (differential, model against published figures): for + 2027-2035, the projections equal the 2026 Trustees Report's Part B + premium (Table V.E2), Part A premiums and deductible (Table V.E1) and + Part D IRMAA amounts (Table V.E4). The Part B IRMAA amounts are within + $0.30 of Table V.E3 through 2034 and $0.80 in 2035, where Table V.E3 + implies a $360.90 standard premium against Table V.E2's $360.60. +2. Statutory rounding: in every projected year, the Part B premium and IRMAA + amounts are multiples of 10 cents, the Part A premiums of $1 and the + deductible of $4. +3. Agreement with the uprater: the Part B premium equals the Trustees series + rounded to 10 cents in every year from 2021. +4. Proportionality: each Part B IRMAA amount stays at its 42 U.S.C. + 1395r(i)(3)(A) multiple of the standard premium, to within 0.1% plus + rounding, as in CMS's 2026 amounts. Each Part D amount equals + 1395w-113(a)(7)(B) applied to the base beneficiary premium. The reduced + Part A premium stays at 55% of the full premium (1395i-2(d)(4)), to + within 0.1% plus $1. CMS rounded 2026's $310.75 to $311, and uprating + from 2026 keeps that 0.04% excess, as it keeps the IRMAA amounts'. +5. Monotonicity: the Part B premium rises every year from 2023, and the Part + A premiums and deductible never fall from 2024. IRMAA thresholds never + fall from one year to the next and rise from tier to tier. Intended + exception: the married-filing-separately top threshold is $500,000 less + the rising unmarried threshold, so it falls each year until the $500,000 + amount is indexed in 2028. +6. Threshold structure: joint thresholds are twice the unmarried amounts + (150% for the top tier), married filing separately uses the unmarried + threshold and the top amount less that threshold, and Part D uses the Part + B thresholds. The top tier stays at $500,000 through 2027. +7. Indexing (differential): the statutory CPI-U schedule reproduces CMS's + published thresholds exactly for 2020-2024, where the model's CPI-U is + the published not seasonally adjusted index, and within one $1,000 step + for 2025-2026, where it is seasonally adjusted. +8. Precedence: a CMS amount added to the YAML for a year replaces the + projection for that year only; later years are still computed from the + statute rather than chained from it. +9. Extension: the new Trustees series grow at their last one-year rate past + 2035 and hold their 2100 value after 2100. +""" + +from types import SimpleNamespace + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters.operations.get_parameter import get_parameter + +from policyengine_us.parameters.uprating_extensions import ( + MEDICARE_IRMAA_APPLICABLE_PERCENTAGES, + MEDICARE_IRMAA_TOP_FIRST_INDEXED_YEAR, + extend_medicare_irmaa_thresholds, + extend_medicare_part_d_irmaa_amounts, + get_medicare_irmaa_thresholds, + get_medicare_irmaa_unmarried_amounts, + get_medicare_part_d_irmaa_amount, +) +from policyengine_us.system import system + +PARAMETERS = system.parameters +MEDICARE = PARAMETERS.gov.hhs.medicare +CMS = "calibration.gov.hhs.cms" +PART_B_SERIES = f"{CMS}.medicare_part_b_premium_per_enrollee" +PART_A_SERIES = f"{CMS}.medicare_part_a_premium_per_enrollee" +DEDUCTIBLE_SERIES = f"{CMS}.medicare_part_a_deductible" +PART_D_SERIES = f"{CMS}.medicare_part_d_base_beneficiary_premium_per_enrollee" +LAST_ANNOUNCED_YEAR = 2026 +LAST_TRUSTEES_YEAR = 2035 +END_YEAR = 2100 +FILING_STATUSES = ("single", "joint", "head_of_household", "surviving_spouse") +ALL_STATUSES = (*FILING_STATUSES, "separate") +PROJECTED_YEARS = st.integers(LAST_ANNOUNCED_YEAR + 1, END_YEAR + 20) +TRUSTEES_YEARS = range(LAST_ANNOUNCED_YEAR + 1, LAST_TRUSTEES_YEAR + 1) + + +def _at(parameter, year): + return parameter(f"{year}-01-01") + + +def _series(path, year): + return _at(get_parameter(PARAMETERS, path), year) + + +def _is_multiple(value, step): + return abs(value / step - round(value / step)) < 1e-6 + + +# 2026 Medicare Trustees Report (transmitted 2026-06-09, sha256 +# ffa56b91...8fa8), intermediate estimates for 2027-2035. +# Table V.E1 (file page 212): inpatient hospital deductible, standard and +# reduced monthly Part A premiums. +TRUSTEES_PART_A_DEDUCTIBLE = dict( + zip( + range(2026, 2036), + (1_736, 1_788, 1_844, 1_900, 1_960, 2_028, 2_092, 2_160, 2_228, 2_296), + ) +) +TRUSTEES_PART_A_FULL_PREMIUM = dict( + zip(TRUSTEES_YEARS, (600, 629, 660, 694, 730, 767, 812, 862, 900)) +) +TRUSTEES_PART_A_REDUCED_PREMIUM = dict( + zip(TRUSTEES_YEARS, (330, 346, 363, 382, 402, 422, 447, 474, 495)) +) +# Table V.E2 (file page 213): standard monthly Part B premium and Part D base +# beneficiary premium. +TRUSTEES_PART_B_PREMIUM = dict( + zip( + TRUSTEES_YEARS, + (209.5, 224.5, 238.5, 255.5, 272.1, 290.2, 313.6, 338.5, 360.6), + ) +) +TRUSTEES_PART_D_BASE_PREMIUM = dict( + zip( + TRUSTEES_YEARS, + (41.33, 43.81, 46.44, 68.93, 71.27, 71.59, 74.01, 75.48, 77.81), + ) +) +# Table V.E3 (file page 215): Part B income-related monthly adjustment +# amounts for the 35, 50, 65, 80 and 85 percent tiers. +TRUSTEES_PART_B_IRMAA = { + 2027: (83.70, 209.40, 335.10, 460.70, 502.60), + 2028: (89.80, 224.50, 359.20, 493.90, 538.80), + 2029: (95.30, 238.40, 381.50, 524.50, 572.20), + 2030: (102.10, 255.40, 408.70, 561.90, 613.00), + 2031: (108.80, 272.10, 435.40, 598.60, 653.00), + 2032: (116.00, 290.10, 464.20, 638.30, 696.30), + 2033: (125.40, 313.60, 501.80, 689.90, 752.60), + 2034: (135.30, 338.40, 541.50, 744.50, 812.20), + 2035: (144.40, 360.90, 577.40, 794.00, 866.20), +} +# Table V.E4 (file page 218): Part D income-related monthly adjustment +# amounts for the same tiers. +TRUSTEES_PART_D_IRMAA = { + 2027: (15.40, 39.70, 64.00, 88.30, 96.40), + 2028: (16.30, 42.10, 67.90, 93.60, 102.20), + 2029: (17.30, 44.60, 71.90, 99.30, 108.40), + 2030: (51.70, 103.40, 155.10, 206.80, 224.00), + 2031: (53.50, 106.90, 160.40, 213.80, 231.60), + 2032: (53.70, 107.40, 161.10, 214.80, 232.70), + 2033: (55.50, 111.00, 166.50, 222.00, 240.50), + 2034: (56.60, 113.20, 169.80, 226.40, 245.30), + 2035: (58.40, 116.70, 175.10, 233.40, 252.90), +} +# 42 U.S.C. 1395r(i)(3)(A): (applicable percentage - 25 points) x 200% of the +# aged actuarial rate, which is 4 x (applicable percentage - 25 points) times +# the standard premium of 50% of that rate. +PART_B_IRMAA_MULTIPLES = (0.4, 1.0, 1.6, 2.2, 2.4) +# CMS fact sheets "2020/2021/2022/2023 Medicare Parts A & B Premiums and +# Deductibles": the unmarried amounts above which the 35, 50, 65 and 80 +# percent tiers begin. 2024-2026 are the YAML's published values. +CMS_UNMARRIED_AMOUNTS = { + 2020: (87_000, 109_000, 136_000, 163_000), + 2021: (88_000, 111_000, 138_000, 165_000), + 2022: (91_000, 114_000, 142_000, 170_000), + 2023: (97_000, 123_000, 153_000, 183_000), + 2024: (103_000, 129_000, 161_000, 193_000), + 2025: (106_000, 133_000, 167_000, 200_000), + 2026: (109_000, 137_000, 171_000, 205_000), +} + + +def _brackets(program, status): + return getattr(program.irmaa, status).brackets + + +def _thresholds(program, status, year): + return [_at(b.threshold, year) for b in _brackets(program, status)] + + +# 1. Source agreement. + + +@pytest.mark.parametrize("year", TRUSTEES_YEARS) +def test_part_b_premium_is_the_trustees_projection(year): + assert _at(MEDICARE.part_b.base_premium, year) == pytest.approx( + TRUSTEES_PART_B_PREMIUM[year], abs=1e-9 + ) + + +@pytest.mark.parametrize("year", TRUSTEES_YEARS) +def test_part_a_premiums_are_the_trustees_projection(year): + assert _at(MEDICARE.part_a.full_premium, year) == pytest.approx( + TRUSTEES_PART_A_FULL_PREMIUM[year], abs=1e-9 + ) + assert _at(MEDICARE.part_a.reduced_premium, year) == pytest.approx( + TRUSTEES_PART_A_REDUCED_PREMIUM[year], abs=1e-9 + ) + + +@pytest.mark.parametrize("year", sorted(TRUSTEES_PART_A_DEDUCTIBLE)) +def test_part_a_deductible_is_the_trustees_figure(year): + """2026 is CMS's announced $1,736; later years are projections.""" + assert _at(MEDICARE.part_a.deductible, year) == pytest.approx( + TRUSTEES_PART_A_DEDUCTIBLE[year], abs=1e-9 + ) + + +@pytest.mark.parametrize("year", TRUSTEES_YEARS) +@pytest.mark.parametrize("status", FILING_STATUSES) +def test_part_b_irmaa_is_within_cents_of_the_trustees_projection(status, year): + tolerance = (0.80 if year == LAST_TRUSTEES_YEAR else 0.30) + 1e-9 + amounts = [_at(b.amount, year) for b in _brackets(MEDICARE.part_b, status)[1:]] + assert amounts == pytest.approx(TRUSTEES_PART_B_IRMAA[year], abs=tolerance) + + +@pytest.mark.parametrize("year", TRUSTEES_YEARS) +@pytest.mark.parametrize("status", FILING_STATUSES) +def test_part_d_irmaa_is_the_trustees_projection(status, year): + """Applying the statute to the Trustees' base beneficiary premium, with + the 20 percent base from 2030, reproduces their Table V.E4.""" + amounts = [_at(b.amount, year) for b in _brackets(MEDICARE.part_d, status)[1:]] + assert amounts == pytest.approx(TRUSTEES_PART_D_IRMAA[year], abs=1e-9) + + +@pytest.mark.parametrize("year", TRUSTEES_YEARS) +def test_married_filing_separately_gets_the_two_top_tiers(year): + for program in (MEDICARE.part_b, MEDICARE.part_d): + separate = [_at(b.amount, year) for b in _brackets(program, "separate")] + single = [_at(b.amount, year) for b in _brackets(program, "single")] + assert separate == [0, *single[-2:]] + + +@pytest.mark.parametrize("year", [2024, 2025, 2026]) +def test_part_d_formula_reproduces_cms_published_amounts(year): + """Differential check of the formula and its rounding against CMS's + published amounts, from CMS's published base beneficiary premiums.""" + base_premium = _series(PART_D_SERIES, year) + for status in ALL_STATUSES: + brackets = _brackets(MEDICARE.part_d, status)[1:] + for bracket, percentage in zip( + brackets, MEDICARE_IRMAA_APPLICABLE_PERCENTAGES[status] + ): + assert get_medicare_part_d_irmaa_amount( + base_premium, percentage, 0.255 + ) == pytest.approx(_at(bracket.amount, year), abs=1e-9) + + +@pytest.mark.parametrize( + "path, values", + [ + (PART_A_SERIES, TRUSTEES_PART_A_FULL_PREMIUM), + (DEDUCTIBLE_SERIES, TRUSTEES_PART_A_DEDUCTIBLE), + (PART_D_SERIES, TRUSTEES_PART_D_BASE_PREMIUM), + ], +) +def test_new_series_transcribe_the_trustees_tables(path, values): + for year, value in values.items(): + assert _series(path, year) == value + + +@pytest.mark.parametrize("year", range(2021, LAST_ANNOUNCED_YEAR + 1)) +def test_part_a_series_agrees_with_announced_premiums(year): + assert _series(PART_A_SERIES, year) == _at(MEDICARE.part_a.full_premium, year) + + +@pytest.mark.parametrize("year", range(2024, LAST_ANNOUNCED_YEAR + 1)) +def test_deductible_series_agrees_with_announced_deductibles(year): + assert _series(DEDUCTIBLE_SERIES, year) == _at(MEDICARE.part_a.deductible, year) + + +# 2. Statutory rounding. + + +@given(PROJECTED_YEARS) +def test_projected_amounts_are_rounded_as_published(year): + assert _is_multiple(_at(MEDICARE.part_b.base_premium, year), 0.1) + assert _is_multiple(_at(MEDICARE.part_a.full_premium, year), 1) + assert _is_multiple(_at(MEDICARE.part_a.reduced_premium, year), 1) + assert _is_multiple(_at(MEDICARE.part_a.deductible, year), 4) + for program in (MEDICARE.part_b, MEDICARE.part_d): + for status in ALL_STATUSES: + for bracket in _brackets(program, status): + assert _is_multiple(_at(bracket.amount, year), 0.1) + + +# 3. Agreement with the uprater. + + +@given(st.integers(2021, END_YEAR + 20)) +def test_part_b_premium_is_the_series_rounded_to_ten_cents(year): + series = _series(PART_B_SERIES, year) + premium = _at(MEDICARE.part_b.base_premium, year) + assert premium == pytest.approx(round(series / 0.1) * 0.1, abs=1e-9) + assert abs(premium - series) <= 0.05 + 1e-9 + + +# 4. Proportionality. + + +@given(PROJECTED_YEARS, st.sampled_from(FILING_STATUSES)) +def test_part_b_irmaa_keeps_its_statutory_multiple(year, status): + premium = _at(MEDICARE.part_b.base_premium, year) + for bracket, multiple in zip( + _brackets(MEDICARE.part_b, status)[1:], PART_B_IRMAA_MULTIPLES + ): + expected = multiple * premium + assert abs(_at(bracket.amount, year) - expected) <= 0.001 * expected + 0.1 + + +@given(PROJECTED_YEARS, st.sampled_from(ALL_STATUSES)) +def test_part_d_irmaa_follows_the_statute(year, status): + base_premium = _series(PART_D_SERIES, year) + base_percent = _at(MEDICARE.part_d.base_beneficiary_premium_percent, year) + for bracket, percentage in zip( + _brackets(MEDICARE.part_d, status)[1:], + MEDICARE_IRMAA_APPLICABLE_PERCENTAGES[status], + ): + assert _at(bracket.amount, year) == get_medicare_part_d_irmaa_amount( + base_premium, percentage, base_percent + ) + + +def test_part_d_base_percent_resets_in_2030(): + percent = MEDICARE.part_d.base_beneficiary_premium_percent + assert _at(percent, 2029) == 0.255 + assert _at(percent, 2030) == 0.2 + + +@given(st.integers(2024, END_YEAR + 20)) +def test_reduced_part_a_premium_is_55_percent_of_the_full_premium(year): + expected = 0.55 * _at(MEDICARE.part_a.full_premium, year) + reduced = _at(MEDICARE.part_a.reduced_premium, year) + assert abs(reduced - expected) <= 0.001 * expected + 1 + + +# 5. Monotonicity. + + +@given(st.integers(2023, END_YEAR - 1), st.integers(1, 80)) +def test_part_b_premium_rises_every_year_from_2023(year, gap): + later = min(year + gap, END_YEAR) + assert _at(MEDICARE.part_b.base_premium, year) < _at( + MEDICARE.part_b.base_premium, later + ) + + +@given(st.integers(2024, END_YEAR + 20), st.integers(1, 80)) +def test_part_a_amounts_never_fall_from_2024(year, gap): + for parameter in ( + MEDICARE.part_a.full_premium, + MEDICARE.part_a.reduced_premium, + MEDICARE.part_a.deductible, + ): + assert _at(parameter, year) <= _at(parameter, year + gap) + + +@given(st.integers(2024, END_YEAR + 20), st.integers(1, 80)) +def test_irmaa_thresholds_never_fall(year, gap): + for program in (MEDICARE.part_b, MEDICARE.part_d): + for status in ALL_STATUSES: + brackets = _brackets(program, status) + for bracket in brackets: + if ( + status == "separate" + and bracket is brackets[-1] + and year < MEDICARE_IRMAA_TOP_FIRST_INDEXED_YEAR + ): + continue # The intended exception; see the next test. + assert _at(bracket.threshold, year) <= _at( + bracket.threshold, year + gap + ) + + +@pytest.mark.parametrize("year", range(2025, MEDICARE_IRMAA_TOP_FIRST_INDEXED_YEAR)) +def test_separate_top_threshold_falls_until_2028(year): + """$500,000 less the unmarried threshold: $397,000 in 2024, $394,000 in + 2025 and $391,000 in 2026 as CMS published, then lower until 2028.""" + top = _brackets(MEDICARE.part_b, "separate")[-1].threshold + assert _at(top, year) < _at(top, year - 1) + single = _thresholds(MEDICARE.part_b, "single", year) + assert _at(top, year) == 500_000 - (single[1] - 1) + + +@given(st.integers(2024, END_YEAR + 20)) +def test_irmaa_tiers_rise_with_income_and_amount(year): + for program in (MEDICARE.part_b, MEDICARE.part_d): + for status in ALL_STATUSES: + brackets = _brackets(program, status) + thresholds = [_at(b.threshold, year) for b in brackets] + amounts = [_at(b.amount, year) for b in brackets] + assert thresholds == sorted(set(thresholds)) + assert amounts == sorted(set(amounts)) + + +# 6. Threshold structure. + + +@given(st.integers(2024, END_YEAR + 20)) +def test_thresholds_follow_the_filing_status_rules(year): + single = _thresholds(MEDICARE.part_b, "single", year) + lower, top = [t - 1 for t in single[1:5]], single[5] + assert _thresholds(MEDICARE.part_b, "head_of_household", year) == single + assert _thresholds(MEDICARE.part_b, "surviving_spouse", year) == single + assert _thresholds(MEDICARE.part_b, "joint", year) == [ + 0, + *(2 * amount + 1 for amount in lower), + 1.5 * top, + ] + assert _thresholds(MEDICARE.part_b, "separate", year) == [ + 0, + lower[0] + 1, + top - lower[0], + ] + for status in ALL_STATUSES: + assert _thresholds(MEDICARE.part_d, status, year) == _thresholds( + MEDICARE.part_b, status, year + ) + + +@given(st.integers(2024, END_YEAR + 20)) +def test_unmarried_amounts_are_whole_thousands(year): + """The first four thresholds sit $1 above a $1,000 multiple ("more + than" the amount); the top tier starts at a $1,000 multiple.""" + single = _thresholds(MEDICARE.part_b, "single", year) + assert all(_is_multiple(t - 1, 1_000) for t in single[1:5]) + assert _is_multiple(single[5], 1_000) + + +@pytest.mark.parametrize("year", range(2024, 2028)) +def test_top_tier_is_not_indexed_before_2028(year): + assert _thresholds(MEDICARE.part_b, "single", year)[5] == 500_000 + assert _thresholds(MEDICARE.part_b, "joint", year)[5] == 750_000 + + +def test_top_tier_is_indexed_from_2028(): + assert _thresholds(MEDICARE.part_b, "single", 2028)[5] > 500_000 + + +@pytest.mark.parametrize("year", range(2024, LAST_ANNOUNCED_YEAR + 1)) +def test_threshold_rules_reproduce_the_published_scales(year): + """Rebuilding each published year's scales from its unmarried amounts + reproduces every filing status's published thresholds.""" + lower = tuple(float(a) for a in CMS_UNMARRIED_AMOUNTS[year]) + rebuilt = get_medicare_irmaa_thresholds(lower, 500_000.0) + for status in ALL_STATUSES: + assert _thresholds(MEDICARE.part_b, status, year) == list(rebuilt[status]) + + +# 7. Indexing against CMS's published thresholds. + + +@pytest.mark.parametrize("year", sorted(CMS_UNMARRIED_AMOUNTS)) +def test_cpi_schedule_reproduces_published_thresholds(year): + lower, top = get_medicare_irmaa_unmarried_amounts(PARAMETERS, year) + published = CMS_UNMARRIED_AMOUNTS[year] + assert top == 500_000 + if year <= 2024: + assert lower == published + else: + # From January 2024 the model's CPI-U is seasonally adjusted, while + # CMS averages the published index; 2025's 12-month averages differ + # by about 0.05%, enough to move two amounts one rounding step. + assert all(abs(a - b) <= 1_000 for a, b in zip(lower, published)) + + +def test_cpi_schedule_starts_in_2020(): + with pytest.raises(ValueError): + get_medicare_irmaa_unmarried_amounts(PARAMETERS, 2019) + + +# 8. Precedence of published amounts. + + +def _medicare_tree_with_2027_values(): + """The IRMAA scales as loaded from YAML, with a 2027 value added to each + bracket, under a root that shares the system's CPI-U and Trustees + series.""" + data = {} + for program in ("part_b", "part_d"): + node = getattr(MEDICARE, program).irmaa + data[program] = {"irmaa": {}} + for status, scale in node.children.items(): + brackets = [] + for index, bracket in enumerate(scale.brackets): + entry = {} + for field in ("threshold", "amount"): + parameter = getattr(bracket, field) + values = { + v.instant_str: v.value + for v in parameter.values_list + if v.instant_str <= f"{LAST_ANNOUNCED_YEAR}-12-31" + } + # A stand-in for a CMS announcement: last year's value + # plus $1, which no projection produces. The first tier + # pays no adjustment in any year. + if field == "threshold" or index > 0: + last = values[f"{LAST_ANNOUNCED_YEAR}-01-01"] + values["2027-01-01"] = last + 1 + entry[field] = {"values": values} + brackets.append(entry) + data[program]["irmaa"][status] = { + "metadata": {"type": "single_amount"}, + "brackets": brackets, + } + data["part_d"]["base_beneficiary_premium_percent"] = { + "values": {"2006-01-01": 0.255, "2030-01-01": 0.2} + } + medicare = ParameterNode("gov.hhs.medicare", data=data) + root = SimpleNamespace( + gov=SimpleNamespace( + hhs=SimpleNamespace(medicare=medicare), bls=PARAMETERS.gov.bls + ), + calibration=PARAMETERS.calibration, + ) + return root, medicare + + +def test_published_values_take_precedence_and_later_years_follow_the_statute(): + root, medicare = _medicare_tree_with_2027_values() + extend_medicare_irmaa_thresholds(root, end_year=2030) + extend_medicare_part_d_irmaa_amounts(root, end_year=2030) + for program in ("part_b", "part_d"): + for status in ALL_STATUSES: + built = _brackets(getattr(medicare, program), status) + system_brackets = _brackets(getattr(MEDICARE, program), status) + for index, (bracket, system_bracket) in enumerate( + zip(built, system_brackets) + ): + assert _at(bracket.threshold, 2027) == ( + _at(bracket.threshold, LAST_ANNOUNCED_YEAR) + 1 + ) + if program == "part_d" and index > 0: + assert _at(bracket.amount, 2027) == ( + _at(bracket.amount, LAST_ANNOUNCED_YEAR) + 1 + ) + # 2028 on is computed from the statute, as in the system. + for year in (2028, 2030): + assert _at(bracket.threshold, year) == _at( + system_bracket.threshold, year + ) + if program == "part_d": + assert _at(bracket.amount, year) == _at( + system_bracket.amount, year + ) + + +# 9. Extension of the new series. + +NEW_SERIES = (PART_A_SERIES, DEDUCTIBLE_SERIES, PART_D_SERIES) + + +@given(st.sampled_from(NEW_SERIES), st.integers(LAST_TRUSTEES_YEAR + 1, END_YEAR)) +def test_new_series_extend_at_the_last_trustees_growth_rate(path, year): + last_growth = _series(path, LAST_TRUSTEES_YEAR) / _series( + path, LAST_TRUSTEES_YEAR - 1 + ) + growth = _series(path, year) / _series(path, year - 1) + assert growth == pytest.approx(last_growth, rel=1e-12) + + +@given(st.sampled_from(NEW_SERIES), st.integers(END_YEAR, END_YEAR + 100)) +def test_new_series_hold_their_2100_value(path, year): + assert _series(path, year) == _series(path, END_YEAR) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_a_premium.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_a_premium.yaml index 27e9423d9c3..6996012aa17 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_a_premium.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_a_premium.yaml @@ -69,3 +69,22 @@ medicare_quarters_of_coverage: 20 output: base_part_a_premium: 6_780 # $565 * 12 months + +# Projected years follow the Trustees' Table V.E1. +- name: 2027 projected reduced Part A premium. + period: 2027 + input: + age: 65 + is_medicare_eligible: true + medicare_quarters_of_coverage: 35 + output: + base_part_a_premium: 3_960 # $330 * 12 months + +- name: 2027 projected full Part A premium. + period: 2027 + input: + age: 65 + is_medicare_eligible: true + medicare_quarters_of_coverage: 20 + output: + base_part_a_premium: 7_200 # $600 * 12 months diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_b_premium.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_b_premium.yaml index 0711fd86c9f..a41f3dcd435 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_b_premium.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/base_part_b_premium.yaml @@ -37,3 +37,21 @@ is_medicare_eligible: true output: base_part_b_premium: 2_434.80 # $202.90 * 12 months + +# After CMS's last announced year (2026) the premium follows the 2026 Medicare +# Trustees Report's projected standard premium (Table V.E2). +- name: 2027 projected Part B premium. + period: 2027 + input: + age: 65 + is_medicare_eligible: true + output: + base_part_b_premium: 2_514 # $209.50 * 12 months + +- name: 2034 projected Part B premium. + period: 2034 + input: + age: 65 + is_medicare_eligible: true + output: + base_part_b_premium: 4_062 # $338.50 * 12 months diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml index 66ce09857a6..e5bd78e1b1b 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/income_adjusted_part_d_premium_surcharge.yaml @@ -129,3 +129,33 @@ medicare_irmaa_magi_two_years_prior: 500_000 output: income_adjusted_part_d_premium_surcharge: 1_029.6 + +# Projected years: 42 U.S.C. 1395w-113(a)(7)(B) applied to the Trustees' +# projected base beneficiary premium (Table V.E2). The base beneficiary +# premium percent falls from 25.5 to 20 percent in 2030, so the 35 percent +# tier's adjustment jumps from $46.44 * 9.5 / 25.5 = $17.30 to $68.93 * 15 / +# 20 = $51.70, as in the Trustees' Table V.E4. Joint 35 percent tier: $238,001 +# to $300,000 in 2029 and $242,001 to $306,000 in 2030. +- name: 2029 joint filers in the 35 percent tier. + period: 2029 + input: + age: 65 + filing_status: JOINT + adjusted_gross_income: + 2027: 270_000 + tax_exempt_interest_income: + 2027: 0 + output: + income_adjusted_part_d_premium_surcharge: 207.6 # $17.30 * 12 months + +- name: 2030 joint filers in the 35 percent tier, after the 20 percent reset. + period: 2030 + input: + age: 65 + filing_status: JOINT + adjusted_gross_income: + 2028: 270_000 + tax_exempt_interest_income: + 2028: 0 + output: + income_adjusted_part_d_premium_surcharge: 620.4 # $51.70 * 12 months diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_part_b_premium.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_part_b_premium.yaml index 5ad0e7b72d0..3309a1da570 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_part_b_premium.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_part_b_premium.yaml @@ -193,3 +193,64 @@ msp_asset_eligible: false output: medicare_part_b_premium: 8_278.8 + +# Projected years. The standard premium follows the Trustees' projection +# (Table V.E2), each income-related adjustment amount grows with it from +# CMS's 2026 amount, and the thresholds follow 42 U.S.C. 1395r(i)(5)'s CPI-U +# schedule: $112,000 / $141,000 / $176,000 / $211,000 / $500,000 unmarried in +# 2027, and twice that (but $888,000 at the top) jointly in 2034. +- name: 2027 single filer in the 50 percent tier. + period: 2027 + input: + age: 65 + filing_status: SINGLE + adjusted_gross_income: + 2025: 160_000 + tax_exempt_interest_income: + 2025: 0 + is_medicare_eligible: true + medicare_enrolled: true + msp_income_eligible: false + msp_asset_eligible: false + output: + # ($209.50 standard + $209.50 adjustment) * 12 months + medicare_part_b_premium: 5_028 + +- name: 2027 separate filer above the falling top threshold. + period: 2027 + input: + age: 65 + filing_status: SEPARATE + adjusted_gross_income: + 2025: 390_000 + tax_exempt_interest_income: + 2025: 0 + is_medicare_eligible: true + medicare_enrolled: true + msp_income_eligible: false + msp_asset_eligible: false + output: + # The top tier starts at $500,000 less the $112,000 unmarried threshold, + # $388,000 (CMS's 2026 figure was $391,000). The 85 percent tier's + # adjustment is $487.00 * 209.50 / 202.90 = $502.84, rounded to $502.80. + # ($209.50 + $502.80) * 12 months + medicare_part_b_premium: 8_547.6 + +- name: 2034 joint filers in the 65 percent tier. + period: 2034 + input: + age: 65 + filing_status: JOINT + adjusted_gross_income: + 2032: 450_000 + tax_exempt_interest_income: + 2032: 0 + is_medicare_eligible: true + medicare_enrolled: true + msp_income_eligible: false + msp_asset_eligible: false + output: + # Joint 65 percent tier: $418,001 to $500,000. Adjustment $324.60 * + # 338.50 / 202.90 = $541.53, rounded to $541.50. + # ($338.50 + $541.50) * 12 months + medicare_part_b_premium: 10_560 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml index 0c450945ee9..ef46dabc333 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_part_b_premium_coverage.yaml @@ -27,3 +27,13 @@ base_part_b_premium: 2_220 output: msp_part_b_premium_coverage: 0 + +- name: MSP part B coverage pays the projected 2027 standard premium + period: 2027 + input: + is_medicare_eligible: true + medicare_enrolled: true + msp_income_eligible: true + msp_asset_eligible: true + output: + msp_part_b_premium_coverage: 2_514 # $209.50 * 12 months From 16eafbad730713cf20e8e87649762c2b54eaf82a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:37:13 -0400 Subject: [PATCH 11/12] Say how to update the projection tests when CMS announces a year Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_medicare_premium_projection.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/policyengine_us/tests/core/test_medicare_premium_projection.py b/policyengine_us/tests/core/test_medicare_premium_projection.py index bc9d205d074..bcf4f8aa5c1 100644 --- a/policyengine_us/tests/core/test_medicare_premium_projection.py +++ b/policyengine_us/tests/core/test_medicare_premium_projection.py @@ -16,6 +16,11 @@ the precedence tests. Household calculations are in the YAML tests under ``tests/policy/baseline/gov/hhs/medicare``. +When CMS announces a year, add its amounts to the YAML and move +``LAST_ANNOUNCED_YEAR`` forward. Later years then grow from the announced +amounts, so the Trustees rows pinned below for those years no longer apply +exactly; update them from the next Trustees Report. + Invariants: 1. Source agreement (differential, model against published figures): for From ce75b4abb8b7c6c4f2cb3e7f774dae6007f762de Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:41:06 -0400 Subject: [PATCH 12/12] Note Medicare's projected years in the program registry Co-Authored-By: Claude Opus 5.5 --- policyengine_us/programs.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 05cb1d13747..981187ff946 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1116,6 +1116,9 @@ programs: Includes Part A (hospital) and Part B (medical) premiums with IRMAA adjustments; gross Part B premium calibration should use the enrollment-gated gross_medicare_part_b_premium_if_enrolled variable. + After CMS's last announced year, premiums and IRMAA amounts follow the + Medicare Trustees' projections and IRMAA thresholds the statutory CPI-U + indexing. # --- HUD programs --- - id: section_8