From 601dc3d77d0ffce843c7cf1993de694575dce7d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:55:55 -0400 Subject: [PATCH 1/4] Fall back to the earliest input year's MAGI for Medicare IRMAA; add a same-year switch When the year two years before the benefit year precedes every input in the simulation, IRMAA MAGI read only default inputs and came out zero, so 2024 and 2025 analyses on the 2024 dataset (and every one-year household) dropped the Part B and Part D surcharges. The earliest input year's IRMAA MAGI now stands in, deflated to the lookback year by per-capita CBO AGI, the index the single-year dataset extension uprates dollar inputs by. Provided lookback-year inputs and a provided IRMAA MAGI still win. gov.simulation.medicare_irmaa_uses_current_year_income (default false) sets the charges from the benefit year's own MAGI, for marginal tax rate and behavioral response work. Part B and Part D now read medicare_irmaa_magi, which selects between medicare_irmaa_magi_two_years_prior and the new medicare_irmaa_tax_year_magi. Co-Authored-By: Claude Opus 5.5 --- changelog.d/irmaa-lookback-fallback.added.md | 1 + changelog.d/irmaa-lookback-fallback.fixed.md | 1 + ...dicare_irmaa_uses_current_year_income.yaml | 14 + .../eligibility/medicare_irmaa_magi.yaml | 76 ++++ .../medicare_irmaa_magi_two_years_prior.yaml | 162 ++++++++- ...enior_household_2015_2016_integration.yaml | 10 +- .../medicare/test_irmaa_first_modeled_year.py | 27 +- .../test_irmaa_lookback_and_switch.py | 344 ++++++++++++++++++ policyengine_us/tools/general.py | 27 ++ .../eligibility/medicare_irmaa_magi.py | 25 ++ .../medicare_irmaa_magi_two_years_prior.py | 108 +++--- .../medicare_irmaa_tax_year_magi.py | 57 +++ .../part_b/gross_medicare_part_b_premium.py | 4 +- ...ncome_adjusted_part_d_premium_surcharge.py | 4 +- 14 files changed, 786 insertions(+), 74 deletions(-) create mode 100644 changelog.d/irmaa-lookback-fallback.added.md create mode 100644 changelog.d/irmaa-lookback-fallback.fixed.md create mode 100644 policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi.py create mode 100644 policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_tax_year_magi.py diff --git a/changelog.d/irmaa-lookback-fallback.added.md b/changelog.d/irmaa-lookback-fallback.added.md new file mode 100644 index 00000000000..67101266cb2 --- /dev/null +++ b/changelog.d/irmaa-lookback-fallback.added.md @@ -0,0 +1 @@ +Added gov.simulation.medicare_irmaa_uses_current_year_income, which sets Medicare Part B and Part D IRMAA from the benefit year's own MAGI (medicare_irmaa_magi, medicare_irmaa_tax_year_magi) for marginal tax rate and behavioral response analysis. diff --git a/changelog.d/irmaa-lookback-fallback.fixed.md b/changelog.d/irmaa-lookback-fallback.fixed.md new file mode 100644 index 00000000000..1b2f49d8fd0 --- /dev/null +++ b/changelog.d/irmaa-lookback-fallback.fixed.md @@ -0,0 +1 @@ +Medicare IRMAA no longer drops to zero when the year two years before the benefit year precedes every input (single-year datasets in their first two years, and households entered for one year): the earliest input year's MAGI stands in, deflated to that year by per-capita CBO AGI. diff --git a/policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml b/policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml new file mode 100644 index 00000000000..48ef6233601 --- /dev/null +++ b/policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml @@ -0,0 +1,14 @@ +description: PolicyEngine sets Medicare income-related monthly adjustment amounts from the benefit year's own modified adjusted gross income, rather than the income from two years earlier that the law uses, if this is true. +# The law sets IRMAA on income from two years before the benefit year, so a +# change in this year's earnings meets the surcharge two years later. Marginal +# tax rate and behavioral response analyses that treat a lasting earnings +# change as meeting the surcharge in the same year turn this on. +values: + 0000-01-01: false +metadata: + unit: bool + period: year + label: Set Medicare IRMAA from current-year income + reference: + - title: 42 U.S.C. 1395r(i)(4)(B) + href: https://www.law.cornell.edu/uscode/text/42/1395r#i_4_B diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi.yaml new file mode 100644 index 00000000000..b40599ff477 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi.yaml @@ -0,0 +1,76 @@ +# The IRMAA brackets apply to MAGI from two years before the benefit year +# unless gov.simulation.medicare_irmaa_uses_current_year_income is true, when +# they apply to the benefit year's own MAGI. + +- name: By default the 2025 charges use 2023 income. + period: 2025 + input: + age: 66 + employment_income: + 2023: 50_000 + 2025: 300_000 + output: + medicare_irmaa_magi: 50_000 + # Below 106,001: the $185 standard premium only. + gross_medicare_part_b_premium: 2_220 + income_adjusted_part_d_premium_surcharge: 0 + +- name: With the switch on the 2025 charges use 2025 income. + period: 2025 + input: + gov.simulation.medicare_irmaa_uses_current_year_income: true + age: 66 + employment_income: + 2023: 50_000 + 2025: 300_000 + output: + medicare_irmaa_magi: 300_000 + # 2025 single, 200,001 up to 500,000: 12 x (185 + 406.90) = 7,102.80. + gross_medicare_part_b_premium: 7_102.8 + # Part D: 12 x 78.60. + income_adjusted_part_d_premium_surcharge: 943.2 + +- name: With the switch on a provided lag MAGI does not set the charges. + period: 2025 + input: + gov.simulation.medicare_irmaa_uses_current_year_income: true + people: + person: + age: 66 + employment_income: 50_000 + tax_units: + tax_unit: + members: [person] + medicare_irmaa_magi_two_years_prior: 300_000 + households: + household: + members: [person] + output: + medicare_irmaa_magi: 50_000 + gross_medicare_part_b_premium: 2_220 + +- name: A joint return's MAGI applies to both spouses with the switch on. + period: 2026 + input: + gov.simulation.medicare_irmaa_uses_current_year_income: true + people: + head: + age: 70 + employment_income: 150_000 + spouse: + age: 68 + employment_income: 150_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + output: + medicare_irmaa_magi: 300_000 + # 2026 joint, 274,001 up to 342,001: $202.90 + $202.90 a month each. + gross_medicare_part_b_premium: [4_869.6, 4_869.6] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml index 4fb58ad3f70..bcda1dddd8a 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml @@ -1,23 +1,36 @@ -# IRMAA uses MAGI from two years before the benefit year. PolicyEngine models -# 2015 onward, so for 2015 and 2016 benefit years the income year (2013, 2014) -# is outside the model: only income provided for that year counts, and the -# MAGI is otherwise zero. +# IRMAA uses MAGI from two years before the benefit year. When the simulation +# holds no input for that year or any earlier one, the MAGI of the earliest +# year with inputs (no earlier than 2015, the first modeled year) stands in, +# deflated to the year two years prior by +# calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita (CBO +# AGI divided by population). That index starts in 2015 and is held at its +# 2015 value before then: +# 2015: 9,033,840,000,000 / 326,078,000 = 27,704.54 +# 2016: 9,033,840,000,000 / 328,964,000 = 27,461.49 +# 2022: 14,821,800,000,000 / 339,654,000 = 43,637.94 +# 2023: 15,347,400,000,000 / 342,835,000 = 44,766.14 +# 2024: 16,685,900,000,000 / 346,588,000 = 48,143.33 +# For 2015 and 2016 benefit years the income year (2013, 2014) precedes the +# model: income provided for it counts, and only that. -- name: Case 1, 2015 benefit year reads no 2013 income. +- name: Case 1, 2015 benefit year with only 2015 inputs uses 2015 income. period: 2015 input: age: 66 employment_income: 500_000 output: - medicare_irmaa_magi_two_years_prior: 0 + # 500,000 x 27,704.54 (2013, held at 2015) / 27,704.54 (2015). + medicare_irmaa_magi_two_years_prior: 500_000 -- name: Case 2, 2016 benefit year reads no 2014 income. +- name: Case 2, 2016 benefit year with only 2016 inputs uses 2016 income. period: 2016 + absolute_error_margin: 0.01 input: age: 66 employment_income: 500_000 output: - medicare_irmaa_magi_two_years_prior: 0 + # 500,000 x 27,704.54 (2014, held at 2015) / 27,461.49 (2016). + medicare_irmaa_magi_two_years_prior: 504_425.34 - name: Case 3, 2015 benefit year counts 2013 income that is provided. period: 2015 @@ -52,28 +65,153 @@ output: medicare_irmaa_magi_two_years_prior: 155_000 -- name: Case 6, 2015 Part B premium is the base premium with no IRMAA. +- name: Case 6, 2015 Part B premium has no IRMAA for 2015 income below the first threshold. period: 2015 input: age: 66 - employment_income: 500_000 + employment_income: 100_000 is_medicare_eligible: true # 2015 standard Part B premium of $104.90 per month, supplied directly so # the case tests only the IRMAA amount. base_part_b_premium: 1_258.8 output: + medicare_irmaa_magi_two_years_prior: 100_000 gross_medicare_part_b_premium: 1_258.8 -- name: Case 7, 2016 Part D IRMAA is zero for an enrolled high earner. +- name: Case 7, 2016 Part D IRMAA is zero for an enrolled earner below the first threshold. period: 2016 + absolute_error_margin: 0.01 input: age: 66 - employment_income: 500_000 + employment_income: 100_000 is_medicare_eligible: true medicare_enrolled: true output: + # 100,000 x 27,704.54 / 27,461.49, under the first threshold of 103,001. + medicare_irmaa_magi_two_years_prior: 100_885.07 income_adjusted_part_d_premium_surcharge: 0 +- name: A 2025 household with only 2024 inputs has IRMAA set on deflated 2024 income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: + 2024: 66 + employment_income: + 2024: 200_000 + tax_units: + tax_unit: + members: [person] + households: + household: + members: [person] + state_code: + 2024: TX + output: + # 2024 MAGI of 200,000 x 44,766.14 (2023) / 48,143.33 (2024). + medicare_irmaa_magi_two_years_prior: 185_970.28 + medicare_irmaa_magi: 185_970.28 + # 2025 single brackets: 167,001 up to 200,001 adds $295.90 a month to the + # $185 standard premium: 12 x (185 + 295.90) = 5,770.80. + gross_medicare_part_b_premium: 5_770.8 + # Part D adds $57.00 a month in the same bracket. + income_adjusted_part_d_premium_surcharge: 684 + +- name: The same household in 2026 reads its 2024 income without deflation. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: + 2024: 66 + employment_income: + 2024: 200_000 + tax_units: + tax_unit: + members: [person] + households: + household: + members: [person] + state_code: + 2024: TX + output: + medicare_irmaa_magi_two_years_prior: 200_000 + # 2026 single brackets: 171,001 up to 205,001 adds $324.60 a month to the + # $202.90 standard premium: 12 x (202.90 + 324.60) = 6,330. + gross_medicare_part_b_premium: 6_330 + income_adjusted_part_d_premium_surcharge: 724.8 + +- name: The data year itself as the benefit year looks back to deflated income too. + period: 2024 + # MAGI is stored as float32, so allow for its rounding. + absolute_error_margin: 0.1 + input: + people: + person: + age: + 2024: 66 + employment_income: + 2024: 200_000 + tax_units: + tax_unit: + members: [person] + households: + household: + members: [person] + state_code: + 2024: TX + output: + # 200,000 x 43,637.94 (2022) / 48,143.33 (2024). + medicare_irmaa_magi_two_years_prior: 181_283.42 + # 2024 single brackets: 161,001 up to 193,001 adds $279.50 a month to the + # $174.70 standard premium: 12 x (174.70 + 279.50) = 5,450.40. + gross_medicare_part_b_premium: 5_450.4 + +- name: Income provided for the year two years prior is used as given. + period: 2025 + input: + people: + person: + age: + 2024: 66 + employment_income: + 2024: 200_000 + tax_units: + tax_unit: + members: [person] + adjusted_gross_income: + 2023: 50_000 + households: + household: + members: [person] + output: + medicare_irmaa_magi_two_years_prior: 50_000 + gross_medicare_part_b_premium: 2_220 + +- name: A provided IRMAA MAGI overrides the fallback. + period: 2025 + input: + people: + person: + age: + 2024: 66 + employment_income: + 2024: 200_000 + tax_units: + tax_unit: + members: [person] + medicare_irmaa_magi_two_years_prior: + 2025: 90_000 + households: + household: + members: [person] + output: + medicare_irmaa_magi: 90_000 + gross_medicare_part_b_premium: 2_220 + - name: Amounts excluded under IRC sections 135, 911, 931 and 933 are added back. period: 2026 input: diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml index a4837d3c5c5..c940dcd51f4 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/senior_household_2015_2016_integration.yaml @@ -1,6 +1,8 @@ # Households with a member aged 65 or older in 2015 and 2016. Medicare IRMAA # looks two years back, to 2013 and 2014, which precede the first modeled year; -# these calculations raised ParameterNotFoundError before (issue #9613). +# these calculations raised ParameterNotFoundError before (issue #9613). With +# inputs only for the benefit year, the IRMAA MAGI is that year's MAGI deflated +# to two years earlier, far below the first IRMAA threshold here. # # Single 66-year-old in Texas with $10,000 of wages: # - Payroll tax: 7.65% x $10,000 = $765. @@ -30,7 +32,8 @@ members: [person] state_code: TX output: - medicare_irmaa_magi_two_years_prior: 0 + # 10,000 x 27,704.54 (2013, held at 2015) / 27,704.54 (2015). + medicare_irmaa_magi_two_years_prior: 10_000 income_tax: 0 ssi: 4_306 snap: 192 @@ -49,7 +52,8 @@ members: [person] state_code: TX output: - medicare_irmaa_magi_two_years_prior: 0 + # 10,000 x 27,704.54 (2014, held at 2015) / 27,461.49 (2016). + medicare_irmaa_magi_two_years_prior: 10_088.51 income_tax: 0 ssi: 4_306 snap: 192 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_first_modeled_year.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_first_modeled_year.py index 3c3c41f93f9..d2a94141895 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_first_modeled_year.py +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_first_modeled_year.py @@ -2,7 +2,9 @@ Issue #9613: a 2015 or 2016 household with a member aged 65 or older raised ParameterNotFoundError because the IRMAA MAGI read 2013 or 2014 income, before -the first year the parameters are backdated to. +the first year the parameters are backdated to. With inputs only for the +benefit year, the IRMAA MAGI is that year's MAGI deflated to two years +earlier; with inputs for 2013 or 2014, only the provided income counts. """ import pytest @@ -62,7 +64,7 @@ def test_first_modeled_year_is_the_backdating_floor(): @pytest.mark.parametrize("year", UNMODELED_LAG_YEARS) @pytest.mark.parametrize("filing_status", FILING_STATUSES) @pytest.mark.parametrize("employment_income", INCOMES) -def test_magi_is_zero_when_lag_precedes_the_model( +def test_benefit_year_income_stands_in_when_lag_precedes_the_model( year, filing_status, employment_income ): sim = senior_household( @@ -74,13 +76,24 @@ def test_magi_is_zero_when_lag_precedes_the_model( }, tax_unit_inputs={"filing_status": {year: filing_status}}, ) - assert sim.calculate("medicare_irmaa_magi_two_years_prior", year)[0] == 0 - # With a zero MAGI, no IRMAA is added to the base Part B premium and no - # Part D surcharge applies. + index = SYSTEM.parameters.calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita + # The index starts in 2015 and is held at that value for 2013 and 2014. + deflator = index(f"{FIRST_MODELED_YEAR}-01-01") / index(f"{year}-01-01") + benefit_year_magi = sim.calculate("medicare_irmaa_tax_year_magi", year)[0] + magi = sim.calculate("medicare_irmaa_magi_two_years_prior", year)[0] + assert magi == pytest.approx(benefit_year_magi * deflator, rel=1e-6) + # The brackets apply to that MAGI: the Part B premium adds the IRMAA + # amount to the base premium, and Part D charges its surcharge. + p = SYSTEM.parameters(f"{year}-01-01").gov.hhs.medicare + scale = filing_status.lower() gross = sim.calculate("gross_medicare_part_b_premium", year)[0] base = sim.calculate("base_part_b_premium", year)[0] - assert gross == pytest.approx(base) - assert sim.calculate("income_adjusted_part_d_premium_surcharge", year)[0] == 0 + assert gross == pytest.approx( + base + 12 * getattr(p.part_b.irmaa, scale).calc(magi), abs=0.01 + ) + assert sim.calculate("income_adjusted_part_d_premium_surcharge", year)[ + 0 + ] == pytest.approx(12 * getattr(p.part_d.irmaa, scale).calc(magi), abs=0.01) @pytest.mark.parametrize("year", MODELED_LAG_YEARS) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py new file mode 100644 index 00000000000..d3084d5e016 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py @@ -0,0 +1,344 @@ +"""Medicare IRMAA's income lookback fallback and same-year income switch. + +The law sets IRMAA on MAGI from two years before the benefit year. When a +simulation holds no input for that year or any earlier one (a single-year +dataset two years or less after its data year, or a household entered for one +year), the MAGI of the earliest year with inputs stands in, deflated to the +lookback year. gov.simulation.medicare_irmaa_uses_current_year_income sets the +charges from the benefit year's own MAGI instead. + +Invariants checked here, for every input drawn: +- the Part B IRMAA amount and the Part D surcharge never fall as MAGI rises; +- both are zero below the first IRMAA threshold and positive from it on; +- the fallback is the earliest input year's MAGI times one positive factor, + the same for every tax unit, so it preserves order and zero; +- provided inputs for the lookback year, or a provided IRMAA MAGI, win; +- the result does not depend on what else was calculated first; +- with the switch on, the charges follow the benefit year's income, so a + counterfactual earnings change in that year moves them, and with it off a + known lag income keeps them fixed. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_us import CountryTaxBenefitSystem, Simulation +from policyengine_us.data.economic_assumptions import DEFAULT_MICRODATA_UPRATING +from policyengine_us.tools.general import earliest_input_year +from policyengine_us.tools.per_capita_uprating import per_capita_path +from policyengine_us.variables.gov.hhs.medicare.eligibility.medicare_irmaa_magi_two_years_prior import ( + LOOKBACK_DEFLATOR, +) +from policyengine_us.variables.household.marginal_tax_rate_helpers import ( + user_set_variables, +) + +SYSTEM = CountryTaxBenefitSystem() +SWITCH = "gov.simulation.medicare_irmaa_uses_current_year_income" +SAME_YEAR = Reform.from_dict({SWITCH: {"2015-01-01.2100-12-31": True}}, "us") +FILING_STATUSES = [ + "SINGLE", + "JOINT", + "HEAD_OF_HOUSEHOLD", + "SURVIVING_SPOUSE", + "SEPARATE", +] +# Years with brackets of their own; earlier years are backdated from 2024. +BRACKET_YEARS = [2024, 2025, 2026] +DEFLATOR = SYSTEM.parameters.calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita + + +def deflator(lag_year, source_year): + return DEFLATOR(f"{lag_year}-01-01") / DEFLATOR(f"{source_year}-01-01") + + +def senior(person_inputs, tax_unit_inputs=None, reform=None): + return Simulation( + situation={ + "people": {"person": {**person_inputs}}, + "tax_units": { + "tax_unit": {"members": ["person"], **(tax_unit_inputs or {})} + }, + "households": {"household": {"members": ["person"]}}, + }, + reform=reform, + ) + + +def part_b_irmaa(sim, year): + return sim.calculate("gross_medicare_part_b_premium", year) - sim.calculate( + "base_part_b_premium", year + ) + + +def part_d_irmaa(sim, year): + return sim.calculate("income_adjusted_part_d_premium_surcharge", year) + + +# Fallback + + +def test_deflator_is_the_dataset_extension_index(): + # Deflating runs the single-year dataset extension backward only while + # both use the same index. + assert LOOKBACK_DEFLATOR == per_capita_path(DEFAULT_MICRODATA_UPRATING) + + +def test_household_with_only_prior_year_inputs_pays_irmaa(): + sim = senior({"age": {2024: 66}, "employment_income": {2024: 200_000}}) + assert earliest_input_year(sim) == 2024 + magi = sim.calculate("medicare_irmaa_magi_two_years_prior", 2025)[0] + assert magi == pytest.approx(200_000 * deflator(2023, 2024), rel=1e-6) + assert part_b_irmaa(sim, 2025)[0] > 0 + assert part_d_irmaa(sim, 2025)[0] > 0 + + +def test_multi_year_inputs_use_the_fallback_only_before_the_first_year(): + # As in a dataset extended from 2024: inputs for 2024 and 2025. + sim = senior( + { + "age": {2024: 66, 2025: 67}, + "employment_income": {2024: 200_000, 2025: 250_000}, + } + ) + + def lag(year): + return sim.calculate("medicare_irmaa_magi_two_years_prior", year)[0] + + def tax_year(year): + return sim.calculate("medicare_irmaa_tax_year_magi", year)[0] + + assert lag(2025) == pytest.approx(tax_year(2024) * deflator(2023, 2024), rel=1e-6) + assert lag(2026) == pytest.approx(tax_year(2024)) + assert lag(2027) == pytest.approx(tax_year(2025)) + assert tax_year(2025) == pytest.approx(250_000) + + +def test_fallback_reads_nothing_before_the_first_input_year(): + sim = senior({"age": {2024: 66}, "employment_income": {2024: 200_000}}) + sim.calculate("gross_medicare_part_b_premium", 2025) + sim.calculate("income_adjusted_part_d_premium_surcharge", 2025) + for variable in ["adjusted_gross_income", "medicare_irmaa_tax_year_magi"]: + years = {period.start.year for period in sim.get_known_periods(variable)} + assert 2023 not in years, variable + + +@pytest.mark.parametrize( + "calculated_first", + [ + [], + [("adjusted_gross_income", 2023)], + [("medicare_irmaa_tax_year_magi", 2023)], + [("income_tax", 2023), ("income_tax", 2021)], + [("household_net_income", 2026), ("medicare_irmaa_magi", 2027)], + ], +) +def test_fallback_does_not_depend_on_calculation_order(calculated_first): + sim = senior({"age": {2024: 66}, "employment_income": {2024: 200_000}}) + for variable, year in calculated_first: + sim.calculate(variable, year) + assert earliest_input_year(sim) == 2024 + assert sim.calculate("medicare_irmaa_magi_two_years_prior", 2025)[ + 0 + ] == pytest.approx(200_000 * deflator(2023, 2024), rel=1e-6) + + +@pytest.mark.parametrize( + "tax_unit_inputs, expected", + [ + ({"adjusted_gross_income": {2023: 50_000}}, 50_000), + ({"medicare_irmaa_tax_year_magi": {2023: 60_000}}, 60_000), + ({"medicare_irmaa_magi_two_years_prior": {2025: 70_000}}, 70_000), + ], +) +def test_provided_lookback_income_is_authoritative(tax_unit_inputs, expected): + sim = senior( + {"age": {2024: 66}, "employment_income": {2024: 500_000}}, + tax_unit_inputs, + ) + assert sim.calculate("medicare_irmaa_magi", 2025)[0] == pytest.approx(expected) + assert part_b_irmaa(sim, 2025)[0] == 0 + + +@settings( + max_examples=10, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists( + st.integers(min_value=-50_000, max_value=2_000_000), min_size=2, max_size=12 + ), + st.sampled_from([2024, 2025]), +) +def test_fallback_scales_every_tax_unit_by_one_positive_factor(incomes, year): + # Benefit years 2024 and 2025 look back to 2022 and 2023, before the 2024 + # inputs; every tax unit's lag MAGI is its 2024 MAGI times the same factor. + people, tax_units, households = {}, {}, {} + for i, income in enumerate(incomes): + people[f"p{i}"] = {"age": {2024: 70}, "employment_income": {2024: income}} + tax_units[f"t{i}"] = {"members": [f"p{i}"]} + households[f"h{i}"] = {"members": [f"p{i}"]} + sim = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + source = sim.calculate("medicare_irmaa_tax_year_magi", 2024) + lag = sim.calculate("medicare_irmaa_magi_two_years_prior", year) + factor = deflator(year - 2, 2024) + assert factor > 0 + np.testing.assert_allclose(lag, source * factor, rtol=1e-6, atol=0.05) + + +# Same-year switch + + +def test_switch_sets_irmaa_from_the_benefit_years_income(): + person = {"age": {2023: 66}, "employment_income": {2023: 50_000, 2025: 300_000}} + law = senior(person) + same_year = senior(person, reform=SAME_YEAR) + assert law.calculate("medicare_irmaa_magi", 2025)[0] == pytest.approx(50_000) + assert part_b_irmaa(law, 2025)[0] == 0 + assert same_year.calculate("medicare_irmaa_magi", 2025)[0] == pytest.approx(300_000) + # 2025 single, 200,001 up to 500,000: $406.90 a month. + assert part_b_irmaa(same_year, 2025)[0] == pytest.approx(12 * 406.9) + assert part_d_irmaa(same_year, 2025)[0] == pytest.approx(12 * 78.6) + + +def test_switch_ignores_a_provided_lag_magi(): + sim = senior( + {"age": {2025: 66}, "employment_income": {2025: 300_000}}, + {"medicare_irmaa_magi_two_years_prior": {2025: 0}}, + reform=SAME_YEAR, + ) + assert sim.calculate("medicare_irmaa_magi", 2025)[0] == pytest.approx(300_000) + + +def test_switch_does_not_deflate_benefit_year_income(): + # Inputs only for 2024: the same-year MAGI is 2025 income carried forward + # from 2024, not the fallback's deflated 2024 income. + sim = senior( + {"age": {2024: 66}, "employment_income": {2024: 200_000}}, reform=SAME_YEAR + ) + assert sim.calculate("medicare_irmaa_magi", 2025)[0] == pytest.approx( + sim.calculate("medicare_irmaa_tax_year_magi", 2025)[0] + ) + + +def bump_earnings(sim, year, delta): + """The earnings change marginal_tax_rate makes, in a branch of ``sim``.""" + branch = sim.get_branch("irmaa_bump") + inputs = user_set_variables(sim) + for variable in sim.tax_benefit_system.variables: + if variable not in inputs or variable == "employment_income": + branch.delete_arrays(variable) + branch.set_input( + "employment_income", year, sim.calculate("employment_income", year) + delta + ) + return branch + + +@pytest.mark.parametrize("same_year", [False, True]) +def test_counterfactual_earnings_move_irmaa_only_with_the_switch(same_year): + # The 2023 MAGI is known, so under the law the 2025 charge is fixed; 2025 + # earnings of 199,500 sit just under the 200,001 threshold. The lag MAGI + # is provided directly: a marginal tax rate branch keeps it, whereas it + # drops the earnings inputs of years other than the one it changes. + sim = senior( + {"age": {2025: 66}, "employment_income": {2025: 199_500}}, + {"medicare_irmaa_magi_two_years_prior": {2025: 150_000}}, + reform=SAME_YEAR if same_year else None, + ) + before = sim.calculate("gross_medicare_part_b_premium", 2025)[0] + branch = bump_earnings(sim, 2025, 1_000) + after = branch.calculate("gross_medicare_part_b_premium", 2025)[0] + if same_year: + # 167,001 up to 200,001 is $295.90 a month; above it, $406.90. + assert after - before == pytest.approx(12 * (406.9 - 295.9)) + else: + # 133,001 up to 167,001: $185 a month on the $185 standard premium. + assert before == pytest.approx(12 * (185 + 185)) + assert after == pytest.approx(before) + + +# Bracket properties + + +def irmaa_by_magi(magis, filing_status, year): + people, tax_units, households = {}, {}, {} + for i, magi in enumerate(magis): + people[f"p{i}"] = { + "age": {year: 70}, + "is_medicare_eligible": {year: True}, + "medicare_enrolled": {year: True}, + } + tax_units[f"t{i}"] = { + "members": [f"p{i}"], + "filing_status": {year: filing_status}, + "medicare_irmaa_magi": {year: magi}, + } + households[f"h{i}"] = {"members": [f"p{i}"]} + sim = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + return part_b_irmaa(sim, year), part_d_irmaa(sim, year) + + +def first_threshold(part, filing_status, year): + scale = getattr( + getattr(SYSTEM.parameters(f"{year}-01-01").gov.hhs.medicare, part).irmaa, + filing_status.lower(), + ) + return scale.thresholds[1] + + +@settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists( + st.floats(min_value=-1e6, max_value=3e6, allow_nan=False), + min_size=2, + max_size=15, + ), + st.sampled_from(FILING_STATUSES), + st.sampled_from(BRACKET_YEARS), +) +def test_surcharges_never_fall_as_magi_rises(magis, filing_status, year): + magis = sorted(magis) + part_b, part_d = irmaa_by_magi(magis, filing_status, year) + assert np.all(np.diff(part_b) >= 0) + assert np.all(np.diff(part_d) >= 0) + + +@settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.data(), + st.sampled_from(FILING_STATUSES), + st.sampled_from(BRACKET_YEARS), +) +def test_surcharges_are_zero_below_the_first_threshold(data, filing_status, year): + threshold = first_threshold("part_b", filing_status, year) + assert threshold == first_threshold("part_d", filing_status, year) + below = data.draw( + st.lists( + st.floats(min_value=-1e6, max_value=threshold, exclude_max=True), + min_size=1, + max_size=10, + ) + ) + above = data.draw( + st.lists(st.floats(min_value=threshold, max_value=5e6), min_size=1, max_size=10) + ) + part_b, part_d = irmaa_by_magi(below + above, filing_status, year) + n = len(below) + assert np.all(part_b[:n] == 0) and np.all(part_d[:n] == 0) + assert np.all(part_b[n:] > 0) and np.all(part_d[n:] > 0) diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 5e9b7accd28..5c6bb5a1a0d 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -39,6 +39,33 @@ def has_input_for_period(simulation, variable, period): return False +def earliest_input_year(simulation): + """The earliest year for which the simulation holds a supplied input. + + Supplied inputs are the values ``set_input`` stored, from a dataset, a + situation, or a branch this simulation reads, and still stores. Core + records them apart from the values formulas calculate, so the answer does + not depend on what has been calculated. Inputs for an eternal variable + have no year and are skipped. Returns None when there are none. + + Before this year every input reads as its default: a variable is carried + over or uprated only from a period it is known in, never back from a + later one. + """ + visible_branches = set(simulation._get_visible_branch_names()) + periods_by_variable = {} + for variable, branch_name, period in getattr(simulation, "_user_input_keys", ()): + if branch_name in visible_branches and period.unit != ETERNITY: + periods_by_variable.setdefault(variable, set()).add((branch_name, period)) + years = [ + period.start.year + for variable, input_keys in periods_by_variable.items() + for branch_name, period in input_keys + & set(simulation.get_holder(variable).get_known_branch_periods()) + ] + return min(years, default=None) + + def tax_unit_non_dep_sum(var, tax_unit, period): return tax_unit.sum( tax_unit.members(var, period) diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi.py new file mode 100644 index 00000000000..c0b5ed8560f --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class medicare_irmaa_magi(Variable): + value_type = float + entity = TaxUnit + label = "Medicare IRMAA MAGI applied in the benefit year" + unit = USD + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/42/1395r#i_4_B" + documentation = ( + "Modified adjusted gross income that the Medicare Part B and Part D " + "income-related monthly adjustment amounts apply to in the benefit " + "year. The law uses the income from two years earlier " + "(medicare_irmaa_magi_two_years_prior). When " + "gov.simulation.medicare_irmaa_uses_current_year_income is true, the " + "benefit year's own income is used instead " + "(medicare_irmaa_tax_year_magi), so the charges move with that year's " + "earnings." + ) + + def formula(tax_unit, period, parameters): + if parameters(period).gov.simulation.medicare_irmaa_uses_current_year_income: + return tax_unit("medicare_irmaa_tax_year_magi", period) + return tax_unit("medicare_irmaa_magi_two_years_prior", period) diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index 0507bc3d442..c10fccc8854 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -1,25 +1,27 @@ +from functools import reduce + from policyengine_us.model_api import * from policyengine_us.tools.parameters import FIRST_MODELED_YEAR +from policyengine_us.variables.gov.hhs.medicare.eligibility.medicare_irmaa_tax_year_magi import ( + PERSON_ADDITIONS, + TAX_UNIT_ADDITIONS, +) + +# Per-capita CBO AGI: the series that extends a single-year dataset's dollar +# inputs to later years when they have no series of their own +# (DEFAULT_MICRODATA_UPRATING, divided by population). Deflating by it runs +# that projection backward. +LOOKBACK_DEFLATOR = ( + "calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita" +) + -# 42 U.S.C. 1395r(i)(4)(A): adjusted gross income (i) determined without -# regard to sections 135, 911, 931 and 933 of the Internal Revenue Code and -# (ii) increased by tax-exempt interest. Income inputs are net of the section -# 911 amounts (Form 2555 lines 45 and 50); sections 135, 931 and 933 are -# above-the-line deductions in this model. Adding each amount back undoes it. -TAX_UNIT_ADDITIONS = [ - "section_911_excluded_income", - "specified_possession_income", - "puerto_rico_income", -] -# Person-level amounts on the filer's return, summed over the members whose -# amounts adjusted gross income counts: the head and spouse, and every member -# for amounts listed in gov.irs.ald.filer_amounts_recorded_on_dependents. A -# dependent's own tax-exempt interest is on the dependent's return. The -# section 135 exclusion is listed: only a bond issued to an owner aged 24 or -# older qualifies (26 U.S.C. 135(c)(1)(B); Form 8815), so an amount recorded -# on a dependent is the filer's exclusion for the dependent's tuition, which -# adjusted gross income deducts and this adds back. -PERSON_ADDITIONS = ["tax_exempt_interest_income", "us_bonds_for_higher_ed"] +def index_at(index, instant): + """The index at ``instant``, held at its first value before the series + begins, as policyengine-core holds an uprating index.""" + value = index(instant) + # ``values_list`` runs from the latest instant to the earliest. + return index.values_list[-1].value if value is None else value class medicare_irmaa_magi_two_years_prior(Variable): @@ -34,40 +36,50 @@ class medicare_irmaa_magi_two_years_prior(Variable): "https://www.irs.gov/pub/irs-prior/f8815--2025.pdf#page=3", ) documentation = ( - "Modified adjusted gross income used to determine Medicare IRMAA " - "charges. Callers may provide this value directly for the current " - "benefit year. When it is not provided, PolicyEngine computes it from " - "two years prior as adjusted gross income plus the head's and " - "spouse's tax-exempt interest and the amounts excluded or deducted " - "under IRC sections 135, 911, 931 and 933. " - "Single-year datasets without lagged income inputs therefore default " - "to the modeled prior-year values, which may be zero. When the year " - "two years prior precedes the first year PolicyEngine models (2015), " - "only income provided for that year counts, and the rest is zero." + "Modified adjusted gross income from two years before the benefit " + "year, which sets Medicare IRMAA charges " + "(medicare_irmaa_tax_year_magi for that year). Callers may provide " + "this value directly for the benefit year. " + "When the simulation holds no input for the year two years prior or " + "any earlier year, every input would read as its default there, so " + "PolicyEngine instead uses the IRMAA MAGI of the earliest year it " + "holds inputs for (no earlier than 2015, the first year it models), " + "deflated to the year two years prior by " + "calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita, " + "the index that extends single-year datasets to later years. A " + "single-year dataset thus supplies its own year's income to the two " + "benefit years whose income year precedes it. Any input supplied for " + "the year two years prior, or earlier, turns this off: the year's " + "inputs are then taken as given. " + "When that year precedes 2015, only income provided for it counts, " + "and the rest is zero." ) def formula(tax_unit, period, parameters): prior_period = period.offset(-2, "year") - if prior_period.start.year >= FIRST_MODELED_YEAR: - filer_amounts = parameters( - prior_period - ).gov.irs.ald.filer_amounts_recorded_on_dependents - return add( - tax_unit, - prior_period, - ["adjusted_gross_income", *TAX_UNIT_ADDITIONS], - ) + tax_unit_non_dep_add( - tax_unit, - prior_period, - PERSON_ADDITIONS, - include_dependents=filer_amounts, + first_input_year = earliest_input_year(tax_unit.simulation) + if first_input_year is None or prior_period.start.year < first_input_year: + # Inputs start after the year two years prior. Use the earliest + # year that has them, which may be the benefit year itself. + source_year = max( + (period.start.year if first_input_year is None else first_input_year), + FIRST_MODELED_YEAR, + ) + source_period = period.offset(source_year - period.start.year, "year") + magi = tax_unit("medicare_irmaa_tax_year_magi", source_period) + index = reduce(getattr, LOOKBACK_DEFLATOR.split("."), parameters) + return ( + magi + * index_at(index, prior_period.start) + / index_at(index, source_period.start) ) - # Benefit years 2015 and 2016 look back to 2013 and 2014, before the - # parameters begin, so income for those years cannot be computed. Use - # income provided for them and treat the rest as zero, the value the - # formula yields in later years when no prior-year income is provided. - # Reading stored values computes and caches nothing for those years; - # tax unit roles are the benefit year's. + if prior_period.start.year >= FIRST_MODELED_YEAR: + return tax_unit("medicare_irmaa_tax_year_magi", prior_period) + # Inputs reach back to 2013 or 2014, before the parameters begin, so + # income for those years cannot be computed. Use income provided for + # them and treat the rest as zero. Reading stored values computes and + # caches nothing for those years; tax unit roles are the benefit + # year's. simulation = tax_unit.simulation filer_amounts = parameters( period diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_tax_year_magi.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_tax_year_magi.py new file mode 100644 index 00000000000..50320f28afa --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_tax_year_magi.py @@ -0,0 +1,57 @@ +from policyengine_us.model_api import * + +# 42 U.S.C. 1395r(i)(4)(A): adjusted gross income (i) determined without +# regard to sections 135, 911, 931 and 933 of the Internal Revenue Code and +# (ii) increased by tax-exempt interest. Income inputs are net of the section +# 911 amounts (Form 2555 lines 45 and 50); sections 135, 931 and 933 are +# above-the-line deductions in this model. Adding each amount back undoes it. +TAX_UNIT_ADDITIONS = [ + "section_911_excluded_income", + "specified_possession_income", + "puerto_rico_income", +] +# Person-level amounts on the filer's return, summed over the members whose +# amounts adjusted gross income counts: the head and spouse, and every member +# for amounts listed in gov.irs.ald.filer_amounts_recorded_on_dependents. A +# dependent's own tax-exempt interest is on the dependent's return. The +# section 135 exclusion is listed: only a bond issued to an owner aged 24 or +# older qualifies (26 U.S.C. 135(c)(1)(B); Form 8815), so an amount recorded +# on a dependent is the filer's exclusion for the dependent's tuition, which +# adjusted gross income deducts and this adds back. +PERSON_ADDITIONS = ["tax_exempt_interest_income", "us_bonds_for_higher_ed"] + + +class medicare_irmaa_tax_year_magi(Variable): + value_type = float + entity = TaxUnit + label = "Medicare IRMAA MAGI on this tax year's return" + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395r#i_4", + "https://www.law.cornell.edu/uscode/text/26/135#c_1", + "https://www.irs.gov/pub/irs-prior/f8815--2025.pdf#page=3", + ) + documentation = ( + "Modified adjusted gross income as Medicare IRMAA defines it, from " + "this tax year's income: adjusted gross income plus the head's and " + "spouse's tax-exempt interest and the amounts excluded or deducted " + "under IRC sections 135, 911, 931 and 933. IRMAA for a benefit year " + "normally applies this amount from two years earlier " + "(medicare_irmaa_magi_two_years_prior)." + ) + + def formula(tax_unit, period, parameters): + filer_amounts = parameters( + period + ).gov.irs.ald.filer_amounts_recorded_on_dependents + return add( + tax_unit, + period, + ["adjusted_gross_income", *TAX_UNIT_ADDITIONS], + ) + tax_unit_non_dep_add( + tax_unit, + period, + PERSON_ADDITIONS, + include_dependents=filer_amounts, + ) diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py index df2cd3bfe20..831bf5e8b9e 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_b/gross_medicare_part_b_premium.py @@ -9,7 +9,7 @@ class gross_medicare_part_b_premium(Variable): definition_period = YEAR defined_for = "is_medicare_eligible" reference = "https://www.medicare.gov/basics/costs/medicare-costs#partB" - documentation = "Annual Medicare Part B premium before Medicare Savings Program coverage, including any income-related monthly adjustment amount. Based on modified adjusted gross income from 2 years prior." + documentation = "Annual Medicare Part B premium before Medicare Savings Program coverage, including any income-related monthly adjustment amount. Based on modified adjusted gross income from 2 years prior, or from the benefit year when gov.simulation.medicare_irmaa_uses_current_year_income is true (medicare_irmaa_magi)." def formula(person, period, parameters): tax_unit = person.tax_unit @@ -21,7 +21,7 @@ def formula(person, period, parameters): is_head_of_household = filing_status == status.HEAD_OF_HOUSEHOLD is_surviving_spouse = filing_status == status.SURVIVING_SPOUSE is_separated = filing_status == status.SEPARATE - magi = tax_unit("medicare_irmaa_magi_two_years_prior", period) + magi = tax_unit("medicare_irmaa_magi", period) base = person("base_part_b_premium", period) p = parameters(period).gov.hhs.medicare.part_b.irmaa diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py index b4813a80226..32a95a71946 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/part_d/income_adjusted_part_d_premium_surcharge.py @@ -9,12 +9,12 @@ class income_adjusted_part_d_premium_surcharge(Variable): definition_period = YEAR defined_for = "medicare_enrolled" reference = "https://www.law.cornell.edu/cfr/text/42/423.286" - documentation = "Annualized Medicare Part D IRMAA surcharge under 42 U.S.C. 1395w-113(a)(7). This is the federally set income-related premium adjustment amount, paid in addition to the beneficiary's underlying Part D plan premium. Annual bracket values come from CMS fact sheets." + documentation = "Annualized Medicare Part D IRMAA surcharge under 42 U.S.C. 1395w-113(a)(7). This is the federally set income-related premium adjustment amount, paid in addition to the beneficiary's underlying Part D plan premium. Annual bracket values come from CMS fact sheets. The brackets apply to medicare_irmaa_magi: income from two years prior, or from the benefit year when gov.simulation.medicare_irmaa_uses_current_year_income is true." def formula(person, period, parameters): tax_unit = person.tax_unit filing_status = tax_unit("filing_status", period) - magi = tax_unit("medicare_irmaa_magi_two_years_prior", period) + magi = tax_unit("medicare_irmaa_magi", period) status = filing_status.possible_values statuses = [ From 701345b605db0eefcbb44ca5882300b6f224849e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:01:40 -0400 Subject: [PATCH 2/4] Draw float32 MAGIs in the IRMAA threshold property tests MAGI is stored as float32, so a float64 draw of 106,000.996 is stored as the 106,001 threshold and correctly pays IRMAA. Draw values the variable can hold. Co-Authored-By: Claude Opus 5.5 --- .../hhs/medicare/test_irmaa_lookback_and_switch.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py index d3084d5e016..c5d2c413194 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py @@ -301,7 +301,7 @@ def first_threshold(part, filing_status, year): ) @given( st.lists( - st.floats(min_value=-1e6, max_value=3e6, allow_nan=False), + st.floats(min_value=-1e6, max_value=3e6, width=32), min_size=2, max_size=15, ), @@ -326,17 +326,23 @@ def test_surcharges_never_fall_as_magi_rises(magis, filing_status, year): st.sampled_from(BRACKET_YEARS), ) def test_surcharges_are_zero_below_the_first_threshold(data, filing_status, year): + # MAGI is stored as float32, so draw values it can hold: 106,000.996 would + # be stored as the 106,001 threshold itself. threshold = first_threshold("part_b", filing_status, year) assert threshold == first_threshold("part_d", filing_status, year) below = data.draw( st.lists( - st.floats(min_value=-1e6, max_value=threshold, exclude_max=True), + st.floats(min_value=-1e6, max_value=threshold, exclude_max=True, width=32), min_size=1, max_size=10, ) ) above = data.draw( - st.lists(st.floats(min_value=threshold, max_value=5e6), min_size=1, max_size=10) + st.lists( + st.floats(min_value=threshold, max_value=5e6, width=32), + min_size=1, + max_size=10, + ) ) part_b, part_d = irmaa_by_magi(below + above, filing_status, year) n = len(below) From e0653be488bc43a68b25427d19c36122b0c666c7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:53:09 -0400 Subject: [PATCH 3/4] Document same-year IRMAA imputation and the deflator gap; skip removed variables Review on 701345b: a household entered for one year imputes the income two years prior from that year's income, so its IRMAA moves with that income even with the switch off. Say so in the variable, the switch and the changelog, and pin it with a test. Document that the CBO AGI series has no 2016-2018 values, which distorts one-year households in 2016-2020. earliest_input_year now skips inputs of variables the system no longer has (it raised VariableNotFoundError) and carries the core 3.32.27 key caveat. Co-Authored-By: Claude Opus 5.5 --- changelog.d/irmaa-lookback-fallback.fixed.md | 2 +- ...dicare_irmaa_uses_current_year_income.yaml | 6 +++- .../test_irmaa_lookback_and_switch.py | 34 +++++++++++++++++++ policyengine_us/tools/general.py | 13 ++++++- .../medicare_irmaa_magi_two_years_prior.py | 18 ++++++++-- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/changelog.d/irmaa-lookback-fallback.fixed.md b/changelog.d/irmaa-lookback-fallback.fixed.md index 1b2f49d8fd0..7d20969f703 100644 --- a/changelog.d/irmaa-lookback-fallback.fixed.md +++ b/changelog.d/irmaa-lookback-fallback.fixed.md @@ -1 +1 @@ -Medicare IRMAA no longer drops to zero when the year two years before the benefit year precedes every input (single-year datasets in their first two years, and households entered for one year): the earliest input year's MAGI stands in, deflated to that year by per-capita CBO AGI. +Medicare IRMAA no longer drops to zero when the year two years before the benefit year precedes every input (single-year datasets in their first two years, and households entered for one year): the earliest input year's MAGI stands in, deflated to that year by per-capita CBO AGI. For a household entered for one year this imputes the lookback income from that year's income, so its IRMAA now moves with that income. diff --git a/policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml b/policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml index 48ef6233601..ead9b664b0d 100644 --- a/policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml +++ b/policyengine_us/parameters/gov/simulation/medicare_irmaa_uses_current_year_income.yaml @@ -2,7 +2,11 @@ description: PolicyEngine sets Medicare income-related monthly adjustment amount # The law sets IRMAA on income from two years before the benefit year, so a # change in this year's earnings meets the surcharge two years later. Marginal # tax rate and behavioral response analyses that treat a lasting earnings -# change as meeting the surcharge in the same year turn this on. +# change as meeting the surcharge in the same year turn this on. Even when it +# is off, a simulation whose earliest input year is the benefit year (a +# household entered for one year) imputes the income two years prior from the +# benefit year's income, deflated, so its charges already move with that +# income; see medicare_irmaa_magi_two_years_prior. values: 0000-01-01: false metadata: diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py index c5d2c413194..78d3428946d 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py @@ -263,6 +263,40 @@ def test_counterfactual_earnings_move_irmaa_only_with_the_switch(same_year): assert after == pytest.approx(before) +def test_one_year_household_irmaa_moves_with_that_years_income(): + # Intended: with inputs only for the benefit year, the income two years + # prior is imputed from that year's income, so a counterfactual earnings + # change moves the charges even with the switch off. 2026 earnings of + # 120,500 deflate to 108,653, under the 109,001 threshold; 121,500 deflate + # to 109,555, over it. + sim = senior( + {"age": {2026: 66}, "employment_income": {2026: 120_500}}, + ) + factor = deflator(2024, 2026) + assert sim.calculate("medicare_irmaa_magi", 2026)[0] == pytest.approx( + 120_500 * factor, rel=1e-6 + ) + before = sim.calculate("gross_medicare_part_b_premium", 2026)[0] + branch = bump_earnings(sim, 2026, 1_000) + after = branch.calculate("gross_medicare_part_b_premium", 2026)[0] + # 2026 single, 109,001 up to 137,001: $81.20 a month. + assert after - before == pytest.approx(12 * 81.2) + + +def test_earliest_input_year_skips_variables_the_system_no_longer_has(): + sim = senior( + { + "age": {2024: 66}, + "employment_income": {2024: 200_000}, + "tax_exempt_interest_income": {2023: 5}, + } + ) + assert earliest_input_year(sim) == 2023 + # A reform that removes a variable leaves its recorded input keys behind. + sim.tax_benefit_system.variables.pop("tax_exempt_interest_income") + assert earliest_input_year(sim) == 2024 + + # Bracket properties diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 5c6bb5a1a0d..b8c6df4400e 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -51,11 +51,22 @@ def earliest_input_year(simulation): Before this year every input reads as its default: a variable is carried over or uprated only from a period it is known in, never back from a later one. + + Inputs of a variable the tax-benefit system no longer has are skipped. + As with ``has_input_for_period``, core versions before 3.32.27 can keep + an input key after the input is deleted, and a value recalculated for + that period then still counts. Formulas that read this cache their + result, so inputs set after they have run do not change it. """ visible_branches = set(simulation._get_visible_branch_names()) + variables = simulation.tax_benefit_system.variables periods_by_variable = {} for variable, branch_name, period in getattr(simulation, "_user_input_keys", ()): - if branch_name in visible_branches and period.unit != ETERNITY: + if ( + branch_name in visible_branches + and period.unit != ETERNITY + and variable in variables + ): periods_by_variable.setdefault(variable, set()).add((branch_name, period)) years = [ period.start.year diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index c10fccc8854..292dbee9318 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -51,8 +51,22 @@ class medicare_irmaa_magi_two_years_prior(Variable): "benefit years whose income year precedes it. Any input supplied for " "the year two years prior, or earlier, turns this off: the year's " "inputs are then taken as given. " - "When that year precedes 2015, only income provided for it counts, " - "and the rest is zero." + "When the earliest input year is the benefit year itself, as for a " + "household entered for one year or a dataset's own year, the income " + "two years prior is imputed from the benefit year's income, so the " + "charges then move with that income in reforms and counterfactual " + "branches, as they do under " + "gov.simulation.medicare_irmaa_uses_current_year_income but " + "deflated. Provide this value, or inputs for the year two years " + "prior, to hold it fixed. " + "CBO AGI has no values for 2016 to 2018 and is held at its 2015 " + "value, so the index drifts down through 2018 and then rises 31% " + "from 2017 to 2019 and 39% from 2018 to 2020: a household entered " + "for 2016, 2017 or 2018 alone gets slightly more than that year's " + "income, and one entered for 2019 or 2020 alone gets that year's " + "income cut by those factors. " + "When the year two years prior precedes 2015, only income provided " + "for it counts, and the rest is zero." ) def formula(tax_unit, period, parameters): From 6c0dd85d3ce07b842d0c4f928cc16be1a8c23460 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:18:15 -0400 Subject: [PATCH 4/4] Skip neutralized variables' inputs; tighten IRMAA lookback docs Re-review nits on e0653be: a neutralized variable reads as its default, but its stored input still pulled earliest_input_year back and switched the fallback off (reproduced: 2025 lag MAGI 0 instead of deflated 2024 MAGI). Skip it, as has_input_for_period does, with a regression test. Scope the pre-2015 docstring sentence to inputs that reach back that far, give the 2019/2020 multipliers directly, and fix the off-by-one in the test module docstring. Co-Authored-By: Claude Opus 5.5 --- .../test_irmaa_lookback_and_switch.py | 19 ++++++++++++++++++- policyengine_us/tools/general.py | 6 ++++-- .../medicare_irmaa_magi_two_years_prior.py | 7 ++++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py index 78d3428946d..14eb48e698c 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/test_irmaa_lookback_and_switch.py @@ -2,7 +2,7 @@ The law sets IRMAA on MAGI from two years before the benefit year. When a simulation holds no input for that year or any earlier one (a single-year -dataset two years or less after its data year, or a household entered for one +dataset in its data year and the year after, or a household entered for one year), the MAGI of the earliest year with inputs stands in, deflated to the lookback year. gov.simulation.medicare_irmaa_uses_current_year_income sets the charges from the benefit year's own MAGI instead. @@ -297,6 +297,23 @@ def test_earliest_input_year_skips_variables_the_system_no_longer_has(): assert earliest_input_year(sim) == 2024 +def test_earliest_input_year_skips_neutralized_variables(): + # A neutralized variable reads as its default, so its input is never used: + # its 2023 input must not turn the 2025 fallback off. + sim = senior( + { + "age": {2024: 66}, + "employment_income": {2024: 200_000}, + "tax_exempt_interest_income": {2023: 5}, + } + ) + sim.tax_benefit_system.neutralize_variable("tax_exempt_interest_income") + assert earliest_input_year(sim) == 2024 + assert sim.calculate("medicare_irmaa_magi_two_years_prior", 2025)[ + 0 + ] == pytest.approx(200_000 * deflator(2023, 2024), rel=1e-6) + + # Bracket properties diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index b8c6df4400e..c5ac8779e6e 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -52,8 +52,9 @@ def earliest_input_year(simulation): over or uprated only from a period it is known in, never back from a later one. - Inputs of a variable the tax-benefit system no longer has are skipped. - As with ``has_input_for_period``, core versions before 3.32.27 can keep + Inputs of a variable the tax-benefit system no longer has, or has + neutralized, are skipped: they are never read. As with + ``has_input_for_period``, core versions before 3.32.27 can keep an input key after the input is deleted, and a value recalculated for that period then still counts. Formulas that read this cache their result, so inputs set after they have run do not change it. @@ -66,6 +67,7 @@ def earliest_input_year(simulation): branch_name in visible_branches and period.unit != ETERNITY and variable in variables + and not variables[variable].is_neutralized ): periods_by_variable.setdefault(variable, set()).add((branch_name, period)) years = [ diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index 292dbee9318..16fc9d61c1d 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -64,9 +64,10 @@ class medicare_irmaa_magi_two_years_prior(Variable): "from 2017 to 2019 and 39% from 2018 to 2020: a household entered " "for 2016, 2017 or 2018 alone gets slightly more than that year's " "income, and one entered for 2019 or 2020 alone gets that year's " - "income cut by those factors. " - "When the year two years prior precedes 2015, only income provided " - "for it counts, and the rest is zero." + "income times 0.765 or 0.721. " + "When inputs reach back to 2013 or 2014 and the year two years prior " + "is one of those years, its income cannot be computed: only income " + "provided for it counts, and the rest is zero." ) def formula(tax_unit, period, parameters):