From 1d369d99ca950069b3ee9acf6fe7110f2facf035 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:59:59 -0400 Subject: [PATCH 1/5] Use CMS Medicare spending per enrollee for the Medicare per capita cost Replace KFF's 2021 traditional-Medicare figure and an unsourced $14,500 2025 estimate (held flat for every later year) with CMS National Health Expenditure Table 17's Medicare spending per enrollee: historical 2013-2024, projected 2025-2034, extended to 2100 at the 2031-2032 rate so the end of sequestration in 2033 does not compound. Co-Authored-By: Claude Opus 5.5 --- .../medicare-per-enrollee-cms.fixed.md | 1 + .../gov/hhs/medicare/per_capita_cost.yaml | 68 ++++++++++++++++--- .../parameters/uprating_extensions.py | 17 +++++ 3 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 changelog.d/medicare-per-enrollee-cms.fixed.md diff --git a/changelog.d/medicare-per-enrollee-cms.fixed.md b/changelog.d/medicare-per-enrollee-cms.fixed.md new file mode 100644 index 00000000000..04369fcfd3f --- /dev/null +++ b/changelog.d/medicare-per-enrollee-cms.fixed.md @@ -0,0 +1 @@ +Value Medicare enrollees at CMS's Medicare spending per enrollee (National Health Expenditure projections, 2013-2034, extended to 2100) instead of a $14,500 estimate held flat from 2025. diff --git a/policyengine_us/parameters/calibration/gov/hhs/medicare/per_capita_cost.yaml b/policyengine_us/parameters/calibration/gov/hhs/medicare/per_capita_cost.yaml index 3e92c3e491c..756e0272c4a 100644 --- a/policyengine_us/parameters/calibration/gov/hhs/medicare/per_capita_cost.yaml +++ b/policyengine_us/parameters/calibration/gov/hhs/medicare/per_capita_cost.yaml @@ -1,12 +1,64 @@ -description: Average Medicare spending per enrolled beneficiary (national average). Represents the annual value of Medicare coverage including Part A and Part B. -values: - 2021-01-01: 11_080 # Traditional Medicare (Part A and/or Part B) per KFF - 2025-01-01: 14_500 # Estimated - update with official Centers for Medicare & Medicaid Services (CMS) data when available +description: The Centers for Medicare and Medicaid Services (CMS) reports and projects this Medicare spending per enrollee, which is all Medicare spending, including payments to Medicare Advantage plans, Part D and program administration, divided by average enrollment in Part A, Part B or both. metadata: - label: Medicare per capita cost unit: currency-USD period: year + label: CMS Medicare spending per enrollee reference: - - title: KFF Medicare Spending Per Beneficiary (2021) - href: https://www.kff.org/medicare/state-indicator/per-beneficiary/ - - title: Note - 2024 value is estimated and should be updated with official Centers for Medicare & Medicaid Services (CMS) data + # Table17 Health Insurance Enrollment and Enrollment Growth Rates, + # "Spending per Enrollee ($)", "Medicare" row: Medicare spending (the same + # row as Table03's Medicare source of funds) divided by Medicare + # enrollment. Historical for 2013-2024, projected for 2025-2034. 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 + # Medicare enrollment is Part A and/or B enrollment by "a person-year + # methodology, which sums the number of months each beneficiary is enrolled + # during the year and divides by 12", with U.S. territories removed. + - title: "CMS National Health Expenditure Accounts: Methodology Paper, 2024, Medicare enrollment" + href: https://www.cms.gov/files/document/definitions-sources-and-methods.pdf#page=34 + # Medicare spending covers Parts A, B, C (Medicare Advantage) and D (file + # page 15) and, as program spending, federal administration and the + # non-medical expenditures of Part C and Part D private plans (file page + # 23). Premiums finance part of that spending rather than reduce it: the + # sponsor analysis counts SMI premiums as household spending on Medicare + # (file page 30). + - title: "CMS National Health Expenditure Accounts: Methodology Paper, 2024, Medicare spending" + href: https://www.cms.gov/files/document/definitions-sources-and-methods.pdf#page=23 + # "Projections of Medicare spending and enrollment used in the NHE + # projections were generated for the most recent Trustees Report", here + # the 2026 report. Last Updated June 24, 2026, sha256 + # ce62fe2c236cc96ce287027784b141584ddf39e62c5fd8ddc3e771a014944817. + - title: "Projections of National Health Expenditures and Enrollment: Methodology and Model Specification" + href: https://www.cms.gov/research-statistics-data-and-systems/statistics-trends-and-reports/nationalhealthexpenddata/downloads/projectionsmethodology.pdf#page=7 +values: + # Historical. + 2013-01-01: 11_483 + 2014-01-01: 11_683 + 2015-01-01: 11_929 + 2016-01-01: 12_119 + 2017-01-01: 12_319 + 2018-01-01: 12_795 + 2019-01-01: 13_366 + 2020-01-01: 13_547 + 2021-01-01: 14_293 + 2022-01-01: 14_921 + 2023-01-01: 15_915 + 2024-01-01: 16_779 + # Projections. + 2025-01-01: 17_751 + 2026-01-01: 18_983 + 2027-01-01: 20_096 + 2028-01-01: 21_310 + 2029-01-01: 22_543 + 2030-01-01: 23_789 + 2031-01-01: 25_185 + 2032-01-01: 26_552 + 2033-01-01: 28_277 + 2034-01-01: 30_129 + # 2035 to 2100 grow at the 2031-2032 rate (26_552 / 25_185, 5.4% a year), + # not the 2033-2034 rate, which carries the end of Medicare sequestration + # on August 31, 2033; 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..df50047bd66 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -880,6 +880,23 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: period_day=1, ) + # CMS Medicare spending per enrollee (January values, last projection + # year 2034), which Medicare cost variables read directly. Sequestration + # cuts Medicare benefit payments 2 percent through August 31, 2033 (2026 + # Medicare Trustees Report, file page 62); its end lifts 2033 and 2034 + # growth, and the Trustees' per-beneficiary growth falls from 6.6 + # percent in 2034 to 5.4 percent in 2035 (Table V.D1, file page 210). + # So each year from 2035 to 2100 grows at the 2031-2032 rate, and later + # years hold the 2100 value. + extend_parameter_values( + parameters.calibration.gov.hhs.medicare.per_capita_cost, + last_projected_year=2034, + end_year=END_YEAR, + period_month=1, + period_day=1, + growth_years=(2031, 2032), + ) + # CBO income-by-source aggregates are used directly and as anchors for # SOI-based income upraters. Extending them in the baseline path keeps # long-run nominal data aging independent of scenario-specific reforms. From cdfe759d6fad3d2a226b2f6de05fb5d9cbf953de Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:18:16 -0400 Subject: [PATCH 2/5] Test the Medicare spending per enrollee series and its consumers Transcription checks against NHE Table 17 (and its spending over enrollment), a cross-source level check against the Trustees Report's per beneficiary cost, Hypothesis properties for order, the 2035-2100 closed form and calendar-year steps, and identities for medicare_gross_cost, medicare_cost and qmb_cost_sharing. Existing expectations move from $14,500/$11,080 to the CMS values. Co-Authored-By: Claude Opus 5.5 --- .../core/test_medicare_per_capita_cost.py | 245 ++++++++++++++++++ .../gov/hhs/medicare/costs/medicare_cost.yaml | 10 +- .../medicare/costs/medicare_gross_cost.yaml | 6 +- .../medicare/savings_programs/msp_cost.yaml | 4 +- .../msp_federal_and_state_cost.yaml | 6 +- .../savings_programs/qmb_cost_sharing.yaml | 4 +- .../hhs/medicare/test_part_b_msp_offset.py | 6 +- .../household/cbo_household_income.yaml | 10 +- .../parameters/test_uprating_extensions.py | 5 + 9 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 policyengine_us/tests/core/test_medicare_per_capita_cost.py diff --git a/policyengine_us/tests/core/test_medicare_per_capita_cost.py b/policyengine_us/tests/core/test_medicare_per_capita_cost.py new file mode 100644 index 00000000000..986236c9d7d --- /dev/null +++ b/policyengine_us/tests/core/test_medicare_per_capita_cost.py @@ -0,0 +1,245 @@ +"""The CMS Medicare spending per enrollee series that values Medicare coverage. + +``calibration.gov.hhs.medicare.per_capita_cost`` holds the "Medicare" row of +"Spending per Enrollee ($)" in CMS's National Health Expenditure projections, +Table 17 (2025-2034 release): Medicare spending over Medicare enrollment. +Spending covers every Part, including payments to Medicare Advantage plans and +program administration; enrollment counts Part A and/or Part B enrollees in +person-years; both exclude the territories (NHEA methodology paper, file pages +15, 23 and 34). 2013-2024 are historical and 2025-2034 projected, from the +projections prepared for the 2026 Medicare Trustees Report. 2035 to 2100 grow +at the 2031-2032 rate, because the end of sequestration on August 31, 2033 +lifts 2033 and 2034 growth. + +``medicare_gross_cost`` returns the series for each enrolled person, +``medicare_cost`` subtracts the person's Part A and gross Part B premiums, and +``qmb_cost_sharing`` pays QMB enrollees a share of it each month. + +Invariants: + +1. Transcription: every value is Table 17's published dollar figure, and is + consistent with the same table's Medicare spending over Medicare enrollment + within rounding. +2. Cross-source level: the Trustees Report's per beneficiary cost (Table V.D1) + runs 5% to 8% above the series in every year from 2015 to 2034. V.D1 sums + per-Part averages (its footnote 2), while Table II.B1 counts 63.4 million + Part B and 56.8 million Part D enrollees among 69.3 million in 2025. +3. Bounds and order: the series is positive and strictly increasing from 2013 + to 2100, and holds the 2100 value afterwards. +4. Extension rule: each year from 2035 to 2100 equals the 2034 value times + (26,552 / 25,185) ** (year - 2034), the closed form of the iterative + extension; the 2035 value is within 0.1% of the 2034 value grown at the + Trustees' 2034-2035 per beneficiary growth. +5. Calendar years: every date within a year reads the January 1 value. +6. Consumers: for an enrolled person, ``medicare_gross_cost`` equals the + series; ``medicare_cost`` equals the series less the Part A premium and the + gross Part B premium, floored at zero, so never exceeds + ``medicare_gross_cost``; a QMB enrollee's monthly ``qmb_cost_sharing`` is + the series over 12 times the QMB rate. A person not enrolled gets zero. +""" + +import pytest +from hypothesis import 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.system import system + +SERIES = "calibration.gov.hhs.medicare.per_capita_cost" +FIRST_YEAR, LAST_PROJECTED_YEAR, END_YEAR = 2013, 2034, 2100 +YEARS = range(FIRST_YEAR, LAST_PROJECTED_YEAR + 1) +GROWTH_YEARS = (2031, 2032) + +# CMS NHE projections tables, Table 17, "Medicare" rows +# (https://www.cms.gov/files/zip/nhe-projections-tables.zip, Last-Modified +# 2026-09-03, sha256 264f8226...cd70). +MEDICARE_SPENDING_PER_ENROLLEE = dict( + zip( + YEARS, + ( + *(11_483, 11_683, 11_929, 12_119, 12_319, 12_795, 13_366, 13_547), + *(14_293, 14_921, 15_915, 16_779, 17_751, 18_983, 20_096, 21_310), + *(22_543, 23_789, 25_185, 26_552, 28_277, 30_129), + ), + ) +) +MEDICARE_SPENDING_BILLIONS = dict( + zip( + YEARS, + ( + *(588.9, 617.3, 647.7, 675.7, 704.9, 751.6, 805.2, 833.8, 894.6), + *(951.7, 1037.3, 1118.0, 1204.0, 1314.6, 1422.7, 1544.2, 1664.6), + *(1783.7, 1911.1, 2035.8, 2188.3, 2354.3), + ), + ) +) +MEDICARE_ENROLLMENT_MILLIONS = dict( + zip( + YEARS, + ( + *(51.3, 52.8, 54.3, 55.8, 57.2, 58.7, 60.2, 61.6, 62.6, 63.8), + *(65.2, 66.6, 67.8, 69.2, 70.8, 72.5, 73.8, 75.0, 75.9, 76.7), + *(77.4, 78.1), + ), + ) +) +# 2026 Medicare Trustees Report, Table V.D1 (file page 210), "Total" average +# per beneficiary cost: historical through 2025, intermediate estimates after. +TRUSTEES_PER_BENEFICIARY_COST = dict( + zip( + range(2015, 2036), + ( + *(12_734, 12_922, 13_127, 13_604, 14_224, 14_401, 15_170, 15_820), + *(16_809, 17_837, 18_894, 20_430, 21_497, 22_783, 24_076, 25_401), + *(26_826, 28_169, 29_989, 31_967, 33_687), + ), + ) +) + +years = st.integers(FIRST_YEAR, END_YEAR) + + +def _series(date): + return get_parameter(system.parameters, SERIES)(date) + + +def _value(year): + return _series(f"{year}-01-01") + + +@pytest.mark.parametrize("year", YEARS) +def test_series_is_table_17_medicare_spending_per_enrollee(year): + assert _value(year) == MEDICARE_SPENDING_PER_ENROLLEE[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 = MEDICARE_SPENDING_BILLIONS[year] + enrollment = MEDICARE_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 <= MEDICARE_SPENDING_PER_ENROLLEE[year] <= high + + +@pytest.mark.parametrize("year", range(2015, LAST_PROJECTED_YEAR + 1)) +def test_trustees_per_beneficiary_cost_runs_5_to_8_percent_above(year): + ratio = TRUSTEES_PER_BENEFICIARY_COST[year] / _value(year) + assert 1.05 <= ratio <= 1.08 + + +def test_2035_extension_follows_the_trustees_2035_growth(): + trustees_growth = ( + TRUSTEES_PER_BENEFICIARY_COST[2035] / TRUSTEES_PER_BENEFICIARY_COST[2034] + ) + assert _value(2035) == pytest.approx( + _value(LAST_PROJECTED_YEAR) * trustees_growth, rel=0.001 + ) + + +@given(years, st.integers(1, END_YEAR - FIRST_YEAR)) +def test_series_is_positive_and_strictly_increasing(year, gap): + later = min(year + gap, END_YEAR) + if later == year: + return + assert 0 < _value(year) < _value(later) + + +@given(st.integers(LAST_PROJECTED_YEAR + 1, END_YEAR)) +def test_extension_is_the_closed_form_of_the_2031_2032_growth(year): + earlier, later = GROWTH_YEARS + growth = ( + MEDICARE_SPENDING_PER_ENROLLEE[later] + / (MEDICARE_SPENDING_PER_ENROLLEE[earlier]) + ) + expected = MEDICARE_SPENDING_PER_ENROLLEE[LAST_PROJECTED_YEAR] * growth ** ( + year - LAST_PROJECTED_YEAR + ) + assert _value(year) == pytest.approx(expected, rel=1e-9) + assert _value(year) / _value(year - 1) == pytest.approx(growth, rel=1e-12) + + +@given(st.integers(END_YEAR, END_YEAR + 100)) +def test_series_holds_the_2100_value_afterwards(year): + assert _value(year) == _value(END_YEAR) + + +@given(years, st.integers(1, 12), st.integers(1, 28)) +def test_every_date_in_a_year_reads_the_january_value(year, month, day): + assert _series(f"{year}-{month:02d}-{day:02d}") == _value(year) + + +def _person_simulation(year, person): + members = ["person"] + return Simulation( + situation={ + "people": {"person": person}, + "tax_units": {"tax_unit": {"members": members}}, + "spm_units": {"spm_unit": {"members": members}}, + "families": {"family": {"members": members}}, + "marital_units": {"marital_unit": {"members": members}}, + "households": { + "household": {"members": members, "state_code": {year: "CA"}} + }, + } + ) + + +@settings(max_examples=20, deadline=None) +@given( + st.integers(2015, END_YEAR), + st.floats(0, 12_000, allow_nan=False), + st.floats(0, 40_000, allow_nan=False), +) +def test_medicare_cost_nets_premiums_from_the_series( + year, part_a_premium, part_b_premium +): + simulation = _person_simulation( + year, + { + "age": {year: 70}, + "is_medicare_eligible": {year: True}, + "medicare_enrolled": {year: True}, + "base_part_a_premium": {year: part_a_premium}, + "gross_medicare_part_b_premium": {year: part_b_premium}, + }, + ) + gross = simulation.calculate("medicare_gross_cost", year)[0] + net = simulation.calculate("medicare_cost", year)[0] + # Variables are float32, so compare to single precision. + assert gross == pytest.approx(_value(year), rel=1e-6) + expected = max(_value(year) - part_a_premium - part_b_premium, 0) + assert net == pytest.approx(expected, rel=1e-6, abs=0.01) + assert net <= gross + + +@settings(max_examples=10, deadline=None) +@given(st.integers(2015, END_YEAR)) +def test_person_not_enrolled_has_no_medicare_cost(year): + simulation = _person_simulation( + year, + { + "age": {year: 70}, + "is_medicare_eligible": {year: True}, + "medicare_enrolled": {year: False}, + }, + ) + assert simulation.calculate("medicare_gross_cost", year)[0] == 0 + assert simulation.calculate("medicare_cost", year)[0] == 0 + + +@settings(max_examples=10, deadline=None) +@given(st.integers(2015, END_YEAR), st.integers(1, 12)) +def test_qmb_cost_sharing_is_the_qmb_rate_of_monthly_spending(year, month): + period = f"{year}-{month:02d}" + rate = system.parameters( + period + ).gov.hhs.medicare.savings_programs.qmb.cost_sharing_rate + simulation = _person_simulation(year, {"msp_category": {period: "QMB"}}) + assert simulation.calculate("qmb_cost_sharing", period)[0] == pytest.approx( + _value(year) / 12 * rate, rel=1e-6 + ) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml index 5cb855c4319..b0fb091fce4 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml @@ -9,7 +9,7 @@ tax_exempt_interest_income: 2023: 0 output: - medicare_cost: 12_280 # $14,500 spending - $2,220 Part B premium (2025) + medicare_cost: 15_531 # $17,751 spending - $2,220 Part B premium (2025) - name: unit test 2 - enrolled, pays full Part A premium period: 2025 @@ -22,7 +22,7 @@ tax_exempt_interest_income: 2023: 0 output: - medicare_cost: 6_064 # $14,500 - $6,216 Part A - $2,220 Part B (2025) + medicare_cost: 9_315 # $17,751 - $6,216 Part A - $2,220 Part B (2025) - name: unit test 3 - enrolled with IRMAA period: 2025 @@ -36,7 +36,7 @@ 2023: 0 filing_status: SINGLE output: - medicare_cost: 6_953.2 # $14,500 - $7,546.80 Part B with IRMAA (2025) + medicare_cost: 10_204.2 # $17,751 - $7,546.80 Part B with IRMAA (2025) - name: unit test 4 - eligible but not enrolled period: 2025 @@ -58,7 +58,7 @@ tax_exempt_interest_income: 2019: 0 output: - medicare_cost: 9_298 # $11,080 - $1,782 Part B (2021: $148.50*12) + medicare_cost: 12_511 # $14,293 - $1,782 Part B (2021: $148.50*12) - name: unit test 6 - direct Part B premium input period: 2025 @@ -68,4 +68,4 @@ medicare_quarters_of_coverage: 40 gross_medicare_part_b_premium: 3_000 output: - medicare_cost: 11_500 # $14,500 spending - $3,000 Part B premium + medicare_cost: 14_751 # $17,751 spending - $3,000 Part B premium diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_gross_cost.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_gross_cost.yaml index cba1201e13c..15eb8725e7d 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_gross_cost.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_gross_cost.yaml @@ -10,7 +10,8 @@ tax_exempt_interest_income: 2023: 0 output: - medicare_gross_cost: 14_500 + # CMS Medicare spending per enrollee, 2025. + medicare_gross_cost: 17_751 - name: unit test 2 - premiums do not reduce gross Medicare value period: 2025 @@ -25,4 +26,5 @@ 2023: 0 filing_status: SINGLE output: - medicare_gross_cost: 14_500 + # CMS Medicare spending per enrollee, 2025. + medicare_gross_cost: 17_751 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_cost.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_cost.yaml index 1e9cdaec98a..55be058ba49 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_cost.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_cost.yaml @@ -11,8 +11,8 @@ base_part_b_premium: 2_434.8 output: # Part B premium: $202.90 * 12 = $2,434.80. - # QMB cost sharing: $14,500 * 20% = $2,900. - msp_cost: 5_334.8 + # QMB cost sharing: $18,983 * 20% = $3,796.60. + msp_cost: 6_231.4 - name: Case 2, SLMB MSP-only beneficiary receives Part B premium value. period: 2026 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_federal_and_state_cost.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_federal_and_state_cost.yaml index da73487521b..0c526fe9c29 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_federal_and_state_cost.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/msp_federal_and_state_cost.yaml @@ -10,9 +10,9 @@ base_part_a_premium: 0 base_part_b_premium: 2_434.8 output: - # CA regular FMAP is 50%; total MSP cost is $5,334.80. - msp_federal_cost: 2_667.4 - msp_state_cost: 2_667.4 + # CA regular FMAP is 50%; total MSP cost is $6,231.40. + msp_federal_cost: 3_115.7 + msp_state_cost: 3_115.7 - name: Case 2, QI cost is federally financed within allocation. period: 2026 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/qmb_cost_sharing.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/qmb_cost_sharing.yaml index 4a812377fd3..1fa5134a84f 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/qmb_cost_sharing.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/qmb_cost_sharing.yaml @@ -4,8 +4,8 @@ input: msp_category: QMB output: - # $14,500 annual Medicare per-capita cost / 12 * 20%. - qmb_cost_sharing: 241.67 + # $18,983 annual Medicare spending per enrollee (2026) / 12 * 20%. + qmb_cost_sharing: 316.38 - name: Case 2, non-QMB MSP categories do not receive QMB cost sharing. period: 2026-01 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py index 611c0bf7592..29ecb083de7 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py @@ -87,7 +87,8 @@ def test_medicare_cost_uses_gross_part_b_before_msp_offset(): ) assert sim.calculate("medicare_part_b_premium", PERIOD)[0] == pytest.approx(0) - assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(12_280) + # $17,751 CMS Medicare spending per enrollee (2025) - $2,220 gross Part B. + assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(15_531) def test_medicare_part_b_premium_is_zero_when_not_enrolled(): @@ -343,7 +344,8 @@ def test_gross_medicare_part_b_premium_if_enrolled_preserves_irmaa(): assert sim.calculate("gross_medicare_part_b_premium_if_enrolled", PERIOD)[ 0 ] == pytest.approx(4_440) - assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(10_060) + # $17,751 CMS Medicare spending per enrollee (2025) - $4,440 gross Part B. + assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(13_311) def test_medicare_irmaa_magi_two_years_prior_falls_back_to_lagged_income(): diff --git a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml index 275f2797e57..07d9f4f1d8e 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/cbo_household_income.yaml @@ -33,10 +33,12 @@ workers_compensation: 1_000 output: cbo_household_market_income: 49_102 - medicare_gross_cost: 14_500 - cbo_household_social_insurance_benefits: 29_500 - cbo_household_income_before_transfers_and_taxes: 78_602 - cbo_household_income: 78_602 + # CMS Medicare spending per enrollee, 2025. + medicare_gross_cost: 17_751 + # $12,000 + $17,751 + $2,000 + $1,000. + cbo_household_social_insurance_benefits: 32_751 + cbo_household_income_before_transfers_and_taxes: 81_853 + cbo_household_income: 81_853 - name: CBO means-tested transfers include only the listed programs period: 2026 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..901e665cd28 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -39,6 +39,11 @@ def test_all_uprating_factors_extend_to_2100(): ("CPI-U", parameters.gov.bls.cpi.cpi_u, "-02-01"), ("Chained CPI-U", parameters.gov.bls.cpi.c_cpi_u, "-02-01"), ("CPI-W", parameters.gov.bls.cpi.cpi_w, "-02-01"), + ( + "CMS Medicare spending per enrollee", + parameters.calibration.gov.hhs.medicare.per_capita_cost, + "-01-01", + ), ] for name, param, date_suffix in uprating_params: From 33a94b4172f459d3e803c37bfae472f8d3e7e65d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:30:10 -0400 Subject: [PATCH 3/5] Update the QMB federal and state benefit cost test to the CMS series Co-Authored-By: Claude Opus 5.5 --- .../income/person/federal_and_state_benefit_cost.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/household/income/person/federal_and_state_benefit_cost.yaml b/policyengine_us/tests/policy/baseline/household/income/person/federal_and_state_benefit_cost.yaml index cdc30cc0523..11dedad24db 100644 --- a/policyengine_us/tests/policy/baseline/household/income/person/federal_and_state_benefit_cost.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/person/federal_and_state_benefit_cost.yaml @@ -84,5 +84,7 @@ base_part_a_premium: 0 base_part_b_premium: 2_434.8 output: - federal_benefit_cost: 2_667.4 - state_benefit_cost: 2_667.4 + # MSP cost: $2,434.80 Part B premium + 20% of $18,983 CMS Medicare + # spending per enrollee (2026) = $6,231.40, split at CA's 50% FMAP. + federal_benefit_cost: 3_115.7 + state_benefit_cost: 3_115.7 From 712abd368a2466948d7a829d6ecb95883c5d1b53 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:54:06 -0400 Subject: [PATCH 4/5] Subtract the expected Part D premium from medicare_cost CMS Medicare spending per enrollee (calibration.gov.hhs.medicare.per_capita_cost, PolicyEngine/policyengine-us#10107) includes Part D, which enrollee premiums partly finance, but medicare_cost subtracted only Part A and Part B premiums. It now also subtracts the Part D enrollment share times (Part D premiums per Part D enrollee + the person's Part D IRMAA), both new calibration series from the 2026 Medicare Trustees Report (Tables III.D3, V.E4 and V.B3). Co-Authored-By: Claude Opus 5.5 --- .../medicare-cost-part-d-premiums.fixed.md | 1 + .../hhs/medicare/part_d/enrollment_share.yaml | 53 +++++++++++ .../part_d/premiums_per_enrollee.yaml | 73 +++++++++++++++ .../parameters/uprating_extensions.py | 14 +++ .../core/test_medicare_per_capita_cost.py | 17 ++-- .../gov/hhs/medicare/costs/medicare_cost.yaml | 90 +++++++++++++++++-- .../hhs/medicare/test_part_b_msp_offset.py | 10 ++- .../gov/hhs/medicare/costs/medicare_cost.py | 24 +++-- 8 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 changelog.d/medicare-cost-part-d-premiums.fixed.md create mode 100644 policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/enrollment_share.yaml create mode 100644 policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/premiums_per_enrollee.yaml diff --git a/changelog.d/medicare-cost-part-d-premiums.fixed.md b/changelog.d/medicare-cost-part-d-premiums.fixed.md new file mode 100644 index 00000000000..b7989573aaa --- /dev/null +++ b/changelog.d/medicare-cost-part-d-premiums.fixed.md @@ -0,0 +1 @@ +Subtract the expected Medicare Part D premium, the Part D enrollment share times Part D premiums per Part D enrollee plus the person's Part D IRMAA, from `medicare_cost`, whose CMS spending per enrollee includes Part D. diff --git a/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/enrollment_share.yaml b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/enrollment_share.yaml new file mode 100644 index 00000000000..6cf55361039 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/enrollment_share.yaml @@ -0,0 +1,53 @@ +description: The Medicare Trustees report and project this share of Medicare enrollees, people with Part A or Part B coverage, who are enrolled in Part D. +metadata: + unit: /1 + period: year + label: Medicare Part D enrollment share + reference: + # Table V.B3, "Medicare Enrollment": Part D enrollment over total + # enrollment, which footnote 2 defines as the "Number of beneficiaries with + # HI and/or SMI coverage". Part D enrollment includes retiree drug subsidy + # beneficiaries (it equals the total of Table IV.B7, file page 153). + # Annual through 2035, then every fifth year through 2100; each value holds + # until the next. 2026 Medicare Trustees 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.B3 + href: https://www.cms.gov/oact/tr/2026#page=196 +values: + # Historical. + 2015-01-01: 0.7517 + 2016-01-01: 0.7569 + 2017-01-01: 0.7579 + 2018-01-01: 0.763 + 2019-01-01: 0.7667 + 2020-01-01: 0.7743 + 2021-01-01: 0.7811 + 2022-01-01: 0.7885 + 2023-01-01: 0.7941 + 2024-01-01: 0.8117 + 2025-01-01: 0.8191 + # Trustees' intermediate estimates. + 2026-01-01: 0.8195 + 2027-01-01: 0.8255 + 2028-01-01: 0.8301 + 2029-01-01: 0.8348 + 2030-01-01: 0.8382 + 2031-01-01: 0.8414 + 2032-01-01: 0.844 + 2033-01-01: 0.8462 + 2034-01-01: 0.8476 + 2035-01-01: 0.8485 + 2040-01-01: 0.8531 + 2045-01-01: 0.8551 + 2050-01-01: 0.8543 + 2055-01-01: 0.8534 + 2060-01-01: 0.8537 + 2065-01-01: 0.8555 + 2070-01-01: 0.8566 + 2075-01-01: 0.8575 + 2080-01-01: 0.8593 + 2085-01-01: 0.8609 + 2090-01-01: 0.863 + 2095-01-01: 0.8638 + 2100-01-01: 0.8639 diff --git a/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/premiums_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/premiums_per_enrollee.yaml new file mode 100644 index 00000000000..871164280c8 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/premiums_per_enrollee.yaml @@ -0,0 +1,73 @@ +description: The Medicare Trustees report and project these Part D premiums from enrollees per Part D enrollee, excluding the income-related monthly adjustment amount. +metadata: + unit: currency-USD + period: year + label: Medicare Part D premiums per Part D enrollee + reference: + # Each value is (premium income - aggregate income-related premiums) / + # Part D enrollment, from the 2026 Medicare Trustees Report (transmitted + # 2026-06-09, retrieved 2026-10-11, sha256 + # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8): + # - Premium income, Table III.D3 (file page 116), in billions. Footnote 1: + # "Premiums include both amounts withheld from Social Security benefits or + # other Federal payments, direct payments to the Federal government, and + # those paid directly to Part D plans." The table is on a cash basis, and + # footnote 6 (file page 117) credits one January's withheld premiums on + # December 31 of the year before: about $0.2 billion in 2015, $0.1 billion + # in 2020 and an estimated $0.2 billion in 2026. Those amounts are moved + # back to 2016, 2021 and 2027. + # - Income-related premiums, Table V.E4 (file page 218), "Aggregate + # premiums", the amount paid "in excess of the Part D plan premium". They + # are part of premium income: they are withheld from benefits (42 U.S.C. + # 1395w-113(c)(4)) and deposited into the Part D account (footnote 73, + # file page 151). medicare_cost subtracts each person's own adjustment + # amount instead. + # - Part D enrollment, Table V.B3 (file page 196), in thousands. It equals + # the total of Table IV.B7 (file page 153), which includes retiree drug + # subsidy beneficiaries. + # Low-income subsidy enrollees pay reduced premiums or none (file page 219); + # Medicare's low-income subsidy payments are expenditures of the Part D + # account, not enrollee premiums (file page 114). So the average is below + # the base beneficiary premium. + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table III.D3 + href: https://www.cms.gov/oact/tr/2026#page=116 + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table V.E4 + href: https://www.cms.gov/oact/tr/2026#page=218 + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table V.B3 + href: https://www.cms.gov/oact/tr/2026#page=196 + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Part D revenue and expenditures + href: https://www.cms.gov/oact/tr/2026#page=109 + - title: 42 U.S.C. ยง 1395w-113(c)(4) - Collection of monthly adjustment amount + href: https://www.law.cornell.edu/uscode/text/42/1395w-113#c_4 +values: + # Historical. + 2015-01-01: 277.6 + 2016-01-01: 300.94 + 2017-01-01: 321.53 + 2018-01-01: 316.61 + 2019-01-01: 298.86 + 2020-01-01: 285.44 + 2021-01-01: 306.15 + 2022-01-01: 309.42 + 2023-01-01: 304.39 + 2024-01-01: 307.7 + # 2025 premiums fell with the voluntary Part D premium demonstration for + # stand-alone prescription drug plans (file page 109). + 2025-01-01: 220.25 + # Trustees' intermediate estimates. The 2030 jump is the Inflation + # Reduction Act's reset of the base beneficiary premium percentage + # (file page 152). + 2026-01-01: 289.79 + 2027-01-01: 373.53 + 2028-01-01: 403.59 + 2029-01-01: 411.27 + 2030-01-01: 637.06 + 2031-01-01: 659.27 + 2032-01-01: 662.52 + 2033-01-01: 684.61 + 2034-01-01: 699.07 + 2035-01-01: 722.05 + # 2036 to 2100 grow at the 2034-2035 rate (722.05 / 699.07, 3.3% a year), + # near the Trustees' 3.1% growth in Part D cost per beneficiary in 2035 + # (Table V.D1, file page 210); 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 df50047bd66..e02e95eacc8 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -897,6 +897,20 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: growth_years=(2031, 2032), ) + # Medicare Part D premiums per Part D enrollee (January values, last + # projection year 2035), which medicare_cost reads directly. Each year + # from 2036 to 2100 grows at the 2034-2035 rate, near the Trustees' 3.1 + # percent growth in Part D cost per beneficiary in 2035 (Table V.D1, file + # page 210); later years hold the 2100 value. The Part D enrollment share + # needs no extension: the Trustees project enrollment through 2100. + extend_parameter_values( + parameters.calibration.gov.hhs.medicare.part_d.premiums_per_enrollee, + last_projected_year=2035, + end_year=END_YEAR, + period_month=1, + period_day=1, + ) + # CBO income-by-source aggregates are used directly and as anchors for # SOI-based income upraters. Extending them in the baseline path keeps # long-run nominal data aging independent of scenario-specific reforms. diff --git a/policyengine_us/tests/core/test_medicare_per_capita_cost.py b/policyengine_us/tests/core/test_medicare_per_capita_cost.py index 986236c9d7d..9e37cb5d1e3 100644 --- a/policyengine_us/tests/core/test_medicare_per_capita_cost.py +++ b/policyengine_us/tests/core/test_medicare_per_capita_cost.py @@ -12,7 +12,8 @@ lifts 2033 and 2034 growth. ``medicare_gross_cost`` returns the series for each enrolled person, -``medicare_cost`` subtracts the person's Part A and gross Part B premiums, and +``medicare_cost`` subtracts the person's Part A, gross Part B and expected +Part D premiums (tested in test_medicare_cost_part_d_premium.py), and ``qmb_cost_sharing`` pays QMB enrollees a share of it each month. Invariants: @@ -32,10 +33,11 @@ Trustees' 2034-2035 per beneficiary growth. 5. Calendar years: every date within a year reads the January 1 value. 6. Consumers: for an enrolled person, ``medicare_gross_cost`` equals the - series; ``medicare_cost`` equals the series less the Part A premium and the - gross Part B premium, floored at zero, so never exceeds - ``medicare_gross_cost``; a QMB enrollee's monthly ``qmb_cost_sharing`` is - the series over 12 times the QMB rate. A person not enrolled gets zero. + series; ``medicare_cost`` equals the series less the Part A premium, the + gross Part B premium and the expected Part D premium, floored at zero, so + never exceeds ``medicare_gross_cost``; a QMB enrollee's monthly + ``qmb_cost_sharing`` is the series over 12 times the QMB rate. A person + not enrolled gets zero. """ import pytest @@ -206,13 +208,16 @@ def test_medicare_cost_nets_premiums_from_the_series( "medicare_enrolled": {year: True}, "base_part_a_premium": {year: part_a_premium}, "gross_medicare_part_b_premium": {year: part_b_premium}, + "income_adjusted_part_d_premium_surcharge": {year: 0}, }, ) gross = simulation.calculate("medicare_gross_cost", year)[0] net = simulation.calculate("medicare_cost", year)[0] # Variables are float32, so compare to single precision. assert gross == pytest.approx(_value(year), rel=1e-6) - expected = max(_value(year) - part_a_premium - part_b_premium, 0) + part_d = system.parameters(f"{year}-01-01").calibration.gov.hhs.medicare.part_d + part_d_premium = part_d.enrollment_share * part_d.premiums_per_enrollee + expected = max(_value(year) - part_a_premium - part_b_premium - part_d_premium, 0) assert net == pytest.approx(expected, rel=1e-6, abs=0.01) assert net <= gross diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml index b0fb091fce4..504cd704414 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/costs/medicare_cost.yaml @@ -1,5 +1,10 @@ +# The expected Part D premium is the Part D enrollment share times (Part D +# premiums per Part D enrollee + the person's Part D IRMAA). 2025: 0.8191 x +# $220.25 = $180.41 without IRMAA. + - name: unit test 1 - enrolled, premium-free Part A period: 2025 + absolute_error_margin: 0.01 input: age: 65 is_medicare_eligible: true @@ -9,10 +14,12 @@ tax_exempt_interest_income: 2023: 0 output: - medicare_cost: 15_531 # $17,751 spending - $2,220 Part B premium (2025) + # $17,751 spending - $2,220 Part B premium - $180.41 Part D (2025) + medicare_cost: 15_350.59 - name: unit test 2 - enrolled, pays full Part A premium period: 2025 + absolute_error_margin: 0.01 input: age: 65 is_medicare_eligible: true @@ -22,10 +29,12 @@ tax_exempt_interest_income: 2023: 0 output: - medicare_cost: 9_315 # $17,751 - $6,216 Part A - $2,220 Part B (2025) + # $17,751 - $6,216 Part A - $2,220 Part B - $180.41 Part D (2025) + medicare_cost: 9_134.59 - name: unit test 3 - enrolled with IRMAA period: 2025 + absolute_error_margin: 0.01 input: age: 65 is_medicare_eligible: true @@ -36,7 +45,9 @@ 2023: 0 filing_status: SINGLE output: - medicare_cost: 10_204.2 # $17,751 - $7,546.80 Part B with IRMAA (2025) + # $17,751 - $7,546.80 Part B with IRMAA - 0.8191 x ($220.25 + $85.80 x 12 + # Part D IRMAA) = $1,023.75 Part D (2025) + medicare_cost: 9_180.45 - name: unit test 4 - eligible but not enrolled period: 2025 @@ -49,6 +60,7 @@ - name: unit test 5 - 2021 data, premium-free period: 2021 + absolute_error_margin: 0.01 input: age: 70 is_medicare_eligible: true @@ -58,14 +70,82 @@ tax_exempt_interest_income: 2019: 0 output: - medicare_cost: 12_511 # $14,293 - $1,782 Part B (2021: $148.50*12) + # $14,293 - $1,782 Part B (2021: $148.50*12) - 0.7811 x $306.15 Part D + medicare_cost: 12_271.87 - name: unit test 6 - direct Part B premium input period: 2025 + absolute_error_margin: 0.01 input: age: 65 is_medicare_eligible: true medicare_quarters_of_coverage: 40 gross_medicare_part_b_premium: 3_000 output: - medicare_cost: 14_751 # $17,751 spending - $3,000 Part B premium + # $17,751 spending - $3,000 Part B premium - $180.41 Part D + medicare_cost: 14_570.59 + +- name: unit test 7 - Part D IRMAA in the 50 percent tier + period: 2025 + absolute_error_margin: 0.01 + input: + age: 65 + is_medicare_eligible: true + medicare_quarters_of_coverage: 40 + filing_status: SINGLE + adjusted_gross_income: + 2023: 150_000 # Single, $133,001 to $167,000: $35.30 a month in 2025 + tax_exempt_interest_income: + 2023: 0 + gross_medicare_part_b_premium: 2_220 + output: + income_adjusted_part_d_premium_surcharge: 423.6 + # $17,751 - $2,220 Part B - 0.8191 x ($220.25 + $423.60) = $527.38 Part D + medicare_cost: 15_003.62 + +- name: unit test 8 - projected year + period: 2027 + absolute_error_margin: 0.01 + input: + age: 67 + is_medicare_eligible: true + medicare_quarters_of_coverage: 40 + gross_medicare_part_b_premium: 2_500 + income_adjusted_part_d_premium_surcharge: 0 + output: + # $20,096 - $2,500 Part B - 0.8255 x $373.53 = $308.35 Part D (2027) + medicare_cost: 17_287.65 + +- name: unit test 9 - Inflation Reduction Act premium reset year, with IRMAA + period: 2030 + absolute_error_margin: 0.01 + input: + age: 70 + is_medicare_eligible: true + medicare_quarters_of_coverage: 40 + gross_medicare_part_b_premium: 3_000 + income_adjusted_part_d_premium_surcharge: 1_000 + output: + # $23,789 - $3,000 Part B - 0.8382 x ($637.06 + $1,000) = $1,372.18 Part D + medicare_cost: 19_416.82 + +- name: unit test 10 - premiums above spending floor the value at zero + period: 2025 + input: + age: 65 + is_medicare_eligible: true + medicare_quarters_of_coverage: 40 + gross_medicare_part_b_premium: 30_000 + output: + medicare_cost: 0 + +- name: unit test 11 - gross cost does not subtract the Part D premium + period: 2025 + input: + age: 65 + is_medicare_eligible: true + medicare_quarters_of_coverage: 40 + gross_medicare_part_b_premium: 2_220 + income_adjusted_part_d_premium_surcharge: 1_029.6 + output: + medicare_gross_cost: 17_751 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py index 29ecb083de7..b15943e5203 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_part_b_msp_offset.py @@ -87,8 +87,9 @@ def test_medicare_cost_uses_gross_part_b_before_msp_offset(): ) assert sim.calculate("medicare_part_b_premium", PERIOD)[0] == pytest.approx(0) - # $17,751 CMS Medicare spending per enrollee (2025) - $2,220 gross Part B. - assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(15_531) + # $17,751 CMS Medicare spending per enrollee (2025) - $2,220 gross Part B + # - 0.8191 x $220.25 expected Part D premium. + assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(15_350.59) def test_medicare_part_b_premium_is_zero_when_not_enrolled(): @@ -344,8 +345,9 @@ def test_gross_medicare_part_b_premium_if_enrolled_preserves_irmaa(): assert sim.calculate("gross_medicare_part_b_premium_if_enrolled", PERIOD)[ 0 ] == pytest.approx(4_440) - # $17,751 CMS Medicare spending per enrollee (2025) - $4,440 gross Part B. - assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(13_311) + # $17,751 CMS Medicare spending per enrollee (2025) - $4,440 gross Part B + # - 0.8191 x $220.25 expected Part D premium. + assert sim.calculate("medicare_cost", PERIOD)[0] == pytest.approx(13_130.59) def test_medicare_irmaa_magi_two_years_prior_falls_back_to_lagged_income(): diff --git a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py index 29da4f02627..d2b7e45b123 100644 --- a/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py +++ b/policyengine_us/variables/gov/hhs/medicare/costs/medicare_cost.py @@ -7,26 +7,38 @@ class medicare_cost(Variable): label = "Medicare cost" documentation = ( "Annual value of Medicare benefits received. Calculated as total " - "Medicare spending on behalf of the beneficiary minus premiums paid. " + "Medicare spending on behalf of the beneficiary, which covers Parts A " + "through D, minus the premiums that finance it: the Part A premium, " + "the gross Part B premium and the expected Part D premium. " "This represents the net benefit value of Medicare coverage." ) unit = USD definition_period = YEAR - reference = "https://www.cms.gov/about-cms/what-we-do/medicare" + reference = ( + "https://www.cms.gov/about-cms/what-we-do/medicare", + "https://www.cms.gov/oact/tr/2026#page=116", + ) defined_for = "medicare_enrolled" def formula(person, period, parameters): + p = parameters(period).calibration.gov.hhs.medicare # Total Medicare spending on behalf of beneficiary - per_capita_spending = parameters( - period - ).calibration.gov.hhs.medicare.per_capita_cost + per_capita_spending = p.per_capita_cost # Premium offsets to Medicare program cost. Use the enrollment-gated # gross Part B premium before MSP offsets so MSP support does not # inflate Medicare's value. part_a_premium = person("base_part_a_premium", period) part_b_premium = person("gross_medicare_part_b_premium_if_enrolled", period) - total_premiums = part_a_premium + part_b_premium + # The spending includes Part D, which enrollee premiums partly finance. + # No variable records Part D enrollment, so subtract the expected + # Part D premium: the Part D enrollment share times the premium per + # Part D enrollee plus the person's income-related adjustment. + part_d_irmaa = person("income_adjusted_part_d_premium_surcharge", period) + part_d_premium = p.part_d.enrollment_share * ( + p.part_d.premiums_per_enrollee + part_d_irmaa + ) + total_premiums = part_a_premium + part_b_premium + part_d_premium # Net benefit = spending - premiums return max_(per_capita_spending - total_premiums, 0) From 4024d2ec08b7b970bfcea89d5873753f6773b790 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:09:29 -0400 Subject: [PATCH 5/5] Test the Part D premium series and medicare_cost's Part D offset Transcription from the Trustees' cells, conservation back to Table III.D3 premium income, bounds, the 2036-2100 extension's closed form, and a Hypothesis differential test of medicare_cost against a reference implementation (0 <= net <= gross, non-increasing in Part D IRMAA). Co-Authored-By: Claude Opus 5.5 --- .../core/test_medicare_cost_part_d_premium.py | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 policyengine_us/tests/core/test_medicare_cost_part_d_premium.py diff --git a/policyengine_us/tests/core/test_medicare_cost_part_d_premium.py b/policyengine_us/tests/core/test_medicare_cost_part_d_premium.py new file mode 100644 index 00000000000..a9dc443b21d --- /dev/null +++ b/policyengine_us/tests/core/test_medicare_cost_part_d_premium.py @@ -0,0 +1,261 @@ +"""The expected Part D premium that ``medicare_cost`` subtracts. + +CMS Medicare spending per enrollee (``calibration.gov.hhs.medicare. +per_capita_cost``) covers Parts A through D, gross of the premiums that +finance it. No variable records Part D enrollment, so ``medicare_cost`` +subtracts an expected Part D premium: + + enrollment_share x (premiums_per_enrollee + Part D IRMAA) + +from two series under ``calibration.gov.hhs.medicare.part_d``, both from the +2026 Medicare Trustees Report: + +- ``premiums_per_enrollee``: Table III.D3 premium income (footnote 6's + December 31 credits moved back a year) less Table V.E4's aggregate + income-related premiums, over Table V.B3's Part D enrollment. +- ``enrollment_share``: Table V.B3's Part D enrollment over its total, people + with Part A and/or Part B coverage. + +Invariants: + +1. Transcription: every value equals its formula applied to the Trustees' + published cells, rounded as stored. +2. Conservation: premiums per Part D enrollee times Part D enrollment, plus + aggregate IRMAA, returns Table III.D3's premium income in every year from + 2015 to 2035, within the tables' rounding. +3. Bounds: the share lies strictly between 0 and 1 and premiums are + positive in every year from 2015 to 2200. The monthly premium per Part D + enrollee is below the Trustees' average paid premium in 2025 and 2026, + since low-income subsidy enrollees pay less or nothing. +4. Extension: each year from 2036 to 2100 equals the 2035 value times + (722.05 / 699.07) ** (year - 2035), and later years hold the 2100 value. + That growth is within half a point of the Trustees' 2035 growth in Part D + cost per beneficiary (Table V.D1). +5. Calendar years: every date in a year reads the January 1 value; between + the Trustees' five-year enrollment points the share holds the earlier one. +6. Consumer (Hypothesis, differential): ``medicare_cost`` equals a reference + implementation, max(gross - Part A - Part B - share x (premium + IRMAA), + 0), for any year and premiums; it lies in [0, ``medicare_gross_cost``], + which does not change with the Part D premium; and it does not rise with + the person's Part D IRMAA. +""" + +import pytest +from hypothesis import 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.system import system + +PREMIUM = "calibration.gov.hhs.medicare.part_d.premiums_per_enrollee" +SHARE = "calibration.gov.hhs.medicare.part_d.enrollment_share" +FIRST_YEAR, LAST_PROJECTED_YEAR, END_YEAR = 2015, 2035, 2100 +PROJECTED_YEARS = range(FIRST_YEAR, LAST_PROJECTED_YEAR + 1) + +# 2026 Medicare Trustees Report (sha256 ffa56b91...8fa8). +# Table III.D3 (file page 116), premium income, $ billions, cash basis. +PREMIUM_INCOME = dict( + zip( + PROJECTED_YEARS, + ( + *(12.7, 13.8, 15.5, 15.9, 15.7, 15.8, 17.0, 17.6, 18.3, 19.2, 14.9), + *(20.1, 25.6, 28.8, 30.5, 51.7, 54.9, 56.4, 59.5, 62.0, 65.3), + ), + ) +) +# Footnote 6 (file page 117): premiums withheld from January benefits that +# were credited on December 31 of the year before, $ billions. +DECEMBER_31_CREDIT = {2015: 0.2, 2020: 0.1, 2026: 0.2} +# Table V.E4 (file page 218), aggregate income-related premiums, $ billions. +IRMAA_AGGREGATE = dict( + zip( + PROJECTED_YEARS, + ( + *(0.9, 1.0, 1.2, 1.4, 1.6, 1.8, 1.8, 1.7, 2.2, 2.2, 2.4, 3.1, 3.5), + *(4.0, 4.6, 10.8, 11.9, 12.6, 13.7, 14.7, 15.9), + ), + ) +) +# Table V.B3 (file page 196), enrollment in thousands: annual through 2035, +# then every fifth year. +ENROLLMENT_YEARS = (*PROJECTED_YEARS, *range(2040, END_YEAR + 1, 5)) +PART_D_ENROLLMENT = dict( + zip( + ENROLLMENT_YEARS, + ( + *(41_786, 43_198, 44_475, 45_798, 47_179, 48_696, 49_976, 51_386), + *(52_893, 55_249, 56_754, 57_973, 59_700, 61_448, 62_975, 64_201), + *(65_224, 66_111, 66_899, 67_661, 68_416, 70_471, 71_528, 72_969), + *(75_043, 77_612, 80_085, 82_775, 85_396, 87_208, 88_382, 88_728), + *(88_845, 89_095), + ), + ) +) +TOTAL_ENROLLMENT = dict( + zip( + ENROLLMENT_YEARS, + ( + *(55_589, 57_073, 58_683, 60_020, 61_535, 62_887, 63_980, 65_166), + *(66_606, 68_066, 69_289, 70_743, 72_322, 74_026, 75_436, 76_598), + *(77_521, 78_327, 79_058, 79_827, 80_632, 82_606, 83_648, 85_413), + *(87_929, 90_916, 93_612, 96_634, 99_585, 101_485, 102_662), + *(102_816, 102_848, 103_133), + ), + ) +) +# Average paid premium per month (file page 217). +AVERAGE_PAID_PREMIUM = {2025: 23.95, 2026: 27.22} +# Table V.D1 (file page 210), Part D average per beneficiary cost. +PART_D_COST_PER_BENEFICIARY = {2034: 4_919, 2035: 5_074} + +years = st.integers(FIRST_YEAR, END_YEAR) + + +def _read(name, date): + return get_parameter(system.parameters, name)(date) + + +def _premium(year): + return _read(PREMIUM, f"{year}-01-01") + + +def _share(year): + return _read(SHARE, f"{year}-01-01") + + +def _accrual_premium_income(year): + return ( + PREMIUM_INCOME[year] + - DECEMBER_31_CREDIT.get(year, 0) + + DECEMBER_31_CREDIT.get(year - 1, 0) + ) + + +@pytest.mark.parametrize("year", PROJECTED_YEARS) +def test_premium_is_trustees_premium_income_net_of_irmaa_per_part_d_enrollee(year): + billions = _accrual_premium_income(year) - IRMAA_AGGREGATE[year] + expected = billions * 1e9 / (PART_D_ENROLLMENT[year] * 1e3) + assert _premium(year) == round(expected, 2) + + +@pytest.mark.parametrize("year", ENROLLMENT_YEARS) +def test_share_is_trustees_part_d_over_total_enrollment(year): + assert _share(year) == round(PART_D_ENROLLMENT[year] / TOTAL_ENROLLMENT[year], 4) + + +@pytest.mark.parametrize("year", PROJECTED_YEARS) +def test_premiums_and_irmaa_return_trustees_premium_income(year): + """Conservation: the series, scaled back up by enrollment, plus IRMAA + gives the published premium income, within rounding of the cells.""" + rebuilt = _premium(year) * PART_D_ENROLLMENT[year] * 1e3 / 1e9 + rebuilt += IRMAA_AGGREGATE[year] + # Each cell is rounded to $0.1 billion, the stored value to the cent. + assert rebuilt == pytest.approx(_accrual_premium_income(year), abs=1e-3) + assert abs(rebuilt - PREMIUM_INCOME[year]) <= 0.2 + 1e-3 + + +@pytest.mark.parametrize("year", sorted(AVERAGE_PAID_PREMIUM)) +def test_monthly_premium_is_below_the_average_paid_premium(year): + assert _premium(year) / 12 < AVERAGE_PAID_PREMIUM[year] + + +@given(st.integers(FIRST_YEAR, END_YEAR + 100)) +def test_share_and_premium_are_bounded(year): + assert 0 < _share(year) < 1 + assert _premium(year) > 0 + + +def test_extension_rate_follows_trustees_part_d_cost_growth(): + growth = _premium(LAST_PROJECTED_YEAR + 1) / _premium(LAST_PROJECTED_YEAR) + trustees = PART_D_COST_PER_BENEFICIARY[2035] / PART_D_COST_PER_BENEFICIARY[2034] + assert abs(growth - trustees) < 0.005 + + +@given(st.integers(LAST_PROJECTED_YEAR + 1, END_YEAR)) +def test_extension_is_the_closed_form_of_the_2034_2035_growth(year): + growth = _premium(LAST_PROJECTED_YEAR) / _premium(LAST_PROJECTED_YEAR - 1) + expected = _premium(LAST_PROJECTED_YEAR) * growth ** (year - LAST_PROJECTED_YEAR) + assert _premium(year) == pytest.approx(expected, rel=1e-9) + + +@given(st.integers(END_YEAR, END_YEAR + 100)) +def test_series_hold_the_2100_values_afterwards(year): + assert _premium(year) == _premium(END_YEAR) + assert _share(year) == _share(END_YEAR) + + +@given(years, st.integers(1, 12), st.integers(1, 28)) +def test_every_date_in_a_year_reads_the_january_value(year, month, day): + date = f"{year}-{month:02d}-{day:02d}" + assert _read(PREMIUM, date) == _premium(year) + assert _read(SHARE, date) == _share(year) + + +@given(st.integers(LAST_PROJECTED_YEAR, END_YEAR)) +def test_share_holds_the_latest_trustees_point(year): + point = max(y for y in ENROLLMENT_YEARS if y <= year) + assert _share(year) == _share(point) + + +def _simulation(year, people): + names = list(people) + return Simulation( + situation={ + "people": people, + "tax_units": {f"tax_unit_{name}": {"members": [name]} for name in names}, + "spm_units": {"spm_unit": {"members": names}}, + "families": {"family": {"members": names}}, + "marital_units": { + f"marital_unit_{name}": {"members": [name]} for name in names + }, + "households": {"household": {"members": names, "state_code": {year: "CA"}}}, + } + ) + + +def _enrollee(year, part_a, part_b, irmaa): + return { + "age": {year: 70}, + "is_medicare_eligible": {year: True}, + "medicare_enrolled": {year: True}, + "base_part_a_premium": {year: part_a}, + "gross_medicare_part_b_premium": {year: part_b}, + "income_adjusted_part_d_premium_surcharge": {year: irmaa}, + } + + +def _reference_medicare_cost(year, part_a, part_b, irmaa): + gross = _read("calibration.gov.hhs.medicare.per_capita_cost", f"{year}-01-01") + part_d = _share(year) * (_premium(year) + irmaa) + return max(gross - part_a - part_b - part_d, 0) + + +premiums = st.floats(0, 40_000, allow_nan=False) +irmaas = st.floats(0, 12_000, allow_nan=False) + + +@settings(max_examples=25, deadline=None) +@given(years, st.floats(0, 12_000, allow_nan=False), premiums, irmaas, irmaas) +def test_medicare_cost_matches_reference_and_bounds( + year, part_a, part_b, irmaa, more_irmaa +): + low, high = sorted((irmaa, irmaa + more_irmaa)) + simulation = _simulation( + year, + { + "low": _enrollee(year, part_a, part_b, low), + "high": _enrollee(year, part_a, part_b, high), + }, + ) + net = simulation.calculate("medicare_cost", year) + gross = simulation.calculate("medicare_gross_cost", year) + # Variables are float32, so compare to single precision. + for value, surcharge in zip(net, (low, high)): + expected = _reference_medicare_cost(year, part_a, part_b, surcharge) + assert value == pytest.approx(expected, rel=1e-6, abs=0.01) + assert (net >= 0).all() + assert (net <= gross).all() + # The gross cost subtracts no premium, so both people have the same one. + assert gross[0] == gross[1] + assert net[1] <= net[0]