From 1793fe4eebc5fbd43f283f68a303c8f8d3e03fb7 Mon Sep 17 00:00:00 2001 From: kert Date: Thu, 12 Nov 2026 04:00:18 -0500 Subject: [PATCH] =?UTF-8?q?fix(pfs):=20lineage=20=E2=80=94=20a=20';'=20end?= =?UTF-8?q?s=20a=20successor=20clause=20only=20before=20a=20verb;=20a=20de?= =?UTF-8?q?leted=20reference=20and=20a=20bundling=20target=20are=20not=20d?= =?UTF-8?q?eletions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'replaced with two new codes; CPT codes 37252 and 37253' keeps naming the successors, 'the reference to CPT code 76000 was intentionally deleted' deletes no code, and in 'bundling of the deleted code, CPT 50395, with CPT code 74485' only 50395 is deleted. deletion_readings(text, code) gives the deleted/cpt_panel readings of one text with the fr_events logic. --- src/pfs/lineage.py | 53 +++++++++++++++++++++++++---- tests/pfs/test_lineage.py | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/pfs/lineage.py b/src/pfs/lineage.py index 8cc063e..490ccd8 100644 --- a/src/pfs/lineage.py +++ b/src/pfs/lineage.py @@ -152,11 +152,30 @@ _SUCCESSOR = re.compile( r"|(? str: def _mask_successors(text: str) -> str: """*text* with the codes of every successor clause blanked out, so "deleted" patterns cannot reach them. A clause runs from its marker to - the end of the sentence, a ";", or the next deletion ("..., and the - deletion of ...").""" + the end of the sentence, a ";" followed by a new verb, or the next + deletion ("..., and the deletion of ..."). A deleted *reference to* a + code deletes nothing: its deleting word is replaced.""" out = [] for sent in re.split(r"(?<=\.)(\s)", text): + sent = _REF_DELETED.sub( + lambda m: m.group(0)[: m.start(1) - m.start(0)] + "struck", sent + ) fd = _FROM_DELETED.search(sent) cr = _CREATING.search(sent) if fd else None if fd and cr and cr.start() < fd.start(): @@ -190,7 +213,7 @@ def _mask_successors(text: str) -> str: + sent[fd.start() :] ) for m in list(_SUCCESSOR.finditer(sent)): - cut = next(m.start(g) for g in range(1, 10) if m.group(g)) + cut = m.start(m.lastindex or 0) tail = sent[cut:] end = _CLAUSE_END.search(tail, 1) stop = end.start() if end else len(tail) @@ -213,6 +236,24 @@ def _deletion_match( return pat.search(masked) +def deletion_readings(text: str, code: str) -> set[str]: + """Which of ``deleted`` and ``cpt_panel`` ``fr_events`` reads for + *code* in one raw *text* (a paragraph, a sentence or a summary): the + same range expansion, FR-cite stripping, successor masking and + patterns. ``{"deleted", "cpt_panel"}`` = the CPT Editorial Panel + deleting the code.""" + code = code.upper() + text = FR_CITE_RE.sub(" ", _expand_ranges(text)) + if code not in find_codes(text): + return set() + masked = _mask_successors(text) + return { + kind + for kind, pat, _direction in _patterns(code) + if kind in ("deleted", "cpt_panel") and _deletion_match(kind, pat, text, masked) + } + + def _patterns(code: str) -> list[tuple[str, re.Pattern[str], str]]: c = re.escape(code) return [ diff --git a/tests/pfs/test_lineage.py b/tests/pfs/test_lineage.py index 0d26afa..19a8cb0 100644 --- a/tests/pfs/test_lineage.py +++ b/tests/pfs/test_lineage.py @@ -11,6 +11,7 @@ from pfs.codetables import ensure_tables from pfs.lineage import ( _expand_ranges, cpt_events, + deletion_readings, fr_events, fr_events_bucketed, lineage, @@ -472,6 +473,76 @@ class TestNewKinds: for code in ("15730", "27369", "50436", "77402"): assert "deleted" not in {e.kind for e in fr_events(store, code)}, code + def test_n7_successors_references_and_bundling_are_not_deleted(self, store): + # public FR text: CY 2017 PFS proposed (P8SNUR46 ¶971) and final + # (WHNGCYHB ¶1967), CY 2023 final (PETM225D ¶781), CY 2019 final + # (9V89NVZG ¶1020) + replaced = ( + "Also we note that the intravascular codes for which we initially " + "established our base/add-on code relationship for CPT codes 88346 " + "and 88350 were deleted in CY 2016 and replaced with two new codes; " + "CPT codes 37252 and 37253. The relationship between 37252 and 37253 " + "represents a 20 percent discount for the add-on code." + ) + _add_para(store, "P8SNUR46", 971, replaced, "CY 2017 PFS Proposed Rule") + _add_para(store, "WHNGCYHB", 1967, replaced, "CY 2017 PFS Final Rule") + _add_para( + store, + "PETM225D", + 781, + "From our re-review of the AMA RUC recommendations, we do note that in " + "the text material accompanying the RUC recommendation for CPT code " + "76000 ( Fluoroscopy (separate procedure), up to 1 hour physician or " + "other qualified health care professional time) codes or language was " + "struck from the text material. It is unclear if the reference to CPT " + "code 76000 was intentionally deleted, but we note that CPT code 76000 " + "has a work RVU value of 0.30 and an intra-service time of 10.0 minutes.", + ) + _add_para( + store, + "9V89NVZG", + 1020, + "Response: Even after taking into consideration the bundling of the " + "deleted code, CPT 50395, with CPT code 74485, we believe that there are " + "efficiencies in the work that are not adequately reflected in the " + "RUC-recommended work RVU for this new code, CPT 50436.", + ) + for code in ("37252", "37253", "76000", "74485", "50436"): + assert "deleted" not in {e.kind for e in fr_events(store, code)}, code + assert fr_events_bucketed(store, [code])[code] == fr_events(store, code) + ev = fr_events(store, "50395") + assert ("deleted", "9V89NVZG", 1020) in { + (e.kind, e.item_key, e.p_id) for e in ev + } + assert fr_events_bucketed(store, ["50395"])["50395"] == ev + # a ";" with a verb after it still ends the successor clause, and + # "bundled with" names what a deleted code went into + _add_para( + store, + "PETM225D", + 1691, + "Code 99601 was replaced with 99602; 99603 was also deleted. " + "Code 99604 was deleted and bundled with 99605. " + "The reference to CPT code 99606, which is being deleted, stays.", + ) + for code in ("99601", "99603", "99604", "99606"): + assert "deleted" in {e.kind for e in fr_events(store, code)}, code + for code in ("99602", "99605"): + assert "deleted" not in {e.kind for e in fr_events(store, code)}, code + + def test_deletion_readings_of_one_text(self): + for code in ("99324", "99337", "99343"): + assert deletion_readings(P1672, code) == {"deleted", "cpt_panel"}, code + assert deletion_readings(P1672, "99341") == set() + merged = ( + "The AMA CPT Editorial Panel deleted domiciliary codes 99324-99340 and " + "merged them into the existing home-based E/M visit codes 99341-99350." + ) + assert deletion_readings(merged, "99330") == {"deleted", "cpt_panel"} + assert deletion_readings(merged, "99343") == set() + assert deletion_readings("Code 99343 was deleted.", "99343") == {"deleted"} + assert deletion_readings("Nothing about it.", "99343") == set() + def test_a_deletion_after_a_semicolon_is_not_masked(self, store): _add_para( store,