From b2f74b4d24a175c97c33b7847b1300f69cfc4cbe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:57:03 -0400 Subject: [PATCH 1/3] Model the Medicare Part D low-income subsidy and count it as a CBO means-tested transfer Adds statutory eligibility for the Part D low-income subsidy (Extra Help): countable income below 150% of the poverty guideline for the family size, countable resources within the CPI-indexed limit plus the burial funds exclusion, and deemed eligibility for Medicaid enrollees, SSI recipients and Medicare Savings Program enrollees. The subsidy is valued at the Medicare Trustees' average payment per subsidy enrollee (Table IV.B9), as CBO assigns it, and joins gov.household.cbo_means_tested_transfers. Co-Authored-By: Claude Opus 5.5 --- changelog.d/medicare-part-d-lis.added.md | 1 + .../low_income_subsidy_per_recipient.yaml | 51 ++++++++++++ .../eligibility/income_limit.yaml | 15 ++++ .../eligibility/ineligible_jurisdictions.yaml | 25 ++++++ .../resources/burial_exclusion.yaml | 18 +++++ .../eligibility/resources/limit/couple.yaml | 40 ++++++++++ .../resources/limit/individual.yaml | 40 ++++++++++ .../eligibility/resources/sources.yaml | 20 +++++ .../income/excluded_sources.yaml | 15 ++++ .../household/cbo_means_tested_transfers.yaml | 5 ++ policyengine_us/programs.yaml | 17 ++++ .../part_d_lis_countable_income.yaml | 79 +++++++++++++++++++ .../part_d_lis_family_size.yaml | 57 +++++++++++++ .../part_d_lis_income_eligible.yaml | 62 +++++++++++++++ .../part_d_lis_resource_eligible.yaml | 63 +++++++++++++++ .../part_d/low_income_subsidy/part_d_lis.py | 28 +++++++ .../part_d_lis_countable_income.py | 40 ++++++++++ .../part_d_lis_deemed_eligible.py | 24 ++++++ .../low_income_subsidy/part_d_lis_eligible.py | 30 +++++++ .../low_income_subsidy/part_d_lis_enrolled.py | 25 ++++++ .../part_d_lis_family_size.py | 24 ++++++ .../part_d_lis_income_eligible.py | 23 ++++++ .../part_d_lis_resource_eligible.py | 29 +++++++ .../takes_up_part_d_lis_if_eligible.py | 18 +++++ 24 files changed, 749 insertions(+) create mode 100644 changelog.d/medicare-part-d-lis.added.md create mode 100644 policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/income_limit.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/ineligible_jurisdictions.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/burial_exclusion.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/individual.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/sources.yaml create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/income/excluded_sources.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.yaml create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/takes_up_part_d_lis_if_eligible.py diff --git a/changelog.d/medicare-part-d-lis.added.md b/changelog.d/medicare-part-d-lis.added.md new file mode 100644 index 00000000000..b37702d66bd --- /dev/null +++ b/changelog.d/medicare-part-d-lis.added.md @@ -0,0 +1 @@ +Model the Medicare Part D low-income subsidy (Extra Help): statutory eligibility valued at the Trustees' average subsidy per enrollee, and count it as a CBO means-tested transfer. diff --git a/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml new file mode 100644 index 00000000000..a5732d624e8 --- /dev/null +++ b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml @@ -0,0 +1,51 @@ +description: The Medicare Trustees report and project these Part D low-income subsidy payments per low-income subsidy enrollee. +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy per recipient + # From 2036, each value is the 2035 value times the growth in Medicare + # spending per enrollee, as for the per-enrollee series beside it. + uprating: calibration.gov.hhs.medicare.per_capita_cost + reference: + # Table IV.B9, "Low-income subsidy", "Subsidy amount": incurred payments + # per low-income subsidy enrollee, beside the subsidy's enrollment (in + # millions). Enrollment times the amount gives Table IV.B10's "Low-income + # subsidy" payments, e.g. 14.4 million x $1,254 = $18.1 billion in 2026. + # 2016 to 2035 come from the 2026 report (file page 158); 2015, which that + # table no longer shows, from the 2025 report (file page 158). The two + # reports differ by $1 to $2 in 2021 to 2023 (revised data). 2026 Medicare + # Trustees Report, transmitted 2026-06-09, retrieved 2026-10-11, sha256 + # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8; 2025 + # report sha256 + # 1b3de7a4faeee5ad42059e10740603182b716a5fa7b53b30d360b45b9e474d95. + - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table IV.B9 + href: https://www.cms.gov/oact/tr/2026#page=158 + - title: 2025 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table IV.B9 + href: https://www.cms.gov/oact/tr/2025#page=158 +values: + # Historical. + 2015-01-01: 2_112 + 2016-01-01: 2_126 + 2017-01-01: 2_156 + 2018-01-01: 2_204 + 2019-01-01: 2_273 + 2020-01-01: 2_507 + 2021-01-01: 2_646 + 2022-01-01: 2_873 + 2023-01-01: 3_092 + 2024-01-01: 2_833 + # 2025 is the first year of the full Inflation Reduction Act benefit + # redesign (2026 report, file page 119), which moved spending from the + # subsidy to the direct subsidy (Table IV.B9). + 2025-01-01: 1_226 + # Trustees' intermediate estimates. + 2026-01-01: 1_254 + 2027-01-01: 1_384 + 2028-01-01: 1_459 + 2029-01-01: 1_579 + 2030-01-01: 1_833 + 2031-01-01: 1_922 + 2032-01-01: 1_956 + 2033-01-01: 1_982 + 2034-01-01: 2_014 + 2035-01-01: 2_076 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/income_limit.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/income_limit.yaml new file mode 100644 index 00000000000..7aa3fcd667a --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/income_limit.yaml @@ -0,0 +1,15 @@ +description: Medicare limits the Part D low-income subsidy to people whose countable income is below this share of the federal poverty guideline for their family size. +values: + 2006-01-01: 1.5 + +metadata: + unit: /1 + period: year + label: Medicare Part D low-income subsidy income limit + reference: + - title: 42 U.S.C. § 1395w-114(a)(3)(A)(ii) - Subsidy eligible individual defined + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_A_ii + - title: 42 CFR § 423.773(a)(1) - Subsidy eligible individual + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(a)(1) + - title: SSA POMS HI 03020.055 - Income Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603020055 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/ineligible_jurisdictions.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/ineligible_jurisdictions.yaml new file mode 100644 index 00000000000..150028aac86 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/ineligible_jurisdictions.yaml @@ -0,0 +1,25 @@ +description: Medicare limits the Part D low-income subsidy to residents of the 50 states and the District of Columbia, so residents of these other jurisdictions are ineligible. +values: + 2006-01-01: + # Territories and freely associated states. + - GU + - MP + - PR + - PW + - VI + # Armed Forces mail codes, which are addresses abroad. + - AA + - AE + - AP + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy ineligible jurisdictions + reference: + - title: 42 U.S.C. § 1395w-114(a)(3)(F) - Treatment of territorial residents + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_F + - title: 42 CFR § 423.772 - Definitions (State) + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772 + - title: 20 CFR § 418.3010(b)(11) - Definitions (State) + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-A/section-418.3010#p-418.3010(b)(11) diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/burial_exclusion.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/burial_exclusion.yaml new file mode 100644 index 00000000000..d6a04e3f272 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/burial_exclusion.yaml @@ -0,0 +1,18 @@ +description: Medicare excludes this amount per person, for the applicant and a spouse living with them, from countable resources as funds expected to be used for burial under the Part D low-income subsidy. +values: + 2006-01-01: 1_500 + +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy burial funds exclusion + reference: + # SSA applies the exclusion unless the applicant says none of the + # resources will go to funeral or burial expenses, without asking for the + # funds' value or designation (POMS HI 03030.020 and HI 03030.025). + - title: 20 CFR § 418.3425(j) - Burial funds resource exclusion + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3425#p-418.3425(j) + - title: SSA POMS HI 03030.020 - Resource Exclusions + href: https://secure.ssa.gov/poms.nsf/lnx/0603030020 + - title: SSA POMS HI 03030.025 - Resource Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603030025 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml new file mode 100644 index 00000000000..fd07ec6bf55 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml @@ -0,0 +1,40 @@ +description: Medicare limits the Part D low-income subsidy to married people living with their spouse whose combined countable resources do not exceed this amount, before the burial funds exclusion. +values: + 2006-01-01: 20_000 + 2007-01-01: 20_410 + 2008-01-01: 20_970 + 2009-01-01: 22_010 + 2011-01-01: 22_260 + 2012-01-01: 23_120 + 2013-01-01: 23_580 + 2014-01-01: 23_860 + 2015-01-01: 24_250 + 2017-01-01: 24_600 + 2018-01-01: 25_150 + 2019-01-01: 25_720 + 2020-01-01: 26_160 + 2021-01-01: 26_520 + 2022-01-01: 27_950 + 2023-01-01: 30_240 + 2024-01-01: 31_360 + 2025-01-01: 32_130 + 2026-01-01: 33_100 + +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy couple resource limit + reference: + # The statute's alternative resource standard: $20,000 in 2006, raised + # each year by the September CPI-U and rounded to $10. Before 2024 it was + # the limit for the partial subsidy (the full subsidy had a lower limit); + # from 2024 it is the limit for the full subsidy, the only one left. SSA's + # table lists both pre-2024 limits; these are the higher ones. SSA's table + # excludes the $3,000 couple burial funds exclusion, which is a separate + # parameter. + - title: 42 U.S.C. § 1395w-114(a)(3)(E)(i) - Alternative resource standard + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E_i + - title: 42 CFR § 423.773(b)(2)(iii) and (d)(2) - Resource thresholds + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2) + - title: SSA POMS HI 03030.025 - Resource Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603030025 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/individual.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/individual.yaml new file mode 100644 index 00000000000..54fc4010afd --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/individual.yaml @@ -0,0 +1,40 @@ +description: Medicare limits the Part D low-income subsidy to unmarried people whose countable resources do not exceed this amount, before the burial funds exclusion. +values: + 2006-01-01: 10_000 + 2007-01-01: 10_210 + 2008-01-01: 10_490 + 2009-01-01: 11_010 + 2011-01-01: 11_140 + 2012-01-01: 11_570 + 2013-01-01: 11_800 + 2014-01-01: 11_940 + 2015-01-01: 12_140 + 2017-01-01: 12_320 + 2018-01-01: 12_600 + 2019-01-01: 12_890 + 2020-01-01: 13_110 + 2021-01-01: 13_290 + 2022-01-01: 14_010 + 2023-01-01: 15_160 + 2024-01-01: 15_720 + 2025-01-01: 16_100 + 2026-01-01: 16_590 + +metadata: + unit: currency-USD + period: year + label: Medicare Part D low-income subsidy individual resource limit + reference: + # The statute's alternative resource standard: $10,000 in 2006, raised + # each year by the September CPI-U and rounded to $10. Before 2024 it was + # the limit for the partial subsidy (the full subsidy had a lower limit); + # from 2024 it is the limit for the full subsidy, the only one left. SSA's + # table lists both pre-2024 limits; these are the higher ones. SSA's table + # excludes the $1,500 burial funds exclusion, which is a separate + # parameter. + - title: 42 U.S.C. § 1395w-114(a)(3)(E)(i) - Alternative resource standard + href: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E_i + - title: 42 CFR § 423.773(b)(2)(iii) and (d)(2) - Resource thresholds + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2) + - title: SSA POMS HI 03030.025 - Resource Limits for Subsidy Eligibility + href: https://secure.ssa.gov/poms.nsf/lnx/0603030025 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/sources.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/sources.yaml new file mode 100644 index 00000000000..e85f02bcbf8 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/sources.yaml @@ -0,0 +1,20 @@ +description: Medicare counts these assets as resources under the Part D low-income subsidy. +values: + 2006-01-01: + - bank_account_assets + - stock_assets + - bond_assets + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy countable resource sources + reference: + # SSA counts liquid resources (cash, financial accounts, stocks, bonds, + # mutual funds, retirement accounts and similar) and equity in real + # property other than the home. The model has no retirement account or + # non-home real property inputs. + - title: 20 CFR § 418.3405 - What types of resources do we count? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3405 + - title: 42 CFR § 423.772 - Definitions (resources) + href: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772 diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/income/excluded_sources.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/income/excluded_sources.yaml new file mode 100644 index 00000000000..18053652968 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/income/excluded_sources.yaml @@ -0,0 +1,15 @@ +description: Medicare excludes these Supplemental Security Income unearned income sources from countable income under the Part D low-income subsidy. +values: + 2006-01-01: + - dividend_income + - interest_income + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy excluded income sources + reference: + - title: 20 CFR § 418.3350(d) - What types of unearned income do we not count? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3350#p-418.3350(d) + - title: SSA POMS HI 03020.050 - Unearned Income Exclusions + href: https://secure.ssa.gov/poms.nsf/lnx/0603020050 diff --git a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml index f8081bc5a9d..b32ee470846 100644 --- a/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml +++ b/policyengine_us/parameters/gov/household/cbo_means_tested_transfers.yaml @@ -11,6 +11,7 @@ values: - reduced_price_school_meals - spm_unit_energy_subsidy - general_assistance + - part_d_lis metadata: unit: list @@ -19,3 +20,7 @@ metadata: reference: - title: Congressional Budget Office, Reconciling the Official Poverty Measure and CBO’s Distributional Analysis of Household Income, Including In-Kind Transfers href: https://www.cbo.gov/publication/61157 + # CBO counts the Medicare Part D low-income subsidy as a means-tested + # transfer, not as Medicare (see cbo_medicare_benefit). + - title: Congressional Budget Office, Sensitivity of the Distribution of Household Income to the Treatment of Health Insurance From 1979 to 2021 (January 2026) + href: https://www.cbo.gov/publication/61910 diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 05cb1d13747..8df4779c589 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1117,6 +1117,23 @@ programs: adjustments; gross Part B premium calibration should use the enrollment-gated gross_medicare_part_b_premium_if_enrolled variable. + - id: medicare_part_d_lis + name: Medicare Part D Extra Help + full_name: Medicare Part D Low-Income Subsidy (Extra Help) + category: Healthcare + agency: HHS + status: partial + coverage: US + variable: part_d_lis + parameter_prefix: gov.hhs.medicare.part_d.low_income_subsidy + verified_start_year: 2015 + notes: >- + Models eligibility (countable income below 150% of the poverty guideline, + resource limits, and deemed eligibility for Medicaid and SSI recipients + and Medicare Savings Program enrollees) and values the subsidy at the + Medicare Trustees' average payment per subsidy enrollee, as CBO does; + the premium and cost-sharing subsidies are not computed per person. + # --- HUD programs --- - id: section_8 name: Section 8 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.yaml new file mode 100644 index 00000000000..f21127b4dbf --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.yaml @@ -0,0 +1,79 @@ +# Countable income: SSI income of the person and a spouse living with them, +# without interest and dividends; the $20 general exclusion and the $65 and +# one-half earned income exclusions apply once to the couple's income +# (20 CFR 418.3310, 418.3325, 418.3350). + +- name: Single person, Social Security and interest; interest excluded. + period: 2026 + input: + people: + person: + age: 70 + social_security: 18_000 + interest_income: 1_000 + dividend_income: 600 + output: + # Unearned 18,000 + 1,000 + 600 - 1,600 excluded = 18,000 a year, + # 1,500 a month; less the $20 general exclusion = 1,480 a month. + # 1,480 x 12 = 17,760. + part_d_lis_countable_income: 17_760 + +- name: Married couple, exclusions applied once to combined income. + period: 2026 + input: + people: + spouse1: + age: 70 + employment_income: 12_000 + spouse2: + age: 67 + social_security: 6_000 + dividend_income: 500 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # Combined unearned 6,000 a year = 500 a month, less $20 = 480. + # Combined earned 12,000 a year = 1,000 a month, less $65 = 935, half + # excluded = 467.50. Monthly 947.50 x 12 = 11,370 for each spouse. + part_d_lis_countable_income: [11_370, 11_370] + +- name: Spouse's income counts even when only one spouse is a Medicare enrollee. + period: 2026 + input: + people: + spouse1: + age: 70 + medicare_enrolled: true + social_security: 12_000 + spouse2: + age: 60 + medicare_enrolled: false + employment_income: 30_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # Unearned 1,000 a month less $20 = 980. Earned 2,500 a month less $65 = + # 2,435, half = 1,217.50. Monthly 2,197.50 x 12 = 26,370. + part_d_lis_countable_income: [26_370, 26_370] + +- name: Earned income only; the unused $20 general exclusion applies to earnings. + period: 2026 + input: + people: + person: + age: 66 + employment_income: 6_000 + output: + # 500 a month less $20 less $65 = 415, half = 207.50; x 12 = 2,490. + part_d_lis_countable_income: 2_490 + +- name: No income. + period: 2026 + input: + people: + person: + age: 66 + output: + part_d_lis_countable_income: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.yaml new file mode 100644 index 00000000000..d0bcd98f082 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.yaml @@ -0,0 +1,57 @@ +# Family size: the person, a spouse living with them, and relatives who +# depend on them for at least half of their support (42 CFR 423.772). + +- name: Single person living alone. + period: 2026 + input: + people: + person: + age: 70 + output: + part_d_lis_family_size: 1 + +- name: Married couple supporting a grandchild. + period: 2026 + input: + people: + head: + age: 70 + spouse: + age: 68 + grandchild: + age: 12 + is_tax_unit_dependent: true + marital_units: + couple: + members: [head, spouse] + grandchild_unit: + members: [grandchild] + tax_units: + tax_unit: + members: [head, spouse, grandchild] + output: + # The couple's family is 3. The grandchild is not a head or spouse, so + # its own family size is 1. + part_d_lis_family_size: [3, 3, 1] + +- name: Parent who is a dependent of an adult child. + period: 2026 + input: + people: + child: + age: 45 + parent: + age: 80 + is_tax_unit_dependent: true + marital_units: + child_unit: + members: [child] + parent_unit: + members: [parent] + tax_units: + tax_unit: + members: [child, parent] + output: + # The parent depends on the child, so the parent's family is just the + # parent; the child's family includes the dependent parent. + part_d_lis_family_size: [2, 1] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.yaml new file mode 100644 index 00000000000..44769a1e266 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.yaml @@ -0,0 +1,62 @@ +# Countable income must be below 150% of the poverty guideline for the +# family size (42 U.S.C. 1395w-114(a)(3)(A)(ii)). 2026 guidelines: $15,960 +# for one person in the contiguous states plus $5,680 per additional person; +# Alaska $19,950 plus $7,100. + +- name: Single person just below 150% of the guideline. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 23_939 + part_d_lis_family_size: 1 + output: + # 1.5 x 15,960 = 23,940. + part_d_lis_income_eligible: true + +- name: Single person exactly at 150% of the guideline is not below it. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 23_940 + part_d_lis_family_size: 1 + output: + part_d_lis_income_eligible: false + +- name: Family of three uses the three-person guideline. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 40_000 + part_d_lis_family_size: 3 + output: + # 1.5 x (15,960 + 2 x 5,680) = 1.5 x 27,320 = 40,980. + part_d_lis_income_eligible: true + +- name: Alaska uses its own guideline. + period: 2026 + input: + people: + person: + part_d_lis_countable_income: 29_000 + part_d_lis_family_size: 1 + households: + household: + members: [person] + state_code: AK + output: + # 1.5 x 19,950 = 29,925; the contiguous-state limit would be 23,940. + part_d_lis_income_eligible: true + +- name: 2023, income from 135% to 150% of the guideline qualified for the partial subsidy. + period: 2023 + input: + people: + person: + part_d_lis_countable_income: 21_000 + part_d_lis_family_size: 1 + output: + # 2023: 1.35 x 14,580 = 19,683 < 21,000 < 1.5 x 14,580 = 21,870. + part_d_lis_income_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.yaml new file mode 100644 index 00000000000..c12e0858819 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.yaml @@ -0,0 +1,63 @@ +# Countable resources of the person and a spouse living with them must not +# exceed the limit plus $1,500 per person for burial funds (POMS HI +# 03030.025, 20 CFR 418.3425(j)). + +- name: Single person at the 2026 limit with the burial exclusion. + period: 2026 + input: + people: + person: + bank_account_assets: 18_090 + output: + # 16,590 + 1,500 = 18,090. + part_d_lis_resource_eligible: true + +- name: Single person one dollar over the 2026 limit. + period: 2026 + input: + people: + person: + bank_account_assets: 18_091 + output: + part_d_lis_resource_eligible: false + +- name: Married couple at the 2026 couple limit, resources combined. + period: 2026 + input: + people: + spouse1: + bank_account_assets: 20_000 + spouse2: + stock_assets: 10_100 + bond_assets: 6_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # 20,000 + 10,100 + 6,000 = 36,100 = 33,100 + 2 x 1,500. + part_d_lis_resource_eligible: [true, true] + +- name: Married couple one dollar over the 2026 couple limit. + period: 2026 + input: + people: + spouse1: + bank_account_assets: 20_001 + spouse2: + stock_assets: 16_100 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + part_d_lis_resource_eligible: [false, false] + +- name: 2023 uses the higher (partial subsidy) limit. + period: 2023 + input: + people: + person: + bank_account_assets: 16_660 + output: + # 15,160 + 1,500 = 16,660, CMS's published 2023 partial subsidy limit. + # The 2023 full subsidy limit was 9,090 + 1,500 = 10,590. + part_d_lis_resource_eligible: true diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.py new file mode 100644 index 00000000000..aa3d928ddea --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class part_d_lis(Variable): + value_type = float + entity = Person + label = "Medicare Part D low-income subsidy" + documentation = ( + "Value of the Part D low-income subsidy (Extra Help) as the " + "Congressional Budget Office counts it: the Medicare Trustees' " + "average subsidy payment per subsidy enrollee, the same for every " + "enrollee. Most of the subsidy pays cost sharing, which depends on " + "each enrollee's drug use, which the model does not have." + ) + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114", + "https://www.cbo.gov/publication/61910", + "https://www.cbo.gov/system/files/2018-07/54234-workingpaper.pdf#page=46", + "https://www.cms.gov/oact/tr/2026#page=158", + ) + defined_for = "part_d_lis_enrolled" + + def formula(person, period, parameters): + p = parameters(period).calibration.gov.hhs.medicare.part_d + enrolled = person("part_d_lis_enrolled", period) + return enrolled * p.low_income_subsidy_per_recipient diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.py new file mode 100644 index 00000000000..686665bd8a5 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_countable_income.py @@ -0,0 +1,40 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) + + +class part_d_lis_countable_income(Variable): + value_type = float + entity = Person + label = "Medicare Part D low-income subsidy countable income" + documentation = ( + "Annual countable income for the Part D low-income subsidy: the " + "Supplemental Security Income definition of income, for the person " + "and a spouse living with them, without in-kind support and " + "maintenance or interest and dividends. The $20 general, $65 earned " + "and one-half earned income exclusions apply once to the combined " + "income." + ) + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_C", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3310", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3325", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3350", + "https://secure.ssa.gov/poms.nsf/lnx/0603020050", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hhs.medicare.part_d.low_income_subsidy.income + earned = person("ssi_earned_income", period) + unearned = person("ssi_unearned_income", period) - add( + person, period, p.excluded_sources + ) + # A spouse's income counts whether or not the spouse applies or is + # eligible, and the exclusions apply once to the couple's income. + couple_earned = person.marital_unit.sum(earned) + couple_unearned = max_(person.marital_unit.sum(unearned), 0) + return _apply_ssi_exclusions(couple_earned, couple_unearned, parameters, period) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.py new file mode 100644 index 00000000000..2dfdc01bafd --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class part_d_lis_deemed_eligible(Variable): + value_type = bool + entity = Person + label = "Deemed eligible for the Medicare Part D low-income subsidy" + documentation = ( + "Treated as eligible for the full subsidy without an income or " + "resource test: full-benefit dual eligibles (Medicaid enrollees), " + "Supplemental Security Income recipients, and people eligible for a " + "Medicare Savings Program (QMB, SLMB or QI)." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_B_v", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(c)", + ) + + def formula(person, period, parameters): + medicaid = person("medicaid_enrolled", period) + ssi = add(person, period, ["ssi"]) > 0 + msp = add(person, period, ["msp_eligible"]) > 0 + return medicaid | ssi | msp diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.py new file mode 100644 index 00000000000..7e07fb76b40 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.py @@ -0,0 +1,30 @@ +from policyengine_us.model_api import * + + +class part_d_lis_eligible(Variable): + value_type = bool + entity = Person + label = "Eligible for the Medicare Part D low-income subsidy" + documentation = ( + "A Medicare enrollee living in the 50 states or the District of " + "Columbia who is deemed eligible or meets the income and resource " + "tests. Before 2024 people with income from 135% to 150% of the " + "poverty guideline, or resources between the lower and higher " + "limits, got a partial subsidy; this covers both." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3101", + ) + defined_for = "medicare_enrolled" + + def formula(person, period, parameters): + p = parameters(period).gov.hhs.medicare.part_d.low_income_subsidy.eligibility + state_code = person.household("state_code_str", period) + in_state = ~np.isin(state_code, p.ineligible_jurisdictions) + deemed = person("part_d_lis_deemed_eligible", period) + income_eligible = person("part_d_lis_income_eligible", period) + resource_eligible = person("part_d_lis_resource_eligible", period) + return in_state & (deemed | (income_eligible & resource_eligible)) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py new file mode 100644 index 00000000000..a1cadfb0488 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class part_d_lis_enrolled(Variable): + value_type = bool + entity = Person + label = "Enrolled in the Medicare Part D low-income subsidy" + documentation = ( + "Medicare enrolls Medicaid enrollees and Supplemental Security Income " + "recipients in a Part D plan with the subsidy without an application. " + "Other eligible people receive it if they take it up." + ) + definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(c)(2)", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3101", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-B/section-423.34", + ) + defined_for = "part_d_lis_eligible" + + def formula(person, period, parameters): + medicaid = person("medicaid_enrolled", period) + ssi = add(person, period, ["ssi"]) > 0 + takes_up = person("takes_up_part_d_lis_if_eligible", period) + return medicaid | ssi | takes_up diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.py new file mode 100644 index 00000000000..9e5a9bf72b8 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_family_size.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class part_d_lis_family_size(Variable): + value_type = int + entity = Person + label = "Medicare Part D low-income subsidy family size" + documentation = ( + "The person, a spouse living with them, and relatives living with " + "them who depend on the person or spouse for at least half of their " + "support, approximated by the dependents of the tax unit the person " + "heads or is the spouse in." + ) + definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.772", + "https://secure.ssa.gov/poms.nsf/lnx/0603020055", + ) + + def formula(person, period, parameters): + couple_size = person.marital_unit.nb_persons() + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + dependents = person.tax_unit("tax_unit_dependents", period) + return couple_size + where(head_or_spouse, dependents, 0) diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.py new file mode 100644 index 00000000000..37e1475241a --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_income_eligible.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.tax_unit_fpg import fpg + + +class part_d_lis_income_eligible(Variable): + value_type = bool + entity = Person + label = "Income eligible for the Medicare Part D low-income subsidy" + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_A_ii", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(a)(1)", + "https://secure.ssa.gov/poms.nsf/lnx/0603020055", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hhs.medicare.part_d.low_income_subsidy.eligibility + family_size = person("part_d_lis_family_size", period) + state_group = person.household("state_group_str", period) + income_limit = p.income_limit * fpg( + family_size, state_group, period, parameters + ) + return person("part_d_lis_countable_income", period) < income_limit diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.py new file mode 100644 index 00000000000..fe7e0580493 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_resource_eligible.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class part_d_lis_resource_eligible(Variable): + value_type = bool + entity = Person + label = "Resource eligible for the Medicare Part D low-income subsidy" + documentation = ( + "Countable resources of the person and a spouse living with them do " + "not exceed the individual or couple limit plus the burial funds " + "exclusion for each of them." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2)", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3410", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3425", + "https://secure.ssa.gov/poms.nsf/lnx/0603030025", + ) + + def formula(person, period, parameters): + p = parameters( + period + ).gov.hhs.medicare.part_d.low_income_subsidy.eligibility.resources + resources = person.marital_unit.sum(add(person, period, p.sources)) + couple_size = person.marital_unit.nb_persons() + limit = where(couple_size == 2, p.limit.couple, p.limit.individual) + return resources <= limit + p.burial_exclusion * couple_size diff --git a/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/takes_up_part_d_lis_if_eligible.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/takes_up_part_d_lis_if_eligible.py new file mode 100644 index 00000000000..f1a359189e5 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/takes_up_part_d_lis_if_eligible.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class takes_up_part_d_lis_if_eligible(Variable): + value_type = bool + entity = Person + label = ( + "Whether an eligible person enrolls in the Medicare Part D low-income subsidy" + ) + documentation = ( + "Whether an eligible person who is neither a Medicaid enrollee nor a " + "Supplemental Security Income recipient applies for the subsidy and " + "enrolls in a Part D plan. Medicare Savings Program enrollees are " + "deemed eligible, but the model has Medicare Savings Program " + "eligibility, not enrollment, so this flag covers them too." + ) + definition_period = YEAR + default_value = True From e7bec1ea01d011c8cac8ea095822bc19fabdcca9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:26:58 -0400 Subject: [PATCH 2/3] Test the Part D low-income subsidy: YAML cases and invariants YAML tests for every new variable, households computed from their inputs (Medicare Savings Program deeming, the subsidy's own tests, the pre-2024 partial range, take-up), and the CBO means-tested list. Python invariants: IV.B9 transcription, IV.B10 conservation, agreement with the per-enrollee series cbo_medicare_benefit subtracts, the CPI rule behind the resource limits, Hypothesis bounds, restated eligibility rules over a seeded sample with income, interest and asset variants, and a differential test against msp_countable_income. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_part_d_lis.py | 444 ++++++++++++++++++ .../low_income_subsidy/integration.yaml | 187 ++++++++ .../part_d/low_income_subsidy/part_d_lis.yaml | 60 +++ .../part_d_lis_deemed_eligible.yaml | 47 ++ .../part_d_lis_eligible.yaml | 82 ++++ .../part_d_lis_enrolled.yaml | 61 +++ .../household/cbo_household_income.yaml | 33 +- 7 files changed, 913 insertions(+), 1 deletion(-) create mode 100644 policyengine_us/tests/core/test_part_d_lis.py create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/integration.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.yaml diff --git a/policyengine_us/tests/core/test_part_d_lis.py b/policyengine_us/tests/core/test_part_d_lis.py new file mode 100644 index 00000000000..632dacf8660 --- /dev/null +++ b/policyengine_us/tests/core/test_part_d_lis.py @@ -0,0 +1,444 @@ +"""Invariants of the Medicare Part D low-income subsidy (``part_d_lis``). + +The subsidy goes to Medicare enrollees in the 50 states and DC who are deemed +eligible (Medicaid enrollees, SSI recipients, Medicare Savings Program +enrollees) or whose countable income is below 150% of the poverty guideline +for their family size and whose countable resources are within the limit +plus $1,500 per person for burial funds (42 U.S.C. 1395w-114(a)(3); 42 CFR +423.773; 20 CFR 418 subpart D). Each enrollee is valued at the Medicare +Trustees' average subsidy per subsidy enrollee, as CBO does. + +Parameters: + +1. Transcription: ``low_income_subsidy_per_recipient`` equals Table IV.B9's + "Subsidy amount" (2026 report; the 2025 report for 2015). +2. Conservation: IV.B9 enrollment times the amount returns Table IV.B10's + low-income subsidy payments, within the cells' rounding. +3. Differential: the same payments over Table V.B3's total enrollment equal + ``part_d_low_income_subsidy_per_enrollee``, the series + ``cbo_medicare_benefit`` subtracts, built from different cells. +4. Indexing: each resource limit is the previous year's limit raised by the + September-to-September change in the CPI-U (not seasonally adjusted), + never lowered, and rounded to the nearest $10, from the statute's 2006 + amounts. SSA's published 2015 couple limit is $10 below the rule; the + parameter keeps the published amount (an intended exception). +5. Bounds (Hypothesis): the amount per recipient is positive and below + Medicare spending per enrollee, and the limits never fall, in every year + from 2015 to 2100. + +Households (a seeded sample in one shared simulation, each household also +run with more Social Security, more interest and more assets): + +6. Countable income equals the rule restated here: the couple's SSI + income without interest and dividends, less $20 a month from unearned + income (the rest from earnings), $65 a month of earnings and half the + remaining earnings. +7. Interest and dividends never change countable income or eligibility. +8. More Social Security never lowers countable income or makes an income + ineligible person income eligible; more assets never make a resource + ineligible person resource eligible. +9. Eligibility equals the restated rule, and the benefit is either zero or + the year's amount per recipient, positive exactly for enrollees, who + are all eligible. +10. Spouses get the same countable income, family size and test results. +11. Differential: for people without interest or dividends, countable income + equals 12 times ``msp_countable_income``, which applies the same SSI + exclusions through a separate formula (single people, and couples where + both spouses are Medicare eligible). +""" + +import numpy as np +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + +P = system.parameters +PER_RECIPIENT = "calibration.gov.hhs.medicare.part_d.low_income_subsidy_per_recipient" +LIS = P.gov.hhs.medicare.part_d.low_income_subsidy + +# 2026 Medicare Trustees Report (sha256 ffa56b91...8fa8), Table IV.B9 (file +# page 158): low-income subsidy enrollment (millions) and subsidy amount ($). +# 2015 from the 2025 report (sha256 1b3de7a4...4d95), Table IV.B9 (page 158). +IV_B9 = { + 2015: (12.1, 2_112), + 2016: (12.4, 2_126), + 2017: (12.7, 2_156), + 2018: (12.9, 2_204), + 2019: (13.1, 2_273), + 2020: (13.1, 2_507), + 2021: (13.2, 2_646), + 2022: (13.7, 2_873), + 2023: (14.3, 3_092), + 2024: (14.5, 2_833), + 2025: (14.2, 1_226), + 2026: (14.4, 1_254), + 2027: (14.8, 1_384), + 2028: (15.2, 1_459), + 2029: (15.5, 1_579), + 2030: (15.8, 1_833), + 2031: (16.0, 1_922), + 2032: (16.2, 1_956), + 2033: (16.4, 1_982), + 2034: (16.6, 2_014), + 2035: (16.7, 2_076), +} +# Table IV.B10 (file page 159), low-income subsidy, $ billions, incurred; +# 2015 from the 2025 report's Table IV.B10 (page 159). +IV_B10 = { + **{2015: 25.6, 2016: 26.4, 2017: 27.3, 2018: 28.5, 2019: 29.7, 2020: 33.0}, + **{2021: 35.0, 2022: 39.4, 2023: 44.2, 2024: 41.2, 2025: 17.4, 2026: 18.1}, + **{2027: 20.5, 2028: 22.1, 2029: 24.5, 2030: 28.9, 2031: 30.7, 2032: 31.7}, + **{2033: 32.4, 2034: 33.3, 2035: 34.8}, +} +# Table V.B3 (file page 196), total enrollment (HI and/or SMI), thousands. +V_B3_TOTAL = dict( + zip( + range(2015, 2036), + (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), + ) +) +# BLS CPI-U, U.S. city average, all items, not seasonally adjusted +# (CUUR0000SA0), September of each year, retrieved 2026-10-11 from the BLS +# public API. (pe-us's gov.bls.cpi.cpi_u holds seasonally adjusted values +# from 2024, so it is not used here.) +CPI_U_SEPTEMBER = { + **{2004: 189.9, 2005: 198.8, 2006: 202.9, 2007: 208.490, 2008: 218.783}, + **{2009: 215.969, 2010: 218.439, 2011: 226.889, 2012: 231.407}, + **{2013: 234.149, 2014: 238.031, 2015: 237.945, 2016: 241.428}, + **{2017: 246.819, 2018: 252.439, 2019: 256.759, 2020: 260.280}, + **{2021: 274.310, 2022: 296.808, 2023: 307.789, 2024: 315.301}, + **{2025: 324.800}, +} +STATUTE_2006 = {"individual": 10_000, "couple": 20_000} +PUBLISHED_EXCEPTIONS = {("couple", 2015): 24_250} # the rule gives 24,260 + + +def _per_recipient(year): + return P.calibration.gov.hhs.medicare.part_d.low_income_subsidy_per_recipient( + f"{year}-01-01" + ) + + +@pytest.mark.parametrize("year", sorted(IV_B9)) +def test_per_recipient_transcribes_table_iv_b9(year): + assert _per_recipient(year) == IV_B9[year][1] + + +@pytest.mark.parametrize("year", sorted(IV_B9)) +def test_enrollment_times_amount_returns_table_iv_b10(year): + enrollment, amount = IV_B9[year] + rebuilt = enrollment * 1e6 * _per_recipient(year) / 1e9 + # Rounding: total to $0.05B, enrollment to 0.05M x amount, amount to $0.5. + tolerance = 0.05 + 0.05e6 * amount / 1e9 + 0.5 * enrollment * 1e6 / 1e9 + assert abs(rebuilt - IV_B10[year]) <= tolerance + + +@pytest.mark.parametrize("year", sorted(IV_B9)) +def test_recipients_times_amount_matches_per_enrollee_series(year): + per_enrollee = ( + P.calibration.gov.hhs.medicare.part_d_low_income_subsidy_per_enrollee( + f"{year}-01-01" + ) + ) + enrollment, _ = IV_B9[year] + rebuilt = enrollment * 1e6 * _per_recipient(year) / (V_B3_TOTAL[year] * 1e3) + # Enrollment rounded to 0.1 million is at worst 0.42% off (2015, 12.1). + assert rebuilt == pytest.approx(per_enrollee, rel=0.005) + + +def _round_to_10(x): + return int(np.floor(x / 10 + 0.5)) * 10 + + +@pytest.mark.parametrize("kind", ["individual", "couple"]) +def test_resource_limits_follow_the_cpi_rule(kind): + limit = getattr(LIS.eligibility.resources.limit, kind) + assert limit("2006-01-01") == STATUTE_2006[kind] + for year in range(2007, 2027): + growth = CPI_U_SEPTEMBER[year - 1] / CPI_U_SEPTEMBER[year - 2] + expected = _round_to_10(limit(f"{year - 1}-01-01") * max(growth, 1)) + expected = PUBLISHED_EXCEPTIONS.get((kind, year), expected) + assert limit(f"{year}-01-01") == expected, year + + +@given(st.integers(2015, 2100)) +def test_amount_positive_and_below_spending(year): + amount = _per_recipient(year) + spending = P.calibration.gov.hhs.medicare.per_capita_cost(f"{year}-01-01") + assert 0 < amount < spending + + +@given(st.integers(2007, 2100)) +def test_resource_limits_never_fall(year): + for kind in ["individual", "couple"]: + limit = getattr(LIS.eligibility.resources.limit, kind) + assert limit(f"{year}-01-01") >= limit(f"{year - 1}-01-01") + + +# Households. +YEAR = 2026 +N = 300 +SEED = 20261011 +STATES = ["TX", "CA", "NY", "AK", "HI", "FL", "PR", "DC"] +VARIANTS = ["base", "more_ss", "more_interest", "more_assets"] + + +def _households(): + rng = np.random.default_rng(SEED) + out = [] + for _ in range(N): + married = bool(rng.random() < 0.5) + + def person(): + return dict( + social_security=int(rng.choice([0, 6_000, 12_000, 18_000, 26_000])), + employment_income=int(rng.choice([0, 0, 0, 4_000, 15_000])), + interest_income=int(rng.choice([0, 0, 300, 4_000])), + dividend_income=int(rng.choice([0, 0, 1_000])), + bank_account_assets=int(rng.choice([0, 3_000, 9_000, 16_000, 40_000])), + stock_assets=int(rng.choice([0, 0, 5_000])), + medicare_enrolled=bool(rng.random() < 0.9), + medicaid_enrolled=bool(rng.random() < 0.15), + ssi=int(rng.choice([0, 0, 0, 0, 2_400])), + msp_eligible=bool(rng.random() < 0.15), + takes_up_part_d_lis_if_eligible=bool(rng.random() < 0.7), + ) + + out.append( + dict( + state=str(rng.choice(STATES)), + dependents=int(rng.choice([0, 0, 0, 1, 2])), + members=[person()] + ([person()] if married else []), + ) + ) + return out + + +HOUSEHOLDS = _households() + + +def _variant(member, variant, is_head): + member = dict(member) + if is_head and variant == "more_ss": + member["social_security"] += 3_000 + if is_head and variant == "more_interest": + member["interest_income"] += 5_000 + member["dividend_income"] += 2_000 + if is_head and variant == "more_assets": + member["bank_account_assets"] += 5_000 + return member + + +def _situation(households, variants, inputs=True): + people, marital_units, tax_units, spm_units, units = {}, {}, {}, {}, {} + rows = [] # (household index, variant, member index, person name) + for v in variants: + for i, h in enumerate(households): + names = [] + for j, member in enumerate(h["members"]): + name = f"{v}_{i}_{j}" + record = _variant(member, v, j == 0) + if not inputs: + record = { + k: record[k] + for k in ( + "social_security", + "employment_income", + "interest_income", + "dividend_income", + ) + } + people[name] = { + "age": {YEAR: 70}, + **{k: {YEAR: val} for k, val in record.items()}, + } + names.append(name) + rows.append((i, v, j, name)) + spouses = list(names) + dependents = [] + for k in range(h["dependents"]): + name = f"{v}_{i}_d{k}" + people[name] = { + "age": {YEAR: 10}, + "is_tax_unit_dependent": {YEAR: True}, + } + marital_units[f"mu_{name}"] = {"members": [name]} + dependents.append(name) + marital_units[f"mu_{v}_{i}"] = {"members": spouses} + members = spouses + dependents + tax_units[f"tu_{v}_{i}"] = {"members": members} + spm_units[f"spm_{v}_{i}"] = {"members": members} + units[f"hh_{v}_{i}"] = { + "members": members, + "state_code": {YEAR: h["state"]}, + } + situation = { + "people": people, + "marital_units": marital_units, + "tax_units": tax_units, + "spm_units": spm_units, + "households": units, + } + return situation, rows + + +@pytest.fixture(scope="module") +def sample(): + situation, rows = _situation(HOUSEHOLDS, VARIANTS) + sim = Simulation(situation=situation) + names = list(situation["people"]) + index = {name: k for k, name in enumerate(names)} + variables = [ + "part_d_lis", + "part_d_lis_enrolled", + "part_d_lis_eligible", + "part_d_lis_deemed_eligible", + "part_d_lis_income_eligible", + "part_d_lis_resource_eligible", + "part_d_lis_countable_income", + "part_d_lis_family_size", + ] + values = {v: sim.calculate(v, YEAR) for v in variables} + return rows, index, values + + +def _restated_countable(members): + earned = sum(m["employment_income"] for m in members) / 12 + unearned = sum(m["social_security"] for m in members) / 12 + general = min(20, unearned) + countable_unearned = unearned - general + countable_earned = max(earned - 65 - (20 - general), 0) / 2 + return 12 * (countable_unearned + countable_earned) + + +def _fpg(state, size): + fpg = P.gov.hhs.fpg(f"{YEAR}-01-01") + group = state if state in ("AK", "HI", "PR") else "CONTIGUOUS_US" + return fpg.first_person[group] + fpg.additional_person[group] * (size - 1) + + +def _value(values, index, variable, name): + return values[variable][index[name]] + + +def test_countable_income_matches_restated_rule(sample): + rows, index, values = sample + for i, v, _, name in rows: + members = [ + _variant(m, v, j == 0) for j, m in enumerate(HOUSEHOLDS[i]["members"]) + ] + expected = _restated_countable(members) + actual = _value(values, index, "part_d_lis_countable_income", name) + assert actual == pytest.approx(expected, abs=0.01), (i, v) + + +def test_interest_and_dividends_change_nothing(sample): + rows, index, values = sample + for i, v, j, name in rows: + if v != "more_interest": + continue + base = f"base_{i}_{j}" + for variable in [ + "part_d_lis_countable_income", + "part_d_lis_income_eligible", + "part_d_lis_eligible", + "part_d_lis", + ]: + assert _value(values, index, variable, name) == _value( + values, index, variable, base + ), (i, variable) + + +def test_more_income_or_assets_never_helps(sample): + rows, index, values = sample + for i, v, j, name in rows: + base = f"base_{i}_{j}" + if v == "more_ss": + assert _value(values, index, "part_d_lis_countable_income", name) >= ( + _value(values, index, "part_d_lis_countable_income", base) + ) + if _value(values, index, "part_d_lis_income_eligible", name): + assert _value(values, index, "part_d_lis_income_eligible", base), i + if v == "more_assets": + if _value(values, index, "part_d_lis_resource_eligible", name): + assert _value(values, index, "part_d_lis_resource_eligible", base), i + + +def test_eligibility_and_benefit_match_restated_rules(sample): + rows, index, values = sample + amount = _per_recipient(YEAR) + excluded = list(LIS.eligibility.ineligible_jurisdictions(f"{YEAR}-01-01")) + limits = LIS.eligibility.resources(f"{YEAR}-01-01") + for i, v, j, name in rows: + h = HOUSEHOLDS[i] + members = [_variant(m, v, k == 0) for k, m in enumerate(h["members"])] + me = members[j] + couple = len(members) + size = couple + h["dependents"] + assert _value(values, index, "part_d_lis_family_size", name) == size + income_ok = _restated_countable(members) < 1.5 * _fpg(h["state"], size) + resources = sum(m["bank_account_assets"] + m["stock_assets"] for m in members) + limit = limits.limit.couple if couple == 2 else limits.limit.individual + resource_ok = resources <= limit + limits.burial_exclusion * couple + deemed = me["medicaid_enrolled"] or me["ssi"] > 0 or me["msp_eligible"] + eligible = ( + me["medicare_enrolled"] + and h["state"] not in excluded + and (deemed or (income_ok and resource_ok)) + ) + automatic = me["medicaid_enrolled"] or me["ssi"] > 0 + enrolled = eligible and (automatic or me["takes_up_part_d_lis_if_eligible"]) + assert _value(values, index, "part_d_lis_income_eligible", name) == income_ok + assert _value(values, index, "part_d_lis_resource_eligible", name) == ( + resource_ok + ) + assert _value(values, index, "part_d_lis_eligible", name) == eligible, i + assert _value(values, index, "part_d_lis_enrolled", name) == enrolled, i + benefit = _value(values, index, "part_d_lis", name) + assert benefit == (amount if enrolled else 0), i + + +def test_spouses_agree(sample): + rows, index, values = sample + for i, v, j, name in rows: + if j != 1: + continue + head = f"{v}_{i}_0" + for variable in [ + "part_d_lis_countable_income", + "part_d_lis_family_size", + "part_d_lis_income_eligible", + "part_d_lis_resource_eligible", + ]: + assert _value(values, index, variable, name) == _value( + values, index, variable, head + ), (i, variable) + + +def test_countable_income_matches_msp_countable_income(): + # No interest or dividends, everyone 70 and so Medicare eligible: the + # MSP formula then counts the same income the same way, through its + # couple branch for married couples and its personal branch otherwise. + households = [ + { + **h, + "members": [ + {**m, "interest_income": 0, "dividend_income": 0} for m in h["members"] + ], + } + for h in HOUSEHOLDS + ] + situation, rows = _situation(households, ["base"], inputs=False) + sim = Simulation(situation=situation) + names = list(situation["people"]) + lis = sim.calculate("part_d_lis_countable_income", YEAR) + msp = sim.calculate("msp_countable_income", f"{YEAR}-01") + medicare_eligible = sim.calculate("is_medicare_eligible", YEAR) + adults = np.array([not name.split("_")[-1].startswith("d") for name in names]) + assert medicare_eligible[adults].all() + np.testing.assert_allclose(lis[adults], 12 * msp[adults], atol=0.01) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/integration.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/integration.yaml new file mode 100644 index 00000000000..a424e9ad85a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/integration.yaml @@ -0,0 +1,187 @@ +# Households computed from their inputs, through the Medicare Savings +# Program and the subsidy's own income and resource tests. 2026 guidelines: +# $15,960 for one person and $21,640 for two (contiguous states); the +# subsidy limit is 150% of them: $23,940 and $32,460. Medicare Savings +# Program QI limit: 135%, $21,546 for one person and $29,214 for two. + +- name: Single retiree deemed eligible through a Medicare Savings Program. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + bank_account_assets: 5_000 + households: + household: + members: [person] + state_code: TX + output: + # Medicare Savings Program countable income (20,000 / 12 - 20) x 12 = + # 19,760 is above the SLMB limit (120%, 19,152) and below the QI limit, + # with resources under the $9,950 limit. + msp_eligible: true + part_d_lis_deemed_eligible: true + part_d_lis_countable_income: 19_760 + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + part_d_lis_eligible: true + part_d_lis_enrolled: true + part_d_lis: 1_254 + +- name: Interest is excluded for the subsidy but not for the Medicare Savings Program. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + interest_income: 5_000 + bank_account_assets: 15_000 + households: + household: + members: [person] + state_code: TX + output: + # Medicare Savings Program countable income (25,000 / 12 - 20) x 12 = + # 24,760 is over the QI limit, and $15,000 is over its $9,950 resource + # limit. The subsidy excludes interest: 19,760 < 23,940, and $15,000 <= + # 16,590 + 1,500. + msp_eligible: false + part_d_lis_deemed_eligible: false + part_d_lis_countable_income: 19_760 + part_d_lis_eligible: true + part_d_lis: 1_254 + +- name: Single retiree with resources over the limit is not eligible. + period: 2026 + input: + people: + person: + age: 68 + social_security: 22_000 + bank_account_assets: 50_000 + households: + household: + members: [person] + state_code: TX + output: + # Countable income (22,000 / 12 - 20) x 12 = 21,760 is under 23,940, but + # $50,000 is over $18,090. + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: false + part_d_lis_deemed_eligible: false + part_d_lis_eligible: false + part_d_lis: 0 + +- name: Married couple eligible through the income and resource tests. + period: 2026 + input: + people: + spouse1: + age: 70 + social_security: 18_000 + bank_account_assets: 20_000 + spouse2: + age: 70 + social_security: 12_000 + bank_account_assets: 10_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + tax_units: + tax_unit: + members: [spouse1, spouse2] + spm_units: + spm_unit: + members: [spouse1, spouse2] + households: + household: + members: [spouse1, spouse2] + state_code: TX + output: + # Combined countable income (30,000 / 12 - 20) x 12 = 29,760 is over the + # couple QI limit (29,214) but under 32,460; combined resources $30,000 + # are under 33,100 + 3,000 = 36,100. + msp_eligible: [false, false] + part_d_lis_family_size: [2, 2] + part_d_lis_countable_income: [29_760, 29_760] + part_d_lis_income_eligible: [true, true] + part_d_lis_resource_eligible: [true, true] + part_d_lis_eligible: [true, true] + part_d_lis: [1_254, 1_254] + +- name: Not taking it up leaves an applicant-path eligible person without it. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + interest_income: 5_000 + bank_account_assets: 15_000 + takes_up_part_d_lis_if_eligible: false + households: + household: + members: [person] + state_code: TX + output: + part_d_lis_eligible: true + part_d_lis_enrolled: false + part_d_lis: 0 + +- name: 2023, a person in the former partial-subsidy range gets the average. + period: 2023 + input: + people: + person: + age: 70 + social_security: 20_500 + bank_account_assets: 12_000 + households: + household: + members: [person] + state_code: TX + output: + # Countable income (20,500 / 12 - 20) x 12 = 20,260: above 135% (19,683) + # and below 150% (21,870) of the 2023 guideline ($14,580). Resources + # $12,000 are above the 2023 full-subsidy limit (9,090 + 1,500 = 10,590) + # and within the partial limit (15,160 + 1,500 = 16,660). + part_d_lis_countable_income: 20_260 + part_d_lis_eligible: true + part_d_lis: 3_092 + +- name: Under-65 Medicare enrollee with a disability on SSI. + period: 2026 + input: + people: + person: + age: 50 + is_disabled: true + medicare_enrolled: true + ssi: 6_000 + bank_account_assets: 1_000 + households: + household: + members: [person] + state_code: TX + output: + part_d_lis_deemed_eligible: true + part_d_lis_enrolled: true + part_d_lis: 1_254 + +- name: Person aged 70 not enrolled in Medicare gets nothing. + period: 2026 + input: + people: + person: + age: 70 + medicare_enrolled: false + social_security: 10_000 + households: + household: + members: [person] + state_code: TX + output: + part_d_lis_eligible: false + part_d_lis: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.yaml new file mode 100644 index 00000000000..6b00b321bd7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis.yaml @@ -0,0 +1,60 @@ +# Each enrollee gets the Trustees' average subsidy per low-income subsidy +# enrollee (2026 report, Table IV.B9; 2015 from the 2025 report). + +- name: Enrollee in 2026. + period: 2026 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 1_254 + +- name: Enrollee in 2015. + period: 2015 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_112 + +- name: Enrollee in 2024, before the Inflation Reduction Act redesign. + period: 2024 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_833 + +- name: Enrollee in 2035, the last Trustees year. + period: 2035 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_076 + +- name: Not enrolled. + period: 2026 + input: + people: + person: + part_d_lis_enrolled: false + output: + part_d_lis: 0 + +- name: Two enrollees in one household get the same average. + period: 2026 + input: + people: + spouse1: + part_d_lis_enrolled: true + spouse2: + part_d_lis_enrolled: true + child: + part_d_lis_enrolled: false + output: + part_d_lis: [1_254, 1_254, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.yaml new file mode 100644 index 00000000000..9f76a8f12ab --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_deemed_eligible.yaml @@ -0,0 +1,47 @@ +# Full-benefit dual eligibles, SSI recipients and Medicare Savings Program +# enrollees are treated as eligible for the full subsidy (42 CFR +# 423.773(c)(1)). + +- name: Medicaid enrollee is deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: true + ssi: 0 + msp_eligible: false + output: + part_d_lis_deemed_eligible: true + +- name: SSI recipient is deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: false + ssi: 1_200 + msp_eligible: false + output: + part_d_lis_deemed_eligible: true + +- name: Medicare Savings Program eligible person is deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: false + ssi: 0 + msp_eligible: true + output: + part_d_lis_deemed_eligible: true + +- name: None of the three is not deemed eligible. + period: 2026 + input: + people: + person: + medicaid_enrolled: false + ssi: 0 + msp_eligible: false + output: + part_d_lis_deemed_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.yaml new file mode 100644 index 00000000000..aa46e6d9793 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_eligible.yaml @@ -0,0 +1,82 @@ +# Eligible: a Medicare enrollee in the 50 states or DC who is deemed eligible +# or meets both the income and resource tests (42 U.S.C. 1395w-114(a)(3)). + +- name: Meets the income and resource tests. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + output: + part_d_lis_eligible: true + +- name: Income eligible but resources over the limit. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: false + output: + part_d_lis_eligible: false + +- name: Deemed eligible with income and resources over the limits. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: true + part_d_lis_income_eligible: false + part_d_lis_resource_eligible: false + output: + part_d_lis_eligible: true + +- name: Not enrolled in Medicare. + period: 2026 + input: + people: + person: + medicare_enrolled: false + part_d_lis_deemed_eligible: true + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + output: + part_d_lis_eligible: false + +- name: Puerto Rico residents are not eligible. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: true + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + households: + household: + members: [person] + state_code: PR + output: + part_d_lis_eligible: false + +- name: District of Columbia residents are eligible. + period: 2026 + input: + people: + person: + medicare_enrolled: true + part_d_lis_deemed_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + households: + household: + members: [person] + state_code: DC + output: + part_d_lis_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.yaml new file mode 100644 index 00000000000..e86f88cc3d6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.yaml @@ -0,0 +1,61 @@ +# Medicaid enrollees and SSI recipients are enrolled without an application; +# other eligible people are enrolled if they take up the subsidy. + +- name: Medicaid enrollee who would not apply is enrolled anyway. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicaid_enrolled: true + ssi: 0 + takes_up_part_d_lis_if_eligible: false + output: + part_d_lis_enrolled: true + +- name: SSI recipient who would not apply is enrolled anyway. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicaid_enrolled: false + ssi: 1_200 + takes_up_part_d_lis_if_eligible: false + output: + part_d_lis_enrolled: true + +- name: Other eligible person who does not take it up. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicaid_enrolled: false + ssi: 0 + takes_up_part_d_lis_if_eligible: false + output: + part_d_lis_enrolled: false + +- name: Other eligible person who takes it up. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicaid_enrolled: false + ssi: 0 + takes_up_part_d_lis_if_eligible: true + output: + part_d_lis_enrolled: true + +- name: Medicaid enrollee who is not eligible (not in Medicare) is not enrolled. + period: 2026 + input: + people: + person: + part_d_lis_eligible: false + medicaid_enrolled: true + ssi: 0 + output: + part_d_lis_enrolled: false 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 d52fdf17f34..19c6830df95 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 @@ -59,6 +59,8 @@ reduced_price_school_meals: 1 spm_unit_energy_subsidy: 9 general_assistance: 6 + part_d_lis: 11 + msp: 100 wic: 100 head_start: 100 early_head_start: 100 @@ -66,7 +68,9 @@ household_state_benefits: 100 household_health_benefits: 100 output: - cbo_household_means_tested_transfers: 55 + # The Medicare Part D low-income subsidy counts; Medicare Savings + # Programs do not. + cbo_household_means_tested_transfers: 66 - name: CBO after-tax income uses federal taxes only and keeps negative income tax period: 2025 @@ -95,3 +99,30 @@ cbo_household_means_tested_transfers: 11_000 cbo_household_federal_taxes: 3_252 cbo_household_income_after_transfers_and_taxes: 45_085 + +- name: CBO income after transfers adds the Part D low-income subsidy + period: 2026 + input: + age: 70 + medicare_enrolled: true + social_security: 20_000 + interest_income: 5_000 + bank_account_assets: 15_000 + state_code: TX + medicaid: 0 + chip: 0 + snap: 0 + housing_assistance: 0 + ssi: 0 + tanf: 0 + free_school_meals: 0 + reduced_price_school_meals: 0 + spm_unit_energy_subsidy: 0 + general_assistance: 0 + output: + # Eligible through the subsidy's own tests (countable income 19,760 < + # 23,940, resources 15,000 <= 18,090), valued at the 2026 Trustees + # average of $1,254 per subsidy enrollee, the only means-tested + # transfer here. + part_d_lis: 1_254 + cbo_household_means_tested_transfers: 1_254 From 9a04c2025dfca20c25afa68a56f28cdc73727ce4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:33:50 -0400 Subject: [PATCH 3/3] Note CBO's institutional adjustment beside the Part D low-income subsidy average Co-Authored-By: Claude Opus 5.5 --- .../medicare/part_d/low_income_subsidy_per_recipient.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml index a5732d624e8..5e9211e701e 100644 --- a/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml +++ b/policyengine_us/parameters/calibration/gov/hhs/medicare/part_d/low_income_subsidy_per_recipient.yaml @@ -18,10 +18,18 @@ metadata: # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8; 2025 # report sha256 # 1b3de7a4faeee5ad42059e10740603182b716a5fa7b53b30d360b45b9e474d95. + # The average includes enrollees in institutions, whom CBO removes along + # with their benefits (Habib 2018, file page 52). CBO's adjusted 2015 + # totals, $22.6 billion for 10.7 million recipients (file page 51), give + # $2,112 per recipient, the same as this table's 2015 average; for 2016 + # CBO's $26.5 billion (from its own baseline) for 11.1 million gives + # $2,387 against $2,126 here. - title: 2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table IV.B9 href: https://www.cms.gov/oact/tr/2026#page=158 - title: 2025 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table IV.B9 href: https://www.cms.gov/oact/tr/2025#page=158 + - title: Habib, How CBO Adjusts for Survey Underreporting of Transfer Income in Its Distributional Analyses, CBO Working Paper 2018-07, Appendix C + href: https://www.cbo.gov/system/files/2018-07/54234-workingpaper.pdf#page=51 values: # Historical. 2015-01-01: 2_112