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..e20ed1c454b --- /dev/null +++ b/changelog.d/premium-inputs-cms-uprating.changed.md @@ -0,0 +1 @@ +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 f488b83bae5..acfebcc638e 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; and health insurance premiums in total, long-term and excluding Medicare Part B, and reported Medicare Part B premiums. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums 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; and physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses. CMS counts premiums outside out-of-pocket spending, so premium inputs follow premium series instead, each a per-person series that grows at its last published one-year rate to 2100 and holds the 2100 value after that. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums, pre-tax health insurance premiums and the premiums people report paying (health insurance premiums excluding Medicare Part B, and the other health insurance premiums that enter SPM resources) follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), which CMS projects to 2034. These are the employer's share of employer plans, the employee's share paid through payroll, and the premiums survey respondents report, most of which people with employer coverage pay; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August of the preceding year (26 U.S.C. 213(d)(10)(B)), which the CBO and CMS projections do not provide. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. The series is a growth proxy, not a forecast of each person's premium: hold-harmless limits and Medicare Advantage premium reductions move some premiums differently. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. ## What the model does not do diff --git a/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml b/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml 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/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..c64cc20f92b --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml @@ -0,0 +1,45 @@ +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, Tables III.C2 and V.E2 #page 89-90,213" + 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/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 c57b2344949..3f283df46dc 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -937,6 +937,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. @@ -965,8 +976,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, @@ -975,6 +986,28 @@ 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, + ) + + # 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_per_capita_uprating.py b/policyengine_us/tests/core/test_per_capita_uprating.py index 99a30adf594..1a4ba4b7e0e 100644 --- a/policyengine_us/tests/core/test_per_capita_uprating.py +++ b/policyengine_us/tests/core/test_per_capita_uprating.py @@ -38,6 +38,8 @@ 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" +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 @@ -60,6 +62,8 @@ def _growth(path, year, parameters=None): ("calibration.gov.cbo.unemployment_compensation", True), (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), @@ -92,6 +96,8 @@ 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), + ("medicare_part_b_premiums_reported", CMS_PART_B), ("child_support_expense", CPI_U), ("household_weight", POPULATION_PATH), ], @@ -404,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]) +@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 new file mode 100644 index 00000000000..6431e6684fd --- /dev/null +++ b/policyengine_us/tests/core/test_premium_input_uprating.py @@ -0,0 +1,426 @@ +"""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 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. 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. 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 of the + preceding year. CBO and CMS do not project that index, 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: + 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)). The series is a growth proxy, not a forecast of each + person's premium: hold-harmless limits and Medicare Advantage premium + reductions move some premiums differently. + +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: 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 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 + 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 +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" +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", +) +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(): + """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} + + +# 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_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, + ), + ) +) + + +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] + + +@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 = ( + _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) + + +@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. +DIRECT_PURCHASE_YEARS = range(2013, 2035) +DIRECT_PURCHASE_SPENDING_PER_ENROLLEE = dict( + zip( + 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), + *(8_364, 8_743, 9_158, 9_589), + ), + ) +) +DIRECT_PURCHASE_SPENDING_BILLIONS = dict( + zip( + 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), + *(288.6, 303.5, 319.0, 335.3), + ), + ) +) +DIRECT_PURCHASE_ENROLLMENT_MILLIONS = dict( + zip( + 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), + *(34.8, 35.0), + ), + ) +) + + +@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", 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.""" + 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 + + +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) + + +# 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), + ( + *(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, 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)) +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 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", 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 + ) + + +# 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.sampled_from((ESI, DIRECT_PURCHASE, MEDICAL_CPI, PART_B)), + st.integers(2015, END_YEAR + 50), +) +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 _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) + + +@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.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 7a673d48992..3b1447dc5e4 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -1,8 +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 spending for health costs, CMS premiums -per enrollee for employer plan premiums and the statutory limit for capped -contributions.""" +expenses and debts, CMS per-capita out-of-pocket spending for health costs, +premiums per enrollee, or the prices that index them, for health insurance +premiums, and the statutory limit for capped contributions.""" from pathlib import Path @@ -21,7 +21,10 @@ 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" +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" HSA_FAMILY_LIMIT = ( "gov.irs.gross_income.health_savings_account_contributions.limit.family" @@ -64,12 +67,23 @@ "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, + # 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, # Contributions under a cap that 26 U.S.C. 223(g) indexes to chained # CPI-U. "health_savings_account_payroll_contributions": HSA_FAMILY_LIMIT, 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..816c69b2014 --- /dev/null +++ b/policyengine_us/tests/microsimulation/test_premium_input_microsim_uprating.py @@ -0,0 +1,185 @@ +"""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 and the price index 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). 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. +""" + +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" +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" +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) +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], + 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], + } + ) + 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..961a542a5ee 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,45 @@ 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_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 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..a37edb03604 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,10 @@ 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. 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. " "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..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 @@ -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 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 71a9825e4ef..b60ee135c54 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,10 @@ 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 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 of the preceding year. + # CBO and CMS do not project 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 500970c641d..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,4 +7,9 @@ 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" + # 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" 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 "