fix(pfs): lineage — a ';' ends a successor clause only before a verb; a deleted reference and a bundling target are not deletions

'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.
This commit is contained in:
kert
2026-11-12 04:00:18 -05:00
parent b6107b95ba
commit 1793fe4eeb
2 changed files with 118 additions and 6 deletions

View File

@@ -152,11 +152,30 @@ _SUCCESSOR = re.compile(
r"|(?<!reference )\b(to)\s+(?:CPT\s+)?codes?\b"
r"|\b(the)\s+addition\s+of\b"
r"|\b(new)\s+(?:CPT\s+)?codes?\b"
r"|\bthe\s+(new)\s+CPT\s+code\b",
r"|\bthe\s+(new)\s+CPT\s+code\b"
r"|\bbundl(?:e|ed|es|ing)\b(?:\s+of\b[^.]{0,120}?)?\s+(with)\b",
re.I,
)
#: a word that makes what follows a ";" a clause of its own
_VERB = (
r"(?:is|are|was|were|be|been|being|has|have|had|will|would|shall|should|"
r"may|might|can|could|must|do|does|did|\w{2,}ed)"
)
#: where a successor clause ends: before the next deletion, or at a ";"
#: that a new verb follows ("replaced with two new codes; CPT codes 37252
#: and 37253" goes on naming the successors)
_CLAUSE_END = re.compile(
rf",\s*and\s+(?:the\s+)?delet|;(?=[^;]*\b{_VERB}\b)|\bdelet\w*", re.I
)
#: "the reference to CPT code 76000 was intentionally deleted": the
#: reference was deleted, not the code (group 1 is the deleting word)
_REF_DELETED = re.compile(
rf"\breference\s+to\s+(?:CPT\s+)?codes?\s+{_CODE}"
rf"(?:\s*(?:,|and|or)\s*(?:and\s+|or\s+)?{_CODE})*\s+"
r"(?:(?:was|were|is|are|has|have|had)\s+)?(?:been\s+|being\s+)?"
r"(?:\w+ly\s+)?(delet\w*)",
re.I,
)
#: where a successor clause ends: before the next deletion
_CLAUSE_END = re.compile(r",\s*and\s+(?:the\s+)?delet|;|\bdelet\w*", re.I)
#: "created 94617 and 94618 from the now deleted 94620": the codes before
#: "from the ... deleted" (after the creating word) are the successors
_FROM_DELETED = re.compile(r"\bfrom\s+the\s+(?:now\s+)?deleted\b", re.I)
@@ -177,10 +196,14 @@ def _blank_codes(text: str) -> 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 [

View File

@@ -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,