Merge P37: CY2027 PFS proposed rule — capture, ingest, APM analysis

This commit is contained in:
kert
2026-08-14 11:09:25 -04:00
13 changed files with 1974 additions and 248 deletions

View File

@@ -1,25 +1,16 @@
"""Fetch + register the CY2026 PFS NPRM (proposed rule) addenda. """Fetch + register PFS NPRM (proposed rule) addenda, by rule year.
Downloads the CMS-1832-P "CY 2026 PFS Proposed Rule Addenda" ZIP, which Downloads a CY{year} PFS Proposed Rule "Addenda" ZIP, which contains
contains Addendum B (proposed RVUs by HCPCS), and optionally registers Addendum B (proposed RVUs by HCPCS), and optionally registers Addendum B
Addendum B in bib against the CY2026 PFS NPRM rule item (2KVJ2HKX), in bib against that year's NPRM rule item, tagged ``sup:{year}_PFS_NPRM``
tagged ``sup:2026_PFS_NPRM`` so Task 5's loader can discover the so the loader can discover the *proposed* Addendum B without touching
*proposed* Addendum B without touching final-rule files (which carry final-rule files (which carry ``sup:{year}_PFS_FR`` — the two namespaces
``sup:2026_PFS_FR`` — the two namespaces must never collide). must never collide).
Source page (CMS-1832-P detail page — the canonical
``.../pfs-federal-regulation-notices/cms-1832-p`` URL 404s; this is the
current path found via the federal-regulation-notices listing):
https://www.cms.gov/medicare/payment/fee-schedules/physician/federal-regulation-notices/cms-1832-p
Download link text on that page: "CY 2026 PFS Proposed Rule Addenda -
Updated 07/29/2025" (the URL path segment literally contains
"07/29/2025" for the update-date suffix — that's CMS's own href, not a
typo here).
Usage: Usage:
uv run python dev/scripts/fetch_nprm_addenda.py --dry-run uv run python dev/scripts/fetch_nprm_addenda.py --dry-run
uv run python dev/scripts/fetch_nprm_addenda.py uv run python dev/scripts/fetch_nprm_addenda.py --year 2027
uv run python dev/scripts/fetch_nprm_addenda.py --register uv run python dev/scripts/fetch_nprm_addenda.py --year 2027 --register
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,36 +18,87 @@ from __future__ import annotations
import argparse import argparse
import re import re
import zipfile import zipfile
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DEST_DIR = ROOT / "data" / "cms" / "pfs_nprm" / "2026"
USER_AGENT = ( USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
) )
# CY2026 PFS Proposed Rule (CMS-1832-P) Addenda ZIP — resolved from the
# CMS-1832-P detail page:
# https://www.cms.gov/medicare/payment/fee-schedules/physician/federal-regulation-notices/cms-1832-p
ADDENDA_URL = (
"https://www.cms.gov/files/zip/"
"cy-2026-pfs-proposed-rule-addenda-updated-07/29/2025.zip"
)
ADDENDA_ZIP_NAME = "cy2026_pfs_nprm_addenda.zip"
ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b") ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
RULE_ITEM_KEY = "2KVJ2HKX"
ADDENDUM_B_TITLE = "CY2026 PFS NPRM Addendum B (proposed RVUs)" @dataclass(frozen=True)
REGISTER_TAGS = ["sup:2026_PFS_NPRM", "module:pfs", "file:rvu", "year:2026"] class YearConfig:
"""Per-rule-year config: where to fetch the addenda ZIP and how to
register Addendum B against the corresponding bib rule item."""
year: int
detail_page_url: str
addenda_url: str
dest_dir: Path
addenda_zip_name: str
rule_item_key: str
addendum_b_title: str
register_tags: list[str]
def download_zip(dest: Path, *, retries: int = 3) -> bool: CONFIGS: dict[int, YearConfig] = {
2026: YearConfig(
year=2026,
detail_page_url=(
"https://www.cms.gov/medicare/payment/fee-schedules/physician/"
"federal-regulation-notices/cms-1832-p"
),
# CY2026 PFS Proposed Rule (CMS-1832-P) Addenda ZIP — resolved from
# the CMS-1832-P detail page above (the canonical
# ".../pfs-federal-regulation-notices/cms-1832-p" URL 404s; this is
# the current path found via the federal-regulation-notices
# listing). Download link text on that page: "CY 2026 PFS Proposed
# Rule Addenda - Updated 07/29/2025" (the URL path segment
# literally contains "07/29/2025" for the update-date suffix —
# that's CMS's own href, not a typo here).
addenda_url=(
"https://www.cms.gov/files/zip/"
"cy-2026-pfs-proposed-rule-addenda-updated-07/29/2025.zip"
),
dest_dir=ROOT / "data" / "cms" / "pfs_nprm" / "2026",
addenda_zip_name="cy2026_pfs_nprm_addenda.zip",
rule_item_key="2KVJ2HKX",
addendum_b_title="CY2026 PFS NPRM Addendum B (proposed RVUs)",
register_tags=["sup:2026_PFS_NPRM", "module:pfs", "file:rvu", "year:2026"],
),
2027: YearConfig(
year=2027,
detail_page_url=(
"https://www.cms.gov/medicare/payment/fee-schedules/physician/"
"federal-regulation-notices/cms-1848-p"
),
# CY2027 PFS Proposed Rule (CMS-1848-P) Addenda ZIP — resolved from
# the CMS-1848-P detail page above (fallback if that 404s: the
# federal-regulation-notices index page). Download link text on
# that page: "CY 2027 PFS Proposed Rule Addenda (Updated
# 07/21/2026)".
addenda_url=(
"https://www.cms.gov/files/zip/"
"cy-2027-pfs-proposed-rule-addenda-updated-07-21-2026.zip"
),
dest_dir=ROOT / "data" / "cms" / "pfs_nprm" / "2027",
addenda_zip_name="cy2027_pfs_nprm_addenda.zip",
rule_item_key="5ITGVDJV",
addendum_b_title="CY2027 PFS NPRM Addendum B (proposed RVUs)",
register_tags=["sup:2027_PFS_NPRM", "module:pfs", "file:rvu", "year:2027"],
),
}
def download_zip(url: str, dest: Path, *, retries: int = 3) -> bool:
"""Download the addenda ZIP if it doesn't already exist.""" """Download the addenda ZIP if it doesn't already exist."""
import time import time
@@ -67,7 +109,7 @@ def download_zip(dest: Path, *, retries: int = 3) -> bool:
for attempt in range(1, retries + 1): for attempt in range(1, retries + 1):
try: try:
resp = httpx.get( resp = httpx.get(
ADDENDA_URL, url,
headers={"User-Agent": USER_AGENT}, headers={"User-Agent": USER_AGENT},
timeout=120, timeout=120,
follow_redirects=True, follow_redirects=True,
@@ -96,11 +138,20 @@ def extract_zip(zip_path: Path, dest_dir: Path) -> list[Path]:
def find_addendum_b(files: list[Path]) -> Path | None: def find_addendum_b(files: list[Path]) -> Path | None:
"""Find the extracted file whose name matches Addendum B.""" """Find the extracted file whose name matches Addendum B.
Excludes macOS ``__MACOSX/._*`` resource-fork junk that CMS's zips
sometimes carry alongside the real file — those AppleDouble files
match the same name pattern and suffix, so they'd otherwise be
eligible candidates too.
"""
candidates = [ candidates = [
f f
for f in files for f in files
if ADDENDUM_B_RE.search(f.name) and f.suffix.lower() in (".xlsx", ".csv") if ADDENDUM_B_RE.search(f.name)
and f.suffix.lower() in (".xlsx", ".csv")
and not f.name.startswith("._")
and "__MACOSX" not in f.parts
] ]
if not candidates: if not candidates:
return None return None
@@ -134,7 +185,7 @@ def _existing_attachment(store: Any, item_key: str, filename: str) -> str | None
return row["key"] if row else None return row["key"] if row else None
def register(addendum_b_path: Path) -> None: def register(cfg: YearConfig, addendum_b_path: Path) -> None:
"""Attach Addendum B to the NPRM rule item and tag it. """Attach Addendum B to the NPRM rule item and tag it.
Idempotent: if an attachment with the Addendum B title already exists Idempotent: if an attachment with the Addendum B title already exists
@@ -147,15 +198,15 @@ def register(addendum_b_path: Path) -> None:
store = connect.bib() store = connect.bib()
existing_key = _existing_attachment(store, RULE_ITEM_KEY, ADDENDUM_B_TITLE) existing_key = _existing_attachment(store, cfg.rule_item_key, cfg.addendum_b_title)
if existing_key is not None: if existing_key is not None:
print( print(
f"SKIP attach — {RULE_ITEM_KEY} already has an attachment titled " f"SKIP attach — {cfg.rule_item_key} already has an attachment "
f"{ADDENDUM_B_TITLE!r} (key {existing_key})" f"titled {cfg.addendum_b_title!r} (key {existing_key})"
) )
else: else:
att_key = store.attach_file( att_key = store.attach_file(
RULE_ITEM_KEY, addendum_b_path, title=ADDENDUM_B_TITLE cfg.rule_item_key, addendum_b_path, title=cfg.addendum_b_title
) )
con = store._con() # noqa: SLF001 — same pattern as bib/oig.py, bib/iom.py con = store._con() # noqa: SLF001 — same pattern as bib/oig.py, bib/iom.py
row = con.execute( row = con.execute(
@@ -164,16 +215,30 @@ def register(addendum_b_path: Path) -> None:
storage_path = row["storage_path"] if row else "<unknown>" storage_path = row["storage_path"] if row else "<unknown>"
print(f"Attached {addendum_b_path.name} (key {att_key}) -> {storage_path}") print(f"Attached {addendum_b_path.name} (key {att_key}) -> {storage_path}")
for tag in REGISTER_TAGS: for tag in cfg.register_tags:
store.add_tag(RULE_ITEM_KEY, tag) store.add_tag(cfg.rule_item_key, tag)
print(f"Tagged {RULE_ITEM_KEY} with: {', '.join(REGISTER_TAGS)}") print(f"Tagged {cfg.rule_item_key} with: {', '.join(cfg.register_tags)}")
item = store.get(RULE_ITEM_KEY) item = store.get(cfg.rule_item_key)
print(f"Current tags on {RULE_ITEM_KEY}: {item.tags}") print(f"Current tags on {cfg.rule_item_key}: {item.tags}")
def main() -> None: def main() -> None:
years = sorted(CONFIGS)
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Fetch + register the CY2026 PFS NPRM addenda" description=(
"Fetch + register PFS NPRM addenda for a given rule year "
f"(available: {', '.join(str(y) for y in years)})."
)
)
parser.add_argument(
"--year",
type=int,
choices=years,
default=2026,
help=(
"Rule year to fetch — one of "
f"{', '.join(str(y) for y in years)} (default: 2026)."
),
) )
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
@@ -183,34 +248,35 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--register", "--register",
action="store_true", action="store_true",
help="After fetching, attach Addendum B to bib item " help="After fetching, attach Addendum B to the rule item and apply "
f"{RULE_ITEM_KEY} and apply the {', '.join(REGISTER_TAGS)} tags.", "its tags for the selected --year.",
) )
args = parser.parse_args() args = parser.parse_args()
cfg = CONFIGS[args.year]
zip_dest = DEST_DIR / ADDENDA_ZIP_NAME zip_dest = cfg.dest_dir / cfg.addenda_zip_name
if args.dry_run: if args.dry_run:
print(f"Would GET {ADDENDA_URL}") print(f"Would GET {cfg.addenda_url}")
print(f"Would save {zip_dest}") print(f"Would save {zip_dest}")
print(f"Would extract into {DEST_DIR}") print(f"Would extract into {cfg.dest_dir}")
if args.register: if args.register:
print( print(
f"Would attach Addendum B to {RULE_ITEM_KEY} " f"Would attach Addendum B to {cfg.rule_item_key} "
f"and tag with: {', '.join(REGISTER_TAGS)}" f"and tag with: {', '.join(cfg.register_tags)}"
) )
return return
DEST_DIR.mkdir(parents=True, exist_ok=True) cfg.dest_dir.mkdir(parents=True, exist_ok=True)
if not download_zip(zip_dest): if not download_zip(cfg.addenda_url, zip_dest):
raise SystemExit(f"Failed to download {ADDENDA_URL}") raise SystemExit(f"Failed to download {cfg.addenda_url}")
files = extract_zip(zip_dest, DEST_DIR) files = extract_zip(zip_dest, cfg.dest_dir)
print(f"\nExtracted {len(files)} files to {DEST_DIR}:") print(f"\nExtracted {len(files)} files to {cfg.dest_dir}:")
for f in files: for f in files:
size_kb = f.stat().st_size / 1024 size_kb = f.stat().st_size / 1024
print(f" {f.relative_to(DEST_DIR)} ({size_kb:.0f} KB)") print(f" {f.relative_to(cfg.dest_dir)} ({size_kb:.0f} KB)")
addendum_b = find_addendum_b(files) addendum_b = find_addendum_b(files)
if addendum_b is None: if addendum_b is None:
@@ -218,12 +284,12 @@ def main() -> None:
"Could not find an Addendum B file (pattern " "Could not find an Addendum B file (pattern "
f"{ADDENDUM_B_RE.pattern!r}) among extracted files." f"{ADDENDUM_B_RE.pattern!r}) among extracted files."
) )
print(f"\nAddendum B: {addendum_b.relative_to(DEST_DIR)}") print(f"\nAddendum B: {addendum_b.relative_to(cfg.dest_dir)}")
preview_addendum_b(addendum_b) preview_addendum_b(addendum_b)
if args.register: if args.register:
print() print()
register(addendum_b) register(cfg, addendum_b)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -36,14 +36,16 @@ def main() -> int:
parser.add_argument( parser.add_argument(
"--nprm", "--nprm",
action="store_true", action="store_true",
help="also load the NPRM Addendum B proposed RVUs into pfs.rvu_proposed", help=(
"also load every registered NPRM Addendum B proposed-RVU "
"partition (pfs.nprm.NPRM_SOURCES) into pfs.rvu_proposed"
),
) )
args = parser.parse_args() args = parser.parse_args()
from cms import ingest_log from cms import ingest_log
from conf.connect import duckdb_batch, publish_replica from conf.connect import duckdb_batch, publish_replica
from pfs.pipe import load_all from pfs.pipe import load_all
from pfs.rules import RULES
run_id = ingest_log.new_run_id() run_id = ingest_log.new_run_id()
@@ -62,22 +64,23 @@ def main() -> int:
) )
if args.nprm: if args.nprm:
from pfs.nprm import load_rvu_proposed from pfs.nprm import NPRM_SOURCES, load_rvu_proposed
print("\n--- NPRM Addendum B (proposed RVUs) ---") print("\n--- NPRM Addendum B (proposed RVUs) ---")
nprm_out = load_rvu_proposed(con) for year, _tag, cms_rule_id, _pincite_key in NPRM_SOURCES:
print(f" pfs.rvu_proposed : {nprm_out}") nprm_out = load_rvu_proposed(con, year=year)
ingest_log.log_ingest( print(f" pfs.rvu_proposed [{cms_rule_id}]: {nprm_out}")
con, ingest_log.log_ingest(
module="pfs", con,
table_name="pfs.rvu_proposed", module="pfs",
rows=nprm_out["rows"], table_name="pfs.rvu_proposed",
source_file=nprm_out["source_file"], rows=nprm_out["rows"],
rule_id="CMS-1832-P", source_file=nprm_out["source_file"],
fr_citation=RULES[2026].proposed.federal_register_citation, rule_id=nprm_out["cms_rule_id"],
pincite_key="2KVJ2HKX", fr_citation=nprm_out["fr_citation"],
run_id=run_id, pincite_key=nprm_out["pincite_key"],
) run_id=run_id,
)
replica = publish_replica("aco") replica = publish_replica("aco")
print(f"replica → {replica}") print(f"replica → {replica}")

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Post-comment-close re-farm for docket CMS-2026-2377 (CY2027 PFS NPRM,
# CMS-1848-P). Scheduled via host crontab for 2026-09-15 (comment period
# closes 2026-09-14 per 91 FR 43842 DATES block). Tracker: issue #615.
#
# Every step is resumable/incremental — safe to re-run on partial failure.
set -uo pipefail
cd "$(dirname "$0")/../.."
export PATH="$HOME/.local/bin:$PATH"
set -a; source .env; set +a
mkdir -p .state/comments
LOG=.state/comments/refarm-CMS-2026-2377.log
exec >>"$LOG" 2>&1
echo "=== re-farm run $(date -Is) ==="
FAILURES=0
uv run stack bib fetch-pfs-comments || { echo "WARN: fetch-pfs-comments rc=$?"; FAILURES=$((FAILURES+1)); }
uv run stack bib backfill-comments || { echo "WARN: backfill rc=$?"; FAILURES=$((FAILURES+1)); }
uv run stack comments extract --docket CMS-2026-2377 || { echo "WARN: extract rc=$?"; FAILURES=$((FAILURES+1)); }
uv run stack comments extract-ocr --docket CMS-2026-2377 || { echo "WARN: ocr rc=$?"; FAILURES=$((FAILURES+1)); }
uv run stack llm index --collection comments --docket CMS-2026-2377 \
|| { echo "WARN: index rc=$?"; FAILURES=$((FAILURES+1)); }
COUNTS=$(uv run python - <<'EOF'
from conf import connect
s = connect.bib()
con = s._con()
total, enriched = con.execute(
"SELECT count(*), sum(CASE WHEN coalesce(abstract,'')<>'' THEN 1 ELSE 0 END) "
"FROM items WHERE url LIKE 'https://www.regulations.gov/comment/CMS-2026-2377-%'"
).fetchone()
print(f"comments={total} enriched={enriched}")
s.close()
EOF
) || { echo "WARN: counts rc=$?"; FAILURES=$((FAILURES+1)); }
echo "$COUNTS"
if [ "$FAILURES" -eq 0 ]; then
RESULT_MSG="Automated post-close re-farm ran $(date -I): $COUNTS."
else
RESULT_MSG="Automated post-close re-farm ran $(date -I) — completed with $FAILURES failed steps — check log. $COUNTS."
fi
curl -sf -X POST \
"https://git.fhirworx.io/api/v1/repos/homelab/stack/issues/615/comments" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: curl/8.5.0" \
-d "{\"body\": \"$RESULT_MSG Log: .state/comments/refarm-CMS-2026-2377.log. Review counts, spot-check indexing, then close this issue and milestone P37.\n\nIf complete: close #615, close milestone P37, and REMOVE the crontab line (re-fires annually).\"}" \
|| echo "WARN: gitea comment failed"
echo "=== done $(date -Is) ==="

View File

@@ -278,8 +278,8 @@ def _(mo):
def _(q): def _(q):
rvu_delta_raw = q(""" rvu_delta_raw = q("""
WITH proposed AS ( WITH proposed AS (
SELECT hcpcs, description, work_rvu, nonfac_pe_rvu, mp_rvu, SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, mp_rvu,
work_rvu + nonfac_pe_rvu + mp_rvu AS total_nf_rvu, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu,
CASE CASE
WHEN hcpcs BETWEEN '99201' AND '99499' THEN 'E/M' WHEN hcpcs BETWEEN '99201' AND '99499' THEN 'E/M'
WHEN hcpcs BETWEEN '10000' AND '19999' THEN 'Integumentary' WHEN hcpcs BETWEEN '10000' AND '19999' THEN 'Integumentary'
@@ -295,9 +295,10 @@ def _(q):
ELSE 'Other' ELSE 'Other'
END AS category END AS category
FROM pfs.rvu_proposed FROM pfs.rvu_proposed
WHERE (modifier IS NULL OR modifier = '') WHERE cms_rule_id = 'CMS-1832-P'
AND (mod IS NULL OR mod = '')
AND status_code = 'A' AND status_code = 'A'
AND nonfac_pe_rvu IS NOT NULL AND non_fac_pe_rvu IS NOT NULL
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1 QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
), ),
final_2025 AS ( final_2025 AS (
@@ -433,13 +434,13 @@ def _(mo, rvu_delta):
def _(con, mo): def _(con, mo):
_new = con.execute(""" _new = con.execute("""
SELECT count(*) FROM pfs.rvu_proposed p SELECT count(*) FROM pfs.rvu_proposed p
WHERE (p.modifier IS NULL OR p.modifier='') AND p.status_code='A' AND p.nonfac_pe_rvu IS NOT NULL 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='')) 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] """).fetchone()[0]
_dropped = con.execute(""" _dropped = con.execute("""
SELECT count(*) FROM pfs.rvu f SELECT count(*) FROM pfs.rvu f
WHERE f.year=2025 AND (f.mod IS NULL OR f.mod='') AND f.status_code='A' 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.hcpcs=f.hcpcs AND (p.modifier IS NULL OR p.modifier='')) 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] """).fetchone()[0]
mo.md(f""" mo.md(f"""
> **Coverage caveat:** {_new} HCPCS codes appear in `pfs.rvu_proposed` > **Coverage caveat:** {_new} HCPCS codes appear in `pfs.rvu_proposed`
@@ -603,7 +604,7 @@ def _(QPP, pl):
def _(alt, apm_thresholds, mo): def _(alt, apm_thresholds, mo):
_df = apm_thresholds.to_pandas() _df = apm_thresholds.to_pandas()
_df["apm_incentive_pct"] = _df["apm_incentive_pct"].fillna(0.0) _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 "Sunset") _df["status"] = _df["apm_incentive_pct"].apply(lambda v: "Paid" if v > 0 else "None under current law")
incentive_chart = ( incentive_chart = (
alt.Chart(_df) alt.Chart(_df)
@@ -611,10 +612,14 @@ def _(alt, apm_thresholds, mo):
.encode( .encode(
x=alt.X("payment_year:O", title="Payment year"), 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)"), 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", "Sunset"])), 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"], tooltip=["performance_year", "payment_year", "apm_incentive_pct", "status"],
) )
.properties(title="APM Incentive Payment Sunset", width=600, height=300) .properties(title="APM Incentive Payment — Not a Clean Sunset", width=600, height=300)
) )
_citations = ", ".join(sorted(set(_df["citation"]))) _citations = ", ".join(sorted(set(_df["citation"])))
@@ -623,12 +628,18 @@ def _(alt, apm_thresholds, mo):
[ [
incentive_chart, incentive_chart,
mo.md(f""" mo.md(f"""
Payment year 2026 (QP Performance Period 2024) is, under The lump-sum APM Incentive Payment does **not** sunset
current law, the **last** year with a lump-sum APM incentive cleanly after payment year 2026. Payment year 2027 (QP
payment (1.88% of base-year paid claims). From payment year Performance Period 2025) has no incentive under current
2027 onward, QP status stops paying a bonus and starts law. The Consolidated Appropriations Act, 2026 (CAA 2026,
(instead) determining *which conversion factor applies* — see Pub. L. 119-75) then **revives a 3.1% incentive for payment
below. 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 2023–2026 **Sources:** `qpp.QPP` performance years 2023–2026
({_citations}). ({_citations}).
@@ -642,8 +653,21 @@ def _(alt, apm_thresholds, mo):
def _(QPP, mo): def _(QPP, mo):
_cy = QPP[2026] _cy = QPP[2026]
_proposed = _cy.proposed _proposed = _cy.proposed
_qp = _cy.qp_thresholds
_partial = _cy.partial_qp_thresholds # 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""" mo.md(f"""
### CY2026 NPRM proposed QPP changes — proposed vs. finalized disposition ### CY2026 NPRM proposed QPP changes — proposed vs. finalized disposition
@@ -658,7 +682,7 @@ def _(QPP, mo):
| Individual-level QP determination (new Threshold Score calc, Sec. 414.1425(b)(3)) | **Finalized as proposed** (90 FR 49923–49928, 49980) | | Individual-level QP determination (new Threshold Score calc, Sec. 414.1425(b)(3)) | **Finalized as proposed** (90 FR 49923–49928, 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) | | 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) | | 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** — remain `qp_thresholds` {_qp.payment_amount_pct:.0f}%/{_qp.patient_count_pct:.0f}% (QP), `partial_qp_thresholds` {_partial.payment_amount_pct:.0f}%/{_partial.patient_count_pct:.0f}% (Partial QP) | | 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 | | 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`. Full sourced narrative: `qpp.QPP[2026].proposed.changes`.

View File

@@ -0,0 +1,779 @@
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("""
# CY2027 PFS Proposed Rule — Financial Changes & Advanced APM
CMS's CY2027 Physician Fee Schedule NPRM (CMS-1848-P, 91 FR 43842,
published July 16, 2026; docket CMS-2026-2377) is a **proposed rule
only** — there is no CY2027 Final Rule (CMS-1848-F) yet, and every
"CY2027" figure below is a proposal, not a finalized payment
parameter. The comment period, thresholds, and Advanced-APM
schedule captured here can all still change before the Final Rule
ships.
**What this means:** every number below is read live from
`pfs.rules.RULES` / `pfs.rules.PROPOSED` (via `proposed_for(2027)`)
and `qpp.QPP` (the repo's rule registries) and the `pfs`/`cms`
DuckLake schemas — nothing here is a hard-coded figure. Columns are
explicitly labeled **"final rule pending"** rather than fabricating
a CY2027-final column that does not exist yet. 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 pfs.rules import RULES, proposed_for
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 Section 2.
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, proposed_for, q
# ── 1. The conversion-factor walk ─────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 1. The Conversion Factor Walk — CY2026 Final → CY2027 Proposed
CY2026 was the first year the PFS published a split conversion
factor — a qualifying-APM (QP) track and a nonqualifying-APM
(non-QP) track, each with a standard and an anesthesia CF. The
CY2027 NPRM proposes new values for all four; the chart below
places the CY2026 **Final Rule** values (the only finalized
baseline that exists) alongside the CY2027 **NPRM proposal**
(final rule pending).
""")
return
@app.cell(hide_code=True)
def _(RULES, pl, proposed_for):
_cy26 = RULES[2026]
_prop = proposed_for(2027)
_rows = [
{"vintage": "CY2026 Final", "family": "Standard", "track": "Non-QP", "cf": _cy26.conversion_factor},
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Standard", "track": "Non-QP", "cf": _prop.conversion_factor},
{"vintage": "CY2026 Final", "family": "Standard", "track": "QP", "cf": _cy26.cf_qp},
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Standard", "track": "QP", "cf": _prop.cf_qp},
{"vintage": "CY2026 Final", "family": "Anesthesia", "track": "Non-QP", "cf": _cy26.anesthesia_cf},
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Anesthesia", "track": "Non-QP", "cf": _prop.anesthesia_cf},
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Anesthesia", "track": "QP", "cf": _prop.anesthesia_cf_qp},
# NOTE: RULES[2026].anesthesia_cf models the non-QP anesthesia CF
# only — the CY2026 Final Rule anesthesia QP CF is not captured
# in the registry (see pfs.rules module docstring), so that bar
# is deliberately omitted rather than guessed. No CY2027-final
# bars exist anywhere in this table — there is no Final Rule yet.
]
cf_walk = pl.DataFrame(_rows)
cf_walk
return (cf_walk,)
@app.cell(hide_code=True)
def _(alt, cf_walk, mo):
_vintage_order = ["CY2026 Final", "CY2027 Proposed (final rule pending)"]
_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=300, height=280)
.facet(column=alt.Column("family:N", title=None))
.resolve_scale(y="independent")
.properties(title="CY2026 Final vs. CY2027 Proposed PFS Conversion Factors")
)
mo.vstack(
[
cf_chart,
mo.md("""
*CY2026 Final anesthesia QP is not shown — that CF is not
captured in the `pfs.rules` registry (see module docstring).
No CY2027-final bars appear anywhere in this notebook — the
Final Rule (CMS-1848-F) has not been published.*
**Sources:** CY2026 Final Rule — 90 FR 49266. CY2027 NPRM —
91 FR 43842 (CMS-1848-P).
"""),
]
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
### Budget-neutrality adjustor decomposition
CMS's own NPRM narrative (91 FR 44242) describes the CY2027 CF
derivation explicitly: start from the **CY2026 conversion factors
with the one-time 2.50% statutory increase backed out**, multiply
by the 0.53% budget-neutrality adjustment, then multiply by the
section 1848(d)(20) qualifying/nonqualifying-APM annual update
(+0.75% / +0.25%). Reproducing that arithmetic against the
registry's actual CY2026-final and CY2027-proposed CFs is a useful
cross-check that the registry's transcription is internally
consistent with CMS's narrative — for the **standard** CF only;
the anesthesia CFs reflect "the same overall PFS adjustments with
the addition of anesthesia-specific PE and MP adjustments" (91 FR
44242) that are not modeled as separate registry fields, so they
are excluded from this reconstruction rather than approximated.
""")
return
@app.cell(hide_code=True)
def _(RULES, mo, pl, proposed_for):
_cy26 = RULES[2026]
_prop = proposed_for(2027)
# CMS's own stated CF-derivation components for CY2027 (91 FR 44242,
# file lines ~39088-39106): back the CY2026 CFs out of the one-time
# 2.50% statutory increase, then reapply BN + the annual update.
# NONE of these bare percentages is a field anywhere in `pfs.rules`
# or `qpp` — there is nothing to attribute-access for them. 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_BACKOUT = 1.0250 # CY2026's one-time +2.50% statutory increase, backed out per 91 FR 44242
_ANNUAL_UPDATE_MULT = {
"Non-QP": 1.0025, # +0.25%/yr nonqualifying-APM update, sec. 1848(d)(20), 91 FR 44242
"QP": 1.0075, # +0.75%/yr qualifying-APM update, sec. 1848(d)(20), 91 FR 44242
}
def _reconstruct(cy2026_final_cf, track, bn_adjustor):
return (cy2026_final_cf / _ONE_TIME_BACKOUT) * bn_adjustor * _ANNUAL_UPDATE_MULT[track]
_bn_rows = [
{
"track": "Non-QP",
"cy2026_final_cf": _cy26.conversion_factor,
"one_time_backout": _ONE_TIME_BACKOUT,
"bn_adjustor": _prop.budget_neutrality_adjustor,
"annual_update_mult": _ANNUAL_UPDATE_MULT["Non-QP"],
"reconstructed_cy2027_proposed_cf": round(
_reconstruct(_cy26.conversion_factor, "Non-QP", _prop.budget_neutrality_adjustor), 4
),
"registry_cy2027_proposed_cf": _prop.conversion_factor,
},
{
"track": "QP",
"cy2026_final_cf": _cy26.cf_qp,
"one_time_backout": _ONE_TIME_BACKOUT,
"bn_adjustor": _prop.budget_neutrality_adjustor,
"annual_update_mult": _ANNUAL_UPDATE_MULT["QP"],
"reconstructed_cy2027_proposed_cf": round(
_reconstruct(_cy26.cf_qp, "QP", _prop.budget_neutrality_adjustor), 4
),
"registry_cy2027_proposed_cf": _prop.cf_qp,
},
]
bn_decomp = pl.DataFrame(_bn_rows).with_columns(
(pl.col("reconstructed_cy2027_proposed_cf") - pl.col("registry_cy2027_proposed_cf"))
.abs()
.round(4)
.alias("abs_diff")
)
mo.vstack(
[
bn_decomp,
mo.md(f"""
`reconstructed_cf = (CY2026_final_cf / one_time_backout) ×
bn_adjustor × annual_update_mult` — `bn_adjustor` and
`registry_cy2027_proposed_cf` are read live from
`RULES[2026]` / `proposed_for(2027)`. `one_time_backout`
({_ONE_TIME_BACKOUT}) and `annual_update_mult`
({_ANNUAL_UPDATE_MULT["Non-QP"]} non-QP /
{_ANNUAL_UPDATE_MULT["QP"]} QP) are **cited transcriptions
of CMS's NPRM narrative (91 FR 44242,
`data/fr_downloads/2026-14327.txt:39088-39106`)**, not
registry fields — no field in `pfs.rules` or `qpp` models
the 2.50%/0.53%/0.25%/0.75% components individually, only
their combined effect on `conversion_factor` / `cf_qp`.
"""),
]
)
return
@app.cell(hide_code=True)
def _(mo, proposed_for):
_prop = proposed_for(2027)
mo.vstack(
[
mo.md("### Caveat — a drafting error in the NPRM's own summary section"),
mo.callout(
mo.md(f"""
`pfs.rules.PROPOSED[2027].notes` documents a transcription
caveat that is worth surfacing here rather than only in
source comments — quoted live below, not retyped:
> {_prop.notes}
"""),
kind="warn",
),
]
)
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` filtered to `cms_rule_id = 'CMS-1848-P'` (14,518
rows as of this ingest) is compared against `pfs.rvu` for CY2026
(the current final baseline — there is no CY2027 final table to
compare against). Both tables are deduplicated to one row per
HCPCS base code (no modifier) via `QUALIFY row_number() ... = 1`
per the P36 convention, `status_code = 'A'` (actively priced), and
— for the proposed table specifically — a non-null non-facility PE
RVU, since a meaningful share of `pfs.rvu_proposed` rows 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
FROM pfs.rvu_proposed
WHERE cms_rule_id = 'CMS-1848-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_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.total_nf_rvu AS total_nf_rvu_proposed,
f26.total_nf_rvu AS total_nf_rvu_2026final
FROM proposed p
JOIN final_2026 f26 USING (hcpcs)
""")
rvu_delta_raw
return (rvu_delta_raw,)
@app.cell(hide_code=True)
def _(RULES, pl, proposed_for, rvu_delta_raw):
_cf_2026_final = RULES[2026].conversion_factor
_cf_2027_proposed = proposed_for(2027).conversion_factor
rvu_delta = rvu_delta_raw.with_columns(
(pl.col("total_nf_rvu_proposed") * _cf_2027_proposed).round(2).alias("dollar_2027proposed"),
(pl.col("total_nf_rvu_2026final") * _cf_2026_final).round(2).alias("dollar_2026final"),
).with_columns(
(pl.col("dollar_2027proposed") - pl.col("dollar_2026final")).round(2).alias("delta_vs_2026final"),
)
rvu_delta
return (rvu_delta,)
@app.cell(hide_code=True)
def _(AMBER, RULES, TEAL, alt, mo, proposed_for, rvu_delta):
_n = 20
_winners = rvu_delta.sort("delta_vs_2026final", descending=True).head(_n)
_losers = rvu_delta.sort("delta_vs_2026final", 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["delta_vs_2026final"].apply(lambda v: "Gain" if v >= 0 else "Loss")
winners_losers_chart = (
alt.Chart(_plot_df)
.mark_bar()
.encode(
x=alt.X("delta_vs_2026final:Q", title="$ change, non-QP CF (national unadjusted, GPCI=1.0)"),
y=alt.Y("label:N", title=None, sort=alt.SortField(field="delta_vs_2026final", 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("delta_vs_2026final:Q", format="$.2f")],
)
.properties(
title="Top 20 Winners / Top 20 Losers — CY2027 Proposed vs. CY2026 Final",
width=700,
height=600,
)
)
mo.vstack(
[
winners_losers_chart,
mo.md(f"""
**Sources:** `pfs.rvu_proposed` (`CMS-1848-P`) — CY2027 NPRM,
{proposed_for(2027).federal_register_citation}. Baseline —
`pfs.rvu` CY2026 Final ({RULES[2026].federal_register_citation}).
Both sides priced with each vintage's own non-QP standard CF
— this delta blends RVU-table changes with the proposed CF
change, it is not an RVU-only comparison.
"""),
]
)
return
@app.cell(hide_code=True)
def _(mo, proposed_for, rvu_delta):
mo.vstack(
[
mo.md(f"""
**Full detail — {rvu_delta.height:,} codes** matched across
the `CMS-1848-P` proposed RVU table and CY2026-final RVU
table (search the table below by HCPCS or description).
"""),
mo.ui.table(
rvu_delta.sort("delta_vs_2026final").to_pandas(),
page_size=25,
label="RVU / Payment Deltas by HCPCS",
),
]
)
return
@app.cell(hide_code=True)
def _(con, mo, proposed_for):
_new = con.execute("""
SELECT count(*) FROM pfs.rvu_proposed p
WHERE p.cms_rule_id = 'CMS-1848-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=2026 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=2026 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-1848-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 the `CMS-1848-P`
> proposed RVU table with no CY2026-final counterpart (new/renumbered
> codes), and {_dropped} CY2026-final codes have no match in the
> proposed table (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-1848-P Addendum B,
{proposed_for(2027).federal_register_citation}. `pfs.rvu` — CY2026
PFS Final Rule Addendum B.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
### Cross-NPRM check — CY2026 NPRM vs. CY2027 NPRM (optional)
`pfs.rvu_proposed` holds **two** NPRM partitions in the same table
— `CMS-1832-P` (CY2026 NPRM, 14,169 rows) and `CMS-1848-P` (CY2027
NPRM, 14,518 rows). For codes proposed in both rulemakings, this
compares total non-facility RVUs NPRM-to-NPRM — a coarse signal of
which codes CMS has been proposing to move for two consecutive
rulemaking cycles, independent of which CF applies.
""")
return
@app.cell(hide_code=True)
def _(q):
cross_nprm = q("""
WITH cy2026_nprm AS (
SELECT hcpcs, description, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
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
),
cy2027_nprm AS (
SELECT hcpcs, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
FROM pfs.rvu_proposed
WHERE cms_rule_id = 'CMS-1848-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
)
SELECT
a.hcpcs, a.description,
a.total_nf_rvu AS total_nf_rvu_cy2026_nprm,
b.total_nf_rvu AS total_nf_rvu_cy2027_nprm,
round(b.total_nf_rvu - a.total_nf_rvu, 4) AS rvu_delta_nprm_to_nprm
FROM cy2026_nprm a
JOIN cy2027_nprm b USING (hcpcs)
ORDER BY abs(b.total_nf_rvu - a.total_nf_rvu) DESC
LIMIT 10
""")
return (cross_nprm,)
@app.cell(hide_code=True)
def _(cross_nprm, mo):
mo.vstack(
[
cross_nprm,
mo.md("""
**Sources:** `pfs.rvu_proposed` — `CMS-1832-P` (90 FR 32352)
and `CMS-1848-P` (91 FR 43842) partitions of the same table.
Total non-facility RVUs only — no dollar conversion, since
the two NPRMs' CFs are not directly comparable across a
one-year gap without also re-basing for the RVU-neutral
budget-neutrality adjustment each rulemaking applies.
"""),
]
)
return
# ── 3. Advanced APM ─────────────────────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 3. 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`). The CY2027 NPRM's own regulatory-impact-analysis recital
covers **QP Performance Period 2027 / payment year 2029** —
`qpp.QPP[2027]`, reachable as `qpp.for_payment_year(2029)`.
`qpp.QPP`'s payment-year keying was corrected under task #620
shortly before this notebook was written — thresholds are keyed to
**payment year**, not performance year, and every table below
labels both explicitly so the correction is visible rather than
silently assumed.
""")
return
@app.cell(hide_code=True)
def _(for_payment_year, mo):
_governs_2029 = for_payment_year(2029)
mo.md(f"""
`qpp.for_payment_year(2029)` resolves to QP Performance Period
**{_governs_2029.performance_year}** ({_governs_2029.citation}) —
the performance period whose determinations govern the payment
year that the CY2027 NPRM's proposed conversion factors apply to.
`qp_cf_applies` is **{_governs_2029.qp_cf_applies}** for this
performance period.
""")
return
@app.cell(hide_code=True)
def _(QPP, pl):
_rows = []
for _perf_year, _qy in sorted(QPP.items()):
_prop = _qy.proposed
_rows.append(
{
"qp_performance_period": _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,
"has_nprm_proposal": _prop is not None,
"citation": _qy.citation,
}
)
apm_thresholds = pl.DataFrame(_rows)
apm_thresholds
return (apm_thresholds,)
@app.cell(hide_code=True)
def _(QPP, mo):
mo.md(f"""
Columns are labeled `qp_performance_period` (the year Advanced-APM
participation is measured) and `payment_year` (the year the
resulting QP status is applied) explicitly and separately — every
threshold in this table governs by **`payment_year`**, per 42 CFR
414.1430(a) and section 1833(z)(2) of the Act. `QPP[2027]`
(payment year {QPP[2027].payment_year}) is the entry this
notebook's CF walk (Section 1) corresponds to.
""")
return
@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=["qp_performance_period", "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 (this
notebook's prior year) 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** — CMS-1848-P proposes to codify that
revival into 42 CFR 414.1450(b)(1) (91 FR 44218-44219,
44286). Payment year 2029 (`QPP[2027]`, the year this
notebook's own CF walk applies to) reverts to **no**
incentive — the amendatory text lists no applicable
percentage for payment year 2029 at all. So the pattern
across 2025-2029 is paid / paid / gap / revived / gap, not
a single cutoff.
**Sources:** `qpp.QPP` performance years 2023-2027
({_citations}).
"""),
]
)
return
@app.cell(hide_code=True)
def _(QPP, mo):
_prop = QPP[2027].proposed
mo.vstack(
[
mo.md(f"""
### CMS-1848-P proposed QPP changes — proposals only, no Final Rule disposition yet
`qpp.QPP[2027].proposed.changes` ({_prop.federal_register_citation},
{_prop.cms_rule_id}) is the single source of truth for the
narrative below — quoted live, not retyped. Unlike the
CY2026 notebook's disposition table, there is **no**
finalized/not-finalized column here: CMS-1848-F has not
been published.
"""),
mo.callout(mo.md(_prop.changes), kind="info"),
]
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
### What QP status is worth in dollars (CY2027 proposed) — 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`,
using **CY2026-final RVUs** (the current baseline; CY2027 has no
finalized RVU table yet) priced at the **CY2027 NPRM's proposed**
QP and non-QP conversion factors:
- **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 _(pl, proposed_for, 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')
""")
_prop = proposed_for(2027)
_rows = []
for _r in _example_rvu.iter_rows(named=True):
_pay_nonqp = round(_r["total_rvu"] * _prop.conversion_factor, 2)
_pay_qp = round(_r["total_rvu"] * _prop.cf_qp, 2)
_rows.append(
{
"hcpcs": _r["hcpcs"],
"description": _r["description"],
"total_rvu_cy2026final": _r["total_rvu"],
"non_qp_payment_cy2027proposed": _pay_nonqp,
"qp_payment_cy2027proposed": _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, proposed_for, qp_differential):
differential_chart = (
alt.Chart(qp_differential.to_pandas())
.mark_bar()
.encode(
x=alt.X("hcpcs:N", title="HCPCS"),
y=alt.Y("qp_differential:Q", title="QP minus non-QP payment ($, national unadjusted)"),
tooltip=["hcpcs", "description", alt.Tooltip("qp_differential:Q", format="$.2f")],
)
.properties(title="Dollar Value of QP Status — CY2027 Proposed (final rule pending)", width=500, height=320)
)
mo.vstack(
[
differential_chart,
mo.md(f"""
**Sources:** RVUs — `pfs.rvu`, CY2026 Final (90 FR 49266).
CFs — `proposed_for(2027)`, CY2027 NPRM
({proposed_for(2027).federal_register_citation},
{proposed_for(2027).cms_rule_id}) — final rule pending.
National unadjusted means GPCI = 1.0 — actual payment
varies by locality.
"""),
]
)
return
# ── 4. Provenance ───────────────────────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 4. Provenance & Comment-Period Status
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, proposed_for):
from datetime import date
_prop = proposed_for(2027)
_close = _prop.comment_close
_today = date.today()
_open = _today <= _close
_status = "OPEN" if _open else "CLOSED"
mo.callout(
mo.md(f"""
**Comment period: {_status}** — {_prop.cms_rule_id} ({_prop.federal_register_citation})
published {_prop.published.isoformat()}, comment period closes
**{_close.isoformat()}** (`proposed_for(2027).comment_close`,
read live from the registry; today is {_today.isoformat()}).
"""),
kind="success" if _open else "neutral",
)
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()

View File

@@ -1,13 +1,18 @@
"""NPRM Addendum B loader → ``pfs.rvu_proposed`` (#592). """NPRM Addendum B loader → ``pfs.rvu_proposed`` (#592, #612).
Loads the CY2026 NPRM Addendum B "Relative Value Units and Related Loads a PFS NPRM's Addendum B "Relative Value Units and Related
Information" workbook — registered in bib under the Information" workbook — registered in bib under a per-year
``sup:2026_PFS_NPRM`` tag — into ``pfs.rvu_proposed``, the ``sup:<year>_PFS_NPRM`` tag — into ``pfs.rvu_proposed``, the
proposed-rule counterpart to the final-rule ``pfs.rvu`` table that proposed-rule counterpart to the final-rule ``pfs.rvu`` table that
``pfs.pipe.load_all`` populates. Reuses ``pfs.pipe``'s header-detect ``pfs.pipe.load_all`` populates. Reuses ``pfs.pipe``'s header-detect
readers and column map instead of reimplementing them; the target readers and column map instead of reimplementing them; the target
schema is intentionally narrower than ``pfs.rvu`` (no PPRRVU-only schema is intentionally narrower than ``pfs.rvu`` (no PPRRVU-only
columns) since Addendum B never carries them. columns) since Addendum B never carries them.
``pfs.rvu_proposed`` holds one partition per ``cms_rule_id`` — CY2026's
NPRM (CMS-1832-P) and CY2027's NPRM (CMS-1848-P) load side by side, and
``load_rvu_proposed`` only ever deletes/reinserts the partition for the
year it was called with (see ``NPRM_SOURCES`` below).
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,29 +21,23 @@ import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
_ADDENDUM_B_TAG = "sup:2026_PFS_NPRM"
_ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b") _ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
# pfs.pipe._RVU_COLUMNS normalizes raw headers to these intermediate # pfs.pipe._RVU_COLUMNS normalizes raw headers to these intermediate
# names; map them onto the pfs.rvu_proposed target schema. # names, which already match the pfs.rvu_proposed target schema (#607
_TARGET_RENAME = { # aligned it with pfs.rvu's column names — no renaming needed here).
"hcpcs": "hcpcs", # The CY2027 Addendum B workbook's headers (CPT1/\nHCPCS, Mod, Status,
"mod": "modifier", # \nWork\nRVUs2, \nNon-\nFacility\nPE\nRVUs2, \nFacility\nPE\nRVUs2,
"description": "description", # Mal-\nPractice\nRVUs2) are already present in _RVU_COLUMNS as
"status_code": "status_code", # variants of the CY2026 PPRRVU headers, so no map changes were needed
"work_rvu": "work_rvu", # for #612 — verified against the real CY2027 file.
"non_fac_pe_rvu": "nonfac_pe_rvu",
"fac_pe_rvu": "fac_pe_rvu",
"mp_rvu": "mp_rvu",
}
_TARGET_COLUMNS = [ _TARGET_COLUMNS = [
"hcpcs", "hcpcs",
"modifier", "mod",
"description", "description",
"status_code", "status_code",
"work_rvu", "work_rvu",
"nonfac_pe_rvu", "non_fac_pe_rvu",
"fac_pe_rvu", "fac_pe_rvu",
"mp_rvu", "mp_rvu",
"cms_rule_id", "cms_rule_id",
@@ -47,13 +46,31 @@ _TARGET_COLUMNS = [
# Intermediate (pre-rename) names as produced by pfs.pipe._normalize_columns. # Intermediate (pre-rename) names as produced by pfs.pipe._normalize_columns.
_RAW_NUMERIC_COLUMNS = ("work_rvu", "non_fac_pe_rvu", "fac_pe_rvu", "mp_rvu") _RAW_NUMERIC_COLUMNS = ("work_rvu", "non_fac_pe_rvu", "fac_pe_rvu", "mp_rvu")
# (year, sup_tag, cms_rule_id, pincite_key) — one entry per NPRM Addendum
# B partition. Add a row here (plus a pincite/proposed_for entry, see
# pfs.rules) to onboard a new year; load_rvu_proposed(year=...) and
# dev/scripts/ingest_pfs.py --nprm both iterate this tuple.
NPRM_SOURCES: tuple[tuple[int, str, str, str], ...] = (
(2026, "sup:2026_PFS_NPRM", "CMS-1832-P", "2KVJ2HKX"),
(2027, "sup:2027_PFS_NPRM", "CMS-1848-P", "5ITGVDJV"),
)
def _discover_addendum_b() -> Path:
def _source_for(year: int) -> tuple[int, str, str, str]:
"""Look up ``year``'s ``NPRM_SOURCES`` entry, or raise ``ValueError``."""
for source in NPRM_SOURCES:
if source[0] == year:
return source
known = ", ".join(str(s[0]) for s in NPRM_SOURCES)
raise ValueError(f"No NPRM source registered for year {year} (known: {known})")
def _discover_addendum_b(tag: str) -> Path:
"""Locate the NPRM Addendum B attachment in the bib store. """Locate the NPRM Addendum B attachment in the bib store.
Queries attachments of items tagged ``sup:2026_PFS_NPRM`` for one Queries attachments of items tagged ``tag`` for one whose filename
whose filename matches ``addendum[_ ]?b`` (case-insensitive), matches ``addendum[_ ]?b`` (case-insensitive), mirroring the join
mirroring the join in ``llm/source.py::_attachment_text``. in ``llm/source.py::_attachment_text``.
""" """
from conf import connect from conf import connect
@@ -67,7 +84,7 @@ def _discover_addendum_b() -> Path:
"JOIN tags t ON t.id = it.tag_id " "JOIN tags t ON t.id = it.tag_id "
"WHERE t.name = ? " "WHERE t.name = ? "
"ORDER BY a.id", "ORDER BY a.id",
(_ADDENDUM_B_TAG,), (tag,),
) )
.fetchall() .fetchall()
) )
@@ -76,17 +93,43 @@ def _discover_addendum_b() -> Path:
if _ADDENDUM_B_RE.search(filename): if _ADDENDUM_B_RE.search(filename):
return Path(row["storage_path"]) return Path(row["storage_path"])
raise FileNotFoundError( raise FileNotFoundError(
f"No Addendum B attachment found among bib items tagged {_ADDENDUM_B_TAG!r}" f"No Addendum B attachment found among bib items tagged {tag!r}"
) )
def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict: def _schema_matches_target(con: Any) -> bool:
"""Load the NPRM Addendum B workbook into ``pfs.rvu_proposed``. """True if ``pfs.rvu_proposed`` doesn't exist yet, or its columns match.
Idempotent: deletes any existing rows for ``cms_rule_id`` before False means a stale table (e.g. the pre-#612 ``modifier`` /
inserting, so re-running the ingest is safe. ``nonfac_pe_rvu`` column names) needs dropping before the
``CREATE TABLE IF NOT EXISTS`` below — that statement is a no-op
against an existing table, so it can't fix a column mismatch on
its own.
"""
rows = con.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'pfs' AND table_name = 'rvu_proposed' "
"ORDER BY ordinal_position"
).fetchall()
if not rows:
return True
existing = [r[0] for r in rows]
return existing == _TARGET_COLUMNS
Returns ``{"rows": int, "source_file": str}``.
def load_rvu_proposed(con: Any, *, year: int) -> dict:
"""Load ``year``'s NPRM Addendum B workbook into ``pfs.rvu_proposed``.
Resolves the bib tag, ``cms_rule_id``, and pincite key from
``NPRM_SOURCES``, and the Federal Register citation from
``pfs.rules.proposed_for(year)``.
Idempotent: deletes any existing rows for that year's
``cms_rule_id`` before inserting, so re-running the ingest is safe
and leaves other years' partitions untouched.
Returns ``{"rows": int, "source_file": str, "cms_rule_id": str,
"fr_citation": str, "pincite_key": str}``.
""" """
import polars as pl import polars as pl
@@ -96,8 +139,13 @@ def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
_read_csv_with_header, _read_csv_with_header,
_read_excel, _read_excel,
) )
from pfs.rules import proposed_for
path = _discover_addendum_b() _, tag, cms_rule_id, pincite_key = _source_for(year)
rule = proposed_for(year)
fr_citation = rule.federal_register_citation if rule is not None else ""
path = _discover_addendum_b(tag)
ext = path.suffix.lower() ext = path.suffix.lower()
if ext == ".csv": if ext == ".csv":
df = _read_csv_with_header(str(path)) df = _read_csv_with_header(str(path))
@@ -138,20 +186,22 @@ def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
& pl.col("hcpcs").str.contains(r"^[A-Za-z0-9]{5}$") & pl.col("hcpcs").str.contains(r"^[A-Za-z0-9]{5}$")
) )
df = df.select(list(_TARGET_RENAME)).rename(_TARGET_RENAME) df = df.select(_TARGET_COLUMNS[:-1]) # all but cms_rule_id, added next
df = df.with_columns(pl.lit(cms_rule_id).alias("cms_rule_id")) df = df.with_columns(pl.lit(cms_rule_id).alias("cms_rule_id"))
df = df.select(_TARGET_COLUMNS) df = df.select(_TARGET_COLUMNS)
con.execute("CREATE SCHEMA IF NOT EXISTS pfs") con.execute("CREATE SCHEMA IF NOT EXISTS pfs")
if not _schema_matches_target(con):
con.execute("DROP TABLE IF EXISTS pfs.rvu_proposed")
con.execute( con.execute(
""" """
CREATE TABLE IF NOT EXISTS pfs.rvu_proposed ( CREATE TABLE IF NOT EXISTS pfs.rvu_proposed (
hcpcs VARCHAR, hcpcs VARCHAR,
modifier VARCHAR, mod VARCHAR,
description VARCHAR, description VARCHAR,
status_code VARCHAR, status_code VARCHAR,
work_rvu DOUBLE, work_rvu DOUBLE,
nonfac_pe_rvu DOUBLE, non_fac_pe_rvu DOUBLE,
fac_pe_rvu DOUBLE, fac_pe_rvu DOUBLE,
mp_rvu DOUBLE, mp_rvu DOUBLE,
cms_rule_id VARCHAR cms_rule_id VARCHAR
@@ -163,10 +213,16 @@ def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
try: try:
con.execute( con.execute(
"INSERT INTO pfs.rvu_proposed SELECT " "INSERT INTO pfs.rvu_proposed SELECT "
"hcpcs, modifier, description, status_code, work_rvu, " "hcpcs, mod, description, status_code, work_rvu, "
"nonfac_pe_rvu, fac_pe_rvu, mp_rvu, cms_rule_id FROM _nprm_df" "non_fac_pe_rvu, fac_pe_rvu, mp_rvu, cms_rule_id FROM _nprm_df"
) )
finally: finally:
con.unregister("_nprm_df") con.unregister("_nprm_df")
return {"rows": df.height, "source_file": str(path)} return {
"rows": df.height,
"source_file": str(path),
"cms_rule_id": cms_rule_id,
"fr_citation": fr_citation,
"pincite_key": pincite_key,
}

View File

@@ -35,6 +35,31 @@ Usage::
RULES[2024].conversion_factor # $33.2875 (dominant) RULES[2024].conversion_factor # $33.2875 (dominant)
RULES[2024].cf_for_date(date(2024, 2, 1)) # $32.7442 (pre-CAA) RULES[2024].cf_for_date(date(2024, 2, 1)) # $32.7442 (pre-CAA)
RULES[2024].cf_for_date(date(2024, 6, 1)) # $33.2875 (post-CAA) RULES[2024].cf_for_date(date(2024, 6, 1)) # $33.2875 (post-CAA)
Proposed (NPRM) parameters — two homes, one accessor
------------------------------------------------------
CMS's NPRM (proposed rule) precedes the Final Rule by months, and for
a stretch of time a year may have only the former. This module has
two places NPRM parameters can live, and a helper that hides which
one applies:
* ``RULES[year].proposed`` — once a year has a Final Rule entry in
``RULES``, its ``ProposedRule`` (if captured) hangs off that entry
(P36 shape; see e.g. ``RULES[2026].proposed``).
* ``PROPOSED[year]`` — a module-level registry for years whose NPRM
has been transcribed but which have **no** Final Rule yet, so there
is no ``RULES[year]`` to hang it on (e.g. CY2027 as of this
writing — see ``PROPOSED[2027]`` below).
Use ``proposed_for(year)`` rather than reaching into either dict
directly — it returns ``RULES[year].proposed`` when ``year`` is in
``RULES``, else ``PROPOSED.get(year)``, else ``None``. This is a
migration path: when CY2027's Final Rule eventually ships,
``RULES[2027]`` gets created with its own ``proposed=...`` block and
the ``PROPOSED[2027]`` entry is retired — ``proposed_for(2027)``
keeps returning the right thing across that move without callers
changing. ``RuleYear`` itself needs no change for this to work.
""" """
from __future__ import annotations from __future__ import annotations
@@ -452,6 +477,95 @@ RULES: dict[int, RuleYear] = {
} }
# ── Proposed-only registry (years with no Final Rule yet) ────────
#
# `RuleYear.proposed` (above) is the home for NPRM parameters once a
# year has a Final Rule to hang them on (P36 shape, e.g. RULES[2026]).
# CY2027's NPRM (CMS-1848-P) has no Final Rule yet, so RULES has no
# 2027 entry — `PROPOSED` holds `ProposedRule` values for such
# final-less years. When CY2027's Final Rule ships, `RULES[2027]` is
# created with `proposed=PROPOSED[2027]` (or an equivalent
# re-transcription) and the `PROPOSED[2027]` entry is retired —
# callers that used `proposed_for(2027)` see no change since the
# helper checks `RULES` first.
PROPOSED: dict[int, ProposedRule] = {
2027: ProposedRule(
# CY2027 PFS NPRM (CMS-1848-P), 91 FR 43842, published
# 2026-07-16, comment period closes 2026-09-14 per the DATES
# block. Transcribed from data/fr_downloads/2026-14327.txt
# per :pincite:`5ITGVDJV`.
cms_rule_id="CMS-1848-P",
fr_document_number="2026-14327",
federal_register_citation="91 FR 43842",
published=date(2026, 7, 16),
comment_close=date(2026, 9, 14),
# Nonqualifying-APM (non-QP) standard CF — RIA detailed
# derivation, file line ~39099 (91 FR 44242): "We estimate the
# CY 2027 PFS nonqualifying APM CF to be 32.8409".
conversion_factor=32.8409,
# Qualifying-APM (QP) standard CF — file line ~39095
# (91 FR 44242): "We estimate the CY 2027 PFS qualifying APM
# CF to be 33.1693".
cf_qp=33.1693,
# Nonqualifying-APM anesthesia CF — file line ~39104
# (91 FR 44242): "the CY 2027 anesthesia nonqualifying APM CF
# to be 20.2143".
anesthesia_cf=20.2143,
# Qualifying-APM anesthesia CF — file line ~39103
# (91 FR 44242): "We estimate the CY 2027 anesthesia
# qualifying APM CF to be 20.4165".
anesthesia_cf_qp=20.4165,
# "0.53 percent positive budget neutrality adjustment", stated
# identically for both QP and non-QP CFs at file lines
# ~39096 and ~39100 (91 FR 44242).
budget_neutrality_adjustor=1.0053,
# HCPCS Q3014 — file line ~2261 (91 FR 43843): "the proposed
# payment amount for HCPCS code Q3014 (Telehealth originating
# site facility fee) is $32.65".
telehealth_originating_site_fee=32.65,
notes=(
"Transcribed from data/fr_downloads/2026-14327.txt (NPRM "
"full text) per :pincite:`5ITGVDJV`. Standard and "
"anesthesia QP/non-QP CFs plus the BN adjustor are from "
"the Regulatory Impact Analysis's detailed CF derivation "
"(section VII.D, file lines ~39088-39106, 91 FR "
"44242) — the earlier plain-language Summary of Costs and "
"Benefits (file lines ~359-364, 91 FR 43842) states the "
"same $0.40/$0.56 deltas but pairs them with the WRONG "
"'current' (CY2026) CF labels/values (mixes CY2026 final "
"non-QP $33.4009 with CY2026 *proposed* QP $33.5875, "
"rather than CY2026 final QP $33.5675) — an apparent "
"drafting error in the published NPRM text itself, not a "
"transcription artifact here. Update-factor components "
"(also file lines ~39085-39087): QP update +0.75%, "
"non-QP update +0.25% (section 1848(d)(20)), not modelled "
"as separate fields. Comment-close DATES block at file "
"lines 108-109 (91 FR 43842)."
),
),
}
def proposed_for(year: int) -> ProposedRule | None:
"""Return the NPRM parameters for ``year``, wherever they live.
Looks up ``RULES[year].proposed`` when ``year`` has a Final Rule
entry in ``RULES``; otherwise falls back to ``PROPOSED.get(year)``
for years whose NPRM has no Final Rule yet (e.g. CY2027 as of this
writing — see ``PROPOSED`` above). Returns ``None`` if neither
source has proposed parameters for ``year``.
This indirection is what lets a year migrate from ``PROPOSED`` to
``RULES[year].proposed`` once its Final Rule ships without
breaking callers: ``proposed_for(year)`` keeps working across that
move since it checks ``RULES`` first.
"""
if year in RULES:
return RULES[year].proposed
return PROPOSED.get(year)
# ── Convenience aliases ────────────────────────────────────────── # ── Convenience aliases ──────────────────────────────────────────
cy2024 = RULES[2024] cy2024 = RULES[2024]

View File

@@ -39,16 +39,28 @@ the first split-CF year) and ``False`` for earlier performance
years. See :class:`pfs.rules.RuleYear` (``cf_qp`` / years. See :class:`pfs.rules.RuleYear` (``cf_qp`` /
``conversion_factor`` on ``RULES[2026]``) for the actual dollar CFs. ``conversion_factor`` on ``RULES[2026]``) for the actual dollar CFs.
APM Incentive Payment sunset APM Incentive Payment sunset — and CAA 2026 revival for 2028
------------------------------ ----------------------------------------------------------------
The lump-sum APM Incentive Payment (a fixed percentage of a QP's The lump-sum APM Incentive Payment (a fixed percentage of a QP's
prior-year Part B paid claims) is statutorily phased out: 5% for prior-year Part B paid claims) is statutorily set at: 5% for payment
payment years 2019-2024, 3.5% for payment year 2025 (QP Performance years 2019-2024, 3.5% for payment year 2025 (QP Performance Period
Period 2023), 1.88% for payment year 2026 (QP Performance Period 2023), 1.88% for payment year 2026 (QP Performance Period 2024).
2024) — and payment year 2026 is, under current law, the *last* year Payment year 2026 was, under prior law, believed the *last* year
these payments are made. ``QppYear.apm_incentive_pct`` is ``None`` these payments would be made — ``QppYear.apm_incentive_pct`` is
for QP Performance Periods 2025 and later. accordingly ``None`` for payment year 2027 (QP Performance Period
2025). The Consolidated Appropriations Act, 2026 (CAA 2026) (Pub. L.
119-75, February 3, 2026) revived a **3.1%** APM Incentive Payment,
but only for payment year 2028 (QP Performance Period 2026) — 42 CFR
414.1450(b)(1)(iv), as amended by CMS-1848-P (91 FR 44286, transcribed
``data/fr_downloads/2026-14327.txt:42632``, "(iv) 2028: 3.1
percent."); narrative at 91 FR 44218-44219
(``2026-14327.txt:37389-37391``, and the proposed-regulatory-text
preview at ``:37436``). There is no revival for payment year 2029 (QP
Performance Period 2027) — ``QppYear.apm_incentive_pct`` is ``None``
there too. So the payment years with a nonzero incentive are 2025,
2026, and (revived) 2028 only — not a clean sunset after 2026. See
``QPP[2026].apm_incentive_pct`` and task #620.
CMS-1832-P (CY2026 PFS proposed rule, 90 FR 32352, transcribed from CMS-1832-P (CY2026 PFS proposed rule, 90 FR 32352, transcribed from
``data/fr_downloads/2025-13271.txt``) proposed several QPP ``data/fr_downloads/2025-13271.txt``) proposed several QPP
@@ -98,6 +110,143 @@ statement of the Medicare-option figure. Rather than reuse that
number under a different provision's field, ``benchmark_nominal_pct`` number under a different provision's field, ``benchmark_nominal_pct``
is left ``None`` on every entry, with the gap documented in is left ``None`` on every entry, with the gap documented in
``RiskStandards.notes``. ``RiskStandards.notes``.
CMS-1848-P (CY2027 PFS proposed rule) — QPP[2027]
----------------------------------------------------
CMS-1848-P (91 FR 43842, published July 16, 2026, transcribed from
``data/fr_downloads/2026-14327.txt``) is captured on ``QPP[2027]``
(QP Performance Period 2027, payment year 2029). Ingested into Zotero
as :pincite:`5ITGVDJV`. No Final Rule (CMS-1848-F) had been published
as of this transcription, so ``QPP[2027].proposed.changes`` states
proposals only, with no finalized-disposition information available.
QPP[2027] current-law thresholds are transcribed from two sources that
agree: the RIA recital "The thresholds to achieve QP status in the
2027 QP Performance Period (2029 payment year) are set to 75 percent
for the payment amount, and 50 percent for the patient count." (91 FR
44260, line 40311-40313), and the amendatory regulatory text at 42 CFR
414.1430(a)(1)(v)/(a)(3)(v) "2029 and thereafter: 75 percent" /
"50 percent" (91 FR 44285-44286, line 42551/42569) — the same
amendatory text at (a)(2)(v)/(a)(4)(v) gives the Partial QP thresholds
"2029 and thereafter: 50 percent" / "35 percent" (91 FR 44286, line
42559/42576). No amendment to 42 CFR 414.1415 (nominal amount
standard / CEHRT) or 414.1420 (Other Payer Advanced APM standard) is
proposed in this NPRM — the amendatory-text item list runs "44. Sec.
414.1400 ... 45. Sec. 414.1425 ..." (line 42368, 42504), skipping both
sections — so ``risk_standards`` and ``cehrt_required`` are carried
forward unchanged, corroborated indirectly by this NPRM's own
statement that the Shared Savings Program ENHANCED track Level E loss
recoupment limit — independently stated as "shall not exceed 8
percent of total Medicare Parts A and B FFS revenue" (91 FR 44069,
line 23890-23891) — equals "the revenue-based nominal amount standard
under the Quality Payment Program (QPP) (42 CFR 414.1415(c)(3)(i)(A))"
(line 23894-23896), consistent with the existing 8% figure.
``qp_cf_applies`` is True: "eligible clinicians who are QPs for the
2024 performance year/2026 payment year and beyond will receive an
increased physician fee schedule update of 0.75 percent qualifying
APM conversion factor" (91 FR 44217, line 30603-30606) confirms the
split-CF policy is not time-limited and persists through payment year
2029. ``apm_incentive_pct`` is None: the amendatory text at 42 CFR
414.1450(b)(1) (91 FR 44286, line 42629-42632) lists applicable
percentages only for payment years 2019-2024, 2025, 2026, and 2028 —
no entry for payment year 2027 or 2029 — so under current law as
restated by this NPRM, no lump-sum APM Incentive Payment is payable
for payment year 2029 (the sunset already captured on QPP[2025]/
QPP[2026] has a gap, not a clean cutoff — see next section).
CMS-1848-P's substantive Advanced-APM-relevant proposals (see
``QPP[2027].proposed.changes`` for full sourcing) are: (1) applying QP
(and, by conforming amendment, Partial QP) status strictly at the
TIN/NPI level rather than to the clinician across all TIN
reassignments, beginning with the 2027 QP Performance Period (91 FR
44215-44217, 44285); (2) a clerical fix to 42 CFR 414.1425(c)(5)(ii)
removing an erroneously-included clause; (3) a clarification for APMs
without a Participation List; (4) a restructuring/condensing of 42
CFR 414.1430(a)/(b) to conform the regulation text to already-enacted
statutory threshold levels (not a new CMS-set threshold change); and
(5) codifying the CAA 2026's 3.1% APM Incentive Payment extension for
payment year 2028 and restructuring 414.1450(b)(1) into a per-year
list.
**Payment-year keying correction — QPP[2023], QPP[2024] (task #620,
refs #613):** An earlier transcription (P36) misread 42 CFR
414.1430(a)'s payment-year-keyed threshold schedule as keyed to *QP
Performance Period*, carrying the 75%/50% QP (50%/35% Partial QP)
step-up back two years too early onto ``QPP[2023]``/``QPP[2024]``.
The statute (section 1833(z)(2) of the Act) and its implementing
regulation key strictly to **payment year**:
- 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii): "2021 through 2026: 50
percent" / "35 percent" (QP payment-amount / patient-count) —
CMS-1848-P amendatory text, 91 FR 44285
(``data/fr_downloads/2026-14327.txt:42548,42566``).
- 42 CFR 414.1430(a)(2)(ii)/(a)(4)(ii): "2021 through 2026: 40
percent" / "25 percent" (Partial QP) — 91 FR 44285-44286
(``2026-14327.txt:42556,42573``).
Original source: 42 CFR 414.1430(a), 89 FR 98564 (CY2024 PFS Final
Rule); restated in CMS-1848-P's amendatory text at 91 FR 44285-44286;
the CAA 2024 Sec. 304 narrative extending payment-year-2025
thresholds through payment year 2026 is at 91 FR 44219
(``2026-14327.txt:37498-37509``: "section 304(a)(2) of the CAA, 2024
... extend[ed] for payment years 2025 and 2026 (performance periods
2023 and 2024) the applicable payment amount and patient count
thresholds"). Accordingly, ``QPP[2023]`` (payment year 2025) and
``QPP[2024]`` (payment year 2026) are corrected from 75%/50% QP,
50%/35% Partial QP to **50%/35% QP, 40%/25% Partial QP**.
``QPP[2025]`` (payment year 2027) and ``QPP[2027]`` (payment year
2029) were already correct at 75%/50% QP, 50%/35% Partial QP and are
**unchanged** by this correction.
**CAA 2026 supersession — QPP[2026] (task #620):** ``QPP[2026]``
(payment year 2028) was previously coded 75%/50% QP, 50%/35% Partial
QP, ``apm_incentive_pct=None`` — accurate when transcribed from the
CY2026 PFS Final Rule narrative ("The thresholds to achieve QP status
in the 2026 QP Performance Period (2028 payment year) are set to 75
percent for the payment amount, and 50 percent for the patient
count.", 90 FR 49980), which was itself correct *at the time of that
rule's publication*. The Consolidated Appropriations Act, 2026 (CAA
2026) (Pub. L. 119-75, February 3, 2026) subsequently, legislatively
restored payment year 2028 to the lower 50%/35% QP, 40%/25% Partial QP
levels and revived a 3.1% APM Incentive Payment for that year — see
CMS-1848-P's own narrative, 91 FR 44219-44220
(``2026-14327.txt:37536-37541``: "these thresholds all increased for
payment year 2027 ... and were legislatively restored to lower levels
for payment year 2028") and the amendatory text at 91 FR 44286 (QP/
Partial QP thresholds: ``2026-14327.txt:42550,42558,42568,42575``;
APM incentive: ``:42632``, "(iv) 2028: 3.1 percent."). **This
statutory change is self-executing and controls regardless of
regulatory-text conformance status** — CMS-1848-P's own amendment of
42 CFR 414.1430/414.1450 to match is only *proposed* as of this
transcription (not yet a Final Rule), and eCFR's currently-displayed
text for these sections does not yet reflect CAA 2026 and does **not**
control. ``QPP[2026]`` is therefore corrected to ``qp_thresholds``
50%/35%, ``partial_qp_thresholds`` 40%/25%, and
``apm_incentive_pct=3.1``, superseding the CY2026 Final Rule narrative
(90 FR 49980) this module originally transcribed. See
``QPP[2026].proposed.changes`` for how this interacts with CMS-1832-P's
(correct, at the time) "no numeric change" disposition, and
task-620-report.md for the full correction table.
**CMS drafting-error note (not a data error in this module):**
CMS-1848-P's own amendatory text contains an internal inconsistency at
42 CFR 414.1430(b)(2)(i)(D) — the All-Payer Combination Option Partial
QP payment-amount threshold for "2029 and thereafter" is printed as
"75 percent" (91 FR 44286, ``2026-14327.txt:42589``), matching the
*QP* (non-Partial) threshold at the parallel (b)(1)(i)(D)
(``:42583``) rather than being lower than it, and conflicting with the
Medicare Option Partial QP payment-amount threshold for the same
period at 414.1430(a)(2)(v), correctly printed as "50 percent"
(``:42559``). Partial QP is by design the *lower* bar, so a Partial QP
figure equal to the QP figure is incoherent; this reads as a
copy-paste error in the NPRM text, not a substantive Medicare-Option-
vs-All-Payer-Option policy difference. This module does not encode the
All-Payer Combination Option thresholds (only the generally-applicable
Medicare Option ones), so this drafting error does not propagate into
any field here — noted so a future grepper who spots "75 percent" at
(b)(2)(i)(D) does not mistake it for evidence that this module's 2029
Partial QP figure (50%, sourced from (a)(2)(v)) is wrong.
""" """
from __future__ import annotations from __future__ import annotations
@@ -212,9 +361,14 @@ class QppYear(BaseModel):
apm_incentive_pct: float | None apm_incentive_pct: float | None
"""Lump-sum APM Incentive Payment, as a % of the QP's base-year """Lump-sum APM Incentive Payment, as a % of the QP's base-year
(payment_year - 1) paid claims for covered professional services — (payment_year - 1) paid claims for covered professional services.
``None`` once the statutory sunset takes effect (payment years ``None`` for payment years without a payment under current law:
2027+, i.e. performance years 2025+).""" originally believed to sunset after payment year 2026 (performance
year 2024), but the Consolidated Appropriations Act, 2026 (CAA
2026) revived a 3.1% payment for payment year 2028 (performance
year 2026) only — payment years 2027 and 2029 remain ``None``. See
module docstring "APM Incentive Payment sunset — and CAA 2026
revival for 2028"."""
qp_cf_applies: bool qp_cf_applies: bool
"""``True`` once the payment year for this performance period uses """``True`` once the payment year for this performance period uses
@@ -237,60 +391,62 @@ class QppYear(BaseModel):
# ── Annual QPP registry ────────────────────────────────────────── # ── Annual QPP registry ──────────────────────────────────────────
# #
# QP / Partial QP thresholds: the statutory schedule (section # QP / Partial QP thresholds are keyed to PAYMENT YEAR by both the
# 1833(z)(2) of the Act) raised the Medicare-option thresholds to 75% # statute (section 1833(z)(2) of the Act) and 42 CFR 414.1430(a) — NOT
# (payment amount) / 50% (patient count) effective QP Performance # to QP Performance Period. (An earlier version of this module read
# Period 2023, with Partial QP thresholds set at the prior period's # the statutory 75%/50% step-up as keyed to performance period,
# QP levels — 50% / 35%. Transcribed from data/fr_downloads/ # shifting it two years too early onto QPP[2023]/QPP[2024]; corrected
# 2025-13271.txt (NPRM) and cross-checked against data/fr_downloads/ # under task #620 / issue #613 — see module docstring "Payment-year
# 2025-19787.txt (Final Rule): # keying correction".) The current-law payment-year schedule per 42
# CFR 414.1430(a), as amended through CMS-1848-P's amendatory text (91
# FR 44285-44286, data/fr_downloads/2026-14327.txt:42546-42576) and
# the CAA 2026 supersession of payment year 2028 (module docstring
# "CAA 2026 supersession"), is:
# #
# - NPRM 90 FR 32772 (line 36531-36537): "...the QP thresholds have # payment year QP (amt / cnt) Partial QP (amt / cnt)
# increased relative to previous years and, effective with the # 2021-2026 50% / 35% 40% / 25%
# 2025 QP Performance Period are now at 75 percent for the # 2027 75% / 50% 50% / 35%
# payment amount and 50 percent for the patient count." # 2028 50% / 35% 40% / 25% (CAA 2026 revival)
# - NPRM 90 FR 32819 (line 40647-40656, Regulatory Impact Analysis): # 2029+ 75% / 50% 50% / 35%
# "the thresholds to achieve QP status beginning in the 2026 QP #
# Performance Period ... will increase from 50 percent to 75 # Mapped onto this registry's performance-year keys (payment_year =
# percent for the payment amount, and from 35 percent to 50 # performance_year + 2):
# percent for the patient count." This RIA sentence is internally #
# inconsistent with the 90 FR 32772 recital above (both appear in # - QPP[2023] (pay 2025), QPP[2024] (pay 2026): 50%/35% QP, 40%/25%
# the same NPRM) — it reads as stale boilerplate describing the # Partial QP — 42 CFR 414.1430(a)(1)(ii)/(a)(2)(ii)/(a)(3)(ii)/
# pre-2023 -> 2023 statutory step-up, not a change CMS-1832-P # (a)(4)(ii), 91 FR 44285-44286; original source 89 FR 98564
# itself proposes. # (CY2024 PFS Final Rule); CAA 2024 Sec. 304 narrative, 91 FR
# - Final Rule 90 FR 49980 (line 69796-69798; corrected from a # 44219.
# mistranscribed "90 FR 49928" — that page marker is at line 64990, # - QPP[2025] (pay 2027), QPP[2027] (pay 2029): 75%/50% QP, 50%/35%
# these lines fall between the [[Page 49980]] marker at line 69792 # Partial QP — unchanged; see each entry's own citation below.
# and the [[Page 49981]] marker at line 69913) drops the "increase # - QPP[2026] (pay 2028): 50%/35% QP, 40%/25% Partial QP, plus a
# from" framing entirely: "The thresholds to achieve QP status in # revived 3.1% APM Incentive Payment — CAA 2026, superseding the
# the 2026 QP Performance Period (2028 payment year) are set to # CY2026 Final Rule narrative (90 FR 49980) this module originally
# 75 percent for the payment amount, and 50 percent for the # transcribed; see module docstring "CAA 2026 supersession".
# patient count" — consistent with 75/50 being the unchanged, #
# already-current value, not a new increase. # ``_qp_thresholds_75_50``/``_qp_thresholds_50_35`` and
# - Final Rule 90 FR 49928 (line 65021-65026): "...if the proposed # ``_partial_qp_thresholds_50_35``/``_partial_qp_thresholds_40_25``
# policy would result in an ACO's payment amount score increasing # below supply the two distinct threshold-sets in play across
# from 48 percent to 51 percent, that would cross the 50 percent # 2023-2027; they are no longer a single constant-returning helper
# Partial QP threshold" — confirms Partial QP payment-amount # because the values now differ by payment year.
# threshold = 50%. The Partial QP patient-count threshold (35%) is
# not independently restated as a bare number in either document;
# it is inferred from the same NPRM RIA sentence's "35 percent"
# figure (the pre-2023 QP patient-count digit, which by the
# statute's step-down design becomes the post-2023 Partial QP
# digit) — see module docstring caveat.
# #
# Risk standards: see module docstring "Nominal amount standard # Risk standards: see module docstring "Nominal amount standard
# sourcing caveat" — 8% revenue-based (42 CFR 414.1415(c)(3)(i)(A)), # sourcing caveat" — 8% revenue-based (42 CFR 414.1415(c)(3)(i)(A)),
# unchanged across 2023-2026 (no proposed or finalized change found in # unchanged across 2023-2027 (no proposed or finalized change found in
# either CY2026 document). Benchmark-based standard (42 CFR # any FR document reviewed). Benchmark-based standard (42 CFR
# 414.1415(c)(3)(i)(B)) left None — not stated as a bare number # 414.1415(c)(3)(i)(B)) left None — not stated as a bare number
# anywhere in this repository's FR corpus. # anywhere in this repository's FR corpus.
# #
# APM incentive %% and qp_cf_applies: transcribed from NPRM 90 FR # APM incentive % and qp_cf_applies: transcribed from NPRM 90 FR
# 32818 / Final Rule 90 FR 49979-49980 ("b. APM Incentive Payments to # 32818 / Final Rule 90 FR 49979-49980 ("b. APM Incentive Payments to
# QPs in Advanced APMs and Other Payer Advanced APMs"), and the # QPs in Advanced APMs and Other Payer Advanced APMs"), and the
# sunset statement at NPRM 90 FR 32696 / Final Rule 90 FR 49838 # sunset statement at NPRM 90 FR 32696 / Final Rule 90 FR 49838
# ("Under current law, payment year 2026 is the last year for these # ("Under current law, payment year 2026 is the last year for these
# payments."). # payments.") — since revised for payment year 2028 by CAA 2026, per
# CMS-1848-P, 91 FR 44218-44219, 44286
# (data/fr_downloads/2026-14327.txt:37389-37391, 37436, 42632); see
# module docstring "APM Incentive Payment sunset — and CAA 2026
# revival for 2028".
_RISK_NOTES = ( _RISK_NOTES = (
"Revenue-based 8% per CY2019 PFS Final Rule (83 FR 59923, " "Revenue-based 8% per CY2019 PFS Final Rule (83 FR 59923, "
@@ -321,67 +477,129 @@ def _risk_standards() -> RiskStandards:
) )
def _qp_thresholds() -> QpThresholds: def _qp_thresholds_75_50() -> QpThresholds:
"""QP thresholds for payment years 2027 and 2029+ (42 CFR
414.1430(a)(1)(iii)/(v), (a)(3)(iii)/(v))."""
return QpThresholds(payment_amount_pct=75.0, patient_count_pct=50.0) return QpThresholds(payment_amount_pct=75.0, patient_count_pct=50.0)
def _partial_qp_thresholds() -> QpThresholds: def _qp_thresholds_50_35() -> QpThresholds:
"""QP thresholds for payment years 2021-2026 and 2028 (CAA 2026
revival) — 42 CFR 414.1430(a)(1)(ii)/(iv), (a)(3)(ii)/(iv), 91 FR
44285-44286."""
return QpThresholds(payment_amount_pct=50.0, patient_count_pct=35.0) return QpThresholds(payment_amount_pct=50.0, patient_count_pct=35.0)
def _partial_qp_thresholds_50_35() -> QpThresholds:
"""Partial QP thresholds for payment years 2027 and 2029+ (42 CFR
414.1430(a)(2)(iii)/(v), (a)(4)(iii)/(v))."""
return QpThresholds(payment_amount_pct=50.0, patient_count_pct=35.0)
def _partial_qp_thresholds_40_25() -> QpThresholds:
"""Partial QP thresholds for payment years 2021-2026 and 2028 (CAA
2026 revival) — 42 CFR 414.1430(a)(2)(ii)/(iv), (a)(4)(ii)/(iv), 91
FR 44285-44286."""
return QpThresholds(payment_amount_pct=40.0, patient_count_pct=25.0)
QPP: dict[int, QppYear] = { QPP: dict[int, QppYear] = {
2023: QppYear( 2023: QppYear(
# Payment year 2025: 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii)
# "2021 through 2026: 50 percent" / "35 percent" (QP), 91 FR
# 44285; (a)(2)(ii)/(a)(4)(ii) "40 percent" / "25 percent"
# (Partial QP), 91 FR 44285-44286 — CMS-1848-P amendatory text
# (data/fr_downloads/2026-14327.txt:42548,42556,42566,42573),
# restating 89 FR 98564 (CY2024 PFS Final Rule) as extended by
# CAA 2024 Sec. 304 (91 FR 44219). Corrected from an earlier
# payment-year-keying error (task #620) — see module docstring
# "Payment-year keying correction".
performance_year=2023, performance_year=2023,
payment_year=2025, payment_year=2025,
qp_thresholds=_qp_thresholds(), qp_thresholds=_qp_thresholds_50_35(),
partial_qp_thresholds=_partial_qp_thresholds(), partial_qp_thresholds=_partial_qp_thresholds_40_25(),
risk_standards=_risk_standards(), risk_standards=_risk_standards(),
apm_incentive_pct=3.5, apm_incentive_pct=3.5,
qp_cf_applies=False, qp_cf_applies=False,
citation="90 FR 49979-49980", citation=(
"90 FR 49979-49980 (APM incentive); thresholds: 89 FR 98564 "
"(CY2024 PFS Final Rule), restated 91 FR 44285-44286 "
"(CMS-1848-P); CAA 2024 Sec. 304, 91 FR 44219 (task #620)"
),
), ),
2024: QppYear( 2024: QppYear(
# Payment year 2026 is the first year with a differential # Payment year 2026 is the first year with a differential
# QP/non-QP conversion factor (section 1848(d)(20) of the # QP/non-QP conversion factor (section 1848(d)(20) of the
# Act) AND still receives the (final year of) lump-sum APM # Act) AND is the last year of the original uninterrupted
# Incentive Payment — see NPRM 90 FR 32818, Final Rule 90 FR # lump-sum APM Incentive Payment run (2019-2026) before the
# 2027 gap and the CAA-2026-only revival for 2028 (see
# QPP[2026]) — see NPRM 90 FR 32818, Final Rule 90 FR
# 49979-49980. # 49979-49980.
# Thresholds: 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii) "2021
# through 2026: 50 percent" / "35 percent" (QP), 91 FR 44285;
# (a)(2)(ii)/(a)(4)(ii) "40 percent" / "25 percent" (Partial
# QP), 91 FR 44285-44286 — same citations as QPP[2023].
# Corrected from an earlier payment-year-keying error (task
# #620) — see module docstring "Payment-year keying
# correction".
performance_year=2024, performance_year=2024,
payment_year=2026, payment_year=2026,
qp_thresholds=_qp_thresholds(), qp_thresholds=_qp_thresholds_50_35(),
partial_qp_thresholds=_partial_qp_thresholds(), partial_qp_thresholds=_partial_qp_thresholds_40_25(),
risk_standards=_risk_standards(), risk_standards=_risk_standards(),
apm_incentive_pct=1.88, apm_incentive_pct=1.88,
qp_cf_applies=True, qp_cf_applies=True,
citation="90 FR 49979-49980", citation=(
"90 FR 49979-49980 (APM incentive, qp_cf_applies); "
"thresholds: 89 FR 98564 (CY2024 PFS Final Rule), restated "
"91 FR 44285-44286 (CMS-1848-P); CAA 2024 Sec. 304, 91 FR "
"44219 (task #620)"
),
), ),
2025: QppYear( 2025: QppYear(
# Under current law, payment year 2026 (performance year # Under prior law, payment year 2026 (performance year 2024)
# 2024) was the last year with a lump-sum APM Incentive # was believed the last year with a lump-sum APM Incentive
# Payment — NPRM 90 FR 32696, Final Rule 90 FR 49838. # Payment — NPRM 90 FR 32696, Final Rule 90 FR 49838. CAA 2026
# subsequently revived a 3.1% incentive for payment year 2028
# only (see QPP[2026]); payment year 2027 (this entry) still
# has no incentive under any law reviewed.
performance_year=2025, performance_year=2025,
payment_year=2027, payment_year=2027,
qp_thresholds=_qp_thresholds(), qp_thresholds=_qp_thresholds_75_50(),
partial_qp_thresholds=_partial_qp_thresholds(), partial_qp_thresholds=_partial_qp_thresholds_50_35(),
risk_standards=_risk_standards(), risk_standards=_risk_standards(),
apm_incentive_pct=None, apm_incentive_pct=None,
qp_cf_applies=True, qp_cf_applies=True,
citation="90 FR 49838", citation="90 FR 49838",
), ),
2026: QppYear( 2026: QppYear(
# Payment year 2028: thresholds and apm_incentive_pct are
# corrected (task #620) to reflect the Consolidated
# Appropriations Act, 2026 (CAA 2026), which superseded the
# CY2026 Final Rule's (accurate-at-the-time) 75%/50% QP,
# 50%/35% Partial QP, no-incentive narrative. See module
# docstring "CAA 2026 supersession" for the full citation
# chain: 42 CFR 414.1430(a)(1)(iv)/(a)(3)(iv) "2028: 50
# percent" / "35 percent" (QP), (a)(2)(iv)/(a)(4)(iv) "40
# percent" / "25 percent" (Partial QP), and 414.1450(b)(1)(iv)
# "2028: 3.1 percent" (APM incentive) — all 91 FR 44286,
# CMS-1848-P amendatory text
# (data/fr_downloads/2026-14327.txt:42550,42558,42568,42575,
# 42632). This is a statutory, self-executing supersession;
# eCFR's unconformed text does not control.
performance_year=2026, performance_year=2026,
payment_year=2028, payment_year=2028,
qp_thresholds=_qp_thresholds(), qp_thresholds=_qp_thresholds_50_35(),
partial_qp_thresholds=_partial_qp_thresholds(), partial_qp_thresholds=_partial_qp_thresholds_40_25(),
risk_standards=_risk_standards(), risk_standards=_risk_standards(),
apm_incentive_pct=None, apm_incentive_pct=3.1,
qp_cf_applies=True, qp_cf_applies=True,
# Corrected from "90 FR 49928" alone: the QP threshold statement citation=(
# itself is at 90 FR 49980 (see docstring pincite above); 49928 "90 FR 49928, 49980 (CY2026 Final Rule narrative — "
# is retained alongside it since it's independently the source "accurate when published, since SUPERSEDED); corrected "
# for the Partial QP payment-amount confirmation (docstring "per CAA 2026 (Pub. L. 119-75), restated 91 FR 44218-44220, "
# pincite, line 65021-65026). "44285-44286 (CMS-1848-P) (task #620)"
citation="90 FR 49928, 49980", ),
# NPRM proposals per :pincite:`2KVJ2HKX` — CMS-1832-P, 90 FR # NPRM proposals per :pincite:`2KVJ2HKX` — CMS-1832-P, 90 FR
# 32352, transcribed from data/fr_downloads/2025-13271.txt; # 32352, transcribed from data/fr_downloads/2025-13271.txt;
# disposition from data/fr_downloads/2025-19787.txt. # disposition from data/fr_downloads/2025-19787.txt.
@@ -416,13 +634,24 @@ QPP: dict[int, QppYear] = {
"beginning with the 2026 QP Performance Period (NPRM " "beginning with the 2026 QP Performance Period (NPRM "
"90 FR 32773). Finalized as proposed (Final Rule 90 FR " "90 FR 32773). Finalized as proposed (Final Rule 90 FR "
"49929: 'finalizing this policy as proposed'). " "49929: 'finalizing this policy as proposed'). "
"QP/Partial QP thresholds: proposed no numeric change " "QP/Partial QP thresholds: CMS-1832-P proposed, and "
"— thresholds remain 75%/50% (QP) and 50%/35% " "the Final Rule finalized, no numeric change from the "
"(Partial QP); see QPP[2026].qp_thresholds and the " "then-current-law payment-year-2028 thresholds "
"module docstring re: the NPRM's internally " "(75%/50% QP, 50%/35% Partial QP; see module "
"inconsistent RIA framing, corrected in the Final " "docstring re: the NPRM's internally inconsistent RIA "
"Rule (NPRM 90 FR 32772 vs 90 FR 32819; Final Rule 90 " "framing, corrected in the Final Rule — NPRM 90 FR "
"FR 49928). " "32772 vs 90 FR 32819; Final Rule 90 FR 49928). That "
"disposition was correct as of this rule's "
"publication. It was SUBSEQUENTLY SUPERSEDED for "
"payment year 2028 by the Consolidated Appropriations "
"Act, 2026 (CAA 2026), which legislatively restored "
"50%/35% QP, 40%/25% Partial QP and revived a 3.1% "
"APM Incentive Payment for that year — see "
"QPP[2026].qp_thresholds / partial_qp_thresholds / "
"apm_incentive_pct (current law, task #620) and the "
'module docstring "CAA 2026 supersession" section; '
"do not read this CMS-1832-P disposition as a "
"statement of current law. "
"Nominal amount standards (42 CFR " "Nominal amount standards (42 CFR "
"414.1415(c)(3)(i)(A)/(B)) and the CEHRT requirement " "414.1415(c)(3)(i)(A)/(B)) and the CEHRT requirement "
"(Sec. 414.1415(a)(i)): proposed no change — neither " "(Sec. 414.1415(a)(i)): proposed no change — neither "
@@ -432,6 +661,106 @@ QPP: dict[int, QppYear] = {
), ),
), ),
), ),
2027: QppYear(
# QP Performance Period 2027 / payment year 2029. Current-law
# thresholds transcribed from data/fr_downloads/2026-14327.txt
# (91 FR 43842, CMS-1848-P) -- see module docstring "CMS-1848-P
# (CY2027 PFS proposed rule) - QPP[2027]" for full sourcing:
# - RIA recital, 91 FR 44260 (line 40311-40313): "The
# thresholds to achieve QP status in the 2027 QP
# Performance Period (2029 payment year) are set to 75
# percent for the payment amount, and 50 percent for the
# patient count."
# - Amendatory text, 42 CFR 414.1430(a)(1)(v)/(a)(3)(v), 91 FR
# 44285-44286 (line 42551, 42569): "2029 and thereafter: 75
# percent" / "50 percent" (QP); (a)(2)(v)/(a)(4)(v), 91 FR
# 44286 (line 42559, 42576): "2029 and thereafter: 50
# percent" / "35 percent" (Partial QP).
# Risk standards / CEHRT: no amendment to 42 CFR 414.1415 or
# 414.1420 in this NPRM (amendatory-text item list skips both,
# 91 FR 44284-44285) -- carried forward unchanged; see
# module docstring for the indirect Shared Savings Program
# Level E cross-check (91 FR 44069).
# apm_incentive_pct: no payment-year-2029 entry in the
# amendatory text at 42 CFR 414.1450(b)(1), 91 FR 44286 (line
# 42629-42632) -- no APM Incentive Payment for this payment
# year under current law as restated by this NPRM.
# qp_cf_applies: "eligible clinicians who are QPs for the 2024
# performance year/2026 payment year and beyond will receive an
# increased physician fee schedule update of 0.75 percent
# qualifying APM conversion factor" (91 FR 44217, line
# 30603-30606) -- split CF persists through payment year 2029.
performance_year=2027,
payment_year=2029,
qp_thresholds=_qp_thresholds_75_50(),
partial_qp_thresholds=_partial_qp_thresholds_50_35(),
risk_standards=_risk_standards(),
apm_incentive_pct=None,
qp_cf_applies=True,
citation="91 FR 44260, 44285-44286",
# NPRM proposals per :pincite:`5ITGVDJV` -- CMS-1848-P, 91 FR
# 43842, transcribed from data/fr_downloads/2026-14327.txt. No
# Final Rule (CMS-1848-F) published as of this transcription --
# proposals only, no finalized disposition available.
proposed=QppProposed(
cms_rule_id="CMS-1848-P",
federal_register_citation="91 FR 43842",
changes=(
"TIN/NPI-level QP and Partial QP status determination: "
"proposed to apply QP status (and, via a conforming "
"amendment, Partial QP status) strictly to the "
"eligible clinician as a TIN/NPI combination, rather "
"than to the clinician (NPI) across all of their TIN "
"reassignments as under current policy, beginning with "
"the 2027 QP Performance Period (NPRM 91 FR "
"44215-44217, section 'Advanced APMs' > 'QP "
"Determination' > '(2) Applying QP Determinations at "
"the TIN/NPI Level'; amendatory text adding Sec. "
"414.1425(c)(8) and (d)(5), 91 FR 44285). "
"APM Entity Termination clerical fix: proposed to "
"remove an erroneously-included clause ('even if such "
"termination date occurs within such QP Performance "
"Period') from 42 CFR 414.1425(c)(5)(ii), correcting a "
"CY2020-PFS-final-rule clerical error that misaligned "
"the QP-status termination text from its Partial-QP "
"counterpart at 414.1425(d)(3)(ii) (NPRM 91 FR "
"44217-44218). "
"Participation List clarification: proposed to clarify "
"that APMs without a Participation List are not "
"thereby excluded from QP determinations or MIPS APM "
"scoring, addressing an interpretive gap for future "
"episode-based/mandatory APM designs (NPRM 91 FR "
"44218). "
"QP/Partial QP threshold regulatory-text conformance: "
"proposed to restructure and condense 42 CFR "
"414.1430(a)/(b) into a consolidated payment-year "
"schedule, conforming the regulation text to "
"already-enacted statutory changes (CAA 2024's "
"extension of payment-year-2025 thresholds through "
"payment year 2026, and subsequently legislated "
"payment-year 2027/2028/2029+ levels) rather than "
"proposing new CMS-set thresholds (NPRM 91 FR "
"44219-44220; amendatory text 91 FR 44285-44286). See "
"QPP[2027].qp_thresholds/partial_qp_thresholds for the "
"values as restated for payment year 2029. "
"APM Incentive Payment: proposed to codify the "
"Consolidated Appropriations Act, 2026's extension of "
"a 3.1 percent lump-sum APM Incentive Payment to "
"payment year 2028 (2026 QP Performance Period), and "
"to restructure 42 CFR 414.1450(b)(1) into a per-year "
"subparagraph list for readability, with no "
"substantive change to any other year's percentage "
"(NPRM 91 FR 44218-44219; RIA 91 FR 44260; amendatory "
"text 91 FR 44286). This amendatory text lists no "
"applicable percentage for payment years 2027 or 2029 "
"(i.e., no incentive payment in those years) and 3.1 "
"percent for payment year 2028 -- the latter directly "
"bears on QPP[2026].apm_incentive_pct (payment year "
"2028), which was corrected under task #620; see "
"module docstring 'CAA 2026 supersession'."
),
),
),
} }

