From b2f74b4d24a175c97c33b7847b1300f69cfc4cbe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:57:03 -0400 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 From ba6b915857358ae050797d6f703a688b390a5b5b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 18:02:59 -0500 Subject: [PATCH 04/11] Pay the Part D low-income subsidy only to Part D plan enrollees The subsidy pays part of a Part D plan's premium and cost sharing, so 42 U.S.C. 1395w-114(a)(3)(A)(i) defines a subsidy eligible individual as one enrolled in a prescription drug plan or MA-PD plan, and 20 CFR 418.3101(b) says an applicant "must enroll in one of these plans to actually receive a subsidy". medicare_part_d_enrolled reached main in #10115 after this branch began, so part_d_lis_enrolled now requires it, including for Medicaid enrollees and SSI recipients: Medicare enrolls them in a plan automatically (42 CFR 423.34), but they can opt out of Part D, for example for TRICARE for Life or VA drug coverage. takes_up_medicare_part_d_if_eligible defaults to true and Microcosm does not yet set it, so the microsimulation is unchanged; a household that sets it to false now gets no subsidy. The seeded household sample in test_part_d_lis.py now draws Part D enrollment and its restated rule requires it; the YAML tests add an opt-out case for each path. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_part_d_lis.py | 20 ++++++++++++---- .../low_income_subsidy/integration.yaml | 22 ++++++++++++++++++ .../part_d_lis_enrolled.yaml | 23 +++++++++++++++++-- .../low_income_subsidy/part_d_lis_enrolled.py | 12 ++++++---- .../takes_up_part_d_lis_if_eligible.py | 9 ++++---- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/policyengine_us/tests/core/test_part_d_lis.py b/policyengine_us/tests/core/test_part_d_lis.py index 632dacf8660..fe4ed04cea7 100644 --- a/policyengine_us/tests/core/test_part_d_lis.py +++ b/policyengine_us/tests/core/test_part_d_lis.py @@ -37,14 +37,18 @@ 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. +9. Eligibility equals the restated rule; enrollees are the eligible people + in a Part D plan who are on Medicaid or SSI or take the subsidy up; and + the benefit is either zero or the year's amount per recipient, positive + exactly for enrollees. 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). + both spouses are Medicare eligible). The sample's states keep SSI's $20 + general exclusion for the Medicare Savings Programs; Illinois and + Mississippi exclude more under 42 U.S.C. 1396a(r)(2), which the subsidy's + income definition sets aside. """ import numpy as np @@ -207,6 +211,7 @@ def person(): 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), + takes_up_medicare_part_d_if_eligible=bool(rng.random() < 0.85), ) out.append( @@ -392,7 +397,12 @@ def test_eligibility_and_benefit_match_restated_rules(sample): 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"]) + in_part_d = me["takes_up_medicare_part_d_if_eligible"] + enrolled = ( + eligible + and in_part_d + 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 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 index a424e9ad85a..83a388e39b9 100644 --- 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 @@ -130,6 +130,28 @@ part_d_lis_enrolled: false part_d_lis: 0 +- name: An eligible person outside Part D gets no subsidy. + period: 2026 + input: + people: + person: + age: 70 + social_security: 20_000 + bank_account_assets: 5_000 + # For example, drug coverage from TRICARE for Life instead. + takes_up_medicare_part_d_if_eligible: false + households: + household: + members: [person] + state_code: TX + output: + # Deemed eligible through a Medicare Savings Program, as in the first + # case, but the subsidy pays only for people in a Part D plan. + part_d_lis_eligible: true + medicare_part_d_enrolled: false + 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: 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 index e86f88cc3d6..bbef7210439 100644 --- 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 @@ -1,5 +1,6 @@ -# Medicaid enrollees and SSI recipients are enrolled without an application; -# other eligible people are enrolled if they take up the subsidy. +# The subsidy goes only to people in a Part D plan. 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 @@ -7,6 +8,7 @@ people: person: part_d_lis_eligible: true + medicare_part_d_enrolled: true medicaid_enrolled: true ssi: 0 takes_up_part_d_lis_if_eligible: false @@ -19,6 +21,7 @@ people: person: part_d_lis_eligible: true + medicare_part_d_enrolled: true medicaid_enrolled: false ssi: 1_200 takes_up_part_d_lis_if_eligible: false @@ -31,6 +34,7 @@ people: person: part_d_lis_eligible: true + medicare_part_d_enrolled: true medicaid_enrolled: false ssi: 0 takes_up_part_d_lis_if_eligible: false @@ -43,18 +47,33 @@ people: person: part_d_lis_eligible: true + medicare_part_d_enrolled: true medicaid_enrolled: false ssi: 0 takes_up_part_d_lis_if_eligible: true output: part_d_lis_enrolled: true +- name: Medicaid enrollee who opted out of Part D gets no subsidy. + period: 2026 + input: + people: + person: + part_d_lis_eligible: true + medicare_part_d_enrolled: false + medicaid_enrolled: true + ssi: 1_200 + takes_up_part_d_lis_if_eligible: true + output: + part_d_lis_enrolled: false + - name: Medicaid enrollee who is not eligible (not in Medicare) is not enrolled. period: 2026 input: people: person: part_d_lis_eligible: false + medicare_part_d_enrolled: true medicaid_enrolled: true ssi: 0 output: 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 index a1cadfb0488..8b23b2be7e2 100644 --- 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 @@ -6,12 +6,15 @@ class part_d_lis_enrolled(Variable): 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." + "Medicare pays the subsidy only for people enrolled in a Part D plan " + "(medicare_part_d_enrolled). It enrolls Medicaid enrollees and " + "Supplemental Security Income recipients in a plan with the subsidy " + "without an application, unless they opt out of Part D. Other " + "eligible people in a Part D plan receive it if they take it up." ) definition_period = YEAR reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_A", "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", @@ -19,7 +22,8 @@ class part_d_lis_enrolled(Variable): defined_for = "part_d_lis_eligible" def formula(person, period, parameters): + in_part_d = person("medicare_part_d_enrolled", period) 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 + return in_part_d & (medicaid | ssi | takes_up) 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 index f1a359189e5..adad6dfb5a3 100644 --- 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 @@ -9,10 +9,11 @@ class takes_up_part_d_lis_if_eligible(Variable): ) 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." + "Supplemental Security Income recipient applies for the subsidy. " + "Medicare Savings Program enrollees are deemed eligible, but the " + "model has Medicare Savings Program eligibility, not enrollment, so " + "this flag covers them too. The subsidy also requires enrollment in " + "a Part D plan (medicare_part_d_enrolled)." ) definition_period = YEAR default_value = True From e4153928add0ad77aa321e850f5a7c0baeec05a1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 18:03:09 -0500 Subject: [PATCH 05/11] Start the subsidy average in 2013, like the per-enrollee series #10118 extended calibration.gov.hhs.medicare.part_d_low_income_subsidy_ per_enrollee and the other Medicare per-enrollee series back to 2013, the first year of per_capita_cost. The per-recipient average started in 2015, so part_d_lis raised ParameterNotFoundError for a 2013 or 2014 enrollee. The 2023 Trustees Report's Table IV.B9 (file page 158), the newest that shows both years, gives $2,023 per subsidy enrollee in 2013 (11.5 million enrollees) and $2,052 in 2014 (11.8 million); its Table IV.B10 gives $23.2 billion and $24.3 billion, which the products match within rounding, and the 2021 report's Table V.B3 enrollment (52,504 and 54,115 thousand) turns them into #10118's per-enrollee values within 0.4%. The invariant tests now cover both years. Before 2015, cbo_household_means_tested_transfers still raises ParameterNotFoundError through Medicaid and SNAP parameters, as it does without this change. The 2026 report's reference now lists both cited pages (119 and 158), and the Habib reference both of its pages (51 and 52), per the repository's reference rules. Co-Authored-By: Claude Opus 5.5 --- .../low_income_subsidy_per_recipient.yaml | 22 ++++++++++----- policyengine_us/tests/core/test_part_d_lis.py | 28 +++++++++++++------ .../part_d/low_income_subsidy/part_d_lis.yaml | 21 +++++++++++++- 3 files changed, 54 insertions(+), 17 deletions(-) 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 5e9211e701e..8ba1270ee3d 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 @@ -12,26 +12,34 @@ metadata: # 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 + # table no longer shows, from the 2025 report (file page 158); 2013 and + # 2014 from the 2023 report (file page 158), the newest that shows both, + # as for the per-enrollee series beside this one. The 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. + # 1b3de7a4faeee5ad42059e10740603182b716a5fa7b53b30d360b45b9e474d95; 2023 + # report retrieved 2026-11-07, sha256 + # 89daaf2909ae18648783f652c05114d007c5f902cc5fa5fad0f76d43f6ccfa67. # 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: "2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Table IV.B9 #page 119,158" + href: https://www.cms.gov/oact/tr/2026#page=119 - 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 + - title: 2023 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/2023#page=158 + - title: "Habib, How CBO Adjusts for Survey Underreporting of Transfer Income in Its Distributional Analyses, CBO Working Paper 2018-07, Appendix C #page 51-52" href: https://www.cbo.gov/system/files/2018-07/54234-workingpaper.pdf#page=51 values: # Historical. + 2013-01-01: 2_023 + 2014-01-01: 2_052 2015-01-01: 2_112 2016-01-01: 2_126 2017-01-01: 2_156 diff --git a/policyengine_us/tests/core/test_part_d_lis.py b/policyengine_us/tests/core/test_part_d_lis.py index fe4ed04cea7..3625a75940d 100644 --- a/policyengine_us/tests/core/test_part_d_lis.py +++ b/policyengine_us/tests/core/test_part_d_lis.py @@ -11,7 +11,8 @@ Parameters: 1. Transcription: ``low_income_subsidy_per_recipient`` equals Table IV.B9's - "Subsidy amount" (2026 report; the 2025 report for 2015). + "Subsidy amount" (2026 report; the 2025 report for 2015; the 2023 report + for 2013 and 2014). 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 @@ -23,8 +24,8 @@ 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. + Medicare spending per enrollee in every year from 2013, the spending + series' first, to 2100, and the limits never fall. Households (a seeded sample in one shared simulation, each household also run with more Social Security, more interest and more assets): @@ -65,8 +66,12 @@ # 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). +# 2015 from the 2025 report (sha256 1b3de7a4...4d95), Table IV.B9 (page 158); +# 2013 and 2014 from the 2023 report (sha256 89daaf29...fa67), Table IV.B9 +# (page 158). IV_B9 = { + 2013: (11.5, 2_023), + 2014: (11.8, 2_052), 2015: (12.1, 2_112), 2016: (12.4, 2_126), 2017: (12.7, 2_156), @@ -90,18 +95,23 @@ 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). +# 2015 from the 2025 report's Table IV.B10 (page 159), 2013 and 2014 from the +# 2023 report's (page 159). IV_B10 = { + **{2013: 23.2, 2014: 24.3}, **{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. +# Table V.B3 (file page 196), total enrollment (HI and/or SMI), thousands; +# 2013 and 2014 from the 2021 report's Table V.B3 (sha256 ef3d1fa7...c8e, +# page 199), which the 2022 to 2026 reports drop. V_B3_TOTAL = dict( zip( - range(2015, 2036), - (55_589, 57_073, 58_683, 60_020, 61_535, 62_887, 63_980, 65_166) + range(2013, 2036), + (52_504, 54_115) + + (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), ) @@ -170,7 +180,7 @@ def test_resource_limits_follow_the_cpi_rule(kind): assert limit(f"{year}-01-01") == expected, year -@given(st.integers(2015, 2100)) +@given(st.integers(2013, 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") 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 index 6b00b321bd7..2402f73921b 100644 --- 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 @@ -1,5 +1,6 @@ # Each enrollee gets the Trustees' average subsidy per low-income subsidy -# enrollee (2026 report, Table IV.B9; 2015 from the 2025 report). +# enrollee (2026 report, Table IV.B9; 2015 from the 2025 report; 2013 and +# 2014 from the 2023 report). - name: Enrollee in 2026. period: 2026 @@ -19,6 +20,24 @@ output: part_d_lis: 2_112 +- name: Enrollee in 2013, the first year of the Medicare spending series. + period: 2013 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_023 + +- name: Enrollee in 2014. + period: 2014 + input: + people: + person: + part_d_lis_enrolled: true + output: + part_d_lis: 2_052 + - name: Enrollee in 2024, before the Inflation Reduction Act redesign. period: 2024 input: From ff54d4a067f6ac3054fe11d8be7e3aac0151ec38 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 01:05:59 -0500 Subject: [PATCH 06/11] Address the r2 law and code reviews (work in progress) - Index the resource limits after 2026 by the unrounded September CPI-U ratio (42 U.S.C. 1395w-114(a)(3)(E)(i)), and add September 2026's CPI-U. - Follow the statute's chain for the couple limit from 2015, entered as a source-conflict candidate against CMS's 2015 memo. - Count non-home real estate for the household head's couple, floor each income source at zero, count both returns' dependents for spouses who file separately, and document the auto-enrollment data contract. - Fix the per-recipient and jurisdiction citations. Co-Authored-By: Claude Opus 5.5 --- .../low_income_subsidy_per_recipient.yaml | 13 ++- .../gov/bls/cpi/cpi_u_nsa_september.yaml | 3 +- .../eligibility/ineligible_jurisdictions.yaml | 2 +- .../resources/household_sources.yaml | 29 +++++ .../eligibility/resources/limit/couple.yaml | 43 +++++-- .../resources/limit/individual.yaml | 13 ++- .../eligibility/resources/sources.yaml | 5 +- .../parameters/uprating_extensions.py | 105 +++++++++++++++--- .../part_d/low_income_subsidy/part_d_lis.py | 17 +-- .../part_d_lis_countable_income.py | 36 ++++-- .../low_income_subsidy/part_d_lis_enrolled.py | 18 ++- .../part_d_lis_family_size.py | 12 +- .../part_d_lis_resource_eligible.py | 24 +++- .../takes_up_part_d_lis_if_eligible.py | 5 +- source_conflicts/candidates.yaml | 41 +++++++ 15 files changed, 303 insertions(+), 63 deletions(-) create mode 100644 policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/household_sources.yaml 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 8ba1270ee3d..3fa0e50fd86 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 @@ -14,9 +14,10 @@ metadata: # 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); 2013 and # 2014 from the 2023 report (file page 158), the newest that shows both, - # as for the per-enrollee series beside this one. The 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 + # as for the per-enrollee series beside this one. The 2026 report moves + # 2021 to 2023 by $1 to $2 from the 2025 report, and 2024 by $15 ($2,848 + # in the 2025 report, $2,833 in the 2026 report). 2026 Medicare Trustees + # Report, transmitted 2026-06-09, retrieved 2026-10-11, sha256 # ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8; 2025 # report sha256 # 1b3de7a4faeee5ad42059e10740603182b716a5fa7b53b30d360b45b9e474d95; 2023 @@ -28,7 +29,7 @@ metadata: # $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 #page 119,158" + - title: "2026 Annual Report of the Boards of Trustees of the Federal Hospital Insurance and Federal Supplementary Medical Insurance Trust Funds, Tables IV.B9 and IV.B10 #page 119,158-159" href: https://www.cms.gov/oact/tr/2026#page=119 - 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 @@ -51,8 +52,8 @@ values: 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). + # redesign (2026 report, file page 119). The average falls from $2,833 in + # 2024 to $1,226 in 2025; the report gives no cause. 2025-01-01: 1_226 # Trustees' intermediate estimates. 2026-01-01: 1_254 diff --git a/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml b/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml index 738737b408f..555a2337d3e 100644 --- a/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml +++ b/policyengine_us/parameters/gov/bls/cpi/cpi_u_nsa_september.yaml @@ -1,4 +1,4 @@ -description: The Bureau of Labor Statistics publishes this September value of the Consumer Price Index for All Urban Consumers, not seasonally adjusted, from which CMS takes the September-to-September percentage change it applies to the Medicaid long-term care home equity limits. +description: The Bureau of Labor Statistics publishes this September value of the Consumer Price Index for All Urban Consumers, not seasonally adjusted, from which CMS takes the September-to-September change it applies to the Medicaid long-term care home equity limits and the Medicare Part D low-income subsidy resource limits. values: # BLS series CUUR0000SA0 (U.S. city average, all items, 1982-84=100). 2018-09-01: 252.439 @@ -9,6 +9,7 @@ values: 2023-09-01: 307.789 2024-09-01: 315.301 2025-09-01: 324.8 + 2026-09-01: 336.94 metadata: unit: currency-USD label: CPI-U, not seasonally adjusted, September 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 index 150028aac86..5dafce4b320 100644 --- 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 @@ -22,4 +22,4 @@ metadata: - 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) + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3010#p-418.3010(b)(11) diff --git a/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/household_sources.yaml b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/household_sources.yaml new file mode 100644 index 00000000000..e7320981b42 --- /dev/null +++ b/policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/household_sources.yaml @@ -0,0 +1,29 @@ +description: Medicare counts these household assets as resources of the household head and spouse under the Part D low-income subsidy. +values: + 2006-01-01: + - household_other_real_estate_equity + +metadata: + unit: list + period: year + label: Medicare Part D low-income subsidy countable household resource sources + reference: + # Equity in real property other than the home counts, whether or not it + # can be sold within 20 workdays. The model holds real estate equity by + # household, so the formula counts it for the household head's couple. + # Rental property equity (household_rental_property_equity) is left out: + # property essential to self-support is excluded (418.3425(c) and (d)), + # rental property used in a trade or business in full and other + # income-producing property up to $6,000 of equity when it returns 6% a + # year (POMS SI 01130.501 and SI 01130.500), and the model can't tell + # which applies. + - title: 20 CFR § 418.3405(b) - What types of resources do we count? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3405#p-418.3405(b) + - title: 20 CFR § 418.3425 - What resources do we exclude from counting? + href: https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3425 + - 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 + - title: SSA POMS SI 01130.501 - Essential Property Excluded Regardless of Value or Rate of Return + href: https://secure.ssa.gov/poms.nsf/lnx/0501130501 + - title: SSA POMS SI 01130.500 - Property Essential to Self-Support - Overview + href: https://secure.ssa.gov/poms.nsf/lnx/0501130500 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 index fd07ec6bf55..454dfc127cb 100644 --- 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 @@ -8,22 +8,31 @@ values: 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 + # From 2015 the statute's formula, which runs $10 to $20 above SSA's + # published amounts (see the note under reference). + # Candidate: us:part-d-lis-couple-resource-limit-2015 until registered. + 2015-01-01: 24_260 + 2017-01-01: 24_620 + 2018-01-01: 25_170 + 2019-01-01: 25_740 + 2020-01-01: 26_180 + 2021-01-01: 26_540 + 2022-01-01: 27_970 + 2023-01-01: 30_260 + 2024-01-01: 31_380 + 2025-01-01: 32_150 + 2026-01-01: 33_120 metadata: unit: currency-USD period: year label: Medicare Part D low-income subsidy couple resource limit + # Years after the last value here are projected in + # parameters/uprating_extensions.py (extend_part_d_lis_resource_limits): the + # previous year's limit times the change in the not seasonally adjusted + # CPI-U from September two years before to September one year before, + # never lowered, rounded to the nearest $10, effective January 1. The + # published September 2026 index gives $34,360 for 2027. 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 @@ -32,9 +41,21 @@ metadata: # 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. + # + # Through 2014 these are SSA's amounts. For 2015, CMS's memo multiplies + # the 2014 limit of $23,860 by 1.01658 (238.031 / 234.149) and rounds to + # $10, which gives $24,260 ($24,255.58 unrounded), but it prints $24,250, + # and SSA has raised its own previous amount each year since. The values + # here apply the formula to the previous year's limit as the statute + # sets it, so they are $10 above SSA's in 2015 and 2016 and $20 above + # from 2017 ($33,120 against $33,100 in 2026). - 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 + - title: CMS, 2015 Medicare Part D Low-Income Subsidy (LIS) Income and Resource Standards, Attachment 4 + href: https://www.cms.gov/files/document/2015-lis-income-and-resourcespdf#page=5 + - title: CMS, Calendar Year (CY) 2026 Resource and Cost-Sharing Limits for Low-Income Subsidy (LIS), October 31, 2025 + href: https://www.cms.gov/files/document/cy2026-lis-resource-limits-memo.pdf#page=2 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 index 54fc4010afd..b24f6e8167c 100644 --- 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 @@ -1,4 +1,4 @@ -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. +description: Medicare limits the Part D low-income subsidy to people who are not married and living with a spouse whose countable resources do not exceed this amount, before the burial funds exclusion. values: 2006-01-01: 10_000 2007-01-01: 10_210 @@ -24,6 +24,12 @@ metadata: unit: currency-USD period: year label: Medicare Part D low-income subsidy individual resource limit + # Years after the last value here are projected in + # parameters/uprating_extensions.py (extend_part_d_lis_resource_limits): the + # previous year's limit times the change in the not seasonally adjusted + # CPI-U from September two years before to September one year before, + # never lowered, rounded to the nearest $10, effective January 1. The + # published September 2026 index gives $17,210 for 2027. 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 @@ -31,10 +37,13 @@ metadata: # 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. + # parameter. A person who is married but separated from their spouse + # counts only their own resources against this limit (20 CFR 418.3410(b)). - 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 + - title: CMS, Calendar Year (CY) 2026 Resource and Cost-Sharing Limits for Low-Income Subsidy (LIS), October 31, 2025 + href: https://www.cms.gov/files/document/cy2026-lis-resource-limits-memo.pdf#page=2 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 index e85f02bcbf8..f427dbed12a 100644 --- 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 @@ -12,8 +12,9 @@ metadata: 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. + # property other than the home. The model has no retirement account + # balances. Real estate is a household amount in the model, so it is in + # household_sources.yaml. - 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) diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index 37caa4f6256..c8c57ed895a 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -575,6 +575,28 @@ def extend_la_cpi_u_indexed_amounts(parameters: ParameterNode, end_year: int) -> ) +def get_september_cpi_u_values(cpi: ParameterNode, year: int) -> Tuple[float, float]: + """The CPI-U for September two years and one year before ``year``. + + CMS indexes the Medicaid home equity limits and the Part D low-income + subsidy resource limits for ``year`` by the change from September of + ``year - 2`` to September of ``year - 1`` in the not seasonally adjusted + CPI-U. ``cpi`` is the ``gov.bls.cpi`` node. The published Septembers in + ``cpi_u_nsa_september`` are used when both are available. Otherwise both + Septembers come from the model's CPI-U series (seasonally adjusted from + January 2024) through ``get_projected_cpi_u_for_month``, so the two are + never mixed. Returns ``(prior September, later September)``. + """ + september = cpi.cpi_u_nsa_september + published = {int(value.instant_str[:4]) for value in september.values_list} + if {year - 2, year - 1} <= published: + return september(f"{year - 2}-09-01"), september(f"{year - 1}-09-01") + return ( + get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 2}-09-01")), + get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 1}-09-01")), + ) + + def get_cms_cpi_u_percentage_increase(cpi: ParameterNode, year: int) -> float: """The CPI-U percentage increase CMS applies to ``year``'s home equity limits. @@ -591,20 +613,10 @@ def get_cms_cpi_u_percentage_increase(cpi: ParameterNode, year: int) -> float: A fall in the index is not an increase, so the percentage is never negative. - ``cpi`` is the ``gov.bls.cpi`` node. The published Septembers in - ``cpi_u_nsa_september`` are used when both are available. Otherwise both - Septembers come from the model's CPI-U series (seasonally adjusted from - January 2024) through ``get_projected_cpi_u_for_month``, so the two are - never mixed. + ``cpi`` is the ``gov.bls.cpi`` node; see ``get_september_cpi_u_values`` + for where the Septembers come from. """ - september = cpi.cpi_u_nsa_september - published = {int(value.instant_str[:4]) for value in september.values_list} - if {year - 2, year - 1} <= published: - current = september(f"{year - 1}-09-01") - prior = september(f"{year - 2}-09-01") - else: - current = get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 1}-09-01")) - prior = get_projected_cpi_u_for_month(cpi.cpi_u, instant(f"{year - 2}-09-01")) + prior, current = get_september_cpi_u_values(cpi, year) change = 100 * (Decimal(repr(current)) / Decimal(repr(prior)) - 1) percentage = change.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) return float(max(percentage, 0)) @@ -674,6 +686,67 @@ def extend_medicaid_home_equity_limits( ) +def adjust_part_d_lis_resource_limit_for_inflation( + prior_limit: float, prior_september: float, current_september: float +) -> float: + """Raise a Part D low-income subsidy resource limit for one year. + + 42 U.S.C. 1395w-114(a)(3)(E)(i)(II) and 42 CFR 423.773(d)(2)(ii) increase + the previous year's limit "by the annual percentage increase in the + consumer price index (all items, U.S. city average) as of September of + the previous year, rounded to the nearest multiple of $10". The increase + is the unrounded ratio of the two Septembers. CMS's CY 2026 memo reports + it as 3.01% (324.800 / 315.301), but the published $16,590 is $16,100 + times the unrounded 1.030127 ($16,585.04), where 1.0301 would give + $16,584.61 and so $16,580. A fall in the index is not an increase, so the + limit never falls: SSA kept the 2009 and 2015 limits for 2010 and 2016. + The arithmetic is decimal, with halves rounding up, so a product that + ends in exactly $5 rounds up as it does on paper. + """ + ratio = max( + Decimal(str(float(current_september))) / Decimal(str(float(prior_september))), + 1, + ) + adjusted = Decimal(str(float(prior_limit))) * ratio + tens = (adjusted / 10).quantize(Decimal(1), rounding=ROUND_HALF_UP) + return float(tens * 10) + + +def extend_part_d_lis_resource_limits(parameters: ParameterNode, end_year: int) -> None: + """Project the CPI-indexed Part D low-income subsidy resource limits. + + Each year after the last value in the YAML, the individual and couple + limits each become the previous year's limit raised by the change from + September two years before to September one year before (see + ``get_september_cpi_u_values``), never lowered, and rounded to the + nearest $10 (see ``adjust_part_d_lis_resource_limit_for_inflation``), + effective January 1. Amounts encoded in the YAML take precedence for + their own years. + + Like the other projections in this module, the values are fixed when the + parameter tree is built: a reform that changes one year's limit does not + carry through to later years. + """ + limits = parameters.gov.hhs.medicare.part_d.low_income_subsidy.eligibility.resources.limit + cpi = parameters.gov.bls.cpi + for limit in (limits.individual, limits.couple): + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in limit.values_list + ) + for year in range(first_projected_year, end_year + 1): + prior_september, current_september = get_september_cpi_u_values(cpi, year) + limit.update( + period=f"year:{year}-01-01:1", + value=adjust_part_d_lis_resource_limit_for_inflation( + limit(f"{year - 1}-01-01"), prior_september, current_september + ), + ) + limit.update( + start=instant(f"{end_year}-01-01"), + value=limit(f"{end_year}-01-01"), + ) + + def extend_wa_millionaires_standard_deduction( parameters: ParameterNode, end_year: int, @@ -1222,6 +1295,12 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: # Medicaid long-term care home equity limits, CMS's rounded CPI-U method extend_medicaid_home_equity_limits(parameters, end_year=END_YEAR) + # Part D low-income subsidy resource limits, raised by the unrounded + # September-to-September CPI-U change under 42 U.S.C. + # 1395w-114(a)(3)(E)(i). Must run after the CPI-U extension above so + # projected Septembers are available. + extend_part_d_lis_resource_limits(parameters, end_year=END_YEAR) + # The limits on a dependent's standard deduction follow 26 U.S.C. # 63(c)(4)'s chained CPI-U schedule, computed from the statutory 1987 and # 1997 bases. Must run after the Chained CPI-U extension above so 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 index aa3d928ddea..b9cd12f3fbc 100644 --- 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 @@ -6,11 +6,15 @@ class part_d_lis(Variable): 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." + "Value of the Part D low-income subsidy (Extra Help): the Medicare " + "Trustees' average subsidy payment per subsidy enrollee, the same for " + "every enrollee, which is how the Congressional Budget Office values " + "it. Most of the subsidy pays cost sharing, which depends on each " + "enrollee's drug use, which the model does not have. Recipients " + "differ from CBO's: CBO draws them at random from the eligible " + "population until they match the administrative count, while the " + "model pays every eligible person in a Part D plan who is on " + "Medicaid or SSI or takes the subsidy up." ) unit = USD definition_period = YEAR @@ -24,5 +28,4 @@ class part_d_lis(Variable): 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 + return person.filled_array(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 index 686665bd8a5..d8c0e1faf92 100644 --- 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 @@ -12,9 +12,14 @@ class part_d_lis_countable_income(Variable): "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." + "maintenance or interest and dividends. Each unearned source counts " + "for each person at no less than zero, so a rental loss does not " + "offset other income. The $20 general, $65 earned and one-half " + "earned income exclusions apply once to the combined income. Not " + "modeled: the Social Security cost-of-living increase, which SSA " + "leaves out through the month after the new poverty guidelines are " + "published (20 CFR 418.3340(f)); the model counts the year's " + "benefits in full." ) unit = USD definition_period = YEAR @@ -23,18 +28,33 @@ class part_d_lis_countable_income(Variable): "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.3335", + "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3340#p-418.3340(f)", "https://www.ecfr.gov/current/title-20/chapter-III/part-418/subpart-D/section-418.3350", "https://secure.ssa.gov/poms.nsf/lnx/0603020050", + "https://secure.ssa.gov/poms.nsf/lnx/0500830505", ) def formula(person, period, parameters): - p = parameters(period).gov.hhs.medicare.part_d.low_income_subsidy.income + p = parameters(period) + excluded = p.gov.hhs.medicare.part_d.low_income_subsidy.income.excluded_sources + # SSI's unearned income sources without the ones the subsidy excludes, + # so a change to SSI's list carries through and an excluded source is + # never subtracted when SSI no longer counts it. + sources = [ + source + for source in p.gov.ssa.ssi.income.sources.unearned + if source not in excluded + ] + # SSI counts net rental income, and expenses above the rent do not + # offset other income (POMS SI 00830.505), so no source counts below + # zero, for either spouse. + unearned = person.filled_array(0.0) + for source in sources: + unearned = unearned + max_(person(source, period), 0) 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) + couple_unearned = person.marital_unit.sum(unearned) 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_enrolled.py b/policyengine_us/variables/gov/hhs/medicare/part_d/low_income_subsidy/part_d_lis_enrolled.py index 8b23b2be7e2..c85ec1c9080 100644 --- 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 @@ -7,17 +7,25 @@ class part_d_lis_enrolled(Variable): label = "Enrolled in the Medicare Part D low-income subsidy" documentation = ( "Medicare pays the subsidy only for people enrolled in a Part D plan " - "(medicare_part_d_enrolled). It enrolls Medicaid enrollees and " - "Supplemental Security Income recipients in a plan with the subsidy " - "without an application, unless they opt out of Part D. Other " - "eligible people in a Part D plan receive it if they take it up." + "(medicare_part_d_enrolled). Medicaid enrollees and Supplemental " + "Security Income recipients get the subsidy without applying; other " + "eligible people in a plan get it if they take it up. CMS enrolls " + "subsidy-eligible people who don't choose a plan, unless they " + "decline Part D or are qualifying covered retirees whose employer " + "gets the retiree drug subsidy (42 CFR 423.34(d) and (e)). So " + "microdata that set takes_up_medicare_part_d_if_eligible should set " + "it true for Medicaid enrollees and SSI recipients other than those " + "two groups; a person whose Part D input is false gets no subsidy. " + "Not modeled: people deemed eligible from July to December stay " + "deemed eligible through the following year (423.773(c)(2)(ii)); the " + "model tests each year on its own." ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_A", "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", + "https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-B/section-423.34#p-423.34(d)", ) defined_for = "part_d_lis_eligible" 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 index 9e5a9bf72b8..c33a393542e 100644 --- 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 @@ -8,8 +8,9 @@ class part_d_lis_family_size(Variable): 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." + "support, approximated by the dependents on the couple's tax " + "returns: a joint return's dependents count once, and when the " + "spouses file separately, the dependents on both returns count." ) definition_period = YEAR reference = ( @@ -19,6 +20,9 @@ class part_d_lis_family_size(Variable): def formula(person, period, parameters): couple_size = person.marital_unit.nb_persons() - head_or_spouse = person("is_tax_unit_head_or_spouse", period) + # Each tax unit's dependents count once, through the unit's head: on a + # joint return the couple has one head, and spouses who file + # separately each head their own return. + head = person("is_tax_unit_head", period) dependents = person.tax_unit("tax_unit_dependents", period) - return couple_size + where(head_or_spouse, dependents, 0) + return couple_size + person.marital_unit.sum(where(head, dependents, 0)) 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 index fe7e0580493..16f82e95acc 100644 --- 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 @@ -8,12 +8,15 @@ class part_d_lis_resource_eligible(Variable): 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." + "exclusion for each of them. Resources are the couple's financial " + "assets and, for the household head's couple, the household's " + "equity in real estate other than the home." ) 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.3405", "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", @@ -23,7 +26,24 @@ 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)) + own = add(person, period, p.sources) + # A household amount does not say whose it is. It counts in full for + # the marital unit of the household head (the oldest member when no + # head is designated), so it counts for one couple. + is_household_head = person("is_household_head", period) + has_household_head = person.household.any(is_household_head) + age = person("age", period) + holds_household_amount = where( + has_household_head, + person.get_rank(person.household, -age, condition=is_household_head) == 0, + person.get_rank(person.household, -age) == 0, + ) + household_amount = person.filled_array(0.0) + for source in p.household_sources: + household_amount = household_amount + person.household(source, period) + resources = person.marital_unit.sum( + own + holds_household_amount * household_amount + ) 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 index adad6dfb5a3..3d4e8172bd7 100644 --- 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 @@ -13,7 +13,10 @@ class takes_up_part_d_lis_if_eligible(Variable): "Medicare Savings Program enrollees are deemed eligible, but the " "model has Medicare Savings Program eligibility, not enrollment, so " "this flag covers them too. The subsidy also requires enrollment in " - "a Part D plan (medicare_part_d_enrolled)." + "a Part D plan (medicare_part_d_enrolled), which CMS arranges for " + "subsidy-eligible people who don't choose one unless they decline " + "(42 CFR 423.34). The default, true, takes everyone up; microdata " + "can set a take-up rate." ) definition_period = YEAR default_value = True diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 6b1dbcbedc4..1c00c91928e 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -11236,6 +11236,47 @@ candidates: state: unreviewed note: 'Main follows the form-line omission; the statute-first rule requires the statutory add-back proposed in the open PR. Code not yet on main: excess_business_loss, net_investment_income_excess_business_loss, gov.irs.investment.income.sources.' +- id: us:part-d-lis-couple-resource-limit-2015 + program: medicare-part-d-low-income-subsidy + years: + from: 2015 + type: calendar + question: Is the 2015 couple resource limit for the Part D low-income subsidy $24,260, the statute's formula applied + to the 2014 limit, or the $24,250 that CMS and SSA published; and does each later limit build on the formula's + amount or on the published one? + sources: + - kind: statute + citation: 42 U.S.C. 1395w-114(a)(3)(E)(i) + url: https://www.law.cornell.edu/uscode/text/42/1395w-114#a_3_E_i + - kind: regulation + citation: 42 CFR 423.773(d)(2)(ii) + url: https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/part-423/subpart-P/section-423.773#p-423.773(d)(2)(ii) + - kind: guidance + citation: CMS, 2015 Medicare Part D Low-Income Subsidy (LIS) Income and Resource Standards, Attachment 4, PDF pp. 5-6 + url: https://www.cms.gov/files/document/2015-lis-income-and-resourcespdf#page=5 + - kind: manual + citation: SSA POMS HI 03030.025, resource limits for prior years + url: https://secure.ssa.gov/poms.nsf/lnx/0603030025 + readings: + statute: The 2014 limit of $23,860 increased by the September 2013 to September 2014 CPI-U change (238.031 / + 234.149) is $24,255.58, which rounds to $24,260. Each later year raises the previous year's limit as the formula + sets it, giving $24,620 in 2017 and $33,120 in 2026. + other: CMS's memo gives the 1.01658 factor and the rounding to $10 but prints $24,250, and POMS lists $24,250 for + 2015 and 2016. SSA raises its own published amount each later year, giving $24,600 in 2017 and $33,100 in 2026. + example: In 2026 a married couple with $36,110 of countable resources and the burial funds exclusion meets the + statutory limit of $33,120 plus $3,000 but not SSA's $33,100 plus $3,000. + pe_us: + follows: statute + code: + - policyengine_us/parameters/gov/hhs/medicare/part_d/low_income_subsidy/eligibility/resources/limit/couple.yaml + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10127 + state: ruled + note: Every other higher (partial subsidy, then full subsidy) limit SSA published from 2007 to 2026 equals the + formula applied to the previous published limit, and the memo's 1.01658 factor reproduces its other 2015 amounts ($7,280, $10,930 and + $12,140), so the $24,250 is the one cell the stated method doesn't produce. The model applies the formula as the + statute and 423.773(d)(2)(ii) state it, from the statute's 2006 amount of $20,000. The difference reaches only + couples whose countable resources fall within $10 to $20 of the limit. - id: us:recovery-rebate-joint-claimable-spouse program: recovery-rebate-credit years: From 33cc03d7f7f43a8aed0a87fbd77b274a89fbfdcb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 01:24:24 -0500 Subject: [PATCH 07/11] Count the subsidy as a health benefit and test the review fixes - Add part_d_lis next to msp_cost in household_health_benefits and healthcare_benefit_value, which count only when include_health_benefits_in_net_income is on. - YAML cases for the indexed 2027 limits, the statutory couple limit, non-home real estate and its attribution, rental losses, separate filers' family size and the Part D input of Medicaid enrollees. - Python tests: the CPI rule through 2027 with no published exception, projected years and a synthetic tree for the extension, exhaustive loops for the bounds, and a sample with separate filers, rental losses, real estate and households on each limit, plus a reach check. - Move the registry entry to the HHS block and fix its notes. - Read only September instants as published CPI-U values. Co-Authored-By: Claude Opus 5.5 --- changelog.d/medicare-part-d-lis.added.md | 2 +- .../household/household_health_benefits.yaml | 1 + .../parameters/uprating_extensions.py | 8 +- policyengine_us/programs.yaml | 39 +- policyengine_us/tests/core/test_part_d_lis.py | 544 ++++++++++++++---- .../low_income_subsidy/integration.yaml | 134 ++++- .../part_d_lis_countable_income.yaml | 40 ++ .../part_d_lis_enrolled.yaml | 18 + .../part_d_lis_family_size.yaml | 54 ++ .../part_d_lis_resource_eligible.yaml | 98 +++- .../household/healthcare_benefit_value.yaml | 10 + .../household/household_health_benefits.yaml | 24 + .../household/healthcare_benefit_value.py | 5 +- 13 files changed, 842 insertions(+), 135 deletions(-) diff --git a/changelog.d/medicare-part-d-lis.added.md b/changelog.d/medicare-part-d-lis.added.md index b37702d66bd..52e90cc1e3a 100644 --- a/changelog.d/medicare-part-d-lis.added.md +++ b/changelog.d/medicare-part-d-lis.added.md @@ -1 +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. +Model the Medicare Part D low-income subsidy (Extra Help): statutory eligibility, with CPI-indexed resource limits, valued at the Trustees' average subsidy per subsidy enrollee for people in a Part D plan; count it as a CBO means-tested transfer and a household health benefit. diff --git a/policyengine_us/parameters/gov/household/household_health_benefits.yaml b/policyengine_us/parameters/gov/household/household_health_benefits.yaml index ba051538c04..6943f21e6cb 100644 --- a/policyengine_us/parameters/gov/household/household_health_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_health_benefits.yaml @@ -3,6 +3,7 @@ values: 2022-01-01: - medicaid_cost - msp_cost + - part_d_lis - chip - assigned_aca_ptc - assigned_ca_premium_subsidy diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index c8c57ed895a..41bfb651905 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -588,7 +588,13 @@ def get_september_cpi_u_values(cpi: ParameterNode, year: int) -> Tuple[float, fl never mixed. Returns ``(prior September, later September)``. """ september = cpi.cpi_u_nsa_september - published = {int(value.instant_str[:4]) for value in september.values_list} + # Only September instants are BLS values; the parameter tree also + # backdates the first value to 2015-01-01. + published = { + int(value.instant_str[:4]) + for value in september.values_list + if value.instant_str.endswith("-09-01") + } if {year - 2, year - 1} <= published: return september(f"{year - 2}-09-01"), september(f"{year - 1}-09-01") return ( diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index f2c2728175a..c5a9a1e6ca8 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -383,6 +383,28 @@ programs: parameter_prefix: gov.aca verified_start_year: 2024 + - 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 and resources within the CPI-indexed limits, or deemed + eligibility for Medicaid enrollees, SSI recipients and people eligible + for a Medicare Savings Program) and pays the subsidy only to people in + a Part D plan: Medicaid enrollees and SSI recipients get it without + applying, and other eligible people take it up through + takes_up_part_d_lis_if_eligible, which defaults to true. Each enrollee + gets the Medicare Trustees' average payment per subsidy enrollee, the + amount CBO assigns each recipient; the premium and cost-sharing + subsidies are not computed per person. + - id: tanf name: TANF full_name: Temporary Assistance for Needy Families @@ -1718,23 +1740,6 @@ 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/core/test_part_d_lis.py b/policyengine_us/tests/core/test_part_d_lis.py index 3625a75940d..f892a84dbd6 100644 --- a/policyengine_us/tests/core/test_part_d_lis.py +++ b/policyengine_us/tests/core/test_part_d_lis.py @@ -1,12 +1,13 @@ """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. +eligible (Medicaid enrollees, SSI recipients, people eligible for a Medicare +Savings Program) 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 in a Part D plan is valued +at the Medicare Trustees' average subsidy per subsidy enrollee, as CBO values +it. Parameters: @@ -21,48 +22,70 @@ 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 in every year from 2013, the spending - series' first, to 2100, and the limits never fall. - -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; enrollees are the eligible people - in a Part D plan who are on Medicaid or SSI or take the subsidy up; and - the benefit is either zero or the year's amount per recipient, positive - exactly for enrollees. -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). The sample's states keep SSI's $20 - general exclusion for the Medicare Savings Programs; Illinois and - Mississippi exclude more under 42 U.S.C. 1396a(r)(2), which the subsidy's - income definition sets aside. + amounts, through 2027, the last year both Septembers are published. In + 2015 the couple limit is the formula's $24,260, not the $24,250 CMS + printed (source-conflict candidate + ``us:part-d-lis-couple-resource-limit-2015``). The Septembers typed here + equal ``cpi_u_nsa_september`` wherever both have a value. +5. Projection: from 2028 each limit is the previous year's raised by the + model's projected September CPI-U, never lowered, and rounded to $10. On + a synthetic parameter tree, values in the YAML stand, published + Septembers come first, a fall in the index leaves the limit unchanged, + and a missing September sends both Septembers to the model's CPI-U + series. +6. Rounding: the year-to-year adjustment reproduces CMS's published 2026 + limits from the unrounded ratio, and rounds an exact $5 up. +7. Bounds: the amount per recipient is positive and below Medicare spending + per enrollee in every year from 2013, the spending series' first, to 2100. + +Households (a seeded sample in one shared simulation, plus households at each +income and resource limit and just past it, each also run with more Social +Security, more interest and more assets): + +8. Countable income equals the rule restated here: the couple's SSI income + without interest and dividends, each source counted at no less than zero + for each person, less $20 a month from unearned income (the rest from + earnings), $65 a month of earnings and half the remaining earnings. +9. Interest and dividends never change countable income or eligibility. +10. 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. +11. Eligibility equals the restated rule: family size counts the dependents + on both returns when spouses file separately, and resources include the + household's other real estate for the couple that holds it. Enrollees + are the eligible people in a Part D plan who are on Medicaid or SSI or + take the subsidy up, and the benefit is either zero or the year's amount + per recipient, positive exactly for enrollees. +12. Spouses get the same countable income, family size and test results. +13. Reach: the sample reaches every branch the properties above check, so a + change to the generator or the seed can't leave them vacuous. +14. Differential: for people without interest, dividends or rental income, + 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). This checks how each + formula gathers the couple's income; property 8 checks the exclusions + themselves. The sample's states keep SSI's $20 general exclusion for the + Medicare Savings Programs; Illinois and Mississippi exclude more under 42 + U.S.C. 1396a(r)(2), which the subsidy's income definition sets aside. """ +from types import SimpleNamespace + import numpy as np import pytest -from hypothesis import given -from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode from policyengine_us import Simulation +from policyengine_us.parameters.uprating_extensions import ( + adjust_part_d_lis_resource_limit_for_inflation, + extend_part_d_lis_resource_limits, + get_september_cpi_u_values, +) 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 +KINDS = ["individual", "couple"] # 2026 Medicare Trustees Report (sha256 ffa56b91...8fa8), Table IV.B9 (file # page 158): low-income subsidy enrollment (millions) and subsidy amount ($). @@ -117,19 +140,24 @@ ) ) # 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.) +# (CUUR0000SA0), September of each year, from the BLS public API: 2004 to +# 2025 retrieved 2026-10-11, 2026 retrieved 2026-11-08. pe-us's +# gov.bls.cpi.cpi_u_nsa_september starts in 2018; the years both hold must +# agree. 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}, + **{2025: 324.800, 2026: 336.940}, } +LAST_SEPTEMBER = max(CPI_U_SEPTEMBER) STATUTE_2006 = {"individual": 10_000, "couple": 20_000} -PUBLISHED_EXCEPTIONS = {("couple", 2015): 24_250} # the rule gives 24,260 +# CMS, CY 2026 Resource and Cost-Sharing Limits for LIS (October 31, 2025), +# page 2, before the burial funds exclusion. +CMS_2025 = {"individual": 16_100, "couple": 32_130} +CMS_2026 = {"individual": 16_590, "couple": 33_100} def _per_recipient(year): @@ -138,6 +166,14 @@ def _per_recipient(year): ) +def _limit(kind): + return getattr(LIS.eligibility.resources.limit, kind) + + +def _round_to_10(x): + return int(np.floor(x / 10 + 0.5)) * 10 + + @pytest.mark.parametrize("year", sorted(IV_B9)) def test_per_recipient_transcribes_table_iv_b9(year): assert _per_recipient(year) == IV_B9[year][1] @@ -165,33 +201,162 @@ def test_recipients_times_amount_matches_per_enrollee_series(year): assert rebuilt == pytest.approx(per_enrollee, rel=0.005) -def _round_to_10(x): - return int(np.floor(x / 10 + 0.5)) * 10 +def test_typed_septembers_match_the_parameter(): + september = P.gov.bls.cpi.cpi_u_nsa_september + # pe-us backdates each parameter's first value to 2015-01-01; only the + # September instants are BLS values. + years = [ + int(value.instant_str[:4]) + for value in september.values_list + if value.instant_str.endswith("-09-01") + ] + assert LAST_SEPTEMBER in years + for year in years: + assert september(f"{year}-09-01") == pytest.approx( + CPI_U_SEPTEMBER[year], abs=5e-4 + ), year -@pytest.mark.parametrize("kind", ["individual", "couple"]) +@pytest.mark.parametrize("kind", KINDS) def test_resource_limits_follow_the_cpi_rule(kind): - limit = getattr(LIS.eligibility.resources.limit, kind) + limit = _limit(kind) assert limit("2006-01-01") == STATUTE_2006[kind] - for year in range(2007, 2027): + for year in range(2007, LAST_SEPTEMBER + 2): 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 + assert limit(f"{year}-07-01") == expected, year -@given(st.integers(2013, 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 +@pytest.mark.parametrize("kind", KINDS) +def test_projected_resource_limits_follow_the_projected_cpi(kind): + limit = _limit(kind) + for year in range(LAST_SEPTEMBER + 2, 2101): + prior, current = get_september_cpi_u_values(P.gov.bls.cpi, year) + expected = _round_to_10(limit(f"{year - 1}-01-01") * max(current / prior, 1)) + assert limit(f"{year}-01-01") == expected, year + # The projection is not flat over the Trustees' window. + assert limit("2035-01-01") > limit(f"{LAST_SEPTEMBER + 1}-01-01") + + +def test_published_2026_limits_need_the_unrounded_ratio(): + # CMS reports the CY 2026 increase as 3.01%, but $16,100 x 1.0301 = + # $16,584.61 rounds to $16,580; the unrounded ratio gives $16,585.04 and + # the published $16,590. CMS's couple limits ($32,130 to $33,100) follow + # the same rule; the model's couple limit builds on the formula's 2025 + # amount, $20 above CMS's (see the couple limit's parameter file). + for kind in KINDS: + assert ( + adjust_part_d_lis_resource_limit_for_inflation( + CMS_2025[kind], 315.301, 324.800 + ) + == CMS_2026[kind] + ) + assert _limit("individual")("2026-01-01") == CMS_2026["individual"] + assert _limit("couple")("2026-01-01") == CMS_2026["couple"] + 20 + + +@pytest.mark.parametrize( + "prior_limit, prior, current, expected", + [ + # 2015 couple: 23,860 x 238.031 / 234.149 = 24,255.58. + (23_860, 234.149, 238.031, 24_260), + # 2016: the index fell (237.945 < 238.031), so the limit stays. + (24_260, 238.031, 237.945, 24_260), + # An exact $5 rounds up. + (1_005, 100.0, 100.0, 1_010), + (1_000, 100.0, 100.5, 1_010), + (1_000, 100.0, 100.4, 1_000), + ], +) +def test_adjustment_rounds_to_the_nearest_10(prior_limit, prior, current, expected): + assert ( + adjust_part_d_lis_resource_limit_for_inflation(prior_limit, prior, current) + == expected + ) -@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") +def _synthetic_tree(nsa_septembers, sa_septembers): + def values(data): + return {"values": data} + + return ParameterNode( + data={ + "gov": { + "hhs": { + "medicare": { + "part_d": { + "low_income_subsidy": { + "eligibility": { + "resources": { + "limit": { + "individual": values( + {"2025-01-01": 16_100} + ), + "couple": values( + { + "2025-01-01": 32_150, + "2026-01-01": 33_120, + } + ), + } + } + } + } + } + } + }, + "bls": { + "cpi": { + "cpi_u_nsa_september": values(nsa_septembers), + "cpi_u": values(sa_septembers), + } + }, + } + } + ) + + +def test_extension_on_a_synthetic_tree(): + nsa = {"2024-09-01": 315.301, "2025-09-01": 324.8, "2026-09-01": 336.94} + nsa["2027-09-01"] = 330.0 # a fall + nsa["2028-09-01"] = 340.0 + # The model's (seasonally adjusted) series differs from the published one, + # so a year that reads it shows which series it used. + sa = {date: 0.99 * value for date, value in nsa.items()} + sa["2029-09-01"] = 0.99 * 350.0 + parameters = _synthetic_tree(nsa, sa) + extend_part_d_lis_resource_limits(parameters, end_year=2031) + limits = parameters.gov.hhs.medicare.part_d.low_income_subsidy.eligibility + limits = limits.resources.limit + individual = {y: limits.individual(f"{y}-01-01") for y in range(2025, 2036)} + couple = {y: limits.couple(f"{y}-01-01") for y in range(2025, 2036)} + # 2026: published Septembers 2024 and 2025; the YAML's 2026 couple stands. + assert individual[2026] == 16_590 + assert couple[2026] == 33_120 + # 2027: published 2025 and 2026. + assert individual[2027] == _round_to_10(16_590 * 336.94 / 324.8) + assert couple[2027] == _round_to_10(33_120 * 336.94 / 324.8) + # 2028: the published index fell, so the limits stay. + assert individual[2028] == individual[2027] + assert couple[2028] == couple[2027] + # 2029: published 2027 and 2028. + assert individual[2029] == _round_to_10(individual[2028] * 340.0 / 330.0) + # 2030: September 2029 is not published, so both Septembers come from + # the model's series (0.99 x 350 / (0.99 x 340), not 0.99 x 350 / 340). + assert individual[2030] == _round_to_10(individual[2029] * 350.0 / 340.0) + # 2031: the model's series holds flat after its last month. + assert individual[2031] == individual[2030] + # After the end year the last value holds. + assert individual[2035] == individual[2031] + assert couple[2035] == couple[2031] + + +def test_amount_positive_and_below_spending(): + for year in range(2013, 2101): + amount = _per_recipient(year) + spending = P.calibration.gov.hhs.medicare.per_capita_cost(f"{year}-01-01") + assert 0 < amount < spending, year # Households. @@ -200,10 +365,15 @@ def test_resource_limits_never_fall(year): SEED = 20261011 STATES = ["TX", "CA", "NY", "AK", "HI", "FL", "PR", "DC"] VARIANTS = ["base", "more_ss", "more_interest", "more_assets"] +LIMITS = LIS.eligibility.resources(f"{YEAR}-01-01") +BURIAL = LIMITS.burial_exclusion -def _households(): +def _random_households(): rng = np.random.default_rng(SEED) + # Features added after the first sample come from their own generator, + # so the original draws stay the same. + extra = np.random.default_rng(SEED + 1) out = [] for _ in range(N): married = bool(rng.random() < 0.5) @@ -222,6 +392,7 @@ def person(): msp_eligible=bool(rng.random() < 0.15), takes_up_part_d_lis_if_eligible=bool(rng.random() < 0.7), takes_up_medicare_part_d_if_eligible=bool(rng.random() < 0.85), + rental_income=int(extra.choice([0, 0, 0, -6_000, 2_400])), ) out.append( @@ -229,12 +400,72 @@ def person(): state=str(rng.choice(STATES)), dependents=int(rng.choice([0, 0, 0, 1, 2])), members=[person()] + ([person()] if married else []), + separate=married and bool(extra.random() < 0.3), + real_estate=int(extra.choice([0, 0, 0, 6_000, 60_000])), + ) + ) + return out + + +def _boundary_households(): + # Not deemed, in a Part D plan, taking the subsidy up, in Texas. + def person(social_security, bank_account_assets=0): + return dict( + social_security=social_security, + employment_income=0, + interest_income=0, + dividend_income=0, + bank_account_assets=bank_account_assets, + stock_assets=0, + medicare_enrolled=True, + medicaid_enrolled=False, + ssi=0, + msp_eligible=False, + takes_up_part_d_lis_if_eligible=True, + takes_up_medicare_part_d_if_eligible=True, + rental_income=0, + ) + + def household(members, real_estate=0): + return dict( + state="TX", + dependents=0, + members=members, + separate=False, + real_estate=real_estate, + ) + + single = LIMITS.limit.individual + BURIAL + couple = LIMITS.limit.couple + 2 * BURIAL + single_income = 1.5 * _fpg("TX", 1) + couple_income = 1.5 * _fpg("TX", 2) + out = [] + for over in (0, 1): + out.append(household([person(6_000, single + over)])) + out.append( + household([person(6_000, couple - 9_000 + over), person(6_000, 9_000)]) + ) + out.append(household([person(6_000, single - 10_000 + over)], 10_000)) + # Countable income at 150% of the guideline, and $12 a year below. + out.append(household([person(int(single_income + 240 - 12 * (1 - over)))])) + out.append( + household( + [ + person(20_000), + person(int(couple_income + 240 - 20_000 - 12 * (1 - over))), + ] ) ) return out -HOUSEHOLDS = _households() +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) + + +HOUSEHOLDS = _random_households() + _boundary_households() def _variant(member, variant, is_head): @@ -249,6 +480,15 @@ def _variant(member, variant, is_head): return member +INCOME_INPUTS = ( + "social_security", + "employment_income", + "interest_income", + "dividend_income", + "rental_income", +) + + def _situation(households, variants, inputs=True): people, marital_units, tax_units, spm_units, units = {}, {}, {}, {}, {} rows = [] # (household index, variant, member index, person name) @@ -259,15 +499,7 @@ def _situation(households, variants, inputs=True): 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", - ) - } + record = {k: record[k] for k in INCOME_INPUTS} people[name] = { "age": {YEAR: 70}, **{k: {YEAR: val} for k, val in record.items()}, @@ -275,6 +507,7 @@ def _situation(households, variants, inputs=True): names.append(name) rows.append((i, v, j, name)) spouses = list(names) + returns = [[name] for name in spouses] if h["separate"] else [list(spouses)] dependents = [] for k in range(h["dependents"]): name = f"{v}_{i}_d{k}" @@ -283,14 +516,18 @@ def _situation(households, variants, inputs=True): "is_tax_unit_dependent": {YEAR: True}, } marital_units[f"mu_{name}"] = {"members": [name]} + # Spouses who file separately claim the dependents in turn. + returns[k % len(returns)].append(name) dependents.append(name) marital_units[f"mu_{v}_{i}"] = {"members": spouses} + for r, members in enumerate(returns): + tax_units[f"tu_{v}_{i}_{r}"] = {"members": members} 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"]}, + "household_other_real_estate_equity": {YEAR: h["real_estate"]}, } situation = { "people": people, @@ -322,32 +559,45 @@ def sample(): return rows, index, values -def _restated_countable(members): +def _restated_countable(members, floor_sources=True): + def unearned(m): + rent = max(m["rental_income"], 0) if floor_sources else m["rental_income"] + return m["social_security"] + rent + 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 + total_unearned = sum(unearned(m) for m in members) / 12 + general = min(20, max(total_unearned, 0)) + countable_unearned = max(total_unearned, 0) - 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 _restated_tests(h, members, real_estate=True): + """Family size, income test and resource test for the household's couple.""" + couple = len(members) + size = couple + h["dependents"] + income_ok = _restated_countable(members) < 1.5 * _fpg(h["state"], size) + resources = sum(m["bank_account_assets"] + m["stock_assets"] for m in members) + # Every adult is 70 and every dependent 10, so the couple holds the + # household's real estate. + resources += h["real_estate"] if real_estate else 0 + limit = LIMITS.limit.couple if couple == 2 else LIMITS.limit.individual + resource_ok = resources <= limit + BURIAL * couple + return size, income_ok, resource_ok, resources - (limit + BURIAL * couple) def _value(values, index, variable, name): return values[variable][index[name]] +def _members(i, v): + return [_variant(m, v, j == 0) for j, m in enumerate(HOUSEHOLDS[i]["members"])] + + 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) + expected = _restated_countable(_members(i, v)) actual = _value(values, index, "part_d_lis_countable_income", name) assert actual == pytest.approx(expected, abs=0.01), (i, v) @@ -384,39 +634,36 @@ def test_more_income_or_assets_never_helps(sample): assert _value(values, index, "part_d_lis_resource_eligible", base), i +def _restated_enrollment(h, me, income_ok, resource_ok): + excluded = list(LIS.eligibility.ineligible_jurisdictions(f"{YEAR}-01-01")) + 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 + in_part_d = me["takes_up_medicare_part_d_if_eligible"] + takes_up = me["takes_up_part_d_lis_if_eligible"] + enrolled = eligible and in_part_d and (automatic or takes_up) + return deemed, eligible, enrolled + + 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 - in_part_d = me["takes_up_medicare_part_d_if_eligible"] - enrolled = ( - eligible - and in_part_d - and (automatic or me["takes_up_part_d_lis_if_eligible"]) + members = _members(i, v) + size, income_ok, resource_ok, _ = _restated_tests(h, members) + _, eligible, enrolled = _restated_enrollment( + h, members[j], income_ok, resource_ok ) + assert _value(values, index, "part_d_lis_family_size", name) == size, i assert _value(values, index, "part_d_lis_income_eligible", name) == income_ok assert _value(values, index, "part_d_lis_resource_eligible", name) == ( resource_ok - ) + ), i 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) @@ -440,15 +687,90 @@ def test_spouses_agree(sample): ), (i, variable) +def test_sample_reaches_every_branch(): + counts = dict.fromkeys( + [ + "couple", + "dependents", + "territory", + "no_medicare", + "deemed_by_medicaid_only", + "deemed_by_ssi_only", + "deemed_by_msp_only", + "passes_both_tests", + "fails_only_resources", + "fails_only_income", + "eligible_without_part_d", + "eligible_not_taking_up", + "automatic_despite_no_take_up", + "enrolled", + "on_resource_limit", + "one_dollar_over_resource_limit", + "on_income_limit", + "separate_returns_split_dependents", + "split_dependents_change_income_test", + "rental_floor_changes_income_test", + "real_estate_changes_resource_test", + ], + 0, + ) + for i, h in enumerate(HOUSEHOLDS): + members = _members(i, "base") + size, income_ok, resource_ok, over = _restated_tests(h, members) + counts["couple"] += len(members) == 2 + counts["dependents"] += h["dependents"] > 0 + counts["territory"] += h["state"] == "PR" + counts["on_resource_limit"] += over == 0 + counts["one_dollar_over_resource_limit"] += over == 1 + countable = _restated_countable(members) + counts["on_income_limit"] += countable == 1.5 * _fpg(h["state"], size) + if h["separate"] and h["dependents"] == 1: + counts["separate_returns_split_dependents"] += 1 + own_return = _restated_countable(members) < 1.5 * _fpg(h["state"], size - 1) + counts["split_dependents_change_income_test"] += own_return != income_ok + netted = _restated_countable(members, floor_sources=False) + counts["rental_floor_changes_income_test"] += income_ok != ( + netted < 1.5 * _fpg(h["state"], size) + ) + without = _restated_tests(h, members, real_estate=False)[2] + counts["real_estate_changes_resource_test"] += without != resource_ok + for me in members: + counts["no_medicare"] += not me["medicare_enrolled"] + routes = [me["medicaid_enrolled"], me["ssi"] > 0, me["msp_eligible"]] + if sum(routes) == 1: + counts["deemed_by_medicaid_only"] += routes[0] + counts["deemed_by_ssi_only"] += routes[1] + counts["deemed_by_msp_only"] += routes[2] + counts["passes_both_tests"] += income_ok and resource_ok + counts["fails_only_resources"] += income_ok and not resource_ok + counts["fails_only_income"] += resource_ok and not income_ok + _, eligible, enrolled = _restated_enrollment(h, me, income_ok, resource_ok) + in_part_d = me["takes_up_medicare_part_d_if_eligible"] + automatic = me["medicaid_enrolled"] or me["ssi"] > 0 + takes_up = me["takes_up_part_d_lis_if_eligible"] + counts["eligible_without_part_d"] += eligible and not in_part_d + counts["eligible_not_taking_up"] += ( + eligible and in_part_d and not automatic and not takes_up + ) + counts["automatic_despite_no_take_up"] += ( + eligible and in_part_d and automatic and not takes_up + ) + counts["enrolled"] += enrolled + unreached = [branch for branch, count in counts.items() if count == 0] + assert not unreached, counts + + 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. + # No interest, dividends or rental income, 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"] + {**m, "interest_income": 0, "dividend_income": 0, "rental_income": 0} + for m in h["members"] ], } for h in HOUSEHOLDS 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 index 83a388e39b9..01d10d70b4b 100644 --- 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 @@ -102,7 +102,7 @@ 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. + # are under 33,120 + 3,000 = 36,120. msp_eligible: [false, false] part_d_lis_family_size: [2, 2] part_d_lis_countable_income: [29_760, 29_760] @@ -207,3 +207,135 @@ output: part_d_lis_eligible: false part_d_lis: 0 + +- name: Non-home real estate counts toward the resource limit. + period: 2026 + input: + people: + person: + age: 72 + social_security: 22_800 + bank_account_assets: 1_000 + households: + household: + members: [person] + state_code: TX + household_other_real_estate_equity: 200_000 + output: + # Countable income (22,800 / 12 - 20) x 12 = 22,560 is over the QI limit + # (21,546), so no Medicare Savings Program deems the person eligible, and + # under 23,940. Resources 1,000 + 200,000 exceed 16,590 + 1,500 = 18,090 + # (20 CFR 418.3405(b)). + msp_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: false + part_d_lis_eligible: false + part_d_lis: 0 + +- name: The same person without the real estate gets the subsidy. + period: 2026 + input: + people: + person: + age: 72 + social_security: 22_800 + bank_account_assets: 1_000 + households: + household: + members: [person] + state_code: TX + output: + msp_eligible: false + part_d_lis_resource_eligible: true + part_d_lis_eligible: true + part_d_lis: 1_254 + +- name: A rental loss does not offset Social Security. + period: 2026 + input: + people: + person: + age: 70 + social_security: 30_000 + rental_income: -8_000 + bank_account_assets: 5_000 + households: + household: + members: [person] + state_code: TX + output: + # SSI counts net rental income but does not let excess expenses offset + # other income (POMS SI 00830.505), so the subsidy counts (30,000 / 12 - + # 20) x 12 = 29,760, over 23,940. Netting the loss would give 21,760. + part_d_lis_countable_income: 29_760 + part_d_lis_income_eligible: false + part_d_lis: 0 + +- name: Spouses filing separately share one family size. + period: 2026 + input: + people: + spouse1: + age: 70 + social_security: 24_000 + spouse2: + age: 68 + social_security: 9_000 + grandchild: + age: 10 + is_tax_unit_dependent: true + marital_units: + couple: + members: [spouse1, spouse2] + grandchild_unit: + members: [grandchild] + tax_units: + spouse1_return: + members: [spouse1] + spouse2_return: + members: [spouse2, grandchild] + spm_units: + spm_unit: + members: [spouse1, spouse2, grandchild] + households: + household: + members: [spouse1, spouse2, grandchild] + state_code: TX + output: + # The grandchild is a dependent on the second spouse's separate return, + # so the couple's family is 3 for both spouses. Combined countable + # income (33,000 / 12 - 20) x 12 = 32,760 is under 150% of the 3-person + # guideline, 1.5 x (15,960 + 2 x 5,680) = 40,980, but over the 2-person + # 32,460, so counting only the first spouse's return would deny that + # spouse. The couple's 2,730 a month exceeds the couple QI limit of + # 29,214 / 12 = 2,434.50, so no Medicare Savings Program deems them. + msp_eligible: [false, false, false] + part_d_lis_family_size: [3, 3, 1] + part_d_lis_countable_income: [32_760, 32_760, 0] + part_d_lis_income_eligible: [true, true, true] + part_d_lis_eligible: [true, true, false] + part_d_lis: [1_254, 1_254, 0] + +- name: 2027 uses the CPI-indexed resource limit. + period: 2027 + input: + people: + person: + age: 70 + social_security: 18_000 + bank_account_assets: 18_500 + households: + household: + members: [person] + state_code: TX + output: + # The 2027 limit is 16,590 x 336.940 / 324.800 = 17,210.04, rounded to + # 17,210 (September 2026 and 2025 CPI-U), so resources may reach 17,210 + # + 1,500 = 18,710. $18,500 is over the Medicare Savings Program's + # resource limit, and countable income 17,760 is under 150% of the + # projected 2027 guideline. + msp_eligible: false + part_d_lis_income_eligible: true + part_d_lis_resource_eligible: true + part_d_lis_eligible: true + part_d_lis: 1_384 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 index f21127b4dbf..66d6d18ca5c 100644 --- 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 @@ -77,3 +77,43 @@ age: 66 output: part_d_lis_countable_income: 0 + +- name: A rental loss counts as zero and does not offset other income. + period: 2026 + input: + people: + person: + age: 70 + social_security: 12_000 + rental_income: -3_000 + output: + # (12,000 / 12 - 20) x 12 = 11,760 (POMS SI 00830.505). + part_d_lis_countable_income: 11_760 + +- name: One spouse's rental loss does not offset the other's income. + period: 2026 + input: + people: + spouse1: + age: 70 + social_security: 12_000 + spouse2: + age: 68 + rental_income: -6_000 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + part_d_lis_countable_income: [11_760, 11_760] + +- name: Rental income counts. + period: 2026 + input: + people: + person: + age: 70 + social_security: 12_000 + rental_income: 2_400 + output: + # (14,400 / 12 - 20) x 12 = 14,160. + part_d_lis_countable_income: 14_160 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 index bbef7210439..c96f6ce6a3d 100644 --- 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 @@ -78,3 +78,21 @@ ssi: 0 output: part_d_lis_enrolled: false + +- name: A Medicaid enrollee whose Part D input is false gets no subsidy. + period: 2026 + input: + people: + person: + age: 70 + part_d_lis_eligible: true + medicaid_enrolled: true + ssi: 0 + # CMS enrolls Medicaid enrollees in a plan unless they decline Part D + # or are qualifying covered retirees (42 CFR 423.34(d), (e)); the + # microdata mark those people with this input. + takes_up_medicare_part_d_if_eligible: false + output: + medicare_part_d_enrolled: false + part_d_lis_enrolled: false + part_d_lis: 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 index d0bcd98f082..5a78aa56f7f 100644 --- 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 @@ -55,3 +55,57 @@ # 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] + +- name: Spouses filing separately count the dependents on both returns. + period: 2026 + input: + people: + spouse1: + age: 70 + spouse2: + age: 68 + grandchild: + age: 10 + is_tax_unit_dependent: true + marital_units: + couple: + members: [spouse1, spouse2] + grandchild_unit: + members: [grandchild] + tax_units: + spouse1_return: + members: [spouse1] + spouse2_return: + members: [spouse2, grandchild] + output: + # Each spouse heads a return; only the second claims the grandchild. + part_d_lis_family_size: [3, 3, 1] + +- name: Each spouse files separately and claims one child. + period: 2026 + input: + people: + spouse1: + age: 70 + child1: + age: 15 + is_tax_unit_dependent: true + spouse2: + age: 68 + child2: + age: 12 + is_tax_unit_dependent: true + marital_units: + couple: + members: [spouse1, spouse2] + child1_unit: + members: [child1] + child2_unit: + members: [child2] + tax_units: + spouse1_return: + members: [spouse1, child1] + spouse2_return: + members: [spouse2, child2] + output: + part_d_lis_family_size: [4, 1, 4, 1] 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 index c12e0858819..d64d43eda80 100644 --- 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 @@ -28,13 +28,15 @@ spouse1: bank_account_assets: 20_000 spouse2: - stock_assets: 10_100 + stock_assets: 10_120 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. + # 20,000 + 10,120 + 6,000 = 36,120 = 33,120 + 2 x 1,500. The statute's + # formula gives $33,120; CMS published $33,100 (see the couple limit's + # parameter file). part_d_lis_resource_eligible: [true, true] - name: Married couple one dollar over the 2026 couple limit. @@ -44,7 +46,7 @@ spouse1: bank_account_assets: 20_001 spouse2: - stock_assets: 16_100 + stock_assets: 16_120 marital_units: marital_unit: members: [spouse1, spouse2] @@ -61,3 +63,93 @@ # 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 + +- name: Single person at the projected 2027 limit. + period: 2027 + input: + people: + person: + bank_account_assets: 18_710 + output: + # 16,590 x 336.940 / 324.800 = 17,210.04, rounded to 17,210; plus 1,500. + part_d_lis_resource_eligible: true + +- name: Single person one dollar over the projected 2027 limit. + period: 2027 + input: + people: + person: + bank_account_assets: 18_711 + output: + part_d_lis_resource_eligible: false + +- name: Married couple at the projected 2027 couple limit. + period: 2027 + input: + people: + spouse1: + bank_account_assets: 37_360 + spouse2: + bank_account_assets: 0 + marital_units: + marital_unit: + members: [spouse1, spouse2] + output: + # 33,120 x 336.940 / 324.800 = 34,358.11, rounded to 34,360; plus 2 x + # 1,500 = 37,360. + part_d_lis_resource_eligible: [true, true] + +- name: Non-home real estate counts for the household head's couple only. + period: 2026 + input: + people: + head: + age: 70 + is_household_head: true + bank_account_assets: 20_000 + spouse: + age: 68 + bank_account_assets: 7_000 + mother: + age: 92 + bank_account_assets: 9_000 + marital_units: + couple: + members: [head, spouse] + mother_unit: + members: [mother] + households: + household: + members: [head, spouse, mother] + household_other_real_estate_equity: 10_000 + output: + # The couple: 20,000 + 7,000 + 10,000 = 37,000 > 33,120 + 3,000 = + # 36,120. The mother: 9,000 <= 16,590 + 1,500 = 18,090. + part_d_lis_resource_eligible: [false, false, true] + +- name: Without a designated head, the oldest member holds the real estate. + period: 2026 + input: + people: + head: + age: 70 + bank_account_assets: 20_000 + spouse: + age: 68 + bank_account_assets: 7_000 + mother: + age: 92 + bank_account_assets: 9_000 + marital_units: + couple: + members: [head, spouse] + mother_unit: + members: [mother] + households: + household: + members: [head, spouse, mother] + household_other_real_estate_equity: 10_000 + output: + # The couple: 27,000 <= 36,120. The mother: 9,000 + 10,000 = 19,000 > + # 18,090. + part_d_lis_resource_eligible: [true, true, false] diff --git a/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml b/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml index 2b7980e1463..596087fbcfa 100644 --- a/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml +++ b/policyengine_us/tests/policy/baseline/household/healthcare_benefit_value.yaml @@ -23,6 +23,16 @@ output: healthcare_benefit_value: 2_434.8 +- name: Case 2b, the Part D low-income subsidy is a healthcare benefit value. + period: 2026 + absolute_error_margin: 0.01 + input: + medicaid_cost: 0 + msp_cost: 2_434.8 + part_d_lis: 1_254 + output: + healthcare_benefit_value: 3_688.8 + - name: Case 3, enrolled CHIP value is included in healthcare benefit value. period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml b/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml index c02d64382ee..2af124fd1bd 100644 --- a/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml +++ b/policyengine_us/tests/policy/baseline/household/household_health_benefits.yaml @@ -38,6 +38,30 @@ household_health_benefits: 4_234.8 household_benefits: 4_234.8 +- name: Case 3b, flag on includes the Part D low-income subsidy. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.simulation.include_health_benefits_in_net_income: true + medicaid_cost: 0 + msp_cost: 2_434.8 + part_d_lis: 1_254 + snap: 0 + output: + household_health_benefits: 3_688.8 + household_benefits: 3_688.8 + +- name: Case 3c, flag off leaves the Part D low-income subsidy out of benefits. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.simulation.include_health_benefits_in_net_income: false + part_d_lis: 1_254 + snap: 0 + output: + household_health_benefits: 0 + household_benefits: 0 + - name: Case 4, legacy wrappers do not double count canonical health values. period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_us/variables/household/healthcare_benefit_value.py b/policyengine_us/variables/household/healthcare_benefit_value.py index 0f68d98f3e9..bf7731ee3d5 100644 --- a/policyengine_us/variables/household/healthcare_benefit_value.py +++ b/policyengine_us/variables/household/healthcare_benefit_value.py @@ -11,11 +11,14 @@ class healthcare_benefit_value(Variable): "Annual canonical household resource value of health coverage. This " "uses annual health-value and government-cost proxy variables directly, " "including assigned_aca_ptc for ACA premium tax credits. CHIP is counted " - "through `chip`, which is gated on eligibility, take-up, and enrollment." + "through `chip`, which is gated on eligibility, take-up, and enrollment. " + "The Medicare Part D low-income subsidy counts through `part_d_lis`; " + "Medicare's own spending (`medicare_cost`) is not included." ) adds = [ "medicaid_cost", "msp_cost", + "part_d_lis", "chip", "assigned_aca_ptc", "assigned_ca_premium_subsidy", From fe4af16ec11cd8af37b76ad75883acdb0fcaa69c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 01:26:29 -0500 Subject: [PATCH 08/11] Reword the couple limit candidate's example Co-Authored-By: Claude Opus 5.5 --- source_conflicts/candidates.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 1c00c91928e..23a95750581 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -11263,8 +11263,8 @@ candidates: sets it, giving $24,620 in 2017 and $33,120 in 2026. other: CMS's memo gives the 1.01658 factor and the rounding to $10 but prints $24,250, and POMS lists $24,250 for 2015 and 2016. SSA raises its own published amount each later year, giving $24,600 in 2017 and $33,100 in 2026. - example: In 2026 a married couple with $36,110 of countable resources and the burial funds exclusion meets the - statutory limit of $33,120 plus $3,000 but not SSA's $33,100 plus $3,000. + example: In 2026 a married couple with $36,110 of resources, $3,000 of them set aside for burial, has $33,110 of + countable resources, within the statutory limit of $33,120 but over SSA's $33,100. pe_us: follows: statute code: @@ -11273,8 +11273,8 @@ candidates: - https://github.com/PolicyEngine/policyengine-us/pull/10127 state: ruled note: Every other higher (partial subsidy, then full subsidy) limit SSA published from 2007 to 2026 equals the - formula applied to the previous published limit, and the memo's 1.01658 factor reproduces its other 2015 amounts ($7,280, $10,930 and - $12,140), so the $24,250 is the one cell the stated method doesn't produce. The model applies the formula as the + formula applied to the previous published limit, and the memo's 1.01658 factor reproduces its other 2015 amounts + ($7,280, $10,930 and $12,140), so the $24,250 is the one cell the stated method doesn't produce. The model applies the formula as the statute and 423.773(d)(2)(ii) state it, from the statute's 2006 amount of $20,000. The difference reaches only couples whose countable resources fall within $10 to $20 of the limit. - id: us:recovery-rebate-joint-claimable-spouse From 2a9b27cda682355485b39cc48443d6bfc0ff5d1c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 01:59:54 -0500 Subject: [PATCH 09/11] Correct the 2027 limit arithmetic in two test comments 16,590 x 336.940 / 324.800 is 17,210.08 and 33,120 x 336.940 / 324.800 is 34,357.92. The rounded limits and the tests' inputs don't change. Co-Authored-By: Claude Opus 5.5 --- .../hhs/medicare/part_d/low_income_subsidy/integration.yaml | 2 +- .../low_income_subsidy/part_d_lis_resource_eligible.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 01d10d70b4b..473be095d6d 100644 --- 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 @@ -329,7 +329,7 @@ members: [person] state_code: TX output: - # The 2027 limit is 16,590 x 336.940 / 324.800 = 17,210.04, rounded to + # The 2027 limit is 16,590 x 336.940 / 324.800 = 17,210.08, rounded to # 17,210 (September 2026 and 2025 CPI-U), so resources may reach 17,210 # + 1,500 = 18,710. $18,500 is over the Medicare Savings Program's # resource limit, and countable income 17,760 is under 150% of the 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 index d64d43eda80..8ca6a68478b 100644 --- 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 @@ -71,7 +71,7 @@ person: bank_account_assets: 18_710 output: - # 16,590 x 336.940 / 324.800 = 17,210.04, rounded to 17,210; plus 1,500. + # 16,590 x 336.940 / 324.800 = 17,210.08, rounded to 17,210; plus 1,500. part_d_lis_resource_eligible: true - name: Single person one dollar over the projected 2027 limit. @@ -95,7 +95,7 @@ marital_unit: members: [spouse1, spouse2] output: - # 33,120 x 336.940 / 324.800 = 34,358.11, rounded to 34,360; plus 2 x + # 33,120 x 336.940 / 324.800 = 34,357.92, rounded to 34,360; plus 2 x # 1,500 = 37,360. part_d_lis_resource_eligible: [true, true] From 4ecac84cc2ac6890a287fa37a6db7a6fee0c3c05 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 02:05:15 -0500 Subject: [PATCH 10/11] State the covered-retiree exception as 42 CFR 423.34(d)(3) does CMS enrolls a qualifying covered retiree only if they are a full-benefit dual eligible whose plan sponsor it approved for the retiree drug subsidy and they confirm that they want a plan; it enrolls no other covered retiree. The docstring had limited the exception to retirees whose employer gets the subsidy. Documentation and a test comment only. Co-Authored-By: Claude Opus 5.5 --- .../low_income_subsidy/part_d_lis_enrolled.yaml | 4 ++-- .../low_income_subsidy/part_d_lis_enrolled.py | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) 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 index c96f6ce6a3d..ed08dcadf32 100644 --- 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 @@ -89,8 +89,8 @@ medicaid_enrolled: true ssi: 0 # CMS enrolls Medicaid enrollees in a plan unless they decline Part D - # or are qualifying covered retirees (42 CFR 423.34(d), (e)); the - # microdata mark those people with this input. + # or are qualifying covered retirees who don't choose one (42 CFR + # 423.34(d), (e)); the microdata mark those people with this input. takes_up_medicare_part_d_if_eligible: false output: medicare_part_d_enrolled: false 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 index c85ec1c9080..275b23a1661 100644 --- 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 @@ -10,12 +10,17 @@ class part_d_lis_enrolled(Variable): "(medicare_part_d_enrolled). Medicaid enrollees and Supplemental " "Security Income recipients get the subsidy without applying; other " "eligible people in a plan get it if they take it up. CMS enrolls " - "subsidy-eligible people who don't choose a plan, unless they " - "decline Part D or are qualifying covered retirees whose employer " - "gets the retiree drug subsidy (42 CFR 423.34(d) and (e)). So " - "microdata that set takes_up_medicare_part_d_if_eligible should set " - "it true for Medicaid enrollees and SSI recipients other than those " - "two groups; a person whose Part D input is false gets no subsidy. " + "subsidy-eligible people who don't choose a plan, and any of them " + "can decline (42 CFR 423.34(d)(1) and (e)). Qualifying covered " + "retirees, whose employer retiree drug coverage qualifies for the " + "retiree drug subsidy, are the exception: CMS enrolls only the " + "full-benefit dual eligibles among them whose plan sponsor it " + "approved for that subsidy, and only if they confirm that they want " + "a plan (423.34(d)(3)). So microdata that set " + "takes_up_medicare_part_d_if_eligible should set it true for " + "Medicaid enrollees and SSI recipients other than those who decline " + "and covered retirees who don't choose a plan; a person whose Part D " + "input is false gets no subsidy. " "Not modeled: people deemed eligible from July to December stay " "deemed eligible through the following year (423.773(c)(2)(ii)); the " "model tests each year on its own." From 2c447a343c2f4d5ecf71cae68f4796118a17e974 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 05:08:32 -0500 Subject: [PATCH 11/11] Test the two-head and September-instant tie-breaks A household with two flagged heads gives its real estate to the older one, and a non-September instant in the September CPI-U node is not a published September. Both rules had no test; the round 3 mutants that reversed them survived. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_part_d_lis.py | 14 ++++++++++ .../part_d_lis_resource_eligible.yaml | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/policyengine_us/tests/core/test_part_d_lis.py b/policyengine_us/tests/core/test_part_d_lis.py index f892a84dbd6..574cd01dbbe 100644 --- a/policyengine_us/tests/core/test_part_d_lis.py +++ b/policyengine_us/tests/core/test_part_d_lis.py @@ -217,6 +217,20 @@ def test_typed_septembers_match_the_parameter(): ), year +def test_september_values_skip_non_september_instants(): + # The backdated 2015-01-01 entry is no BLS September, so 2017, which + # needs Septembers 2015 and 2016, reads both from the model's series. + cpi = ParameterNode( + data={ + "cpi_u_nsa_september": { + "values": {"2015-01-01": 237.945, "2016-09-01": 241.428} + }, + "cpi_u": {"values": {"2015-09-01": 200.0, "2016-09-01": 210.0}}, + } + ) + assert get_september_cpi_u_values(cpi, 2017) == (200.0, 210.0) + + @pytest.mark.parametrize("kind", KINDS) def test_resource_limits_follow_the_cpi_rule(kind): limit = _limit(kind) 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 index 8ca6a68478b..bf2e67838fb 100644 --- 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 @@ -127,6 +127,32 @@ # 36,120. The mother: 9,000 <= 16,590 + 1,500 = 18,090. part_d_lis_resource_eligible: [false, false, true] +- name: With two flagged heads, the older one holds the real estate. + period: 2026 + input: + people: + younger: + age: 70 + is_household_head: true + bank_account_assets: 9_000 + older: + age: 80 + is_household_head: true + bank_account_assets: 9_000 + marital_units: + younger_unit: + members: [younger] + older_unit: + members: [older] + households: + household: + members: [younger, older] + household_other_real_estate_equity: 10_000 + output: + # The younger: 9,000 <= 16,590 + 1,500 = 18,090. The older: 9,000 + + # 10,000 = 19,000 > 18,090. + part_d_lis_resource_eligible: [true, false] + - name: Without a designated head, the oldest member holds the real estate. period: 2026 input: