From b076456914b903aac108912d8951bbf84d021cdd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 06:48:12 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 772c2ab3384ba9a2bfd9b56260f2b1aa577347af Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:36:00 -0400 Subject: [PATCH 9/9] Address review: qualify proxies, pin all BLS means and employee shares - Long-term care: CPI-U medical care by calendar year is a proxy for the chained August index 26 U.S.C. 213(d)(10)(B) uses; CMS's projections have no long-term care premium series. - Part B: within an income tier, a premium is a fixed multiple of the standard premium before rounding; hold-harmless and Medicare Advantage reductions move some premiums differently. - Direct purchase is the closest CMS series for the self-employed slice, not a claim about how each premium was bought. - Move neutrality is conditional on a producer removing moved dollars. - Tests pin BLS means for 2015-2024 and the employee share in every year 2024-2034; one Trustees reference. Co-Authored-By: Claude Opus 5.5 --- docs-quarto/methodology/spm-poverty.qmd | 2 +- .../medicare_part_b_premium_per_enrollee.yaml | 4 +- .../tests/core/test_premium_input_uprating.py | 132 ++++++++++++------ .../test_premium_input_microsim_uprating.py | 5 +- .../health/health_insurance_premiums.py | 3 +- ...urance_premiums_without_medicare_part_b.py | 4 +- .../long_term_health_insurance_premiums.py | 9 +- .../medicare_part_b_premiums_reported.py | 7 +- 8 files changed, 112 insertions(+), 54 deletions(-) diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index da7bc2f33ce..190897bc8a4 100644 --- a/docs-quarto/methodology/spm-poverty.qmd +++ b/docs-quarto/methodology/spm-poverty.qmd @@ -50,7 +50,7 @@ The [MOOP decomposition page](moop-decomposition.qmd) records the design and the Population results use the Microcosm US dataset. The current default is Build P: 57,240 Current Population Survey Annual Social and Economic Supplement households with 166,321 people, whose weights are fitted to 5,659 administrative and demographic targets (IRS Statistics of Income by state and by income, Census population estimates by age, sex and state, CMS Medicaid and Marketplace enrollment, USDA SNAP participation and benefits, SSA SSI, TANF, state tax collections and BEA national accounts). Poverty rates are never a target. That is a rule of the calibration, not an accident: a model whose weights were fitted to survey-based poverty rates could not be checked against them. -2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses and over-the-counter health expenses that enter SPM resources; 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. +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; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August (26 U.S.C. 213(d)(10)(B)), which nobody projects. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. 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/medicare_part_b_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml index 01ee320a3e4..323dfad8f92 100644 --- 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 @@ -10,9 +10,7 @@ metadata: # 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 + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Tables III.C2 and V.E2 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 diff --git a/policyengine_us/tests/core/test_premium_input_uprating.py b/policyengine_us/tests/core/test_premium_input_uprating.py index 3420a5f6d9a..8ae4c3d5b0f 100644 --- a/policyengine_us/tests/core/test_premium_input_uprating.py +++ b/policyengine_us/tests/core/test_premium_input_uprating.py @@ -9,21 +9,26 @@ 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 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. + pay most of these dollars. Microcosm's planned pre-tax producer routes + employees' payroll-deducted premiums to + ``pre_tax_health_insurance_premiums``, and under the disjoint input contract + those dollars must then leave these inputs. All three follow CMS's + employer-sponsored insurance (ESI) premium per enrollee, so such a 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. -- ``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. + without employer-sponsored coverage. It follows the closest CMS series for + them, the direct purchase premium per enrollee (individually bought plans, + Marketplace plans and Medigap). +- ``long_term_health_insurance_premiums``: no dataset fills it, and CMS's + health expenditure projections have no long-term care insurance premium + series. 26 U.S.C. 213(d)(10)(B) indexes the deductible limits on these + premiums by the chained CPI-U medical care component for August, which + nobody projects, so the input follows CBO's projection of the CPI-U medical + care component by calendar year as a proxy. - ``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)). + within an income tier, a premium with its income-related adjustment is a + fixed multiple of the standard premium before rounding (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``. @@ -38,8 +43,10 @@ 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. +3. Move neutrality: every input that can hold employees' shares of ESI + premiums names the same series, so moving a premium between them does not + change its growth. (This tests the engine side; it does not test that a + producer removes moved dollars from the reported inputs.) 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 @@ -112,9 +119,10 @@ def test_no_premium_input_follows_out_of_pocket_spending(): 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.""" + """A producer that moves an employee's payroll-deducted premium out of + the reported premium inputs into the pre-tax input, as the disjoint input + contract requires, leaves the dollars' growth unchanged only if all of + these inputs follow one series.""" assert {system.variables[name].uprating for name in EMPLOYEE_ESI_INPUTS} == {ESI} @@ -123,35 +131,70 @@ def test_inputs_holding_employee_esi_shares_follow_one_series(): # 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, -} +ESI_YEARS = range(2024, 2035) +ESI_SPENDING_BILLIONS = dict( + zip( + ESI_YEARS, + ( + *(1_429.1, 1_549.8, 1_650.5, 1_732.9, 1_820.4, 1_896.9, 1_978.0), + *(2_058.6, 2_148.4, 2_246.7, 2_343.1), + ), + ) +) +ESI_ENROLLMENT_MILLIONS = dict( + zip( + ESI_YEARS, + (178.6, 179.9, 179.8, 180.0, 180.4, 180.1, 179.8, 179.5, 179.2, 178.9, 178.5), + ) +) +# Private business, federal and state and local employer contributions. +EMPLOYER_CONTRIBUTIONS_BILLIONS = dict( + zip( + ESI_YEARS, + ( + 753 + 47 + 247, + 818 + 49 + 268, + 865 + 56 + 288, + 905 + 60 + 305, + 947 + 63 + 323, + 985 + 66 + 339, + 1_024 + 69 + 356, + 1_064 + 72 + 372, + 1_109 + 75 + 390, + 1_159 + 79 + 410, + 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): +def _employee_contributions_per_enrollee(year): """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.""" + employee = ESI_SPENDING_BILLIONS[year] - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] + return employee / ESI_ENROLLMENT_MILLIONS[year] - 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] +@pytest.mark.parametrize("start, end", [(2024, 2034), (2026, 2034)]) +def test_employee_contributions_per_enrollee_grow_with_the_esi_index(start, end): years = end - start - employee_growth = (per_enrollee(end) / per_enrollee(start)) ** (1 / years) + employee_growth = ( + _employee_contributions_per_enrollee(end) + / _employee_contributions_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 + + +@pytest.mark.parametrize("year", ESI_YEARS) +def test_employees_pay_a_steady_share_of_esi_premiums(year): + """Employees pay 26.6% to 26.8% of ESI premiums in every year from 2024 + to 2034, so their contributions grow with the premium.""" + employee_share = ( + 1 - EMPLOYER_CONTRIBUTIONS_BILLIONS[year] / ESI_SPENDING_BILLIONS[year] + ) + assert 0.266 <= employee_share <= 0.268 # Direct purchase: the same tables, Table 17 "Direct Purchase" rows. @@ -234,8 +277,17 @@ def test_direct_purchase_index_dips_where_cms_reports_it(): ), ) ) -# BLS series CUUR0000SAM, mean of the 12 published monthly indexes. -BLS_MEDICAL_CPI_ANNUAL_MEAN = {2022: 546.553, 2023: 549.084, 2024: 563.841} +# BLS series CUUR0000SAM, mean of the 12 published monthly indexes, derived +# from the monthly values the BLS API returned on 2026-10-11. +BLS_MEDICAL_CPI_ANNUAL_MEAN = dict( + zip( + range(2015, 2025), + ( + *(446.752, 463.675, 475.322, 484.707, 498.413, 518.875, 525.276), + *(546.553, 549.084, 563.841), + ), + ) +) @pytest.mark.parametrize("year", sorted(CBO_MEDICAL_CPI)) @@ -252,7 +304,7 @@ def test_medical_cpi_levels_match_cbo_percent_changes(year): assert change == pytest.approx(CBO_MEDICAL_CPI_PERCENT_CHANGE[year], abs=0.001) -@pytest.mark.parametrize("year", [2022, 2023, 2024]) +@pytest.mark.parametrize("year", sorted(BLS_MEDICAL_CPI_ANNUAL_MEAN)) 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 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 4ccd833ab36..816c69b2014 100644 --- a/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py +++ b/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py @@ -14,8 +14,9 @@ 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. + unchanged in every later year (Hypothesis). A producer that routes a + reported premium through payroll and removes it from the reported inputs, + as the disjoint input contract requires, makes such a split. 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. 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 93107c1e06c..a37edb03604 100644 --- a/policyengine_us/variables/household/expense/health/health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/health_insurance_premiums.py @@ -8,7 +8,8 @@ class health_insurance_premiums(Variable): unit = USD definition_period = YEAR # Survey data put premiums here only for self-employed people under 65 - # without employer-sponsored coverage, who buy plans directly. + # without employer-sponsored coverage. CMS's premium per enrollee for + # plans bought outside an employer is the closest series for them. uprating = "calibration.gov.hhs.cms.direct_purchase_premium_per_enrollee" documentation = ( "Person-level health insurance premiums supplied directly as an input. " 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 e5ea67ae17a..2e500de85af 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 @@ -10,6 +10,6 @@ class health_insurance_premiums_without_medicare_part_b(Variable): # 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. + # pre_tax_health_insurance_premiums, so a premium moved from one input + # to the other grows the same way. uprating = "calibration.gov.hhs.cms.esi_premium_per_enrollee" 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 5d91d8d3ff2..bc3eeb47c12 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,7 +7,10 @@ class long_term_health_insurance_premiums(Variable): label = "Long-term health insurance premiums" unit = USD definition_period = YEAR - # 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. + # No dataset fills this input, and CMS's health expenditure projections + # have no long-term care insurance premium series. 26 U.S.C. + # 213(d)(10)(B) indexes the deductible limits on these premiums by the + # chained CPI-U medical care component for August; nobody projects that + # index, so this uses CBO's projection of the CPI-U medical care + # component, by calendar year, as a proxy. uprating = "gov.bls.cpi.cpi_u_medical_care" 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 c332c7bd9c2..b9b28844663 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,6 +7,9 @@ class medicare_part_b_premiums_reported(Variable): label = "Medicare Part B premiums (reported)" definition_period = YEAR unit = USD - # A Part B premium, with any income-related adjustment, is a fixed - # multiple of the standard premium (42 U.S.C. 1395r(i)(3)). + # Grows with the standard premium: within an income tier, a premium with + # its income-related adjustment is a fixed multiple of the standard + # premium before rounding (42 U.S.C. 1395r(i)(3)), though hold-harmless + # limits and Medicare Advantage premium reductions move some people's + # premiums differently. uprating = "calibration.gov.hhs.cms.medicare_part_b_premium_per_enrollee"