diff --git a/src/pfs/rules/__init__.py b/src/pfs/rules/__init__.py index ab19a11..aa557f9 100644 --- a/src/pfs/rules/__init__.py +++ b/src/pfs/rules/__init__.py @@ -25,7 +25,9 @@ Mid-year CF splits documented by :pincite:`VVBEVYLC`:: All other CY2002 – CY2026 years have a single CF for the full year. CY2026 is the first year with four simultaneous CFs (APM / non-APM -× standard / anesthesia) under MACRA 2015. +× standard / anesthesia) under MACRA 2015. CY2027 (CMS-1848-F) keeps the +split; its CFs come from the final rule itself (:pincite:`JCWARTJD`), +not the AMA table, which had not published CY2027 at transcription. Usage:: @@ -49,17 +51,17 @@ one applies: (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). + is no ``RULES[year]`` to hang it on (CY2027 lived here from its + July 2026 NPRM until the final rule of Nov 9, 2026; empty since). 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. +migration path: when a year's Final Rule ships, ``RULES[year]`` gets +created with its own ``proposed=...`` block and the ``PROPOSED[year]`` +entry is retired — ``proposed_for(year)`` keeps returning the right +thing across that move without callers changing (CY2027 made the move +on 2026-11-11). ``RuleYear`` itself needs no change for this to work. """ from __future__ import annotations @@ -420,7 +422,10 @@ RULES: dict[int, RuleYear] = { 2025, 32.3465, "expiration of CY2024 temporary 2.93% update" ), clinical_labor_rate_update=True, - telehealth_originating_site_fee=29.96, + # Was 29.96 — that is CY2024's fee. CMS's Q3014 history, + # Table A-C1 of the CY2027 final rule (91 FR 71027), gives + # CY2025 = $31.01 (MEI 3.5%). + telehealth_originating_site_fee=31.01, federal_register_citation="89 FR 98452", ), 2026: RuleYear( @@ -442,7 +447,10 @@ RULES: dict[int, RuleYear] = { anesthesia_cf=20.4976, # non-APM anesthesia budget_neutrality_adjustor=1.0049, # +0.49% BN adjustment clinical_labor_rate_update=True, - telehealth_originating_site_fee=30.38, + # Was 30.38, which matches no year in CMS's Q3014 history + # (Table A-C1, 91 FR 71027): CY2026 = $31.85 (MEI 2.7%), + # the same amount the NPRM proposed. + telehealth_originating_site_fee=31.85, # Corrected from "90 FR 101174" (mistranscribed; no such page in # this rule). The Final Rule (RIN 0938-AV50, Vol. 90 No. 212, # Nov 5, 2025) runs 90 FR 49266-50481; per this registry's @@ -474,6 +482,93 @@ RULES: dict[int, RuleYear] = { ), ), ), + 2027: RuleYear( + # CY2027 PFS Final Rule (CMS-1848-F), FR Doc 2026-22743, + # 91 FR 70992-72020, published 2026-11-09, effective + # 2027-01-01, per :pincite:`JCWARTJD`. CFs and the BN adjustor + # are the RIA's CF derivation at 91 FR 71653 + # (data/fr_downloads/2026-22743.txt lines ~67750-67767): the + # CY2026 CFs *without* the one-year 2.50% statutory increase, + # times a -0.12% budget-neutrality adjustment, times the + # section 1848(d)(20) updates (QP +0.75%, non-QP +0.25%). + year=2027, + conversion_factor=32.6286, # nonqualifying APM standard + cf_qp=32.9548, # qualifying APM standard + cf_segments=_full_year( + 2027, + 32.6286, + "non-APM standard CF; CY2026's one-year 2.50% increase removed", + ), + # Nonqualifying APM anesthesia CF; the QP anesthesia CF is + # 20.3068 (same sentence, 91 FR 71653) — RuleYear has no field + # for it yet. + anesthesia_cf=20.1056, + budget_neutrality_adjustor=0.9988, # "-0.12 percent" + # HCPCS Q3014, Table A-C1 (an image in both the PDF and the + # text edition), 91 FR 71027: $32.68, MEI 2.6%. + telehealth_originating_site_fee=32.68, + # clinical_labor_rate_update / supply_equipment_price_update + # stay False: the rule continues only a phased pricing + # transition for three supply packs (Table A-B4), no general + # clinical-labor or supply/equipment refresh. + federal_register_citation="91 FR 70992", + # NPRM values, moved here from PROPOSED[2027] when the Final + # Rule shipped (see module docstring). + proposed=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)." + ), + ), + ), } @@ -481,70 +576,15 @@ RULES: dict[int, RuleYear] = { # # `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` holds `ProposedRule` values for years whose NPRM is out +# but whose Final Rule is not. When the Final Rule ships, `RULES[year]` +# is created with `proposed=` that block and the `PROPOSED[year]` entry +# is retired — callers that used `proposed_for(year)` see no change +# since the helper checks `RULES` first. Empty since CY2027's final +# rule (CMS-1848-F) moved its NPRM under RULES[2027]; CY2028's NPRM +# (expected July 2027) goes here 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)." - ), - ), -} +PROPOSED: dict[int, ProposedRule] = {} def proposed_for(year: int) -> ProposedRule | None: @@ -552,8 +592,8 @@ def proposed_for(year: int) -> ProposedRule | None: 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 + for years whose NPRM has no Final Rule yet (see ``PROPOSED`` + above). Returns ``None`` if neither source has proposed parameters for ``year``. This indirection is what lets a year migrate from ``PROPOSED`` to diff --git a/tests/pfs/test_rules.py b/tests/pfs/test_rules.py index a867ddb..0698023 100644 --- a/tests/pfs/test_rules.py +++ b/tests/pfs/test_rules.py @@ -104,21 +104,21 @@ class TestRuleYear: class TestRulesDict: - """RULES maps calendar year -> RuleYear for 2014-2026.""" + """RULES maps calendar year -> RuleYear for 2014-2027.""" - EXPECTED_YEARS = list(range(2014, 2027)) + EXPECTED_YEARS = list(range(2014, 2028)) def test_count(self): - assert len(RULES) == 13 + assert len(RULES) == 14 def test_keys(self): assert sorted(RULES.keys()) == self.EXPECTED_YEARS - @pytest.mark.parametrize("year", list(range(2014, 2027))) + @pytest.mark.parametrize("year", list(range(2014, 2028))) def test_values_are_ruleyear(self, year): assert isinstance(RULES[year], RuleYear) - @pytest.mark.parametrize("year", list(range(2014, 2027))) + @pytest.mark.parametrize("year", list(range(2014, 2028))) def test_year_field_matches_key(self, year): assert RULES[year].year == year @@ -156,7 +156,8 @@ class TestSpecificValues: assert cy2026.cf_qp == 33.5675 def test_cy2026_telehealth_fee(self): - assert cy2026.telehealth_originating_site_fee == 30.38 + # Table A-C1 (Q3014 history), 91 FR 71027 — was mis-recorded as 30.38. + assert cy2026.telehealth_originating_site_fee == 31.85 def test_cy2026_federal_register(self): assert cy2026.federal_register_citation == "90 FR 49266" @@ -168,7 +169,8 @@ class TestSpecificValues: assert cy2025.conversion_factor == 32.3465 def test_cy2025_telehealth_fee(self): - assert cy2025.telehealth_originating_site_fee == 29.96 + # Table A-C1, 91 FR 71027 — 29.96 was CY2024's fee. + assert cy2025.telehealth_originating_site_fee == 31.01 def test_cy2024_conversion_factor(self): assert cy2024.conversion_factor == 33.2875 @@ -319,11 +321,14 @@ class TestProposedForHelper: # 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_resolves_from_proposed_registry_when_year_absent_from_rules( + self, monkeypatch + ): + # A final-less year (CY2028's NPRM, next July) lives in PROPOSED. + prop = RULES[2027].proposed.model_copy(update={"cms_rule_id": "CMS-9999-P"}) + monkeypatch.setitem(PROPOSED, 2028, prop) + assert 2028 not in RULES + assert proposed_for(2028) is prop def test_missing_year_returns_none(self): assert proposed_for(1999) is None @@ -335,15 +340,16 @@ class TestProposedForHelper: 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 + def test_2027_moved_from_proposed_to_rules(self): + # The CY2027 final rule shipped: its NPRM block now hangs off + # RULES[2027] and the PROPOSED entry is retired. + assert 2027 not in PROPOSED + assert proposed_for(2027) is RULES[2027].proposed + assert proposed_for(2027).cms_rule_id == "CMS-1848-P" class TestCy2027Proposed: - """PROPOSED[2027] — CY2027 NPRM (CMS-1848-P) transcribed values. + """proposed_for(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`. @@ -352,7 +358,7 @@ class TestCy2027Proposed: def test_identity_fields(self): from datetime import date - p = PROPOSED[2027] + p = proposed_for(2027) assert p.cms_rule_id == "CMS-1848-P" assert p.fr_document_number == "2026-14327" assert p.federal_register_citation == "91 FR 43842" @@ -363,31 +369,62 @@ class TestCy2027Proposed: # 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 + assert proposed_for(2027).comment_close == date(2026, 9, 14) + assert proposed_for(2027).comment_close > proposed_for(2027).published def test_standard_conversion_factors(self): - p = PROPOSED[2027] + p = proposed_for(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] + p = proposed_for(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 + assert proposed_for(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 + assert proposed_for(2027).telehealth_originating_site_fee == 32.65 def test_not_equal_to_cy2026_final_or_proposed(self): - p2027 = PROPOSED[2027] + p2027 = proposed_for(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 + + +class TestCy2027Final: + """RULES[2027] — CY2027 PFS Final Rule (CMS-1848-F), 91 FR 70992, + published 2026-11-09, per :pincite:`JCWARTJD`. CF sentence at + 91 FR 71653; Q3014 fee in Table A-C1, 91 FR 71027.""" + + def test_conversion_factors(self): + r = RULES[2027] + assert r.conversion_factor == 32.6286 # nonqualifying APM + assert r.cf_qp == 32.9548 # qualifying APM + assert r.anesthesia_cf == 20.1056 # nonqualifying APM anesthesia + + def test_budget_neutrality_and_fee(self): + r = RULES[2027] + assert r.budget_neutrality_adjustor == 0.9988 # -0.12 percent + assert r.telehealth_originating_site_fee == 32.68 + + def test_citation_and_single_segment(self): + from datetime import date + + r = RULES[2027] + assert r.federal_register_citation == "91 FR 70992" + assert r.cf_for_date(date(2027, 7, 1)) == 32.6286 + + def test_final_differs_from_proposed(self): + r = RULES[2027] + # NPRM 32.8409 / 33.1693 → final 32.6286 / 32.9548: BN flipped + # from +0.53% proposed to -0.12% final. + assert r.conversion_factor < r.proposed.conversion_factor + assert r.cf_qp < r.proposed.cf_qp