View File

@@ -145,8 +145,17 @@ CLASSIFY_TOOL = LLMTool(
# Verified against the regulations.gov docket abstract (2026-08-12): # Verified against the regulations.gov docket abstract (2026-08-12):
# CMS-2025-0304 is the CY2026 *PFS* NPRM, not the OPPS rule — skin-substitute # CMS-2025-0304 is the CY2026 *PFS* NPRM, not the OPPS rule — skin-substitute
# payment moved into the PFS rule in CY2026. # payment moved into the PFS rule in CY2026.
DOCKET_RULES: dict[str, str] = {"CMS-2025-0304": "CMS-1832-P"} # CMS-2026-2377 is the CY2027 PFS NPRM (verified against the FR API 2026-08-13 —
# FR doc 2026-14327, 91 FR 43842).
DOCKET_RULES: dict[str, str] = {
"CMS-2025-0304": "CMS-1832-P",
"CMS-2026-2377": "CMS-1848-P",
}
# This prompt is scoped to docket CMS-2025-0304 only (skin-substitute
# reclassification). DOCKET_RULES above maps more dockets (e.g.
# CMS-2026-2377) to their CMS rule IDs, but do not reuse this prompt
# for CMS-2026-2377 or any other docket without rewriting it.
_SYSTEM = """\ _SYSTEM = """\
You classify public comments submitted on CMS's CY2026 PFS proposed rule You classify public comments submitted on CMS's CY2026 PFS proposed rule
(CMS-1832-P, docket CMS-2025-0304), specifically regarding the proposal to (CMS-1832-P, docket CMS-2025-0304), specifically regarding the proposal to

View File

@@ -1,14 +1,15 @@
"""Tests for pfs.nprm — NPRM Addendum B loader → pfs.rvu_proposed (#592).""" """Tests for pfs.nprm — NPRM Addendum B loader → pfs.rvu_proposed (#592, #612)."""
from __future__ import annotations from __future__ import annotations
import duckdb import duckdb
import pytest
from pfs import nprm from pfs import nprm
def _fixture(tmp_path): def _fixture(tmp_path, name="CY2026_NPRM_Addendum_B.csv"):
p = tmp_path / "CY2026_NPRM_Addendum_B.csv" p = tmp_path / name
p.write_text( p.write_text(
"HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU," "HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU,"
"NON-FAC PE RVU,FAC PE RVU,MP RVU\n" "NON-FAC PE RVU,FAC PE RVU,MP RVU\n"
@@ -18,20 +19,135 @@ def _fixture(tmp_path):
return p return p
def _fixture_2027(tmp_path):
"""CY2027 partition with a distinct HCPCS set so the two are distinguishable."""
p = tmp_path / "CY2027_NPRM_Addendum_B.csv"
p.write_text(
"HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU,"
"NON-FAC PE RVU,FAC PE RVU,MP RVU\n"
"99214,,Office visit est L4,A,1.9,2.1,0.75,0.15\n"
"0002A,,Admin covid dose 2,X,0.0,0.0,0.0,0.0\n"
)
return p
def _patch_discovery(monkeypatch, mapping):
"""monkeypatch._discover_addendum_b(tag) -> path, from a {tag: path} dict."""
def _fake(tag):
try:
return mapping[tag]
except KeyError:
raise FileNotFoundError(
f"No Addendum B attachment found among bib items tagged {tag!r}"
) from None
monkeypatch.setattr(nprm, "_discover_addendum_b", _fake)
def test_load_rvu_proposed_loads_and_reloads(tmp_path, monkeypatch): def test_load_rvu_proposed_loads_and_reloads(tmp_path, monkeypatch):
path = _fixture(tmp_path) path = _fixture(tmp_path)
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path) _patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
con = duckdb.connect() con = duckdb.connect()
out = nprm.load_rvu_proposed(con) out = nprm.load_rvu_proposed(con, year=2026)
assert out["rows"] == 2 assert out["rows"] == 2
assert out["cms_rule_id"] == "CMS-1832-P"
row = con.execute( row = con.execute(
"SELECT hcpcs, work_rvu, cms_rule_id FROM pfs.rvu_proposed WHERE hcpcs='99213'" "SELECT hcpcs, work_rvu, cms_rule_id FROM pfs.rvu_proposed WHERE hcpcs='99213'"
).fetchone() ).fetchone()
assert row == ("99213", 1.3, "CMS-1832-P") assert row == ("99213", 1.3, "CMS-1832-P")
nprm.load_rvu_proposed(con) # idempotent delete-and-reload nprm.load_rvu_proposed(con, year=2026) # idempotent delete-and-reload
assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2 assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2
def test_nprm_sources_covers_both_years():
years = {source[0] for source in nprm.NPRM_SOURCES}
assert years == {2026, 2027}
def test_load_rvu_proposed_two_partitions_side_by_side(tmp_path, monkeypatch):
"""Loading both years leaves both rule partitions intact side by side."""
path_2026 = _fixture(tmp_path)
path_2027 = _fixture_2027(tmp_path)
_patch_discovery(
monkeypatch,
{"sup:2026_PFS_NPRM": path_2026, "sup:2027_PFS_NPRM": path_2027},
)
con = duckdb.connect()
nprm.load_rvu_proposed(con, year=2026)
nprm.load_rvu_proposed(con, year=2027)
counts = dict(
con.execute(
"SELECT cms_rule_id, count(*) FROM pfs.rvu_proposed GROUP BY 1"
).fetchall()
)
assert counts == {"CMS-1832-P": 2, "CMS-1848-P": 2}
def test_load_rvu_proposed_reload_one_leaves_other_intact(tmp_path, monkeypatch):
"""Reloading the 2026 partition doesn't touch the 2027 partition's rows."""
path_2026 = _fixture(tmp_path)
path_2027 = _fixture_2027(tmp_path)
_patch_discovery(
monkeypatch,
{"sup:2026_PFS_NPRM": path_2026, "sup:2027_PFS_NPRM": path_2027},
)
con = duckdb.connect()
nprm.load_rvu_proposed(con, year=2026)
nprm.load_rvu_proposed(con, year=2027)
nprm.load_rvu_proposed(con, year=2026) # reload just 2026
counts = dict(
con.execute(
"SELECT cms_rule_id, count(*) FROM pfs.rvu_proposed GROUP BY 1"
).fetchall()
)
assert counts == {"CMS-1832-P": 2, "CMS-1848-P": 2}
row_2027 = con.execute(
"SELECT hcpcs, work_rvu FROM pfs.rvu_proposed WHERE cms_rule_id='CMS-1848-P' "
"AND hcpcs='99214'"
).fetchone()
assert row_2027 == ("99214", 1.9)
def test_load_rvu_proposed_missing_tag_raises_naming_it(tmp_path, monkeypatch):
"""Discovery failure names the missing tag in the error."""
_patch_discovery(monkeypatch, {}) # no tags registered at all
con = duckdb.connect()
with pytest.raises(FileNotFoundError, match="sup:2026_PFS_NPRM"):
nprm.load_rvu_proposed(con, year=2026)
def test_load_rvu_proposed_unknown_year_raises():
con = duckdb.connect()
with pytest.raises(ValueError, match="2099"):
nprm.load_rvu_proposed(con, year=2099)
def test_load_rvu_proposed_drops_and_recreates_stale_schema(tmp_path, monkeypatch):
"""A pre-existing table with old column names is dropped and rebuilt.
Regression guard for the pre-#612 table (columns ``modifier`` /
``nonfac_pe_rvu`` instead of ``mod`` / ``non_fac_pe_rvu``) — CREATE
TABLE IF NOT EXISTS alone won't fix a live mismatched table.
"""
path = _fixture(tmp_path)
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
con = duckdb.connect()
con.execute("CREATE SCHEMA IF NOT EXISTS pfs")
con.execute(
"CREATE TABLE pfs.rvu_proposed ("
"hcpcs VARCHAR, modifier VARCHAR, description VARCHAR, "
"status_code VARCHAR, work_rvu DOUBLE, nonfac_pe_rvu DOUBLE, "
"fac_pe_rvu DOUBLE, mp_rvu DOUBLE, cms_rule_id VARCHAR)"
)
out = nprm.load_rvu_proposed(con, year=2026)
assert out["rows"] == 2
cols = [r[0] for r in con.execute("DESCRIBE pfs.rvu_proposed").fetchall()]
assert cols == nprm._TARGET_COLUMNS
def _dirty_fixture(tmp_path): def _dirty_fixture(tmp_path):
"""Real-file quirks: padded cells, "NA" markers, a footnote row. """Real-file quirks: padded cells, "NA" markers, a footnote row.
@@ -55,11 +171,11 @@ def _dirty_fixture(tmp_path):
def test_load_rvu_proposed_strips_padding(tmp_path, monkeypatch): def test_load_rvu_proposed_strips_padding(tmp_path, monkeypatch):
"""Whitespace-padded HCPCS/numeric cells load clean, not null.""" """Whitespace-padded HCPCS/numeric cells load clean, not null."""
path = _dirty_fixture(tmp_path) path = _dirty_fixture(tmp_path)
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path) _patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
con = duckdb.connect() con = duckdb.connect()
nprm.load_rvu_proposed(con) nprm.load_rvu_proposed(con, year=2026)
row = con.execute( row = con.execute(
"SELECT hcpcs, description, work_rvu, nonfac_pe_rvu, fac_pe_rvu, mp_rvu " "SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, fac_pe_rvu, mp_rvu "
"FROM pfs.rvu_proposed WHERE hcpcs='99213'" "FROM pfs.rvu_proposed WHERE hcpcs='99213'"
).fetchone() ).fetchone()
assert row == ("99213", "Office visit est", 1.3, 1.5, 0.55, 0.1) assert row == ("99213", "Office visit est", 1.3, 1.5, 0.55, 0.1)
@@ -68,11 +184,11 @@ def test_load_rvu_proposed_strips_padding(tmp_path, monkeypatch):
def test_load_rvu_proposed_na_marker_becomes_null(tmp_path, monkeypatch): def test_load_rvu_proposed_na_marker_becomes_null(tmp_path, monkeypatch):
""" "NA" in a numeric cell loads as SQL NULL, not a cast error.""" """ "NA" in a numeric cell loads as SQL NULL, not a cast error."""
path = _dirty_fixture(tmp_path) path = _dirty_fixture(tmp_path)
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path) _patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
con = duckdb.connect() con = duckdb.connect()
nprm.load_rvu_proposed(con) nprm.load_rvu_proposed(con, year=2026)
row = con.execute( row = con.execute(
"SELECT work_rvu, nonfac_pe_rvu, fac_pe_rvu, mp_rvu " "SELECT work_rvu, non_fac_pe_rvu, fac_pe_rvu, mp_rvu "
"FROM pfs.rvu_proposed WHERE hcpcs='99214'" "FROM pfs.rvu_proposed WHERE hcpcs='99214'"
).fetchone() ).fetchone()
assert row == (None, None, None, None) assert row == (None, None, None, None)
@@ -81,9 +197,9 @@ def test_load_rvu_proposed_na_marker_becomes_null(tmp_path, monkeypatch):
def test_load_rvu_proposed_drops_footnote_rows(tmp_path, monkeypatch): def test_load_rvu_proposed_drops_footnote_rows(tmp_path, monkeypatch):
"""A footnote row (first cell = long sentence) is not a HCPCS code.""" """A footnote row (first cell = long sentence) is not a HCPCS code."""
path = _dirty_fixture(tmp_path) path = _dirty_fixture(tmp_path)
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path) _patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
con = duckdb.connect() con = duckdb.connect()
out = nprm.load_rvu_proposed(con) out = nprm.load_rvu_proposed(con, year=2026)
assert out["rows"] == 2 # only 99213 and 99214 — footnote row excluded assert out["rows"] == 2 # only 99213 and 99214 — footnote row excluded
assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2 assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2
footnote = con.execute( footnote = con.execute(

View File

@@ -5,13 +5,23 @@ Covers every line of pfs/rules/__init__.py:
- RULES dict: years 2014-2026 (13 entries), all RuleYear instances - RULES dict: years 2014-2026 (13 entries), all RuleYear instances
- Convenience aliases: cy2024, cy2025, cy2026 - Convenience aliases: cy2024, cy2025, cy2026
- Specific parameter values for spot-checked years - Specific parameter values for spot-checked years
- PROPOSED registry (final-less years) and proposed_for() helper
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from pfs.rules import RULES, ProposedRule, RuleYear, cy2024, cy2025, cy2026 from pfs.rules import (
PROPOSED,
RULES,
ProposedRule,
RuleYear,
cy2024,
cy2025,
cy2026,
proposed_for,
)
# ── RuleYear model ─────────────────────────────────────────────── # ── RuleYear model ───────────────────────────────────────────────
@@ -296,3 +306,88 @@ class TestProposedRule:
assert p.anesthesia_cf_qp is None assert p.anesthesia_cf_qp is None
assert p.budget_neutrality_adjustor == 1.0 assert p.budget_neutrality_adjustor == 1.0
assert p.telehealth_originating_site_fee is None assert p.telehealth_originating_site_fee is None
# ── PROPOSED registry + proposed_for() ───────────────────────────
class TestProposedForHelper:
"""proposed_for() resolution across RULES[year].proposed and PROPOSED."""
def test_resolves_from_rules_when_year_present(self):
# 2026 is in RULES and has a .proposed block — proposed_for
# should return that same object, not consult PROPOSED.
assert proposed_for(2026) is RULES[2026].proposed
def test_resolves_from_proposed_registry_when_year_absent_from_rules(self):
# 2027 has no Final Rule yet — no RULES[2027] — so proposed_for
# falls back to the PROPOSED registry.
assert 2027 not in RULES
assert proposed_for(2027) is PROPOSED[2027]
def test_missing_year_returns_none(self):
assert proposed_for(1999) is None
assert 1999 not in RULES
assert 1999 not in PROPOSED
def test_rules_year_without_proposed_block_returns_none(self):
# 2025 is in RULES but has no .proposed block.
assert RULES[2025].proposed is None
assert proposed_for(2025) is None
def test_2027_in_proposed_not_rules(self):
assert 2027 in PROPOSED
assert 2027 not in RULES
with pytest.raises(KeyError):
RULES[2027] # noqa: B018
class TestCy2027Proposed:
"""PROPOSED[2027] — CY2027 NPRM (CMS-1848-P) transcribed values.
Source: data/fr_downloads/2026-14327.txt, 91 FR 43842, published
2026-07-16. Per :pincite:`5ITGVDJV`.
"""
def test_identity_fields(self):
from datetime import date
p = PROPOSED[2027]
assert p.cms_rule_id == "CMS-1848-P"
assert p.fr_document_number == "2026-14327"
assert p.federal_register_citation == "91 FR 43842"
assert p.published == date(2026, 7, 16)
def test_comment_close_date(self):
from datetime import date
# DATES block, file lines 108-109: "comments must be received
# ... by September 14, 2026."
assert PROPOSED[2027].comment_close == date(2026, 9, 14)
assert PROPOSED[2027].comment_close > PROPOSED[2027].published
def test_standard_conversion_factors(self):
p = PROPOSED[2027]
# RIA detailed derivation, file lines ~39095-39102 (91 FR 44242).
assert p.cf_qp == 33.1693 # qualifying APM
assert p.conversion_factor == 32.8409 # nonqualifying APM
def test_anesthesia_conversion_factors(self):
p = PROPOSED[2027]
assert p.anesthesia_cf_qp == 20.4165 # qualifying APM
assert p.anesthesia_cf == 20.2143 # nonqualifying APM
def test_budget_neutrality_adjustor(self):
# "0.53 percent positive budget neutrality adjustment"
assert PROPOSED[2027].budget_neutrality_adjustor == 1.0053
def test_telehealth_originating_site_fee(self):
# HCPCS Q3014, file line ~2261 (91 FR 43843): "$32.65"
assert PROPOSED[2027].telehealth_originating_site_fee == 32.65
def test_not_equal_to_cy2026_final_or_proposed(self):
p2027 = PROPOSED[2027]
p2026 = RULES[2026].proposed
assert p2027.conversion_factor != RULES[2026].conversion_factor
assert p2027.conversion_factor != p2026.conversion_factor
assert p2027.cf_qp != p2026.cf_qp

View File

@@ -3,12 +3,13 @@
Covers src/qpp/__init__.py: Covers src/qpp/__init__.py:
- QpThresholds / RiskStandards / QppProposed model construction - QpThresholds / RiskStandards / QppProposed model construction
- QppYear model construction with required and default fields - QppYear model construction with required and default fields
- QPP dict: performance years 2023-2026, payment_year = performance_year + 2 - QPP dict: performance years 2023-2027, payment_year = performance_year + 2
- qp_cf_applies: False before performance year 2024, True from 2024 onward - qp_cf_applies: False before performance year 2024, True from 2024 onward
- Every entry has a non-empty citation - Every entry has a non-empty citation
- QPP[2026].proposed is CMS-1832-P - QPP[2026].proposed is CMS-1832-P; QPP[2027].proposed is CMS-1848-P
- for_payment_year(year) lookup, including KeyError on miss - for_payment_year(year) lookup, including KeyError on miss
- Exact-value asserts for transcribed thresholds (see task-6-report.md) - Exact-value asserts for transcribed thresholds (see task-6-report.md,
task-7-report.md)
""" """
from __future__ import annotations from __future__ import annotations
@@ -110,10 +111,10 @@ class TestQppYear:
class TestQppDict: class TestQppDict:
EXPECTED_YEARS = [2023, 2024, 2025, 2026] EXPECTED_YEARS = [2023, 2024, 2025, 2026, 2027]
def test_count(self): def test_count(self):
assert len(QPP) == 4 assert len(QPP) == 5
def test_keys(self): def test_keys(self):
assert sorted(QPP.keys()) == self.EXPECTED_YEARS assert sorted(QPP.keys()) == self.EXPECTED_YEARS
@@ -150,9 +151,17 @@ class TestQpCfApplies:
def test_2026_true(self): def test_2026_true(self):
assert QPP[2026].qp_cf_applies is True assert QPP[2026].qp_cf_applies is True
def test_2027_true(self):
assert QPP[2027].qp_cf_applies is True
class TestApmIncentivePct: class TestApmIncentivePct:
"""Lump-sum APM Incentive Payment sunsets after payment year 2026.""" """Lump-sum APM Incentive Payment: 5% (2019-2024), 3.5% (2025),
1.88% (2026), then a gap for 2027 — but NOT a clean sunset: the
Consolidated Appropriations Act, 2026 (CAA 2026) revived a 3.1%
payment for payment year 2028 only (42 CFR 414.1450(b)(1)(iv), as
amended by CMS-1848-P, 91 FR 44286), before the gap resumes for
2029."""
def test_2023_incentive(self): def test_2023_incentive(self):
assert QPP[2023].apm_incentive_pct == 3.5 assert QPP[2023].apm_incentive_pct == 3.5
@@ -163,8 +172,22 @@ class TestApmIncentivePct:
def test_2025_no_incentive(self): def test_2025_no_incentive(self):
assert QPP[2025].apm_incentive_pct is None assert QPP[2025].apm_incentive_pct is None
def test_2026_no_incentive(self): def test_2026_incentive_revived_by_caa_2026(self):
assert QPP[2026].apm_incentive_pct is None # Payment year 2028: CAA 2026 revived a 3.1% APM Incentive
# Payment — 42 CFR 414.1450(b)(1)(iv), as amended by
# CMS-1848-P, 91 FR 44286 (data/fr_downloads/
# 2026-14327.txt:42632, "(iv) 2028: 3.1 percent."); narrative
# at 91 FR 44218-44219 (2026-14327.txt:37389-37391, 37436).
# This supersedes the CY2026 Final Rule's apm_incentive_pct
# = None (90 FR 49838/49980).
assert QPP[2026].apm_incentive_pct == 3.1
def test_2027_no_incentive(self):
# Payment year 2029: 42 CFR 414.1450(b)(1) (as amended by
# CMS-1848-P, 91 FR 44286) lists applicable percentages only
# for payment years 2019-2024, 2025, 2026, and 2028 — no entry
# for 2029.
assert QPP[2027].apm_incentive_pct is None
# ── QPP[2026].proposed — CMS-1832-P ────────────────────────────── # ── QPP[2026].proposed — CMS-1832-P ──────────────────────────────
@@ -186,25 +209,75 @@ class TestProposed:
assert QPP[2025].proposed is None assert QPP[2025].proposed is None
# ── QPP[2027].proposed — CMS-1848-P ──────────────────────────────
class TestProposed2027:
def test_2027_has_proposed(self):
p = QPP[2027].proposed
assert p is not None
assert p.cms_rule_id == "CMS-1848-P"
assert p.federal_register_citation == "91 FR 43842"
def test_2027_changes_non_empty(self):
assert len(QPP[2027].proposed.changes) > 0
def test_2027_no_numeric_change_blocks(self):
# CMS-1848-P's threshold/incentive amendments conform the
# regulation text to already-enacted statutory levels rather
# than proposing new CMS-set numeric values — see
# QppProposed.qp_thresholds docstring.
p = QPP[2027].proposed
assert p.qp_thresholds is None
assert p.partial_qp_thresholds is None
assert p.risk_standards is None
# ── Exact transcribed threshold values ─────────────────────────── # ── Exact transcribed threshold values ───────────────────────────
class TestSpecificValues: class TestSpecificValues:
"""Verify transcribed QP/Partial QP thresholds and risk standards. """Verify transcribed QP/Partial QP thresholds and risk standards.
See task-6-report.md transcription table for sourcing per value.""" Thresholds are keyed to PAYMENT YEAR by 42 CFR 414.1430(a), not QP
Performance Period — see module docstring "Payment-year keying
correction" (task #620). Payment years 2021-2026 (QPP[2023] pay
2025, QPP[2024] pay 2026) and 2028 (QPP[2026], restored by CAA
2026) sit at 50%/35% QP, 40%/25% Partial QP; payment years 2027
and 2029+ (QPP[2025], QPP[2027]) sit at 75%/50% QP, 50%/35%
Partial QP. See task-6-report.md / task-7-report.md for the
original transcription tables and task-620-report.md for the
correction."""
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026]) @pytest.mark.parametrize("year", [2025, 2027])
def test_qp_thresholds(self, year): def test_qp_thresholds_75_50(self, year):
assert QPP[year].qp_thresholds.payment_amount_pct == 75.0 assert QPP[year].qp_thresholds.payment_amount_pct == 75.0
assert QPP[year].qp_thresholds.patient_count_pct == 50.0 assert QPP[year].qp_thresholds.patient_count_pct == 50.0
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026]) @pytest.mark.parametrize("year", [2023, 2024, 2026])
def test_partial_qp_thresholds(self, year): def test_qp_thresholds_50_35(self, year):
# 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii) "2021 through 2026: 50
# percent" / "35 percent" (91 FR 44285); (a)(1)(iv)/(a)(3)(iv)
# "2028: 50 percent" / "35 percent" (91 FR 44286) — CAA 2026
# for QPP[2026]/payment year 2028.
assert QPP[year].qp_thresholds.payment_amount_pct == 50.0
assert QPP[year].qp_thresholds.patient_count_pct == 35.0
@pytest.mark.parametrize("year", [2025, 2027])
def test_partial_qp_thresholds_50_35(self, year):
assert QPP[year].partial_qp_thresholds.payment_amount_pct == 50.0 assert QPP[year].partial_qp_thresholds.payment_amount_pct == 50.0
assert QPP[year].partial_qp_thresholds.patient_count_pct == 35.0 assert QPP[year].partial_qp_thresholds.patient_count_pct == 35.0
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026]) @pytest.mark.parametrize("year", [2023, 2024, 2026])
def test_partial_qp_thresholds_40_25(self, year):
# 42 CFR 414.1430(a)(2)(ii)/(a)(4)(ii) "2021 through 2026: 40
# percent" / "25 percent" (91 FR 44285-44286); (a)(2)(iv)/
# (a)(4)(iv) "2028: 40 percent" / "25 percent" (91 FR 44286) —
# CAA 2026 for QPP[2026]/payment year 2028.
assert QPP[year].partial_qp_thresholds.payment_amount_pct == 40.0
assert QPP[year].partial_qp_thresholds.patient_count_pct == 25.0
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026, 2027])
def test_risk_standards(self, year): def test_risk_standards(self, year):
# Revenue-based standard (42 CFR 414.1415(c)(3)(i)(A)) is # Revenue-based standard (42 CFR 414.1415(c)(3)(i)(A)) is
# transcribed (8%); the benchmark-based standard (42 CFR # transcribed (8%); the benchmark-based standard (42 CFR
@@ -214,7 +287,7 @@ class TestSpecificValues:
assert QPP[year].risk_standards.revenue_nominal_pct == 8.0 assert QPP[year].risk_standards.revenue_nominal_pct == 8.0
assert QPP[year].risk_standards.benchmark_nominal_pct is None assert QPP[year].risk_standards.benchmark_nominal_pct is None
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026]) @pytest.mark.parametrize("year", [2023, 2024, 2025, 2026, 2027])
def test_cehrt_required(self, year): def test_cehrt_required(self, year):
assert QPP[year].cehrt_required is True assert QPP[year].cehrt_required is True
@@ -235,6 +308,9 @@ class TestForPaymentYear:
def test_2028_maps_to_performance_2026(self): def test_2028_maps_to_performance_2026(self):
assert for_payment_year(2028) is QPP[2026] assert for_payment_year(2028) is QPP[2026]
def test_2029_maps_to_performance_2027(self):
assert for_payment_year(2029) is QPP[2027]
def test_unknown_payment_year_raises_keyerror(self): def test_unknown_payment_year_raises_keyerror(self):
with pytest.raises(KeyError): with pytest.raises(KeyError):
for_payment_year(1999) for_payment_year(1999)

View File

@@ -158,6 +158,12 @@ def test_docket_rules_maps_cy2026_pfs():
assert DOCKET_RULES["CMS-2025-0304"] == "CMS-1832-P" assert DOCKET_RULES["CMS-2025-0304"] == "CMS-1832-P"
def test_docket_rules_maps_cy2027_pfs():
from rex.comments.classify import DOCKET_RULES
assert DOCKET_RULES["CMS-2026-2377"] == "CMS-1848-P"
def test_system_prompt_names_the_pfs_rule(): def test_system_prompt_names_the_pfs_rule():
from rex.comments.classify import _SYSTEM from rex.comments.classify import _SYSTEM