fix(pfs): families — exception-safe test restore, narrow load_families catch, non-empty activity guard, token-bucketed stem merge (refs #687)

This commit is contained in:
kert
2026-09-09 12:03:35 -04:00
parent a8d829d3ff
commit 054058cab3
2 changed files with 135 additions and 17 deletions

View File

@@ -8,10 +8,13 @@ DuckDB, no narwhals — it must import inside the ``llm`` container.
from __future__ import annotations from __future__ import annotations
import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Mapping, Sequence from typing import Any, Mapping, Sequence
_logger = logging.getLogger(__name__)
# HCPCS level II (letter + 4 digits) or CPT (5 digits). Validation against # HCPCS level II (letter + 4 digits) or CPT (5 digits). Validation against
# the fee schedule happens at lookup time (pfs.valuation), not here. # the fee schedule happens at lookup time (pfs.valuation), not here.
CODE_RE = re.compile(r"\b(?:[A-Z]\d{4}|\d{5})\b", re.IGNORECASE) CODE_RE = re.compile(r"\b(?:[A-Z]\d{4}|\d{5})\b", re.IGNORECASE)
@@ -218,15 +221,28 @@ def derive_families(
c: frozenset(e.value for e in elements.get(c, ()) if e.type == "activity") c: frozenset(e.value for e in elements.get(c, ()) if e.type == "activity")
for c in codes for c in codes
} }
for i, a in enumerate(codes): # Inverted index token -> codes, so we only compare pairs that share at
for b in codes[i + 1 :]: # least one stem token. Exact for the >= 0.5 threshold: any qualifying
if ( # pair has a non-empty intersection (jaccard > 0 implies shared tokens),
stems[a] # so no comparison is skipped, but the O(n^2) all-pairs scan is.
and stems[b] buckets: dict[str, list[str]] = {}
and _jaccard(stems[a], stems[b]) >= 0.5 for c, toks in stems.items():
and acts[a] == acts[b] for t in toks:
): buckets.setdefault(t, []).append(c)
union(a, b) seen: set[tuple[str, str]] = set()
for members in buckets.values():
for i, a in enumerate(members):
for b in members[i + 1 :]:
pair = (a, b) if a < b else (b, a)
if pair in seen:
continue
seen.add(pair)
if (
acts[a]
and acts[a] == acts[b]
and _jaccard(stems[a], stems[b]) >= 0.5
):
union(a, b)
groups: dict[str, list[str]] = {} groups: dict[str, list[str]] = {}
for c in codes: for c in codes:
@@ -285,8 +301,12 @@ def load_families(con: Any) -> dict[str, Family]:
try: try:
rows = read_families(con) rows = read_families(con)
except Exception: # table missing on an old replica except Exception as exc: # table/schema missing on an old replica
return {} msg = str(exc)
if "code_family" in msg and ("Catalog" in msg or "does not exist" in msg):
_logger.info("pfs.code_family not present; no derived families: %s", exc)
return {}
raise
out: dict[str, Family] = {} out: dict[str, Family] = {}
for r in rows: for r in rows:
fam = out.get(r.key) fam = out.get(r.key)

View File

