diff --git a/changelog.d/us-medicare-irmaa-holdout-1206.added.md b/changelog.d/us-medicare-irmaa-holdout-1206.added.md new file mode 100644 index 0000000000..9eb31afe24 --- /dev/null +++ b/changelog.d/us-medicare-irmaa-holdout-1206.added.md @@ -0,0 +1 @@ +Report held-out Medicare Part B and Part D IRMAA payers and income-related premiums against the 2026 Trustees Report Tables V.E3 and V.E4 in `us_medicare_irmaa_holdout.json`; validation only, never a calibration target or release gate. With no weighted payer (as on the locked policyengine-us 2.2.1 for 2024) it reports `no_payers` and withholds the comparisons (#1206). diff --git a/changelog.d/us-medicare-part-b-premium-target-enrollees-1206.fixed.md b/changelog.d/us-medicare-part-b-premium-target-enrollees-1206.fixed.md new file mode 100644 index 0000000000..bf703c6aa1 --- /dev/null +++ b/changelog.d/us-medicare-part-b-premium-target-enrollees-1206.fixed.md @@ -0,0 +1 @@ +Bind the CMS Part B premiums-from-enrollees target to `gross_medicare_part_b_premium_if_enrolled`, the enrollment-gated gross premium, instead of `gross_medicare_part_b_premium`, which also charged eligible non-enrollees (#1206). On the locked policyengine-us 2.2.1 the bound premium carries no 2024 income-related premium, so the row reads the standard premium alone, and its calibration miss widens, until Microcosm's engine includes PolicyEngine/policyengine-us#10106. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 964f502d48..7903bdc4da 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -224,6 +224,23 @@ SSA's SSI Annual Statistical Report 2024, Table 9. This held-out diagnostic never feeds calibration or refuses a release; unavailable measurements are reported explicitly. The report records the difference between SSA's December 2024 recipient snapshot and the model's positive annual benefits. +It also writes `us_medicare_irmaa_holdout.json`: weighted Medicare Part B and +Part D income-related premium (IRMAA) payers and annual amounts, compared with +the 2026 Medicare Trustees Report's calendar-year rows in Tables V.E3 and V.E4 +(microcosm#1206). Both Parts count `medicare_enrolled` people only (Part A +and/or Part B). Part B is +`gross_medicare_part_b_premium_if_enrolled - base_part_b_premium`, because the +base premium is defined for every eligible person. Part D is +`income_adjusted_part_d_premium_surcharge`, which policyengine-us charges every +Medicare enrollee above the thresholds until PolicyEngine/policyengine-us#10115 +gates it on Part D enrollment. IRMAA rests on income from two years before +the period. The locked policyengine-us 2.2.1 reads that year's inputs, and a +single-year release H5 has none, so a 2024 release on that engine reports +`status: no_payers` (the batched scorer gives exactly that on the certified +`populace-us-2024-spm-20260915` file) until the engine includes +PolicyEngine/policyengine-us#10106. A Part with no weighted payer never reports +a Trustees comparison. It is held out the same way: no calibration row, no +release refusal. Before a release rerun, run the small-H5 guard sweep described in [the release build rule](us-release-build-rule.md#post-export-scoring). That fixture check is separate from full-export timing and release diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/fiscal_targets.py b/packages/microcosm-build/src/microcosm/build/us_runtime/fiscal_targets.py index 2f0818f70e..7df8db9b95 100644 --- a/packages/microcosm-build/src/microcosm/build/us_runtime/fiscal_targets.py +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/fiscal_targets.py @@ -452,8 +452,18 @@ def _us_hierarchy_seed( ), }, ), + # 2025 Trustees Report Table III.C3 Part B premiums from enrollees. + # gross_medicare_part_b_premium is defined for is_medicare_eligible, so + # it also charges eligible people who never enrolled. PolicyEngine-US + # documents the enrollment-gated gross_medicare_part_b_premium_if_enrolled + # for CMS premiums-from-enrollees targets (microcosm#1206). + # The target includes income-related premiums (IRMAA). On the locked + # policyengine-us 2.2.1 a 2024 release has none, because IRMAA rests on + # income two years earlier and the H5 carries 2024 inputs only, so this + # row reads the standard premium alone until Microcosm's engine includes + # PolicyEngine/policyengine-us#10106 (see microcosm#1206 for the sums). ("cms_medicare", "actual_amount", "premiums_from_enrollees"): ( - "gross_medicare_part_b_premium", + "gross_medicare_part_b_premium_if_enrolled", "cms_medicare", {"target_role": "medicare_part_b_premium_total"}, ), diff --git a/packages/microcosm-build/src/microcosm/build/us_runtime/medicare_irmaa_holdout.py b/packages/microcosm-build/src/microcosm/build/us_runtime/medicare_irmaa_holdout.py new file mode 100644 index 0000000000..a2e95d9d28 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/us_runtime/medicare_irmaa_holdout.py @@ -0,0 +1,387 @@ +"""Held-out Medicare IRMAA payers and premiums, never a calibration target. + +The US release reports this diagnostic beside the SSI holdout. The 2026 +Medicare Trustees Report's Part B and Part D income-related premium tables are +comparators for the model's payers and income-related premiums, not fitted +rows or a release gate (microcosm#1206). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any + +import numpy as np + +__all__ = [ + "MEDICARE_IRMAA_HOLDOUT_VARIABLES", + "MEDICARE_IRMAA_TRUSTEES_2026", + "MedicareIrmaaBenchmark", + "TRUSTEES_REPORT_2026_PDF_SHA256", + "TRUSTEES_REPORT_2026_TITLE", + "TRUSTEES_REPORT_2026_URL", + "medicare_irmaa_holdout_from_sim", + "medicare_irmaa_holdout_payload", + "medicare_irmaa_holdout_unavailable", + "write_medicare_irmaa_holdout", +] + + +@dataclass(frozen=True) +class MedicareIrmaaBenchmark: + """One calendar-year row of a Trustees income-related premium table.""" + + part: str + label: str + table: str + printed_page: int + pdf_page: int + # "Beneficiaries affected (millions)" and "Aggregate premiums (billions)", + # published to one decimal place and stored here in persons and dollars. + beneficiaries_affected: int + aggregate_premiums: int + aggregate_premiums_definition: str + + @property + def average_monthly_premium_per_payer(self) -> float: + return self.aggregate_premiums / self.beneficiaries_affected / 12 + + +TRUSTEES_REPORT_2026_TITLE = ( + "2026 Annual Report of the Boards of Trustees of the Federal Hospital " + "Insurance and Federal Supplementary Medical Insurance Trust Funds" +) +TRUSTEES_REPORT_2026_URL = "https://www.cms.gov/oact/tr/2026" +#: SHA-256 of the report PDF the rows below were read from (271 pages). +TRUSTEES_REPORT_2026_PDF_SHA256 = ( + "ffa56b9137006872300b0346149eae1613d09a172b6ba118aad48e66dfc48fa8" +) +#: Rounding unit of the published counts (0.1 million) and dollars (0.1 billion). +_PUBLISHED_PAYER_UNIT = 100_000 +_PUBLISHED_PREMIUM_UNIT = 100_000_000 + +#: Tables V.E3 and V.E4, "Historical data", calendar year 2024. Fixed held-out +#: observations, never passed to the calibration target registry. +MEDICARE_IRMAA_TRUSTEES_2026 = MappingProxyType( + { + 2024: MappingProxyType( + { + "part_b": MedicareIrmaaBenchmark( + part="part_b", + label="Medicare Part B income-related premium", + table="V.E3", + printed_page=209, + pdf_page=215, + beneficiaries_affected=4_800_000, + aggregate_premiums=12_800_000_000, + aggregate_premiums_definition=( + "Represents the total amount paid by affected " + "beneficiaries in excess of the Part B standard premium." + ), + ), + "part_d": MedicareIrmaaBenchmark( + part="part_d", + label="Medicare Part D income-related premium", + table="V.E4", + printed_page=212, + pdf_page=218, + beneficiaries_affected=4_200_000, + aggregate_premiums=2_200_000_000, + aggregate_premiums_definition=( + "Represents the total amount paid by affected " + "beneficiaries in excess of the Part D plan premium." + ), + ), + } + ), + } +) + +_ENROLLED = "medicare_enrolled" +_PART_B_GROSS = "gross_medicare_part_b_premium_if_enrolled" +_PART_B_BASE = "base_part_b_premium" +_PART_D_SURCHARGE = "income_adjusted_part_d_premium_surcharge" +#: The person-level variables the release scorer computes, in request order. +MEDICARE_IRMAA_HOLDOUT_VARIABLES: tuple[str, ...] = ( + _ENROLLED, + _PART_B_GROSS, + _PART_B_BASE, + _PART_D_SURCHARGE, +) +_MODEL_MEASURES = MappingProxyType( + { + "part_b": ( + f"{_PART_B_GROSS} minus {_PART_B_BASE}, for persons with {_ENROLLED}" + ), + "part_d": f"{_PART_D_SURCHARGE}, for persons with {_ENROLLED}", + } +) +_NO_PAYERS_REASON = ( + "No weighted Medicare enrollee pays an income-related premium, so no " + "Trustees comparison is reported. The premium for a year rests on " + "modified adjusted gross income from two years earlier. On " + "policyengine-us 2.2.1 this outcome is expected for a release H5 that " + "carries only the release year's inputs, because that engine computes the " + "income from the earlier year's inputs; PolicyEngine/policyengine-us#10106 " + "adds a fallback to the earliest input year." +) +_NO_ENROLLEES_REASON = ( + "No weighted person is enrolled in Medicare, so no Trustees comparison is reported." +) +_NO_PART_PAYERS = "No weighted enrollee pays this Part's income-related premium." +_GATES = ( + f"{_ENROLLED} covers Part A and/or Part B, so the Part B measure also " + "counts any Part A-only enrollee the engine charges. The Part D measure is " + "the surcharge as the engine gates it: every Medicare enrollee before " + "PolicyEngine/policyengine-us#10115, Part D enrollees after it. A " + "surcharge on a person outside Medicare enrollment is excluded and " + "reported as outside_enrollment_weighted_amount." +) + + +def medicare_irmaa_holdout_unavailable( + error: Exception, *, period: int, release_id: str | None = None +) -> dict[str, Any]: + """An observational receipt when plan discovery or scoring is unavailable.""" + payload: dict[str, Any] = { + "schema_version": 1, + "status": "unavailable", + "period": int(period), + "target_role": "validation", + "enforced": False, + "used_for_calibration": False, + "source_url": TRUSTEES_REPORT_2026_URL, + "error": f"{type(error).__name__}: {error}", + } + if release_id is not None: + payload["release_id"] = release_id + return payload + + +def _relative_difference(model: float, benchmark: float) -> float: + return model / benchmark - 1 + + +def _part_row( + part: str, + amounts: np.ndarray, + weights: np.ndarray, + benchmark: MedicareIrmaaBenchmark | None, + *, + compare: bool, +) -> dict[str, Any]: + """One Part's totals; comparisons need ``compare`` and a weighted payer.""" + payers = amounts > 0 + weighted_payers = float(weights[payers].sum()) + weighted_premiums = float((weights * amounts).sum()) + row: dict[str, Any] = { + "part": part, + "model_measure": _MODEL_MEASURES[part], + "weighted_payers": weighted_payers, + "weighted_aggregate_premiums": weighted_premiums, + "average_monthly_premium_per_payer": ( + weighted_premiums / weighted_payers / 12 if weighted_payers else None + ), + "benchmark_payers": None, + "benchmark_aggregate_premiums": None, + "benchmark_average_monthly_premium_per_payer": None, + "payers_relative_difference": None, + "aggregate_premiums_relative_difference": None, + } + if benchmark is None: + return row + row.update( + { + "label": benchmark.label, + "benchmark_table": benchmark.table, + "benchmark_printed_page": benchmark.printed_page, + "benchmark_pdf_page": benchmark.pdf_page, + "benchmark_aggregate_premiums_definition": ( + benchmark.aggregate_premiums_definition + ), + "benchmark_payers": benchmark.beneficiaries_affected, + "benchmark_aggregate_premiums": benchmark.aggregate_premiums, + "benchmark_average_monthly_premium_per_payer": ( + benchmark.average_monthly_premium_per_payer + ), + } + ) + if compare and not weighted_payers > 0: + row["comparison_withheld"] = _NO_PART_PAYERS + elif compare: + row["payers_relative_difference"] = _relative_difference( + weighted_payers, benchmark.beneficiaries_affected + ) + row["aggregate_premiums_relative_difference"] = _relative_difference( + weighted_premiums, benchmark.aggregate_premiums + ) + return row + + +def medicare_irmaa_holdout_payload( + enrolled: np.ndarray, + part_b_gross_if_enrolled: np.ndarray, + part_b_base: np.ndarray, + part_d_surcharge: np.ndarray, + weights: np.ndarray, + *, + period: int, + release_id: str | None = None, +) -> dict[str, Any]: + """Weighted IRMAA payers and annual income-related premiums by Part. + + Both Parts count ``medicare_enrolled`` people only. A person's Part B + income-related premium is their enrollment-gated gross Part B premium + less the standard premium; ``base_part_b_premium`` is defined for every + Medicare-eligible person, so a non-enrollee's base premium never counts. + The Part D amount is the engine's surcharge; any surcharge on a + non-enrollee is excluded and reported. A payer is a person with a positive + annual amount. Each person carries their household's calibrated weight, + as supplied by the written-H5 scorer's person results; the function reads + its inputs and never writes them. + + The status is ``available`` when some weighted enrollee pays. With no + weighted enrollee (``no_medicare_enrollees``) or no weighted payer in + either Part (``no_payers``), the model totals are still reported but the + Trustees comparisons are withheld and a reason is given. A Part with no + weighted payer never reports a comparison, whatever the status. + """ + enrolled_values = np.asarray(enrolled) + part_b_gross, part_b_base, part_d_surcharge, weights = ( + np.asarray(values, dtype=np.float64) + for values in (part_b_gross_if_enrolled, part_b_base, part_d_surcharge, weights) + ) + if enrolled_values.ndim != 1 or any( + values.shape != enrolled_values.shape + for values in (part_b_gross, part_b_base, part_d_surcharge, weights) + ): + raise ValueError( + "Medicare IRMAA holdout needs aligned one-dimensional person arrays." + ) + if enrolled_values.dtype != np.bool_: + numeric = enrolled_values.astype(np.float64) + if not np.isin(numeric, (0.0, 1.0)).all(): + raise ValueError(f"{_ENROLLED} must be boolean.") + enrolled_values = numeric == 1.0 + if any( + not np.isfinite(values).all() + for values in (part_b_gross, part_b_base, part_d_surcharge, weights) + ) or np.any(weights < 0): + raise ValueError( + "Medicare IRMAA holdout needs finite values and nonnegative weights." + ) + if np.any(part_d_surcharge < 0): + raise ValueError(f"{_PART_D_SURCHARGE} must be nonnegative.") + part_b_amount = np.where(enrolled_values, part_b_gross - part_b_base, 0.0) + if np.any(part_b_amount < 0): + raise ValueError( + f"{_PART_B_GROSS} is below {_PART_B_BASE} for an enrolled person." + ) + part_d_amount = np.where(enrolled_values, part_d_surcharge, 0.0) + part_d_outside = np.where(enrolled_values, 0.0, part_d_surcharge) + + weighted_enrollees = float(weights[enrolled_values].sum()) + any_payer = bool(weights[(part_b_amount > 0) | (part_d_amount > 0)].sum() > 0) + if weighted_enrollees <= 0: + status, reason = "no_medicare_enrollees", _NO_ENROLLEES_REASON + elif not any_payer: + status, reason = "no_payers", _NO_PAYERS_REASON + else: + status, reason = "available", None + compare = status == "available" + + benchmarks = MEDICARE_IRMAA_TRUSTEES_2026.get(int(period)) + parts = [ + _part_row( + "part_b", + part_b_amount, + weights, + None if benchmarks is None else benchmarks["part_b"], + compare=compare, + ), + _part_row( + "part_d", + part_d_amount, + weights, + None if benchmarks is None else benchmarks["part_d"], + compare=compare, + ), + ] + parts[1]["outside_enrollment_weighted_amount"] = float( + (weights * part_d_outside).sum() + ) + payload: dict[str, Any] = { + "schema_version": 1, + "status": status, + "period": int(period), + "target_role": "validation", + "enforced": False, + "used_for_calibration": False, + "measure": "Medicare income-related premium (IRMAA) payers and amounts", + "weight_basis": "person's calibrated household_weight", + "payer": "a Medicare enrollee with a positive annual income-related premium", + "weighted_medicare_enrollees": weighted_enrollees, + "benchmark": { + "source": TRUSTEES_REPORT_2026_TITLE, + "source_url": TRUSTEES_REPORT_2026_URL, + "source_pdf_sha256": TRUSTEES_REPORT_2026_PDF_SHA256, + "tables": ["V.E3", "V.E4"], + "columns": [ + "Beneficiaries affected (millions)", + "Aggregate premiums (billions)", + ], + "year": None if benchmarks is None else int(period), + "basis": None if benchmarks is None else "Historical data", + "published_rounding": { + "payers": _PUBLISHED_PAYER_UNIT, + "aggregate_premiums": _PUBLISHED_PREMIUM_UNIT, + }, + "available_years": sorted(MEDICARE_IRMAA_TRUSTEES_2026), + }, + "comparability": ( + "The Trustees report the number of beneficiaries affected and the " + "aggregate additional premium amounts collected in a calendar " + "year; the tables do not state whether the count is an average " + "month, a year-end or an ever-in-year count. The model counts " + "Medicare enrollees with a positive annual income-related premium " + f"in {int(period)} and sums those annual amounts. {_GATES} " + "This is a held-out diagnostic, not a calibration target or a " + "release gate." + ), + "parts": parts, + } + if reason is not None: + payload["reason"] = reason + if release_id is not None: + payload["release_id"] = release_id + return payload + + +def medicare_irmaa_holdout_from_sim( + simulation: Any, *, period: int, release_id: str | None = None +) -> dict[str, Any]: + """Measure aligned person results from the household-batched H5 scorer.""" + results = { + variable: simulation.calculate(variable, period) + for variable in MEDICARE_IRMAA_HOLDOUT_VARIABLES + } + return medicare_irmaa_holdout_payload( + np.asarray(results[_ENROLLED]), + np.asarray(results[_PART_B_GROSS]), + np.asarray(results[_PART_B_BASE]), + np.asarray(results[_PART_D_SURCHARGE]), + np.asarray(results[_ENROLLED].weights), + period=period, + release_id=release_id, + ) + + +def write_medicare_irmaa_holdout(payload: dict[str, Any], path: Path | str) -> Path: + path = Path(path) + path.write_text( + json.dumps(payload, indent=2, sort_keys=True, allow_nan=False) + "\n" + ) + return path diff --git a/packages/microcosm-build/tests/engine_contract/us/test_us_fiscal_targets.py b/packages/microcosm-build/tests/engine_contract/us/test_us_fiscal_targets.py index efa3e4dda8..bb2c739eee 100644 --- a/packages/microcosm-build/tests/engine_contract/us/test_us_fiscal_targets.py +++ b/packages/microcosm-build/tests/engine_contract/us/test_us_fiscal_targets.py @@ -55,3 +55,29 @@ def test_m_chip_state_set_matches_engine_separate_chip_child_limits() -> None: reviewed_carve_outs = {"44"} assert no_separate_child_chip == _M_CHIP_STATE_FIPS | reviewed_carve_outs assert not reviewed_carve_outs & _M_CHIP_STATE_FIPS + + +def test_medicare_part_b_premium_target_variable_is_enrollment_gated() -> None: + """The locked engine's premiums-from-enrollees variable (microcosm#1206). + + The target binds ``gross_medicare_part_b_premium_if_enrolled``. Pin that it + exists in the locked policyengine-us as a person-level annual amount gated + on ``medicare_enrolled``, and that the variable it replaced is gated only + on eligibility, so an engine bump that moves either gate re-opens the + mapping review. + """ + from policyengine_us import CountryTaxBenefitSystem + + base_variable, family, _ = fiscal_targets.DIRECT_LEDGER_TARGETS[ + ("cms_medicare", "actual_amount", "premiums_from_enrollees") + ] + assert family == "cms_medicare" + variables = CountryTaxBenefitSystem().variables + enrolled = variables[base_variable] + assert base_variable == "gross_medicare_part_b_premium_if_enrolled" + assert enrolled.entity.key == "person" + assert enrolled.definition_period == "year" + assert enrolled.defined_for == "medicare_enrolled" + assert variables["gross_medicare_part_b_premium"].defined_for == ( + "is_medicare_eligible" + ) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_refresh_builder.py b/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_refresh_builder.py index 3c62d5fd73..9adfe409b7 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_refresh_builder.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_refresh_builder.py @@ -4079,7 +4079,10 @@ def test_pinned_chronicle_feed_compiles_no_unsupported_ledger_filters() -> None: list. That is deliberate — the rows keep pinning the compile the supported/identity classification was reviewed against, and :func:`test_pinned_chronicle_feed_state_surface_compiles_no_unsupported_filters` - is the arm that meets the restated keys on the live feed. + is the arm that meets the restated keys on the live feed. The rows also + predate microcosm#1206, so the Part B premium row still names + ``gross_medicare_part_b_premium``; the live mapping binds + ``gross_medicare_part_b_premium_if_enrolled``. """ builder = _load_builder_module() @@ -6256,6 +6259,39 @@ def calculate(variable, period): def simulate(reform): return SimpleNamespace(calculate=calculate) + if name == "medicare_irmaa_holdout": + captured["irmaa_holdout_scored"] = self.dataset_path + record.update( + baseline_passes=1, + reform_passes=0, + reform_systems=0, + n_batches=1, + max_batch_households=2, + ) + + def calculate(variable, period): + assert period == builder.PERIOD + # One enrollee in IRMAA tier 1 (2024: $69.90 and $12.90 a + # month) and one eligible non-enrollee. + values = { + "medicare_enrolled": [True, False], + "gross_medicare_part_b_premium_if_enrolled": [ + 12 * (174.70 + 69.90), + 0.0, + ], + "base_part_b_premium": [12 * 174.70, 12 * 174.70], + "income_adjusted_part_d_premium_surcharge": [ + 12 * 12.90, + 0.0, + ], + } + return builder._PostExportValues( + np.array(values[variable]), np.array([2.0, 3.0]) + ) + + def simulate(reform): + return SimpleNamespace(calculate=calculate) + return SimpleNamespace(name=name, simulate=simulate, record=lambda: record) def finish_consumer(self, scoring): @@ -6348,6 +6384,18 @@ def fake_smoke(*, simulate, period): assert working_age["share"] == 0 assert older["weighted_ssi_recipients"] == 3 assert older["share"] == 1 + assert captured["irmaa_holdout_scored"] == captured["written_dataset"] + irmaa = json.loads((release_dir / "us_medicare_irmaa_holdout.json").read_text()) + assert irmaa["status"] == "available" + assert irmaa["release_id"] == release_id + assert irmaa["enforced"] is False + assert irmaa["used_for_calibration"] is False + assert irmaa["benchmark"]["year"] == 2024 + part_b, part_d = irmaa["parts"] + assert part_b["weighted_payers"] == 2 + assert part_b["weighted_aggregate_premiums"] == pytest.approx(2 * 12 * 69.90) + assert part_d["weighted_payers"] == 2 + assert part_d["weighted_aggregate_premiums"] == pytest.approx(2 * 12 * 12.90) build_manifest = json.loads((release_dir / "build_manifest.json").read_text()) release_manifest = json.loads((release_dir / "release_manifest.json").read_text()) if not skipped_smoke: @@ -6369,9 +6417,13 @@ def fake_smoke(*, simulate, period): assert block["post_export_scoring"] == { "dataset_sha256": build_manifest["dataset"]["sha256"], "consumers": ( - ["ssi_social_security_holdout"] + ["ssi_social_security_holdout", "medicare_irmaa_holdout"] if skipped_smoke - else ["reform_coverage_smoke", "ssi_social_security_holdout"] + else [ + "reform_coverage_smoke", + "ssi_social_security_holdout", + "medicare_irmaa_holdout", + ] ), } @@ -9008,6 +9060,7 @@ def fake_release_gate_failures(*args, **kwargs): "reform_validation", "demographics", "ssi_social_security_holdout", + "medicare_irmaa_holdout", ] assert captured["diagnostics"]["default_dataset"] == { "method": "l0_refit", @@ -12454,6 +12507,69 @@ def score_holdout(name, consumer, **kwargs): assert failures == [] +@pytest.mark.parametrize( + "scoring_available", (True, False), ids=("available", "unavailable") +) +def test_build_manifests_selects_medicare_irmaa_holdout_for_publication( + monkeypatch, tmp_path, scoring_available +) -> None: + from microcosm.build.us_runtime.medicare_irmaa_holdout import ( + medicare_irmaa_holdout_payload, + ) + from microcosm.data.contract import ( + _check_local_artifact_hashes, + _check_release_manifest, + ) + from microcosm.data.release import _release_manifest_release_artifacts + + builder = _load_builder_module() + release_id, release_dir, artifact_root = _gate_evidence_release_dir( + builder, monkeypatch, tmp_path, coverage={} + ) + + def score_holdout(name, consumer, **kwargs): + assert name == "medicare_irmaa_holdout" + if not scoring_available: + raise RuntimeError("holdout scoring is unavailable") + return medicare_irmaa_holdout_payload( + np.array([True, False]), + np.array([3_000.0, 0.0]), + np.array([2_000.0, 2_000.0]), + np.array([200.0, 0.0]), + np.array([1.0, 2.0]), + period=builder.PERIOD, + ) + + monkeypatch.setattr(builder, "_score_post_export_consumer", score_holdout) + builder._write_medicare_irmaa_holdout( + release_dir=release_dir, + dataset_path=artifact_root / builder.DATASET_FILENAME, + release_id=release_id, + ) + builder._build_manifests( + **_minimal_manifest_kwargs(builder, release_id, release_dir, artifact_root) + ) + + filename = "us_medicare_irmaa_holdout.json" + holdout_path = release_dir / filename + holdout = json.loads(holdout_path.read_text()) + assert holdout["status"] == ("available" if scoring_available else "unavailable") + assert holdout["release_id"] == release_id + manifest = json.loads((release_dir / "release_manifest.json").read_text()) + assert manifest["artifacts"]["us_medicare_irmaa_holdout"] == { + "kind": "diagnostics", + "path": filename, + "repo_id": builder.REPO_ID, + "revision": release_id, + "sha256": hashlib.sha256(holdout_path.read_bytes()).hexdigest(), + } + assert filename in _release_manifest_release_artifacts(release_dir) + failures: list[str] = [] + _check_release_manifest(manifest, release_id, failures) + _check_local_artifact_hashes(release_dir, manifest, failures) + assert failures == [] + + def test_build_manifests_binds_gate_evidence_and_the_qrf_tail_register( monkeypatch, tmp_path ) -> None: diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_targets.py b/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_targets.py index de4f6c6827..3cd976bac3 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_targets.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_fiscal_targets.py @@ -1,6 +1,8 @@ """Tests split from packages/microcosm-build/tests/test_us_fiscal_targets.py.""" # ruff: noqa: F403, F405 +import dataclasses + from test_support.microcosm_build.us_fiscal_targets import * @@ -893,8 +895,11 @@ def test_pinned_feed_national_state_surface_restores_the_fences( ) -> None: """The release surface on the pinned feed: 33,250 compiled targets after Medicaid substitution, 6,102 national_state targets at registry - f96be9f41781, 32 CHIP rows none of them for an M-CHIP state, no - other-income row and no tips return count. The 408 state AGI-band rows of + e1f9906bf0e0, 32 CHIP rows none of them for an M-CHIP state, no + other-income row and no tips return count. microcosm#1206 moved the + version from f96be9f41781 by binding the Part B premium target to + gross_medicare_part_b_premium_if_enrolled; the counts are unchanged. + The 408 state AGI-band rows of microcosm#940 (51 states x 4 bands x 2 measures) took it from 32,842 / 5,694 at d315c75804ef on the c5e5bf8 feed; before microcosm#956 it was 32,867 / 5,719 at d5f9d854fe11, and before decision d179 dropped the @@ -903,7 +908,7 @@ def test_pinned_feed_national_state_surface_restores_the_fences( registry, surface, _ = pinned_feed_national_state_surface assert len(registry.specs) == 33_250 assert len(surface.specs) == 6_102 - assert surface.version == "f96be9f41781" + assert surface.version == "e1f9906bf0e0" chip = [ spec for spec in surface.specs @@ -5140,16 +5145,22 @@ def test_soi_return_count_targets_expose_count_mode_without_base_variable() -> N assert "base_variables" not in spec.metadata -def test_medicare_part_b_premium_reference_uses_gross_premium_income() -> None: - source_record_id = ( - "cms_medicare.cy2024.part_b_premium_income." - "premiums_from_enrollees.actual_amount" - ) +_MEDICARE_PART_B_PREMIUM_RECORD_ID = ( + "cms_medicare.cy2024.part_b_premium_income.premiums_from_enrollees.actual_amount" +) +_MEDICARE_PART_B_PREMIUM_KEY = ( + "cms_medicare", + "actual_amount", + "premiums_from_enrollees", +) + + +def _compiled_medicare_part_b_premium_spec(): registry = compile_us_fiscal_target_registry( [ *packaged_reference_facts(), _dynamic_ledger_fact( - source_record_id=source_record_id, + source_record_id=_MEDICARE_PART_B_PREMIUM_RECORD_ID, source_name="cms_medicare", measure_id="actual_amount", groupby_value_id="premiums_from_enrollees", @@ -5158,13 +5169,63 @@ def test_medicare_part_b_premium_reference_uses_gross_premium_income() -> None: ], allow_unaged_dollar_targets=True, ) - specs = {spec.name: spec for spec in registry.specs} - spec = specs[source_record_id] + return specs[_MEDICARE_PART_B_PREMIUM_RECORD_ID] + + +def test_medicare_part_b_premium_reference_uses_enrolled_gross_premium() -> None: + """CMS premiums from enrollees bind the enrollment-gated gross premium. + + ``gross_medicare_part_b_premium`` is defined for ``is_medicare_eligible`` + and so also charges eligible non-enrollees (microcosm#1206). + """ + + spec = _compiled_medicare_part_b_premium_spec() assert spec.family == "cms_medicare" + assert spec.value == 139_837_000_000 assert spec.metadata["target_role"] == "medicare_part_b_premium_total" - assert spec.metadata["base_variable"] == "gross_medicare_part_b_premium" + assert spec.metadata["base_variable"] == ( + "gross_medicare_part_b_premium_if_enrolled" + ) assert spec.metadata["source_measure_id"] == "actual_amount" + assert spec.metadata["measure_mode"] == "sum" + assert "calibration_role" not in spec.metadata + + +def test_medicare_part_b_premium_remap_changes_only_the_bound_variable( + monkeypatch, +) -> None: + """Differential invariant for microcosm#1206's remap. + + Compiling the same ledger fact under the pre-#1206 mapping and the + current one yields the same target (name, value, period, entity, + family, sign, hierarchy and every metadata key) except the bound + variable. The value the calibration matches is the fact's value both + times. + """ + + current = _compiled_medicare_part_b_premium_spec() + base_variable, family, metadata = fiscal_targets.DIRECT_LEDGER_TARGETS[ + _MEDICARE_PART_B_PREMIUM_KEY + ] + assert base_variable == "gross_medicare_part_b_premium_if_enrolled" + monkeypatch.setitem( + fiscal_targets.DIRECT_LEDGER_TARGETS, + _MEDICARE_PART_B_PREMIUM_KEY, + ("gross_medicare_part_b_premium", family, metadata), + ) + previous = _compiled_medicare_part_b_premium_spec() + + assert previous.metadata["base_variable"] == "gross_medicare_part_b_premium" + current_metadata = dict(current.metadata) + previous_metadata = dict(previous.metadata) + assert current_metadata.pop("base_variable") != previous_metadata.pop( + "base_variable" + ) + assert current_metadata == previous_metadata + assert dataclasses.replace(current, metadata={}) == dataclasses.replace( + previous, metadata={} + ) def test_census_pep_population_age_facts_compile_to_count_targets() -> None: diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_medicare_irmaa_holdout.py b/packages/microcosm-build/tests/engine_free/us/test_us_medicare_irmaa_holdout.py new file mode 100644 index 0000000000..e7ec18e9b8 --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/us/test_us_medicare_irmaa_holdout.py @@ -0,0 +1,461 @@ +"""Held-out Medicare IRMAA reporting without a country engine (microcosm#1206). + +Invariants: + +* The holdout never changes weights or any other input: it reads read-only + arrays and returns a new payload. +* Weighted payers and premiums are linear in the weights, invariant to person + order, and additive over any split of the persons (household batches). +* ``0 <= payers <= weighted enrollees`` for each Part; premiums are + nonnegative, and zero when no weight pays. +* Non-enrollees never reach either Part's measure, whatever premium or + surcharge the engine assigns them; a Part D surcharge outside enrollment + is reported, not counted. +* With no weighted enrollee or no weighted payer, the Trustees comparisons + are withheld (never reported as -100%) and a reason is given. +* The Trustees rows are validation only: no ledger mapping binds an IRMAA + measure, the pinned Chronicle feed carries no income-related premium record + set, and only the release tool imports the holdout. +""" + +import json +import math +import re +from importlib.resources import files + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from microcosm.build.us_runtime import fiscal_targets +from microcosm.build.us_runtime.medicare_irmaa_holdout import ( + MEDICARE_IRMAA_HOLDOUT_VARIABLES, + MEDICARE_IRMAA_TRUSTEES_2026, + TRUSTEES_REPORT_2026_URL, + medicare_irmaa_holdout_payload, + medicare_irmaa_holdout_unavailable, + write_medicare_irmaa_holdout, +) +from test_support.paths import paths_for + +# 2024 standard Part B premium and the tier 1 and tier 5 IRMAA amounts from +# Trustees Report Table V.E3 (monthly), and the Part D tier 1 amount (V.E4). +_BASE = 12 * 174.70 +_PART_B_TIER_1 = 12 * 69.90 +_PART_B_TIER_5 = 12 * 419.30 +_PART_D_TIER_1 = 12 * 12.90 + + +def _payload(enrolled, gross, base, part_d, weights, *, period=2024, **kwargs): + return medicare_irmaa_holdout_payload( + np.asarray(enrolled), + np.asarray(gross, dtype=np.float64), + np.asarray(base, dtype=np.float64), + np.asarray(part_d, dtype=np.float64), + np.asarray(weights, dtype=np.float64), + period=period, + **kwargs, + ) + + +def test_trustees_2026_rows_match_tables_v_e3_and_v_e4(): + """CY2024 historical rows: V.E3 p. 209 (PDF p. 215), V.E4 p. 212 (PDF p. 218).""" + rows = MEDICARE_IRMAA_TRUSTEES_2026[2024] + part_b, part_d = rows["part_b"], rows["part_d"] + assert (part_b.table, part_b.printed_page, part_b.pdf_page) == ("V.E3", 209, 215) + assert (part_d.table, part_d.printed_page, part_d.pdf_page) == ("V.E4", 212, 218) + assert part_b.beneficiaries_affected == 4_800_000 + assert part_b.aggregate_premiums == 12_800_000_000 + assert part_d.beneficiaries_affected == 4_200_000 + assert part_d.aggregate_premiums == 2_200_000_000 + assert part_b.average_monthly_premium_per_payer == pytest.approx(222.2222222) + assert part_d.average_monthly_premium_per_payer == pytest.approx(43.6507937) + assert "Part B standard premium" in part_b.aggregate_premiums_definition + assert "Part D plan premium" in part_d.aggregate_premiums_definition + assert sorted(MEDICARE_IRMAA_TRUSTEES_2026) == [2024] + with pytest.raises(TypeError): + MEDICARE_IRMAA_TRUSTEES_2026[2025] = rows + with pytest.raises(TypeError): + rows["part_c"] = part_b + + +def test_holdout_variables_are_the_engine_measures_in_request_order(): + assert MEDICARE_IRMAA_HOLDOUT_VARIABLES == ( + "medicare_enrolled", + "gross_medicare_part_b_premium_if_enrolled", + "base_part_b_premium", + "income_adjusted_part_d_premium_surcharge", + ) + + +def test_payers_and_premiums_count_only_enrollees_for_part_b(): + # Enrollee in tier 5; enrollee paying no IRMAA; eligible non-enrollee whose + # eligibility-gated base premium must not count; someone not eligible. + payload = _payload( + [True, True, False, False], + [_BASE + _PART_B_TIER_5, _BASE, 0.0, 0.0], + [_BASE, _BASE, _BASE, 0.0], + [_PART_D_TIER_1, 0.0, 0.0, 0.0], + [2.0, 3.0, 5.0, 7.0], + release_id="fixture", + ) + part_b, part_d = payload["parts"] + assert part_b["part"] == "part_b" + assert part_b["weighted_payers"] == 2 + assert part_b["weighted_aggregate_premiums"] == pytest.approx(2 * _PART_B_TIER_5) + assert part_b["average_monthly_premium_per_payer"] == pytest.approx(419.30) + assert part_b["benchmark_payers"] == 4_800_000 + assert part_b["payers_relative_difference"] == pytest.approx(2 / 4_800_000 - 1) + assert part_b["aggregate_premiums_relative_difference"] == pytest.approx( + 2 * _PART_B_TIER_5 / 12_800_000_000 - 1 + ) + assert part_b["benchmark_table"] == "V.E3" + assert part_d["part"] == "part_d" + assert part_d["weighted_payers"] == 2 + assert part_d["weighted_aggregate_premiums"] == pytest.approx(2 * _PART_D_TIER_1) + assert part_d["benchmark_table"] == "V.E4" + assert part_d["outside_enrollment_weighted_amount"] == 0 + assert payload["weighted_medicare_enrollees"] == 5 + assert "reason" not in payload + assert payload["release_id"] == "fixture" + assert payload["status"] == "available" + assert payload["target_role"] == "validation" + assert payload["enforced"] is False + assert payload["used_for_calibration"] is False + assert "gate" not in payload + assert payload["benchmark"]["source_url"] == TRUSTEES_REPORT_2026_URL + assert payload["benchmark"]["year"] == 2024 + assert payload["benchmark"]["tables"] == ["V.E3", "V.E4"] + assert "ever-in-year" in payload["comparability"] + assert "#10115" in payload["comparability"] + + +def test_a_part_d_surcharge_outside_enrollment_is_reported_not_counted(): + payload = _payload( + [True, False], + [_BASE + _PART_B_TIER_1, 0.0], + [_BASE, _BASE], + [_PART_D_TIER_1, _PART_D_TIER_1], + [2.0, 3.0], + ) + part_d = payload["parts"][1] + assert part_d["weighted_payers"] == 2 + assert part_d["weighted_aggregate_premiums"] == pytest.approx(2 * _PART_D_TIER_1) + assert part_d["outside_enrollment_weighted_amount"] == pytest.approx( + 3 * _PART_D_TIER_1 + ) + + +def test_no_weighted_payer_withholds_the_trustees_comparisons(): + """Enrollees but no income-related premium (pe-us 2.2.1 on a 2024 H5).""" + payload = _payload( + [True, True, False], [_BASE, _BASE, 0.0], [_BASE] * 3, [0.0] * 3, [4, 5, 6] + ) + assert payload["status"] == "no_payers" + assert "two years" in payload["reason"] + assert "#10106" in payload["reason"] + assert payload["weighted_medicare_enrollees"] == 9 + for row in payload["parts"]: + assert row["weighted_payers"] == 0 + assert row["weighted_aggregate_premiums"] == 0 + assert row["benchmark_payers"] is not None + assert row["payers_relative_difference"] is None + assert row["aggregate_premiums_relative_difference"] is None + assert payload["enforced"] is False + assert payload["used_for_calibration"] is False + + +def test_no_weighted_enrollee_withholds_the_trustees_comparisons(): + for enrolled, weights in (([False], [1.0]), ([True], [0.0]), ([], [])): + n = len(enrolled) + payload = _payload( + enrolled, [_BASE + _PART_B_TIER_1] * n, [_BASE] * n, [0.0] * n, weights + ) + assert payload["status"] == "no_medicare_enrollees" + assert payload["weighted_medicare_enrollees"] == 0 + for row in payload["parts"]: + assert row["payers_relative_difference"] is None + assert row["aggregate_premiums_relative_difference"] is None + + +def test_numeric_enrollment_flags_are_accepted_and_others_refused(): + flags = _payload( + [1, 0], [_BASE + _PART_B_TIER_1, 0.0], [_BASE, _BASE], [0, 0], [1, 1] + ) + booleans = _payload( + [True, False], [_BASE + _PART_B_TIER_1, 0.0], [_BASE, _BASE], [0, 0], [1, 1] + ) + assert flags == booleans + with pytest.raises(ValueError, match="boolean"): + _payload([2, 0], [0, 0], [0, 0], [0, 0], [1, 1]) + + +def test_a_period_without_a_trustees_row_reports_null_comparisons(tmp_path): + payload = _payload( + [True], [_BASE + _PART_B_TIER_1], [_BASE], [0.0], [4.0], period=2025 + ) + assert payload["benchmark"]["year"] is None + assert payload["benchmark"]["available_years"] == [2024] + for row in payload["parts"]: + assert row["benchmark_payers"] is None + assert row["benchmark_aggregate_premiums"] is None + assert row["payers_relative_difference"] is None + assert row["aggregate_premiums_relative_difference"] is None + part_b, part_d = payload["parts"] + assert part_b["weighted_payers"] == 4 + assert part_d["weighted_payers"] == 0 + assert part_d["average_monthly_premium_per_payer"] is None + path = write_medicare_irmaa_holdout(payload, tmp_path / "diagnostic.json") + assert json.loads(path.read_text()) == payload + + +def test_unavailable_payload_is_observational(): + payload = medicare_irmaa_holdout_unavailable( + KeyError("base_part_b_premium"), period=2024, release_id="fixture" + ) + assert payload["status"] == "unavailable" + assert payload["enforced"] is False + assert payload["used_for_calibration"] is False + assert payload["target_role"] == "validation" + assert payload["error"] == "KeyError: 'base_part_b_premium'" + assert payload["release_id"] == "fixture" + + +@pytest.mark.parametrize( + "arrays, message", + [ + (([True, False], [0.0], [0.0, 0.0], [0.0, 0.0], [1, 1]), "aligned"), + (([True], [math.nan], [0.0], [0.0], [1]), "finite"), + (([True], [0.0], [0.0], [0.0], [-1]), "nonnegative weights"), + (([True], [_BASE], [_BASE], [-1.0], [1]), "nonnegative"), + (([True], [_BASE - 1], [_BASE], [0.0], [1]), "below"), + ], +) +def test_malformed_inputs_are_refused_not_silently_reported(arrays, message): + with pytest.raises(ValueError, match=message): + _payload(*arrays) + + +# --------------------------------------------------------------------------- +# Property-based invariants +# --------------------------------------------------------------------------- + +_TIERS = (0.0, 69.90, 174.70, 279.50, 384.30, 419.30) +_PART_D_TIERS = (0.0, 12.90, 33.30, 53.80, 74.20, 81.00) + + +@st.composite +def _people(draw, min_size=0, max_size=40): + n = draw(st.integers(min_value=min_size, max_value=max_size)) + eligible = draw(st.lists(st.booleans(), min_size=n, max_size=n)) + takes_up = draw(st.lists(st.booleans(), min_size=n, max_size=n)) + tiers = draw(st.lists(st.integers(0, 5), min_size=n, max_size=n)) + weights = draw( + st.lists( + st.floats(0, 1e4, allow_nan=False, allow_infinity=False), + min_size=n, + max_size=n, + ) + ) + enrolled = np.asarray(eligible, dtype=bool) & np.asarray(takes_up, dtype=bool) + tier = np.asarray(tiers, dtype=np.int64) + base = np.where(np.asarray(eligible, dtype=bool), _BASE, 0.0) + irmaa = 12 * np.asarray(_TIERS)[tier] + gross = np.where(enrolled, base + irmaa, 0.0) + part_d = np.where(enrolled, 12 * np.asarray(_PART_D_TIERS)[tier], 0.0) + return enrolled, gross, base, part_d, np.asarray(weights, dtype=np.float64) + + +def _totals(payload): + return [ + (row["weighted_payers"], row["weighted_aggregate_premiums"]) + for row in payload["parts"] + ] + + +@settings(max_examples=200, deadline=None) +@given(_people()) +def test_holdout_never_writes_its_inputs(people): + arrays = [np.array(values, copy=True) for values in people] + for values in arrays: + values.flags.writeable = False + payload = _payload(*arrays) + for before, after in zip(people, arrays, strict=True): + np.testing.assert_array_equal(before, after) + json.dumps(payload, allow_nan=False) + + +@settings(max_examples=200, deadline=None) +@given(_people(), st.floats(0.5, 4.0, allow_nan=False)) +def test_totals_scale_with_the_weights(people, scale): + enrolled, gross, base, part_d, weights = people + reference = _totals(_payload(enrolled, gross, base, part_d, weights)) + scaled = _totals(_payload(enrolled, gross, base, part_d, weights * scale)) + for (payers, premiums), (scaled_payers, scaled_premiums) in zip( + reference, scaled, strict=True + ): + assert scaled_payers == pytest.approx(payers * scale, rel=1e-9, abs=1e-6) + assert scaled_premiums == pytest.approx(premiums * scale, rel=1e-9, abs=1e-3) + + +@settings(max_examples=200, deadline=None) +@given(_people(min_size=1), st.randoms(use_true_random=False), st.data()) +def test_totals_are_order_invariant_and_additive_over_batches(people, rng, data): + enrolled, gross, base, part_d, weights = people + n = len(weights) + reference = _totals(_payload(*people)) + order = list(range(n)) + rng.shuffle(order) + shuffled = _totals(_payload(*(values[order] for values in people))) + split = data.draw(st.integers(0, n)) + first = _totals(_payload(*(values[:split] for values in people))) + second = _totals(_payload(*(values[split:] for values in people))) + for whole, permuted, left, right in zip( + reference, shuffled, first, second, strict=True + ): + for index in range(2): + assert permuted[index] == pytest.approx(whole[index], rel=1e-9, abs=1e-3) + assert left[index] + right[index] == pytest.approx( + whole[index], rel=1e-9, abs=1e-3 + ) + + +@settings(max_examples=200, deadline=None) +@given(_people()) +def test_payers_and_premiums_are_bounded_by_enrollment(people): + enrolled, gross, base, part_d, weights = people + payload = _payload(*people) + weighted_enrollees = float(weights[enrolled].sum()) + for row in payload["parts"]: + assert 0 <= row["weighted_payers"] <= weighted_enrollees + 1e-6 + assert row["weighted_aggregate_premiums"] >= 0 + if row["weighted_payers"] == 0: + assert row["weighted_aggregate_premiums"] == 0 + assert row["average_monthly_premium_per_payer"] is None + + +@settings(max_examples=200, deadline=None) +@given(_people(min_size=1), st.data()) +def test_non_enrollees_never_reach_either_measure(people, data): + """Whatever premiums a non-enrollee carries leave both Parts' totals, + comparisons and status unchanged; their Part D surcharge is reported.""" + enrolled, gross, base, part_d, weights = people + reference = _payload(*people) + + def noise(): + return np.asarray( + data.draw( + st.lists( + st.floats(0, 1e5, allow_nan=False), + min_size=len(base), + max_size=len(base), + ) + ) + ) + + surcharge = noise() + perturbed = _payload( + enrolled, + np.where(enrolled, gross, noise()), + np.where(enrolled, base, noise()), + np.where(enrolled, part_d, surcharge), + weights, + ) + assert perturbed["status"] == reference["status"] + assert perturbed["parts"][0] == reference["parts"][0] + outside = perturbed["parts"][1].pop("outside_enrollment_weighted_amount") + reference["parts"][1].pop("outside_enrollment_weighted_amount") + assert perturbed["parts"][1] == reference["parts"][1] + assert outside == pytest.approx( + float((weights * np.where(enrolled, 0.0, surcharge)).sum()), + rel=1e-9, + abs=1e-6, + ) + + +@settings(max_examples=200, deadline=None) +@given(_people()) +def test_comparisons_are_reported_exactly_when_that_part_has_a_payer(people): + """A Part compares with the Trustees only when one of its weighted + enrollees pays; no Part is ever reported at -100%.""" + enrolled, gross, base, part_d, weights = people + payload = _payload(*people) + part_payers = {"part_b": gross - base > 0, "part_d": part_d > 0} + pays = { + part: bool(weights[enrolled & payers].sum() > 0) + for part, payers in part_payers.items() + } + assert (payload["status"] == "available") == any(pays.values()) + for row in payload["parts"]: + assert (row["payers_relative_difference"] is not None) == pays[row["part"]] + if row["payers_relative_difference"] is not None: + # A positive weighted payer count; a vanishing weight can still + # round model / benchmark - 1 to exactly -1. + assert row["payers_relative_difference"] >= -1 + assert row["weighted_payers"] > 0 + + +def test_a_part_without_payers_withholds_only_its_own_comparison(): + """Part D all zero (pe-us#10115 with no Part D take-up input yet).""" + payload = _payload( + [True, True], [_BASE + _PART_B_TIER_1, _BASE], [_BASE, _BASE], [0, 0], [2, 3] + ) + part_b, part_d = payload["parts"] + assert payload["status"] == "available" + assert part_b["payers_relative_difference"] == pytest.approx(2 / 4_800_000 - 1) + assert "comparison_withheld" not in part_b + assert part_d["weighted_payers"] == 0 + assert part_d["payers_relative_difference"] is None + assert part_d["aggregate_premiums_relative_difference"] is None + assert part_d["comparison_withheld"].startswith("No weighted enrollee") + + +# --------------------------------------------------------------------------- +# Validation only: the Trustees IRMAA rows cannot reach calibration +# --------------------------------------------------------------------------- + + +def test_no_ledger_mapping_binds_an_irmaa_measure(): + irmaa_only = {"base_part_b_premium", "income_adjusted_part_d_premium_surcharge"} + mapped = [] + for mapping in ( + fiscal_targets.DIRECT_LEDGER_TARGETS, + fiscal_targets.INDICATOR_LEDGER_TARGETS, + ): + for key, (base_variable, _, metadata) in mapping.items(): + variables = ( + (base_variable,) if isinstance(base_variable, str) else base_variable + ) + mapped.append((key, set(variables), dict(metadata))) + for key, variables, metadata in mapped: + assert not variables & irmaa_only, key + assert "irmaa" not in json.dumps([key, metadata]).lower(), key + assert "income_related" not in json.dumps([key, metadata]).lower(), key + + +def test_pinned_chronicle_feed_carries_no_income_related_premium_rows(): + """Only a feed re-pin could bring a Trustees IRMAA row to calibration.""" + scope = json.loads( + files("microcosm.build.us").joinpath("chronicle_feed_scope.json").read_text() + ) + assert scope["pairs"] + for pair in scope["pairs"]: + text = json.dumps(pair).lower() + assert "irmaa" not in text and "income_related" not in text, pair + + +def test_only_the_release_tool_imports_the_holdout(): + """No calibration, registry or gate module can read the Trustees rows.""" + root = paths_for("microcosm-build").repository + pattern = re.compile(r"medicare_irmaa_holdout|MEDICARE_IRMAA_TRUSTEES_2026") + module = "medicare_irmaa_holdout.py" + readers = sorted( + path.relative_to(root).as_posix() + for directory in ("packages", "tools") + for path in (root / directory).rglob("*.py") + if "tests" not in path.relative_to(root).parts + and path.name != module + and pattern.search(path.read_text(encoding="utf-8")) + ) + assert readers == ["tools/build_us_fiscal_refresh_release.py"] diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_post_export_scoring.py b/packages/microcosm-build/tests/engine_free/us/test_us_post_export_scoring.py index 69bc7e36f1..fd4477bb4d 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_post_export_scoring.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_post_export_scoring.py @@ -464,6 +464,7 @@ def default_tax_benefit_system(*args, **kwargs): "reform_validation", "demographics", "ssi_social_security_holdout", + "medicare_irmaa_holdout", ] smoke_keys = { (probe.budget_measure, int(probe.period or builder.PERIOD), None) @@ -477,6 +478,12 @@ def default_tax_benefit_system(*args, **kwargs): ("ssi", builder.PERIOD, None), ("social_security", builder.PERIOD, None), ) + assert plan.baseline_plan("medicare_irmaa_holdout") == ( + ("medicare_enrolled", builder.PERIOD, None), + ("gross_medicare_part_b_premium_if_enrolled", builder.PERIOD, None), + ("base_part_b_premium", builder.PERIOD, None), + ("income_adjusted_part_d_premium_surcharge", builder.PERIOD, None), + ) validation = plan.baseline_plan("reform_validation") assert ("income_tax", builder.PERIOD, None) in validation assert ("state_code_str", builder.PERIOD, None) in validation @@ -495,7 +502,82 @@ def default_tax_benefit_system(*args, **kwargs): result=_empty_calibration_result(), release_id="fixture", ) - assert list(skipped) == ["demographics", "ssi_social_security_holdout"] + assert list(skipped) == [ + "demographics", + "ssi_social_security_holdout", + "medicare_irmaa_holdout", + ] + + +def test_medicare_irmaa_holdout_is_invariant_to_household_batch_size( + builder, tmp_path +) -> None: + frame = _nested_frame() + consumer = builder._medicare_irmaa_holdout_consumer + reference = consumer(_unbatched_simulate(frame, _EngineLog())) + assert reference["status"] == "available" + part_b, part_d = reference["parts"] + # The fixture's payers sit in several households, and two eligible + # non-enrollees carry a base premium the Part B measure must not count. + assert part_b["weighted_payers"] > 0 + assert part_d["weighted_payers"] > 0 + for batch_size in (1, 2, None): + log = _EngineLog() + scorer = _scorer(builder, frame, log, batch_size, tmp_path / str(batch_size)) + plan = builder._record_post_export_baseline_plan(consumer) + scoring = scorer.open_consumer("medicare_irmaa_holdout", plan) + payload = consumer(scoring.simulate) + assert payload == reference + record = scorer.finish_consumer(scoring) + assert record["baseline_passes"] == 1 + assert record["reform_passes"] == record["reform_systems"] == 0 + assert len(log.constructions) == scorer.n_batches + scorer.close() + + +def test_medicare_irmaa_holdout_measurement_error_is_report_only( + builder, monkeypatch, tmp_path +) -> None: + def unavailable(*args, **kwargs): + raise ValueError("fixture unavailable") + + monkeypatch.setattr(builder, "_score_post_export_consumer", unavailable) + builder._write_medicare_irmaa_holdout( + release_dir=tmp_path, + dataset_path=tmp_path / "fixture.h5", + release_id="fixture", + ) + payload = json.loads((tmp_path / "us_medicare_irmaa_holdout.json").read_text()) + assert payload["status"] == "unavailable" + assert payload["error"] == "ValueError: fixture unavailable" + assert payload["enforced"] is False + assert payload["used_for_calibration"] is False + + +def test_medicare_irmaa_holdout_discovery_error_does_not_fail_the_plan( + builder, monkeypatch +) -> None: + def unavailable(*args, **kwargs): + raise KeyError("income_adjusted_part_d_premium_surcharge") + + monkeypatch.setattr(builder, "medicare_irmaa_holdout_from_sim", unavailable) + args = SimpleNamespace( + skip_reform_coverage_smoke=True, + skip_reform_validation=True, + skip_out_of_sample_reforms=True, + skip_demographics=True, + maximum_microsim_batch_size=3, + ) + plan = builder._record_post_export_scoring_plan( + args, + n_households=7, + result=_empty_calibration_result(), + release_id="fixture", + ) + assert plan.error is None + assert plan.terminal_failures() == [] + assert plan.baseline_plan("medicare_irmaa_holdout") == () + assert builder._medicare_irmaa_holdout_consumer(None)["status"] == "unavailable" def test_ssi_social_security_holdout_is_invariant_to_household_batch_size( @@ -656,6 +738,7 @@ def test_an_unbuildable_plan_is_recorded_and_refused_not_raised( "reform_validation", "demographics", "ssi_social_security_holdout", + "medicare_irmaa_holdout", ] empty = builder._PostExportScoringPlan( n_households=7, maximum_batch_size=3, baseline_plans={} @@ -733,6 +816,7 @@ def test_main_and_writers_never_build_a_whole_pool_simulation(builder) -> None: "_write_reform_validation", "_write_demographics", "_write_ssi_social_security_holdout", + "_write_medicare_irmaa_holdout", ): _, tree = _function_source(builder, name) called = _called_names(tree) diff --git a/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py b/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py index 8aab193a3d..284149bdf1 100644 --- a/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py +++ b/packages/microcosm-build/tests/engine_free/us/test_us_spine_blindness.py @@ -276,6 +276,8 @@ "l0_refit_export.py", "late_producer_dag.py", # Pure contract/schedule derivation; no treatment. "medicaid_take_up.py", + # Reviewed held-out Medicare IRMAA diagnostic; no population treatment. + "medicare_irmaa_holdout.py", "misc_itemized.py", "nonzero_shares.py", "operator_boundary.py", # Raw-stage validator; no population treatment. diff --git a/packages/microcosm-build/tests/engine_scenario/us/test_us_medicare_irmaa_holdout.py b/packages/microcosm-build/tests/engine_scenario/us/test_us_medicare_irmaa_holdout.py new file mode 100644 index 0000000000..ae27f02f28 --- /dev/null +++ b/packages/microcosm-build/tests/engine_scenario/us/test_us_medicare_irmaa_holdout.py @@ -0,0 +1,122 @@ +"""The IRMAA holdout's measures on the locked policyengine-us (microcosm#1206).""" + +import numpy as np +import pytest + +from microcosm.build.us_runtime.medicare_irmaa_holdout import ( + MEDICARE_IRMAA_HOLDOUT_VARIABLES, + medicare_irmaa_holdout_payload, +) + +# 2026 Trustees Report Tables V.E3 (p. 209) and V.E4 (p. 212), 2024 row, +# 85 percent column: the top monthly income-related adjustment amounts. +_TRUSTEES_2024_PART_B_TOP_MONTHLY = 419.30 +_TRUSTEES_2024_PART_D_TOP_MONTHLY = 81.00 + + +def _person(take_up: bool) -> dict: + return {"age": {"2024": 70}, "takes_up_medicare_if_eligible": {"2024": take_up}} + + +def test_holdout_measures_follow_the_engine_on_a_household() -> None: + """An enrollee and an eligible non-enrollee, each a single filer whose + IRMAA MAGI ($600,000) is given for 2024, so it sits in the top tier. + + The engine charges the enrollee the Trustees' 2024 top-tier amounts, and + gives the non-enrollee the eligibility-gated base premium but no + enrollment-gated gross premium: the holdout counts only the enrollee. + """ + from policyengine_us import Simulation + + situation = { + "people": {"enrollee": _person(True), "non_enrollee": _person(False)}, + "tax_units": { + "enrollee_unit": { + "members": ["enrollee"], + "medicare_irmaa_magi_two_years_prior": {"2024": 600_000}, + }, + "non_enrollee_unit": { + "members": ["non_enrollee"], + "medicare_irmaa_magi_two_years_prior": {"2024": 600_000}, + }, + }, + "families": {"family": {"members": ["enrollee", "non_enrollee"]}}, + "spm_units": {"spm_unit": {"members": ["enrollee", "non_enrollee"]}}, + "marital_units": { + "enrollee_marital_unit": {"members": ["enrollee"]}, + "non_enrollee_marital_unit": {"members": ["non_enrollee"]}, + }, + "households": { + "household": { + "members": ["enrollee", "non_enrollee"], + "state_code": {"2024": "TX"}, + } + }, + } + simulation = Simulation(situation=situation) + values = { + variable: np.asarray(simulation.calculate(variable, 2024)) + for variable in MEDICARE_IRMAA_HOLDOUT_VARIABLES + } + enrolled = values["medicare_enrolled"] + gross = values["gross_medicare_part_b_premium_if_enrolled"] + base = values["base_part_b_premium"] + part_d = values["income_adjusted_part_d_premium_surcharge"] + + np.testing.assert_array_equal(enrolled, [True, False]) + assert base[1] == base[0] > 0 + assert gross[1] == 0 + assert gross[0] - base[0] == pytest.approx(12 * _TRUSTEES_2024_PART_B_TOP_MONTHLY) + assert part_d[0] == pytest.approx(12 * _TRUSTEES_2024_PART_D_TOP_MONTHLY) + assert part_d[1] == 0 + + payload = medicare_irmaa_holdout_payload( + enrolled, gross, base, part_d, np.array([1.0, 1.0]), period=2024 + ) + part_b_row, part_d_row = payload["parts"] + assert part_b_row["weighted_payers"] == 1 + assert part_b_row["average_monthly_premium_per_payer"] == pytest.approx( + _TRUSTEES_2024_PART_B_TOP_MONTHLY + ) + assert part_d_row["weighted_payers"] == 1 + assert part_d_row["average_monthly_premium_per_payer"] == pytest.approx( + _TRUSTEES_2024_PART_D_TOP_MONTHLY + ) + + +def test_a_single_year_household_has_no_irmaa_on_the_locked_engine() -> None: + """Pin today's zero so an engine bump changes it visibly (microcosm#1206). + + A release H5 carries inputs for its year only. On policyengine-us 2.2.1, + IRMAA rests on income two years earlier, which such a household lacks, so + a 2024 enrollee with $600,000 of 2024 wages pays none and the holdout + withholds its Trustees comparisons. A policyengine-us that includes + PolicyEngine/policyengine-us#10106 falls back to the earliest input year; + when Microcosm moves to one, this test fails and should be rewritten to + expect the top tier. + """ + from policyengine_us import Simulation + + person = _person(True) | {"employment_income": {"2024": 600_000}} + situation = { + "people": {"enrollee": person}, + "tax_units": {"tax_unit": {"members": ["enrollee"]}}, + "families": {"family": {"members": ["enrollee"]}}, + "spm_units": {"spm_unit": {"members": ["enrollee"]}}, + "marital_units": {"marital_unit": {"members": ["enrollee"]}}, + "households": { + "household": {"members": ["enrollee"], "state_code": {"2024": "TX"}} + }, + } + simulation = Simulation(situation=situation) + values = [ + np.asarray(simulation.calculate(variable, 2024)) + for variable in MEDICARE_IRMAA_HOLDOUT_VARIABLES + ] + assert simulation.calculate("adjusted_gross_income", 2024)[0] >= 600_000 + payload = medicare_irmaa_holdout_payload(*values, np.array([1.0]), period=2024) + assert payload["status"] == "no_payers", payload["parts"] + assert payload["weighted_medicare_enrollees"] == 1 + for row in payload["parts"]: + assert row["weighted_payers"] == 0 + assert row["payers_relative_difference"] is None diff --git a/test_support/microcosm_build/us_post_export_scoring.py b/test_support/microcosm_build/us_post_export_scoring.py index 875bf13e73..ca8704a815 100644 --- a/test_support/microcosm_build/us_post_export_scoring.py +++ b/test_support/microcosm_build/us_post_export_scoring.py @@ -221,6 +221,10 @@ def sum(self) -> float: "medicare_cost", "head_start", "wic", + "medicare_enrolled", + "base_part_b_premium", + "gross_medicare_part_b_premium_if_enrolled", + "income_adjusted_part_d_premium_surcharge", } ) _SPM_UNIT_VARIABLES = frozenset({"snap", "housing_assistance", "spm_unit_benefits"}) @@ -257,6 +261,37 @@ def _household_positions(frame: Frame, entity: str) -> np.ndarray: return household_positions.reindex(unit_households.to_numpy()).to_numpy() +_MEDICARE_HOLDOUT_FIXTURE_VARIABLES = frozenset( + { + "medicare_enrolled", + "base_part_b_premium", + "gross_medicare_part_b_premium_if_enrolled", + "income_adjusted_part_d_premium_surcharge", + } +) + + +def _medicare_holdout_fixture_values(person: pd.DataFrame, variable: str): + """Fixture Medicare semantics spread over several households. + + Not the law: people aged 30 or older are "eligible" so that payers sit + in several households. Eligible people with ``person_id % 5 == 0`` do not + enroll, yet carry the eligibility-gated base premium, as in + policyengine-us. Integer dollars keep batched sums exact. + """ + person_id = person["person_id"].to_numpy() + eligible = person["age"].to_numpy() >= 30 + enrolled = eligible & (person_id % 5 != 0) + tier = person_id % 3 + if variable == "medicare_enrolled": + return enrolled + if variable == "base_part_b_premium": + return np.where(eligible, 2_096.0, 0.0) + if variable == "gross_medicare_part_b_premium_if_enrolled": + return np.where(enrolled, 2_096.0 + 840.0 * tier, 0.0) + return np.where(enrolled, 156.0 * tier, 0.0) + + def _native_values(frame: Frame, variable: str, period: int, delta: float): person = frame.table("person") state_fips = frame.table("household")["state_fips"].to_numpy() @@ -273,6 +308,8 @@ def _native_values(frame: Frame, variable: str, period: int, delta: float): return (person["age"].to_numpy() < 18).astype(np.float64) if variable == "in_poverty": return person["person_id"].to_numpy() % 3 == 0 + if variable in _MEDICARE_HOLDOUT_FIXTURE_VARIABLES: + return _medicare_holdout_fixture_values(person, variable) entity = _native_entity(variable) ids = frame.table(entity)[f"{entity}_id"].to_numpy() base = zlib.crc32(f"{variable}@{period}".encode()) % 89 diff --git a/tools/build_us_fiscal_refresh_release.py b/tools/build_us_fiscal_refresh_release.py index f1310ed554..6b64ce7059 100644 --- a/tools/build_us_fiscal_refresh_release.py +++ b/tools/build_us_fiscal_refresh_release.py @@ -263,6 +263,11 @@ attach_l0_refit_entity_weights, load_us_frame, ) +from microcosm.build.us_runtime.medicare_irmaa_holdout import ( + medicare_irmaa_holdout_from_sim, + medicare_irmaa_holdout_unavailable, + write_medicare_irmaa_holdout, +) from microcosm.build.us_runtime.nonzero_shares import us_nonzero_shares from microcosm.build.us_runtime.parity_reference import ( EcpsParityReference, @@ -4927,6 +4932,15 @@ def _ssi_social_security_holdout_consumer(simulate) -> dict[str, Any]: return ssi_social_security_holdout_unavailable(error, period=PERIOD) +def _medicare_irmaa_holdout_consumer(simulate) -> dict[str, Any]: + try: + return medicare_irmaa_holdout_from_sim(simulate(None), period=PERIOD) + except Exception as error: + # As for the SSI holdout: an optional diagnostic adds no failure to + # plan discovery when its inputs are unavailable (microcosm#1206). + return medicare_irmaa_holdout_unavailable(error, period=PERIOD) + + def _reform_validation_consumer( *, result, release_id: str ) -> Callable[[Callable[[Any], Any] | None], dict[str, Any]]: @@ -4964,6 +4978,7 @@ def _post_export_consumers( if not args.skip_demographics: consumers["demographics"] = _demographics_consumer consumers["ssi_social_security_holdout"] = _ssi_social_security_holdout_consumer + consumers["medicare_irmaa_holdout"] = _medicare_irmaa_holdout_consumer return consumers @@ -10285,6 +10300,37 @@ def _write_ssi_social_security_holdout( ) +def _write_medicare_irmaa_holdout( + *, + release_dir: Path, + dataset_path: Path, + release_id: str, + post_export_scorer: _HouseholdBatchedPostExportScorer | None = None, + baseline_plan: Sequence[PostExportKey] | None = None, + maximum_microsim_batch_size: int | None = DEFAULT_MAXIMUM_MICROSIM_BATCH_SIZE, +) -> None: + """Report held-out IRMAA payers and premiums; misses never refuse a release.""" + try: + payload = _score_post_export_consumer( + "medicare_irmaa_holdout", + _medicare_irmaa_holdout_consumer, + dataset_path=dataset_path, + post_export_scorer=post_export_scorer, + baseline_plan=baseline_plan, + maximum_microsim_batch_size=maximum_microsim_batch_size, + ) + payload["release_id"] = release_id + except Exception as error: + # Reporting is observational, including an unavailable measurement. + # It must not introduce a new release refusal or calibration target. + payload = medicare_irmaa_holdout_unavailable( + error, period=PERIOD, release_id=release_id + ) + write_medicare_irmaa_holdout( + payload, release_dir / "us_medicare_irmaa_holdout.json" + ) + + def _build_manifests( *, release_id: str, @@ -10879,6 +10925,18 @@ def _build_manifests( if (release_dir / "us_ssi_social_security_holdout.json").exists() else {} ), + **( + { + "us_medicare_irmaa_holdout": _artifact_entry( + "us_medicare_irmaa_holdout.json", + _sha256(release_dir / "us_medicare_irmaa_holdout.json"), + kind="diagnostics", + revision=release_id, + ) + } + if (release_dir / "us_medicare_irmaa_holdout.json").exists() + else {} + ), **( { "reform_validation": _artifact_entry( @@ -15741,6 +15799,22 @@ def _main(argv: Sequence[str] | None = None) -> int | None: "us_ssi_social_security_holdout", release_dir / "us_ssi_social_security_holdout.json", ) + telemetry.stage( + "medicare_irmaa_holdout", + message="Writing held-out Medicare IRMAA payers and premiums.", + ) + _write_medicare_irmaa_holdout( + release_dir=release_dir, + dataset_path=dataset_path, + release_id=release_id, + post_export_scorer=post_export_scorer, + baseline_plan=post_export_scoring_plan.baseline_plan("medicare_irmaa_holdout"), + ) + if telemetry is not None: + telemetry.attach_artifact( + "us_medicare_irmaa_holdout", + release_dir / "us_medicare_irmaa_holdout.json", + ) # Every post-export engine stage has run; free the scorer's batch frames. # The manifest must pin the bytes those stages scored, and both manifests # carry how they were scored (Route A remediation PR-3).