Files
stack/notebooks/cy2026_pfs_proposed_rule.py
kert 73017081ef fix(notebooks): stop rendering years as 2,027 — plain_years display cast (closes #643)
marimo's data-table viewer formats integer columns with thousands
separators, so int32/int64 year columns (DuckDB SELECT year, registry-
built frames) displayed as "2,027". New conf.display.plain_years casts
year-like integer columns (year, *_year, *_period; autodetected or
explicit, polars + pandas) to strings at the display boundary only —
analysis frames keep integer dtypes, chart encodings (already :O) are
untouched.

Applied at every affected display site: pfs_calcs carrier/SQL result
tables, pfs_reconciliation delta table, cy2026/cy2027 APM-threshold
tables, cms_quality_measures pipeline-result accordions. All five
notebooks re-executed headlessly in the notebooks container
(nb_integration ci-smoke set): pass=5, displayed year values now
serialize as strings.
2026-08-18 10:07:47 -04:00

809 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import marimo
__generated_with = "0.23.1"
app = marimo.App(width="medium")
@app.cell(hide_code=True)
def _():
import marimo as mo
return (mo,)
@app.cell(hide_code=True)
def _(mo):
mo.md("""
# CY2026 PFS Proposed Rule — Financial Changes & Advanced APM
CMS's CY2026 Physician Fee Schedule rulemaking is unusual: the NPRM
(CMS-1832-P, 90 FR 32352, July 16, 2025) proposed the **first-ever
split conversion factor** — separate dollar amounts for clinicians who
qualify as Advanced APM Participants (QPs) versus everyone else — and
the Final Rule (90 FR 49266) changed several of those numbers again
before taking effect.
**Mechanism:** MACRA 2015 (section 1848(d)(20) of the Act) sets two
statutory annual updates starting payment year 2026 — +0.75%/yr for
the qualifying-APM (QP) conversion factor, +0.25%/yr for everyone
else — on top of a one-time 2.50% increase and the usual budget
neutrality adjustment. This notebook walks the CF from CY2025 through
the NPRM to the Final Rule, quantifies which HCPCS codes moved the
most between the proposed and final RVU tables, and lays out what
Advanced APM (QP) status is worth in dollars for CY2026.
**What this means:** every number below is read live from
`pfs.rules.RULES` / `qpp.QPP` (the repo's rule registries) and the
`pfs`/`cms` DuckLake schemas — nothing here is a hard-coded figure.
All dollar figures use **national, GPCI-unadjusted RVUs (GPCI = 1.0)**
— a deliberate scope simplification, disclosed once here rather than
on every figure; real payment varies by locality via `pfs.gpci`,
which this notebook does not join.
""")
return
@app.cell(hide_code=True)
def _():
import altair as alt
import polars as pl
from conf import connect
from conf.display import plain_years
from pfs.rules import RULES
from qpp import QPP, for_payment_year
connect.theme()
# connect.theme() puts assets/ on sys.path — reuse the HTI-5 design
# tokens directly instead of re-typing hex literals for the gain/loss
# diverging pair used in Sections 2-3.
from fhirworx import AMBER, TEAL
# PFS reference data lives in the DuckLake lakehouse (M5, #514) —
# notebooks connect read-only.
con = connect.ducklake()
def q(sql):
return con.execute(sql).pl()
return AMBER, QPP, RULES, TEAL, alt, con, for_payment_year, pl, plain_years, q
# ── 1. The conversion-factor walk ─────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 1. The Conversion Factor Walk — CY2025 → NPRM → Final Rule
CY2026 is the first year the PFS publishes **four** conversion
factors at once: a standard and an anesthesia CF, each split into a
qualifying-APM (QP) and nonqualifying-APM (non-QP) track. The chart
below places every published value — CY2025's single CF, the NPRM's
proposed values, and the Final Rule's finalized values — on one
timeline.
""")
return
@app.cell(hide_code=True)
def _(RULES, pl):
_cy25 = RULES[2025]
_cy26 = RULES[2026]
_prop = _cy26.proposed
_rows = [
{"vintage": "CY2025 Final", "family": "Standard", "track": "Non-QP", "cf": _cy25.conversion_factor},
{"vintage": "CY2026 Proposed", "family": "Standard", "track": "Non-QP", "cf": _prop.conversion_factor},
{"vintage": "CY2026 Final", "family": "Standard", "track": "Non-QP", "cf": _cy26.conversion_factor},
{"vintage": "CY2026 Proposed", "family": "Standard", "track": "QP", "cf": _prop.cf_qp},
{"vintage": "CY2026 Final", "family": "Standard", "track": "QP", "cf": _cy26.cf_qp},
{"vintage": "CY2026 Proposed", "family": "Anesthesia", "track": "Non-QP", "cf": _prop.anesthesia_cf},
{"vintage": "CY2026 Final", "family": "Anesthesia", "track": "Non-QP", "cf": _cy26.anesthesia_cf},
{"vintage": "CY2026 Proposed", "family": "Anesthesia", "track": "QP", "cf": _prop.anesthesia_cf_qp},
# NOTE: RULES[2026].anesthesia_cf models the non-QP anesthesia CF
# only — a final QP anesthesia CF is not yet captured in the
# registry (see pfs.rules module docstring), so that bar is
# deliberately omitted rather than guessed.
]
cf_walk = pl.DataFrame(_rows)
cf_walk
return (cf_walk,)
@app.cell(hide_code=True)
def _(alt, cf_walk, mo):
_vintage_order = ["CY2025 Final", "CY2026 Proposed", "CY2026 Final"]
_track_domain = ["Non-QP", "QP"]
cf_chart = (
alt.Chart(cf_walk.to_pandas())
.mark_bar()
.encode(
x=alt.X("vintage:N", title=None, sort=_vintage_order),
xOffset=alt.XOffset("track:N", sort=_track_domain),
y=alt.Y("cf:Q", title="Conversion factor ($/RVU)", scale=alt.Scale(zero=False)),
color=alt.Color("track:N", title="Track", scale=alt.Scale(domain=_track_domain)),
tooltip=["vintage", "family", "track", alt.Tooltip("cf:Q", format="$.4f")],
)
.properties(width=260, height=280)
.facet(column=alt.Column("family:N", title=None))
.resolve_scale(y="independent")
.properties(title="CY2026 PFS Conversion Factors — Proposed vs. Final")
)
mo.vstack(
[
cf_chart,
mo.md("""
*Anesthesia QP (final) is not shown — the Final Rule did not
restate a separate QP anesthesia CF in a form captured by the
`pfs.rules` registry yet.*
**Sources:** CY2025 — 89 FR 98452. CY2026 NPRM — 90 FR 32352
(CMS-1832-P). CY2026 Final Rule — 90 FR 49266.
"""),
]
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
### Budget-neutrality adjustor decomposition
CMS's own NPRM narrative (90 FR 32802) attributes the CY2026 CF
change to three multiplicative pieces: a one-time +2.50% statutory
increase, the +0.25%/+0.75% nonqualifying/qualifying-APM annual
updates, and the budget-neutrality (BN) adjustor. Compounding those
stated percentages against the CY2025 CF reproduces the registry's
actual proposed/final values to within rounding — a useful
cross-check that the registry's transcription is internally
consistent with CMS's narrative.
""")
return
@app.cell(hide_code=True)
def _(RULES, mo, pl):
_cy25_cf = RULES[2025].conversion_factor
_cy26 = RULES[2026]
_prop = _cy26.proposed
# CMS's own stated CF-update components (90 FR 32802) — a one-time
# statutory increase plus the MACRA 2015 sec. 1848(d)(20) annual
# updates. NONE of these three percentages is a field anywhere in
# `pfs.rules` or `qpp` — there is nothing to attribute-access. They
# are kept as literals ONLY because CMS's narrative states them as
# bare numbers, not derived from any other registry value; this is a
# cited transcription of that narrative, not a second source of
# truth for `budget_neutrality_adjustor` or the CFs themselves
# (both of which — `bn_adjustor` / `registry_cf` below — ARE read
# live from the registry).
_ONE_TIME_INCREASE_MULT = 1.0250 # +2.50% single-year statutory increase, 90 FR 32802
_ANNUAL_UPDATE_MULT = {
"Non-QP": 1.0025, # +0.25%/yr nonqualifying-APM update, 90 FR 32802
"QP": 1.0075, # +0.75%/yr qualifying-APM update, 90 FR 32802
}
def _reconstruct(track, bn_adjustor):
return _cy25_cf * _ONE_TIME_INCREASE_MULT * _ANNUAL_UPDATE_MULT[track] * bn_adjustor
_bn_rows = [
{
"vintage": "CY2026 Proposed",
"track": "Non-QP",
"one_time_mult": _ONE_TIME_INCREASE_MULT,
"annual_update_mult": _ANNUAL_UPDATE_MULT["Non-QP"],
"bn_adjustor": _prop.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct("Non-QP", _prop.budget_neutrality_adjustor), 4),
"registry_cf": _prop.conversion_factor,
},
{
"vintage": "CY2026 Proposed",
"track": "QP",
"one_time_mult": _ONE_TIME_INCREASE_MULT,
"annual_update_mult": _ANNUAL_UPDATE_MULT["QP"],
"bn_adjustor": _prop.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct("QP", _prop.budget_neutrality_adjustor), 4),
"registry_cf": _prop.cf_qp,
},
{
"vintage": "CY2026 Final",
"track": "Non-QP",
"one_time_mult": _ONE_TIME_INCREASE_MULT,
"annual_update_mult": _ANNUAL_UPDATE_MULT["Non-QP"],
"bn_adjustor": _cy26.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct("Non-QP", _cy26.budget_neutrality_adjustor), 4),
"registry_cf": _cy26.conversion_factor,
},
{
"vintage": "CY2026 Final",
"track": "QP",
"one_time_mult": _ONE_TIME_INCREASE_MULT,
"annual_update_mult": _ANNUAL_UPDATE_MULT["QP"],
"bn_adjustor": _cy26.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct("QP", _cy26.budget_neutrality_adjustor), 4),
"registry_cf": _cy26.cf_qp,
},
]
bn_decomp = pl.DataFrame(_bn_rows).with_columns(
(pl.col("reconstructed_cf") - pl.col("registry_cf")).abs().round(4).alias("abs_diff")
)
mo.vstack(
[
bn_decomp,
mo.md(f"""
`reconstructed_cf = CY2025_CF × one_time_mult × annual_update_mult × bn_adjustor`
— `bn_adjustor` and `registry_cf` are read live from
`RULES[2026]` / `RULES[2026].proposed`. `one_time_mult`
({_ONE_TIME_INCREASE_MULT}) and `annual_update_mult`
({_ANNUAL_UPDATE_MULT["Non-QP"]} non-QP /
{_ANNUAL_UPDATE_MULT["QP"]} QP) are **cited transcriptions of
CMS's NPRM narrative (90 FR 32802)**, not registry fields —
no field in `pfs.rules` or `qpp` models the 2.50%/0.25%/0.75%
statutory update components individually, only their combined
effect on `conversion_factor` / `cf_qp`.
"""),
]
)
return
# ── 2. RVU-level deltas ────────────────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 2. RVU-Level Deltas — Which HCPCS Codes Move the Most
`pfs.rvu_proposed` (14,169 rows as of this ingest, CMS-1832-P) is
compared against `pfs.rvu` for CY2025 (pre-rule baseline) and CY2026
(what actually got finalized). Both `pfs.rvu` and `pfs.rvu_proposed`
are deduplicated to one row per HCPCS base code (no modifier),
`status_code = 'A'` (actively priced), and — for the proposed table
specifically — a non-null non-facility PE RVU, since ~29% of
`pfs.rvu_proposed` rows (as of this ingest) carry a null
non-facility *or* facility PE RVU (CMS's Addendum B only populates
the setting a code is actually priced in).
""")
return
@app.cell(hide_code=True)
def _(q):
rvu_delta_raw = q("""
WITH proposed AS (
SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, mp_rvu,
work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu,
CASE
WHEN hcpcs BETWEEN '99201' AND '99499' THEN 'E/M'
WHEN hcpcs BETWEEN '10000' AND '19999' THEN 'Integumentary'
WHEN hcpcs BETWEEN '20000' AND '29999' THEN 'Musculoskeletal'
WHEN hcpcs BETWEEN '30000' AND '39999' THEN 'Resp/Cardiovascular'
WHEN hcpcs BETWEEN '40000' AND '49999' THEN 'Digestive'
WHEN hcpcs BETWEEN '50000' AND '59999' THEN 'Urinary/Genital'
WHEN hcpcs BETWEEN '60000' AND '69999' THEN 'Endocrine/Nervous'
WHEN hcpcs BETWEEN '70000' AND '79999' THEN 'Radiology'
WHEN hcpcs BETWEEN '80000' AND '89999' THEN 'Path/Lab'
WHEN hcpcs BETWEEN '90000' AND '99199' THEN 'Medicine'
WHEN hcpcs LIKE 'G%' THEN 'G-codes'
ELSE 'Other'
END AS category
FROM pfs.rvu_proposed
WHERE cms_rule_id = 'CMS-1832-P'
AND (mod IS NULL OR mod = '')
AND status_code = 'A'
AND non_fac_pe_rvu IS NOT NULL
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
),
final_2025 AS (
SELECT hcpcs, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
FROM pfs.rvu
WHERE year = 2025 AND (mod IS NULL OR mod = '') AND status_code = 'A'
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
),
final_2026 AS (
SELECT hcpcs, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
FROM pfs.rvu
WHERE year = 2026 AND (mod IS NULL OR mod = '') AND status_code = 'A'
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
)
SELECT
p.hcpcs,
p.description,
p.category,
p.total_nf_rvu AS total_nf_rvu_proposed,
f25.total_nf_rvu AS total_nf_rvu_2025,
f26.total_nf_rvu AS total_nf_rvu_2026final
FROM proposed p
JOIN final_2025 f25 USING (hcpcs)
JOIN final_2026 f26 USING (hcpcs)
""")
rvu_delta_raw
return (rvu_delta_raw,)
@app.cell(hide_code=True)
def _(RULES, pl, rvu_delta_raw):
_cf_2025 = RULES[2025].conversion_factor
_cf_2026_proposed = RULES[2026].proposed.conversion_factor
_cf_2026_final = RULES[2026].conversion_factor
rvu_delta = (
rvu_delta_raw.with_columns(
(pl.col("total_nf_rvu_proposed") * _cf_2026_proposed).round(2).alias("dollar_proposed"),
(pl.col("total_nf_rvu_2025") * _cf_2025).round(2).alias("dollar_2025"),
(pl.col("total_nf_rvu_2026final") * _cf_2026_final).round(2).alias("dollar_2026final"),
)
.with_columns(
(pl.col("dollar_proposed") - pl.col("dollar_2025")).round(2).alias("delta_vs_2025"),
(pl.col("dollar_proposed") - pl.col("dollar_2026final")).round(2).alias("delta_vs_final"),
)
)
return (rvu_delta,)
@app.cell(hide_code=True)
def _(mo):
baseline_picker = mo.ui.radio(
options={
"NPRM impact — proposed vs. CY2025 final (what the rule proposed to change)": "delta_vs_2025",
"Proposed→finalized drift — proposed vs. CY2026 final (what the comment period changed)": "delta_vs_final",
},
value="NPRM impact — proposed vs. CY2025 final (what the rule proposed to change)",
label="Compare",
)
baseline_picker
return (baseline_picker,)
@app.cell(hide_code=True)
def _(AMBER, RULES, TEAL, alt, baseline_picker, mo, rvu_delta):
_col = baseline_picker.value
_n = 20
_winners = rvu_delta.sort(_col, descending=True).head(_n)
_losers = rvu_delta.sort(_col, descending=False).head(_n)
_top40 = _winners.vstack(_losers)
_plot_df = _top40.to_pandas()
_plot_df["label"] = _plot_df["hcpcs"] + "" + _plot_df["description"].str.slice(0, 40)
_plot_df["sign"] = _plot_df[_col].apply(lambda v: "Gain" if v >= 0 else "Loss")
winners_losers_chart = (
alt.Chart(_plot_df)
.mark_bar()
.encode(
x=alt.X(f"{_col}:Q", title="$ change (national unadjusted, GPCI=1.0)"),
y=alt.Y("label:N", title=None, sort=alt.SortField(field=_col, order="descending")),
color=alt.Color(
"sign:N",
title=None,
scale=alt.Scale(domain=["Gain", "Loss"], range=[TEAL, AMBER]),
legend=alt.Legend(orient="top"),
),
tooltip=["hcpcs", "description", alt.Tooltip(f"{_col}:Q", format="$.2f")],
)
.properties(
title=f"Top 20 Winners / Top 20 Losers — {baseline_picker.value}",
width=700,
height=600,
)
)
mo.vstack(
[
winners_losers_chart,
mo.md(f"""
**Sources:** `pfs.rvu_proposed` — CMS-1832-P NPRM,
{RULES[2026].proposed.federal_register_citation}. Baseline —
`pfs.rvu` CY2025 Final ({RULES[2025].federal_register_citation})
or CY2026 Final ({RULES[2026].federal_register_citation}), per
the comparison selected above.
"""),
]
)
return
@app.cell(hide_code=True)
def _(mo, rvu_delta):
mo.vstack(
[
mo.md(f"""
**Full detail — {rvu_delta.height:,} codes** matched across
proposed, CY2025-final, and CY2026-final RVU tables (search
the table below by HCPCS or description).
"""),
mo.ui.table(
rvu_delta.sort("delta_vs_2025").to_pandas(),
page_size=25,
label="RVU / Payment Deltas by HCPCS",
),
]
)
return
@app.cell(hide_code=True)
def _(con, mo):
_new = con.execute("""
SELECT count(*) FROM pfs.rvu_proposed p
WHERE p.cms_rule_id = 'CMS-1832-P' AND (p.mod IS NULL OR p.mod='') AND p.status_code='A' AND p.non_fac_pe_rvu IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM pfs.rvu f WHERE f.year=2025 AND f.hcpcs=p.hcpcs AND (f.mod IS NULL OR f.mod=''))
""").fetchone()[0]
_dropped = con.execute("""
SELECT count(*) FROM pfs.rvu f
WHERE f.year=2025 AND (f.mod IS NULL OR f.mod='') AND f.status_code='A'
AND NOT EXISTS (SELECT 1 FROM pfs.rvu_proposed p WHERE p.cms_rule_id = 'CMS-1832-P' AND p.hcpcs=f.hcpcs AND (p.mod IS NULL OR p.mod=''))
""").fetchone()[0]
mo.md(f"""
> **Coverage caveat:** {_new} HCPCS codes appear in `pfs.rvu_proposed`
> with no CY2025-final counterpart (new/renumbered codes), and {_dropped}
> CY2025-final codes have no match in `pfs.rvu_proposed` (dropped,
> bundled, or excluded from the Addendum B extract used to build the
> lake table). Both groups are excluded from the delta analysis above
> rather than shown with a fabricated baseline.
**Source:** `pfs.rvu_proposed` — CMS-1832-P Addendum B, 90 FR 32352.
`pfs.rvu` — annual PFS Final Rule Addendum B.
""")
return
# ── 3. Specialty impact ────────────────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 3. Specialty Impact
CMS's own specialty-impact table — **Table 92, "CY 2026 PFS Estimated
Impact on Total Allowed Charges by Specialty"** (NPRM, 90 FR
3280232806) — is what the brief calls for here. It is **not
available as transcribable text**: the Federal Register's own
full-text extraction of the NPRM embeds Table 92 as five scanned TIFF
graphics (`EP16JY25.178``.182`), not selectable text, in both the
plain-text and PDF-text-layer versions of `data/fr_downloads/
2025-13271.{txt,pdf}` that are ingested into this repository. There is
also no specialty-to-HCPCS mapping in the lake (`information_schema`
shows only `cms`, `opps`, and `pfs` schemas — no `reference_data`
schema exists to join against). CMS's real specialty attribution uses
claims-weighted utilization by specialty, which this repo does not
ingest.
**What follows instead** is a coarse CPT-range proxy — the same
category buckets used in `skin_sub_budget_neutrality.py` — applied to
the RVU deltas computed in Section 2. It approximates *which kinds of
services* moved, not *which specialties* were affected, and is
explicitly **not** a substitute for Table 92.
""")
return
@app.cell(hide_code=True)
def _(pl, rvu_delta):
category_impact = (
rvu_delta.group_by("category")
.agg(
pl.col("hcpcs").count().alias("codes"),
pl.col("delta_vs_2025").sum().round(0).alias("total_delta_vs_2025"),
pl.col("delta_vs_final").sum().round(0).alias("total_delta_vs_final"),
)
.sort("total_delta_vs_2025", descending=True)
)
category_impact
return (category_impact,)
@app.cell(hide_code=True)
def _(AMBER, TEAL, alt, category_impact, mo):
_df = category_impact.to_pandas()
_df["sign"] = _df["total_delta_vs_2025"].apply(lambda v: "Net gain" if v >= 0 else "Net loss")
category_chart = (
alt.Chart(_df)
.mark_bar()
.encode(
x=alt.X("total_delta_vs_2025:Q", title="Aggregate $ change vs. CY2025 (proxy category)"),
y=alt.Y("category:N", title=None, sort="-x"),
color=alt.Color(
"sign:N",
title=None,
scale=alt.Scale(domain=["Net gain", "Net loss"], range=[TEAL, AMBER]),
legend=alt.Legend(orient="top"),
),
tooltip=["category", "codes", alt.Tooltip("total_delta_vs_2025:Q", format="$,.0f")],
)
.properties(title="CPT-Range Proxy — Not CMS's Official Specialty Table", width=700, height=350)
)
mo.vstack(
[
category_chart,
mo.md("""
*Proxy category buckets, not specialty attribution. CMS's
official specialty impacts: Table 92, NPRM 90 FR 3280232806
(embedded graphic, not machine-readable in this repo's FR
corpus). Public-use file with granular specialty impacts:
cms.gov, CY2026 PFS proposed rule downloads.*
"""),
]
)
return
# ── 4. Advanced APM requirements ───────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 4. Advanced APM (QP) Requirements
Under MACRA 2015, clinicians who participate heavily enough in an
Advanced Alternative Payment Model (Advanced APM) during a "QP
Performance Period" become Qualifying APM Participants (QPs) for the
payment year two years later (`payment_year = performance_year + 2`).
QP status has, historically, meant MIPS exclusion and a lump-sum
incentive payment; starting payment year 2026 it also means a
**different conversion factor**.
""")
return
@app.cell(hide_code=True)
def _(for_payment_year, mo):
_governs_2026 = for_payment_year(2026)
mo.md(f"""
`qpp.for_payment_year(2026)` resolves to QP Performance Period
**{_governs_2026.performance_year}** ({_governs_2026.citation}) — the
performance period whose determinations govern CY2026 payment, and
the first performance period where `qp_cf_applies` is
**{_governs_2026.qp_cf_applies}**.
""")
return
@app.cell(hide_code=True)
def _(QPP, pl, plain_years):
_rows = []
for _perf_year, _qy in sorted(QPP.items()):
_prop = _qy.proposed
_rows.append(
{
"performance_year": _perf_year,
"payment_year": _qy.payment_year,
"qp_payment_pct": _qy.qp_thresholds.payment_amount_pct,
"qp_patient_pct": _qy.qp_thresholds.patient_count_pct,
"partial_qp_payment_pct": _qy.partial_qp_thresholds.payment_amount_pct,
"partial_qp_patient_pct": _qy.partial_qp_thresholds.patient_count_pct,
"revenue_nominal_pct": _qy.risk_standards.revenue_nominal_pct,
"benchmark_nominal_pct": _qy.risk_standards.benchmark_nominal_pct,
"apm_incentive_pct": _qy.apm_incentive_pct,
"qp_cf_applies": _qy.qp_cf_applies,
"cehrt_required": _qy.cehrt_required,
"proposed_numeric_change": (
"none — see disposition table below" if _prop is not None else "n/a"
),
"citation": _qy.citation,
}
)
apm_thresholds = pl.DataFrame(_rows)
plain_years(apm_thresholds)
return (apm_thresholds,)
@app.cell(hide_code=True)
def _(alt, apm_thresholds, mo):
_df = apm_thresholds.to_pandas()
_df["apm_incentive_pct"] = _df["apm_incentive_pct"].fillna(0.0)
_df["status"] = _df["apm_incentive_pct"].apply(lambda v: "Paid" if v > 0 else "None under current law")
incentive_chart = (
alt.Chart(_df)
.mark_bar()
.encode(
x=alt.X("payment_year:O", title="Payment year"),
y=alt.Y("apm_incentive_pct:Q", title="APM incentive payment (% of base-year Part B paid claims)"),
color=alt.Color(
"status:N",
title=None,
scale=alt.Scale(domain=["Paid", "None under current law"]),
),
tooltip=["performance_year", "payment_year", "apm_incentive_pct", "status"],
)
.properties(title="APM Incentive Payment — Not a Clean Sunset", width=600, height=300)
)
_citations = ", ".join(sorted(set(_df["citation"])))
mo.vstack(
[
incentive_chart,
mo.md(f"""
The lump-sum APM Incentive Payment does **not** sunset
cleanly after payment year 2026. Payment year 2027 (QP
Performance Period 2025) has no incentive under current
law. The Consolidated Appropriations Act, 2026 (CAA 2026,
Pub. L. 119-75) then **revives a 3.1% incentive for payment
year 2028 only** (`QPP[2026]`) — CMS-1848-P proposes to
codify that revival into 42 CFR 414.1450(b)(1) (91 FR
44218-44219, 44286), superseding the CY2026 Final Rule's
then-accurate "no incentive after 2026" narrative reflected
in the disposition table below. So the pattern across
payment years 2025-2028 is paid / paid / gap / revived, not
a single cutoff.
**Sources:** `qpp.QPP` performance years 20232026
({_citations}).
"""),
]
)
return
@app.cell(hide_code=True)
def _(QPP, mo):
_cy = QPP[2026]
_proposed = _cy.proposed
# As of the CY2026 PFS Final Rule (90 FR 49980), the payment-year-2028
# QP/Partial QP thresholds were understood to be 75%/50% (QP) and
# 50%/35% (Partial QP) -- CMS-1832-P proposed, and the Final Rule
# finalized, "no numeric change" from those then-current-law figures.
# These two literals are a CITED TRANSCRIPTION of that now-superseded
# disposition, not a live read of `QPP[2026].qp_thresholds` /
# `partial_qp_thresholds` -- those fields were corrected under task
# #620 (CAA 2026 legislatively restored 50%/35% QP, 40%/25% Partial
# QP for payment year 2028; see the `qpp` module docstring "CAA 2026
# supersession"). Pinning the historical values here rather than
# reading the registry live preserves what CMS actually said in this
# disposition table instead of retroactively rewriting it.
_qp_then = "75%/50%"
_partial_then = "50%/35%"
mo.md(f"""
### CY2026 NPRM proposed QPP changes — proposed vs. finalized disposition
Every FR page-pincite in the "Disposition" column below is quoted
**verbatim from `qpp.QPP[2026].proposed.changes`**
({_proposed.federal_register_citation}, {_proposed.cms_rule_id}) — that
field is the single source of truth for this table; nothing here is a
second, independently-typed citation.
| Proposal | Disposition |
|---|---|
| Individual-level QP determination (new Threshold Score calc, Sec. 414.1425(b)(3)) | **Finalized as proposed** (90 FR 4992349928, 49980) |
| Attribution-eligible beneficiary definition — expand 6th criterion beyond E/M services | **Finalized WITH MODIFICATION** — dual E/M + covered-professional-services methodology (90 FR 49980) |
| Sunset the Medical Home Model 50-clinician limit (Sec. 414.1415(c)(7)) after the 2025 QP Performance Period | **Finalized as proposed** (90 FR 49929) |
| QP / Partial QP percentage thresholds | **No numeric change proposed** — remained `qp_thresholds` {_qp_then} (QP), `partial_qp_thresholds` {_partial_then} (Partial QP) as understood at CY2026 Final Rule publication (90 FR 49980) — **CITED TRANSCRIPTION, since SUPERSEDED for payment year 2028 by CAA 2026; see `QPP[2026].qp_thresholds`/`partial_qp_thresholds` for current law** |
| Nominal-amount risk standards (42 CFR 414.1415(c)(3)(i)) / CEHRT requirement | **No change proposed** — cross-referenced by section only |
Full sourced narrative: `qpp.QPP[2026].proposed.changes`.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
### What QP status is worth in dollars — 3 example HCPCS
National, unadjusted (GPCI = 1.0) payment for a single unit of
service, computed as `(work_rvu + non_fac_pe_rvu + mp_rvu) × CF`,
comparing the QP and non-QP conversion factors for both the NPRM and
the Final Rule:
- **99213** — established-patient office visit, low complexity (E/M)
- **27447** — total knee arthroplasty (major procedure)
- **70553** — MRI brain, without and with contrast (imaging)
""")
return
@app.cell(hide_code=True)
def _(RULES, pl, q):
_example_rvu = q("""
SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, mp_rvu,
work_rvu + non_fac_pe_rvu + mp_rvu AS total_rvu
FROM pfs.rvu
WHERE year = 2026 AND (mod IS NULL OR mod = '')
AND hcpcs IN ('99213', '27447', '70553')
""")
_cy26 = RULES[2026]
_prop = _cy26.proposed
_rows = []
for _r in _example_rvu.iter_rows(named=True):
for _vintage, _cf_nonqp, _cf_qp in (
("CY2026 Proposed", _prop.conversion_factor, _prop.cf_qp),
("CY2026 Final", _cy26.conversion_factor, _cy26.cf_qp),
):
_pay_nonqp = round(_r["total_rvu"] * _cf_nonqp, 2)
_pay_qp = round(_r["total_rvu"] * _cf_qp, 2)
_rows.append(
{
"hcpcs": _r["hcpcs"],
"description": _r["description"],
"vintage": _vintage,
"non_qp_payment": _pay_nonqp,
"qp_payment": _pay_qp,
"qp_differential": round(_pay_qp - _pay_nonqp, 2),
}
)
qp_differential = pl.DataFrame(_rows)
qp_differential
return (qp_differential,)
@app.cell(hide_code=True)
def _(alt, mo, qp_differential):
differential_chart = (
alt.Chart(qp_differential.to_pandas())
.mark_bar()
.encode(
x=alt.X("hcpcs:N", title="HCPCS"),
xOffset=alt.XOffset("vintage:N", sort=["CY2026 Proposed", "CY2026 Final"]),
y=alt.Y("qp_differential:Q", title="QP minus non-QP payment ($, national unadjusted)"),
color=alt.Color("vintage:N", title=None, sort=["CY2026 Proposed", "CY2026 Final"]),
tooltip=["hcpcs", "description", "vintage", alt.Tooltip("qp_differential:Q", format="$.2f")],
)
.properties(title="Dollar Value of QP Status — 3 Example Codes", width=500, height=320)
)
mo.vstack(
[
differential_chart,
mo.md("""
**Sources:** RVUs — `pfs.rvu`, CY2026 (90 FR 49266). CFs —
`pfs.rules.RULES[2026]` (final, 90 FR 49266) and
`RULES[2026].proposed` (NPRM, 90 FR 32352). National unadjusted
means GPCI = 1.0 — actual payment varies by locality.
"""),
]
)
return
# ── 5. Provenance ───────────────────────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 5. Provenance
Ingest-log entries (`cms.ingest_log`) for every table this notebook
reads from — `pfs.rvu` and `pfs.rvu_proposed` — so every figure above
can be traced back to a specific ingest run, source file, and Federal
Register citation. (`pfs.gpci` is not queried anywhere in this
notebook — see the intro's GPCI = 1.0 scope note — so it is
deliberately excluded here rather than claimed as a source.)
""")
return
@app.cell(hide_code=True)
def _(mo, q):
provenance = q("""
SELECT run_id, ingested_at, module, table_name, rule_id,
source_file, sha256, rows, fr_citation, pincite_key
FROM cms.ingest_log
WHERE table_name IN ('pfs.rvu', 'pfs.rvu_proposed')
ORDER BY ingested_at DESC
""")
mo.ui.table(provenance.to_pandas(), label="Ingest Log — Tables Used in This Notebook")
return
if __name__ == "__main__":
app.run()