@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import time
import duckdb import duckdb
import pytest
from pfs.codetables import ( from pfs.codetables import (
ElementRow, ElementRow,
@@ -25,6 +28,21 @@ from pfs.families import (
) )
@pytest.fixture
def restore_families():
"""Snapshot ``pfs.families.FAMILIES`` and restore it in teardown, even
if the test body raises — a test that calls ``refresh_from`` must not
leave the live registry clobbered for the rest of the session."""
from pfs import families as mod
before = dict(mod.FAMILIES)
try:
yield mod
finally:
mod.FAMILIES.clear()
mod.FAMILIES.update(before)
class TestFindCodes: class TestFindCodes:
def test_hcpcs_and_cpt(self): def test_hcpcs_and_cpt(self):
assert find_codes("Codes G0556 and 99490 apply; see g0557.") == ( assert find_codes("Codes G0556 and 99490 apply; see g0557.") == (
@@ -197,11 +215,70 @@ class TestDerive:
keys = {r.key for r in rows} keys = {r.key for r in rows}
assert len(keys) == 1 and next(iter(keys)).startswith("REM-MNTR") assert len(keys) == 1 and next(iter(keys)).startswith("REM-MNTR")
def test_no_activity_elements_never_merges_on_stem_alone(self):
# Both codes have identical stem tokens (jaccard == 1.0, well past
# the 0.5 threshold) but neither has an activity element — an empty
# activity set must not compare equal-and-qualifying, or every
# activity-less code with a common description would merge with
# every other one. Word order differs so a stem-based merge would
# be visible as a shared key even though the codes never touch.
elements = {"11111": [], "22222": []}
descriptions = {"11111": "Foo bar widget", "22222": "Bar widget foo"}
rows = derive_families(elements, {}, descriptions)
keys = {r.code: r.key for r in rows}
assert keys["11111"] != keys["22222"]
def test_disjoint_stem_never_merges(self):
# 10000/10001 share enough stem tokens (and a matching activity) to
# merge; 20000's stem shares no token with either, so it must never
# even be compared, let alone merged.
elements = {
"10000": [_el("10000", "activity", "act-a")],
"10001": [_el("10001", "activity", "act-a")],
"20000": [_el("20000", "activity", "act-a")],
}
descriptions = {
"10000": "Alpha beta gamma",
"10001": "Alpha beta delta",
"20000": "Zeta eta theta",
}
rows = derive_families(elements, {}, descriptions)
keys = {r.code: r.key for r in rows}
assert keys["10000"] == keys["10001"]
assert keys["20000"] != keys["10000"]
def test_many_distinct_stems_is_fast(self):
# ~2,000 codes whose descriptions share no stem token with any
# other code's. The token-bucketed merge must not degrade to the
# O(n^2) all-pairs scan this guards against; kept generous (< 2s)
# so CI timing noise doesn't make it flaky.
def _word(i: int) -> str:
letters = []
n = i + 1
while n:
n, r = divmod(n - 1, 26)
letters.append(chr(97 + r))
return "".join(reversed(letters))
n = 2000
elements = {}
descriptions = {}
for i in range(n):
code = f"{10000 + i}"
elements[code] = [_el(code, "activity", "act")]
descriptions[code] = f"stem{_word(i)} term{_word(i + n)}"
start = time.perf_counter()
rows = derive_families(elements, {}, descriptions)
elapsed = time.perf_counter() - start
assert len(rows) == n
assert elapsed < 2.0
class TestLoadAndRefresh: class TestLoadAndRefresh:
def test_load_and_refresh_in_place(self): def test_load_and_refresh_in_place(self, restore_families):
from pfs import families as mod mod = restore_families
con = duckdb.connect(":memory:") con = duckdb.connect(":memory:")
try: try:
ensure_tables(con) ensure_tables(con)
@@ -239,8 +316,29 @@ class TestLoadAndRefresh:
and set(mod.FAMILIES) == {"CCM"} and set(mod.FAMILIES) == {"CCM"}
and "G2058" in mod.FAMILIES["CCM"].codes and "G2058" in mod.FAMILIES["CCM"].codes
) )
# restore for other tests finally:
mod.FAMILIES.clear() con.close()
mod.FAMILIES.update(before)
def test_schema_present_table_absent_returns_empty(self):
# pfs schema created (e.g. by an earlier ensure_tables call for a
# sibling table) but pfs.code_family itself never materialized —
# an old replica shape, not a real error.
con = duckdb.connect(":memory:")
try:
con.execute("CREATE SCHEMA IF NOT EXISTS pfs;")
assert load_families(con) == {}
finally:
con.close()
def test_wrong_shape_table_raises(self):
# A pfs.code_family table exists but not in the expected shape —
# this is a real bug, not an absent table, and must not be
# swallowed.
con = duckdb.connect(":memory:")
try:
con.execute("CREATE SCHEMA IF NOT EXISTS pfs;")
con.execute("CREATE TABLE pfs.code_family (only_col VARCHAR);")
with pytest.raises(Exception):
load_families(con)
finally: finally:
con.close() con.close()