Skip to content

US Medicare IRMAA: enrollee-gated Part B premium target and held-out IRMAA validation rows (#1206) - #1211

Merged
MaxGhenis merged 5 commits into
mainfrom
irmaa-1206-targets
Oct 12, 2026
Merged

MaxGhenis merged 5 commits into
mainfrom
irmaa-1206-targets

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Implements the Microcosm changes Max approved in d1372 (2026-10-12) for #1206: changes 1 and 3. Change 2 waits on PolicyEngine/policyengine-us#10115, and change 4 is out of scope.

1. Part B premium target binds the enrollment-gated premium

The CMS premiums-from-enrollees target (2025 Trustees Report Table III.C3, CY2024 $139.837B, Chronicle package cms-medicare-trustees-report-2025-part-b-premium-income) moves from gross_medicare_part_b_premium to gross_medicare_part_b_premium_if_enrolled.

  • The old variable is defined for is_medicare_eligible, so it also charged eligible people who never enrolled.
  • The new one is defined for medicare_enrolled, and pe-us documents it "for CMS premiums-from-enrollees calibration targets".
  • Nothing else about the target changes: same name, value, period, family and metadata.

pe-us version. Microcosm locks policyengine-us 2.2.1 (uv.lock; the extras allow >=2.2.1,<3), which does not include PolicyEngine/policyengine-us#10106.

  • #10106 merged on main as 8df4dffb2f on 2026-10-12T01:03Z.
  • The first PyPI release that includes it is 2.40.0 (uploaded 2026-10-12T06:49Z). I checked the wheels: 2.40.0 and 2.40.1 ship medicare_irmaa_magi and the same-year switch; 2.39.1 does not.

Microcosm locks the engine its certified default was built with (#1086), so moving to ≥2.40.0 is a separate step, not done here. Until then the target reads without #10106.

What the target reads on the certified file (populace-us-2024-spm-20260915, 2024, uncalibrated weighted sums). These come from real microsimulations in the #1206 diagnosis (crfb-m4a/research/irmaa-run/diagnosis/part_b_target_check_*.json):

pe-us old: gross_medicare_part_b_premium new: ..._if_enrolled
2.2.1, locked (2024 IRMAA is $0) $128.23B (−8.3%) $120.58B (−13.8%)
2.2.1 + #10106 $148.61B (+6.3%) $137.65B (−1.6%)

Under 2.2.1 the old mapping misses by less only because it charges eligible non-enrollees the base premium.

Caveat for #1206 (not changed here). The $139.837B total includes $14.410B from disabled enrollees under 65; enrollees aged 65 and over paid $125.427B. Source: 2025 Trustees Report Table III.C1, CY2024, PDF p. 86. The model has no Medicare enrollees under 65 (#876), so about 10.3% of this target is out of the model's reach. Whether to target the 65-and-over row instead is a methodology call, raised on #1206.

3. Held-out IRMAA validation rows

A new file, us_medicare_irmaa_holdout.json, is written beside us_ssi_social_security_holdout.json and follows the same pattern as #1178.

  • It is scored on the written release H5 through the household-batched post-export scorer.
  • It is listed in the release manifest as a diagnostics artifact.
  • It is never a calibration row and never a release refusal. An unavailable measurement is reported as status: unavailable.

It compares the model with the 2026 Medicare Trustees Report. These are the CY2024 historical rows, checked against the report PDF (sha256 ffa56b91…, pinned in the module):

Table Page (printed / PDF) Beneficiaries affected Aggregate premiums
Part B V.E3 209 / 215 4.8M $12.8B
Part D V.E4 212 / 218 4.2M $2.2B

Model measures, per person, weighted by the calibrated household weight. Both Parts count medicare_enrolled people only (Part A and/or Part B):

  • Part B: gross_medicare_part_b_premium_if_enrolled − base_part_b_premium, because base_part_b_premium is defined for every eligible person. This matches the table's footnote 2: amounts "in excess of the Part B standard premium".
  • Part D: income_adjusted_part_d_premium_surcharge. pe-us charges it to every Medicare enrollee above the thresholds until pe-us#10115 gates it on Part D enrollment. Any surcharge outside Medicare enrollment is excluded and reported as outside_enrollment_weighted_amount.
  • Payer: an enrollee with a positive annual amount. The tables don't say whether "beneficiaries affected" is an average-month, year-end or ever-in-year count, and the payload says so.
  • Status: available when some weighted enrollee pays. Otherwise it is no_payers or no_medicare_enrollees: the model totals are still reported, the Trustees comparisons are withheld rather than shown as −100%, and the payload gives the reason.

Source registration. The rows are cited constants in the module: title, URL, tables, printed and PDF pages, PDF hash, footnote definitions. That is how the SSI holdout registers SSA Table 9. I did not add a Chronicle package, because Chronicle facts feed the calibration compiler and these rows must stay out of it. The pinned feed carries no IRMAA record set, and a test pins that.

What the holdout will read. On locked 2.2.1 it reports no_payers for 2024, because 2024 IRMAA is effectively $0 on the certified file. Verified end to end: the builder's own _write_medicare_irmaa_holdout, run through the real household-batched scorer on the certified H5 (sha256 6496cc43…), wrote status: no_payers. It found 57,519,410.6 weighted Medicare enrollees and no payer in either Part, in 46.7 s (crfb-m4a/research/irmaa-run/holdout_check/scorer_pinned.json). In the diagnosis run (irmaa_aggregates_pinned-pe-us-2.2.1.json), the weighted count of tax units with a nonzero two-years-prior MAGI rounds to 0.00M, and both IRMAA totals round to $0.000B. pe-us 2.2.1 takes that MAGI from 2022 AGI and income, and the H5 carries 2024 inputs only. With #10106 the diagnosis measured Part B IRMAA among enrollees at $17.067B and the Part D surcharge at $3.278B for 2024 (irmaa_aggregates_patched-2.2.1-pr10106.json).

Invariants (tested)

  • The remap changes only the bound variable. A differential test compiles the same ledger fact under the old and new mapping. Only base_variable differs.
  • The engine gates hold. An engine-contract test pins that, in locked pe-us, _if_enrolled is a yearly Person variable gated on medicare_enrolled, and the old variable is gated on is_medicare_eligible.
  • The holdout never writes its inputs, so it never changes weights. Hypothesis feeds it read-only arrays. It runs after export, on the bytes the manifest pins.
  • Totals behave like weighted sums. Payers and premiums scale linearly with the weights, are invariant to person order, and add over any split of persons (Hypothesis). The payload is identical for household batch sizes 1, 2 and unbatched through the real scorer code.
  • Totals are bounded. For each Part, 0 ≤ payers ≤ weighted enrollees. Premiums are ≥ 0, and $0 when no weight pays.
  • Non-enrollees never reach either Part's measure, whatever premium or surcharge they carry (Hypothesis). A Part D surcharge outside enrollment is reported, not counted.
  • Comparisons are reported exactly when some weighted enrollee pays (Hypothesis). Otherwise the status is no_payers or no_medicare_enrollees, never a −100% comparison.
  • Validation only. No ledger mapping binds base_part_b_premium or income_adjusted_part_d_premium_surcharge or carries an IRMAA role. No pinned Chronicle record set mentions IRMAA or income-related premiums. Only tools/build_us_fiscal_refresh_release.py imports the holdout module.
  • Engine and table agree. An engine-scenario test on pe-us 2.2.1 checks the measures on a household with one enrollee and one eligible non-enrollee, with the MAGI supplied. The engine's 2024 top-tier amounts ($419.30 Part B, $81.00 Part D a month) equal Tables V.E3/V.E4.
  • Today's zero is pinned. A second scenario test shows a 2024-only household with $600k of wages pays no IRMAA on 2.2.1, and the holdout reports no_payers. It fails, by design, once Microcosm's engine includes #10106.
  • Registry version. The national_state surface version moves from f96be9f41781 to e1f9906bf0e0, because it hashes target metadata. Target counts are unchanged. National concept-budget keys exclude base_variable, so this target's loss weight is unchanged.

Review

Independent review by GPT-6.1 Sol at 42d899c (~/reviews/irmaa-1206/review_42d899c52.md) asked for changes. All findings are addressed in 5570ee8, and the delta review there approved (review_5570ee8b7.md). Its five non-blocking observations are addressed in 8714515: per-Part withholding of comparisons, the verified scorer-path outcome in the guide, conditional reason wording, a robust import guard, and the changelog direction. The final delta review approved at 8714515 with no defects found (review_871451578.md). B1 is the no_payers status plus a locked-engine scenario test. B2 is the caveat in the code comment and changelog. N1 names the gates. N2 masks Part D by enrollment. N3 adds the import guard and drops the exact-set assertion. N4 is a fixture note.

Not in this PR

axiom: n/a: Microcosm calibration-target mapping and a validation diagnostic, no policy rule.

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 11, 2026 22:10
…1206)

The CMS premiums-from-enrollees target (2025 Trustees Report Table III.C3,
CY2024 $139.837B) was mapped to gross_medicare_part_b_premium, which
PolicyEngine-US defines for is_medicare_eligible, so it also charged eligible
people who never enrolled. Map it to gross_medicare_part_b_premium_if_enrolled,
defined for medicare_enrolled and documented in PolicyEngine-US for this
target.

Tests: the compiled spec binds the new variable; a differential test compiles
the same fact under the old and new mapping and finds only base_variable
differs; an engine-contract test pins both variables' gates in the locked
policyengine-us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds us_medicare_irmaa_holdout.json beside the SSI holdout: weighted Part B
and Part D income-related premium payers and annual amounts on the written
release H5, compared with the 2026 Medicare Trustees Report, Tables V.E3
(p. 209) and V.E4 (p. 212), CY2024 historical rows: 4.8M payers and $12.8B
(Part B), 4.2M and $2.2B (Part D). Validation only: it never enters the
calibration registry and never refuses a release; an unavailable measurement
is reported as such.

Part B is gross_medicare_part_b_premium_if_enrolled minus base_part_b_premium
for medicare_enrolled people, because PolicyEngine-US defines the base premium
for every eligible person. Part D is income_adjusted_part_d_premium_surcharge.

Invariants, tested (Hypothesis where stated): inputs are never written;
totals scale with weights, are order invariant and additive over batches;
payers are bounded by weighted enrollees; non-enrollees never reach Part B;
the payload is batch-size invariant through the household-batched scorer;
no ledger mapping or pinned Chronicle record set carries an IRMAA row. An
engine-scenario test checks the measures on pe-us 2.2.1 and that its 2024
top-tier amounts equal the Trustees table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis changed the title US Medicare IRMAA: enrollee-gated Part B premium target and IRMAA validation rows (#1206) US Medicare IRMAA: enrollee-gated Part B premium target and held-out IRMAA validation rows (#1206) Oct 12, 2026
MaxGhenis and others added 3 commits October 11, 2026 22:38
The registry version hashes target metadata, so binding the Part B premium
target to gross_medicare_part_b_premium_if_enrolled moves it from
f96be9f41781 to e1f9906bf0e0. Target counts are unchanged, and the
differential test pins that only base_variable differs. National targets'
concept-budget keys exclude base_variable, so the target's loss weight is
unchanged too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…2.2.1 caveat (#1206)

Independent review (GPT-6.1 Sol, ~/reviews/irmaa-1206/review_42d899c52.md):

- B1: on the locked policyengine-us 2.2.1 a 2024 release has no IRMAA (it
  rests on income two years earlier, absent from a single-year H5), so the
  holdout would have reported "available" with -100% comparisons. It now
  reports status no_payers (or no_medicare_enrollees) with a reason and
  withholds the Trustees comparisons. An engine-scenario test pins the
  locked engine's zero for a 2024-only household, so a bump that includes
  pe-us#10106 changes it visibly.
- B2: the target comment and changelog say the bound premium carries no
  2024 IRMAA until Microcosm's engine includes pe-us#10106.
- N1: the payload and agent guide name both enrollment gates (Part A and/or
  B; Part D surcharge on every enrollee before pe-us#10115).
- N2: Part D now counts medicare_enrolled people only, like Part B, and
  reports any surcharge outside enrollment; the Hypothesis property perturbs
  non-enrollees' Part B and Part D values.
- N3: the feed test keeps only the IRMAA text check; a new test pins that
  only the release tool imports the holdout.
- N4: the historical compiled-filter fixture's docstring notes it predates
  the remap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from the approving delta review (review_5570ee8b7.md):

- A Part with no weighted payer now withholds its own Trustees comparison
  (comparison_withheld) even when the other Part has payers, so no Part is
  ever reported at -100% (e.g. pe-us#10115 before Part D take-up exists).
  The Hypothesis property checks comparisons per Part.
- The no-payers reason says the outcome is expected on policyengine-us
  2.2.1 rather than asserting that engine as the cause, and no longer says
  "rather than -100%" for years with no Trustees row.
- The agent guide states the verified outcome: the release builder's
  batched scorer reports no_payers on the certified
  populace-us-2024-spm-20260915 file under pe-us 2.2.1 (57.52M weighted
  enrollees, no payer).
- The import guard tests relative path parts, so a checkout under a
  directory named tests cannot exclude every file.
- The changelog says the Part B row's calibration miss widens until the
  engine includes pe-us#10106.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 12, 2026 09:13
@MaxGhenis
MaxGhenis merged commit 1239276 into main Oct 12, 2026
10 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged at the reviewed head 8714515 as a merge commit. The gates held:

  • gh pr checks exit 0 on the latest run: all 10 jobs, including both engine-us jobs and integration-uk.
  • MERGEABLE, not a draft, no changes requested.

Independent review (GPT-6.1 Sol, via Subfleet):

  • 42d899c: REQUEST_CHANGES. Two blocking findings: the locked-engine zero, and the Part B target caveat.
  • 5570ee8: APPROVE.
  • 8714515: APPROVE, no defects.

The review files are in ~/reviews/irmaa-1206/.

Real-data runs of the holdout on the certified populace-us-2024-spm-20260915 H5 (sha256 6496cc43…), 2024. Script and outputs are in crfb-m4a/research/irmaa-run/holdout_check/.

  1. Release builder path, locked pe-us 2.2.1. The builder's _write_medicare_irmaa_holdout, through the household-batched scorer, gave status: no_payers: 57,519,410.6 weighted Medicare enrollees and no payer in either Part (46.7 s).

  2. Direct microsim, pe-us 2.2.1 with the #10106 backport. status: available:

    Payers Aggregate vs Trustees CY2024 (4.8M / $12.8B; 4.2M / $2.2B)
    Part B 5.649M $17.067B +17.7% payers, +33.3% dollars
    Part D 5.649M $3.278B +34.5% payers, +49.0% dollars

    Part D payers equal Part B payers because the engine charges the Part D surcharge to every Medicare enrollee until Charge the Part D income-related premium only to Part D enrollees policyengine-us#10115. The dollar totals match the US Medicare IRMAA: enrollee-gated Part B premium target, Part D take-up input, IRMAA validation rows, SOI age-by-AGI counts #1206 diagnosis's own 2024 run ($17.067B and $3.278B).

No data was regenerated, released or published.

@MaxGhenis
MaxGhenis deleted the irmaa-1206-targets branch October 12, 2026 11:54
juaristi22 added a commit that referenced this pull request Oct 12, 2026
The branch now sits on 51a2637. main brought the landlord work of
microcosm#1145: the 13 SPI Table 3.7 property-income amounts bound unscaled
and uprated by the band index, the property components stage, the PRIS
resource, and policyengine-uk 2.123.0; and #1188 and #1211. Where the two
sides touched one hunk the rebase kept the branch's side; this commit puts
both sides in and regenerates what depends on them, on the 942a1fa feed.

- The SPI property-income amount target takes main's note and band index
  and keeps this branch's taxpayer filter (income_tax > 0), as every other
  SPI row does here (decision "taxpayer basis everywhere").
- National references: 1,281 active, the branch's 1,268 plus the 13 property
  amounts.
- The vendor selections keep both sides' resources;
  hmrc_property_rental_income_facts.json is vendored again under the 942a1fa
  feed identity, and the UK spine parity fixture follows it.
- The two national parity receipts and the reference membership are
  regenerated; the local references, the local census and the local parity
  receipt come out byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant