fix(pfs): I2/I3/I7 — refresh_from merges over hand families, derive_families keys are unique and code-shaped, fix FAMILIES docstring

I2 (Ruling 17): `refresh_from` used to `FAMILIES.clear()` before loading
the derived table, so any hand family the derivation table doesn't
reach (e.g. only CCM got re-derived) vanished from the live registry.
Seed from HAND_FAMILIES first, then let derived rows extend/override
per key — a hand key present in the table gets the derived (superset)
codes, HAND_FAMILIES' own name/synonyms carry through via
`load_families`; a hand key absent from the table survives untouched.

I3: the live table has 627 stem-derived keys shared by >= 2 unrelated
components (`GENE` x13, `ADM` x12) — `load_families` silently merged
them under one key. `derive_families` now appends the group's
representative code to a colliding non-hand key (`GENE-81105`) and
rejects members that aren't code-shaped (`CODE_RE.fullmatch`) before
deriving anything, so a corpus artifact like a bare `\x1a` can't become
a family.

I7: `FAMILIES`'s docstring claimed the chat/notebooks see the derived
registry too; only the `stack pfs` CLI calls `refresh_from` today (the
spec's import-time-load ask is blocked by the no-DuckDB-at-import
constraint — controller to file a follow-up). Also adds a public
`FR_CITE_RE` alias for `pfs.lineage` to reuse (M1, next commit).

Claude-Session: https://claude.ai/code/session_01Aum3pEMAM3yQVdFSdVe6Gc
This commit is contained in:
kert
2026-09-09 14:10:36 -04:00
parent c8ba1bbefa
commit 9e374e8c29
2 changed files with 129 additions and 16 deletions

View File

@@ -22,6 +22,9 @@ CODE_RE = re.compile(r"\b(?:[A-Z]\d{4}|\d{5})\b", re.IGNORECASE)
# "91 FR 43842" — an FR page number is five digits and so reads as a CPT # "91 FR 43842" — an FR page number is five digits and so reads as a CPT
# code; citations are removed before scanning so they never become one. # code; citations are removed before scanning so they never become one.
_FR_CITE_RE = re.compile(r"\b\d{1,3}\s+FR\s+\d{3,6}\b", re.IGNORECASE) _FR_CITE_RE = re.compile(r"\b\d{1,3}\s+FR\s+\d{3,6}\b", re.IGNORECASE)
#: Public alias — ``pfs.lineage.fr_events`` reuses this so a citation's
#: page number never triggers an event pattern (e.g. "crosswalk") either.
FR_CITE_RE = _FR_CITE_RE
def find_codes(text: str) -> tuple[str, ...]: def find_codes(text: str) -> tuple[str, ...]:
@@ -76,9 +79,16 @@ HAND_FAMILIES: dict[str, Family] = {
), ),
} }
#: Live registry. Starts as the hand list; ``refresh_from(con)`` swaps in #: Live registry. Starts as the hand list; ``refresh_from(con)`` merges in
#: the derived families (``pfs.code_family``) in place so every importer #: the derived families (``pfs.code_family``) in place — hand keys seeded
#: (chat, notebooks) sees the same dict object. #: first, derived rows extend/override per key — so every importer sees
#: the same dict object. Only the ``stack pfs`` CLI (``elements``,
#: ``lineage``, ``families``) calls ``refresh_from`` today; the spec's
#: §Components asked for an import-time load, but that needs a live
#: DuckDB connection, which is forbidden at import (the ``llm`` chat
#: container must import this module with no database reachable). Until
#: something calls ``refresh_from`` in that path too — a controller
#: follow-up issue — the chat sees hand families only.
FAMILIES: dict[str, Family] = dict(HAND_FAMILIES) FAMILIES: dict[str, Family] = dict(HAND_FAMILIES)
@@ -195,10 +205,13 @@ def _name_group(
stems: Mapping[str, frozenset[str]], stems: Mapping[str, frozenset[str]],
descriptions: Mapping[str, str], descriptions: Mapping[str, str],
tenure: Any, tenure: Any,
) -> tuple[str, str]: ) -> tuple[str, str, str]:
"""The stem-derived ``(key, name)`` for a group with no hand family — """The stem-derived ``(key, name, representative code)`` for a group
shared by ungrouped components and by the leftover codes a hand-family with no hand family — shared by ungrouped components and by the
split (see ``derive_families``) doesn't reach.""" leftover codes a hand-family split (see ``derive_families``) doesn't
reach. The representative code is returned so a caller can disambiguate
a key collision (#687 live corpus: 627 stem keys shared by >= 2
unrelated components, e.g. ``GENE`` x13)."""
rep = sorted(members, key=lambda c: (-tenure(c), c))[0] rep = sorted(members, key=lambda c: (-tenure(c), c))[0]
key = ( key = (
"-".join( "-".join(
@@ -209,7 +222,16 @@ def _name_group(
or rep or rep
) )
name = descriptions.get(rep) or rep name = descriptions.get(rep) or rep
return key, name return key, name, rep
def _unique_key(key: str, rep: str, used_keys: set[str]) -> str:
"""*key*, or ``key-rep`` if *key* is already taken by another group —
every non-hand key ``derive_families`` hands out must be unique."""
if key in used_keys:
key = f"{key}-{rep}"
used_keys.add(key)
return key
def _group_rows( def _group_rows(
@@ -287,7 +309,14 @@ def derive_families(
BFS reaches keep their own stem-keyed family, same as an ungrouped BFS reaches keep their own stem-keyed family, same as an ungrouped
component. component.
""" """
codes = sorted(set(elements) | set(events) | set(descriptions)) # Reject members that aren't code-shaped before deriving anything —
# a corpus parsing artifact (e.g. a bare '\x1a') must never become a
# family of its own.
codes = sorted(
c
for c in (set(elements) | set(events) | set(descriptions))
if CODE_RE.fullmatch(c)
)
parent = {c: c for c in codes} parent = {c: c for c in codes}
adj: dict[str, set[str]] = {c: set() for c in codes} adj: dict[str, set[str]] = {c: set() for c in codes}
@@ -355,6 +384,7 @@ def derive_families(
return until - since return until - since
rows: list[Any] = [] rows: list[Any] = []
used_keys: set[str] = set()
for members in groups.values(): for members in groups.values():
member_set = set(members) member_set = set(members)
hands = [k for k in HAND_FAMILIES if set(HAND_FAMILIES[k].codes) & member_set] hands = [k for k in HAND_FAMILIES if set(HAND_FAMILIES[k].codes) & member_set]
@@ -388,14 +418,16 @@ def derive_families(
) )
unreached = [c for c in members if c not in assigned] unreached = [c for c in members if c not in assigned]
for sub in _connected(unreached, adj): for sub in _connected(unreached, adj):
key, name = _name_group(sub, stems, descriptions, tenure) key, name, rep = _name_group(sub, stems, descriptions, tenure)
key = _unique_key(key, rep, used_keys)
rows.extend(_group_rows(key, name, sub, elements, events)) rows.extend(_group_rows(key, name, sub, elements, events))
continue continue
hand = hands[0] if hands else None hand = hands[0] if hands else None
if hand: if hand:
key, name = hand, HAND_FAMILIES[hand].name key, name = hand, HAND_FAMILIES[hand].name
else: else:
key, name = _name_group(members, stems, descriptions, tenure) key, name, rep = _name_group(members, stems, descriptions, tenure)
key = _unique_key(key, rep, used_keys)
rows.extend(_group_rows(key, name, members, elements, events)) rows.extend(_group_rows(key, name, members, elements, events))
return sorted(rows, key=lambda r: (r.key, r.code)) return sorted(rows, key=lambda r: (r.key, r.code))
@@ -425,9 +457,18 @@ def load_families(con: Any) -> dict[str, Family]:
def refresh_from(con: Any) -> int: def refresh_from(con: Any) -> int:
"""Merge ``pfs.code_family`` into the live registry: hand families
seed it first, so a hand key the derivation table doesn't reach still
survives; a hand key the table *does* reach is replaced by the
derived (superset) codes — ``load_families`` already carries the
hand's own name/synonyms forward for that key (Ruling 17: a bare
``FAMILIES.clear()`` used to wipe every hand family the moment any
derived row loaded)."""
derived = load_families(con) derived = load_families(con)
if not derived: if not derived:
return 0 return 0
merged = dict(HAND_FAMILIES)
merged.update(derived)
FAMILIES.clear() FAMILIES.clear()
FAMILIES.update(derived) FAMILIES.update(merged)
return len(FAMILIES) return len(FAMILIES)

View File

@@ -313,6 +313,47 @@ class TestDerive:
assert keys["10000"] == keys["10001"] assert keys["10000"] == keys["10001"]
assert keys["20000"] != keys["10000"] assert keys["20000"] != keys["10000"]
def test_non_code_member_is_dropped(self):
# I3: a corpus parsing artifact like '\x1a' must never become a
# family of its own — reject non-code-shaped members before
# deriving anything.
elements = {
"99453": [_el("99453", "activity", "device-data-review")],
"\x1a": [_el("\x1a", "activity", "device-data-review")],
}
descriptions = {
"99453": "Rem mntr physiol param setup",
"\x1a": "corpus artifact",
}
rows = derive_families(elements, {}, descriptions)
assert {r.code for r in rows} == {"99453"}
def test_duplicate_derived_keys_get_representative_suffix(self):
# I3: 627 stem keys in the live table are shared by >= 2 unrelated
# components (e.g. "GENE" x13); a collision must not silently
# merge them under `load_families`' one-row-per-key model.
elements = {
"10001": [_el("10001", "activity", "act-a")],
"10002": [_el("10002", "activity", "act-a")],
"20001": [_el("20001", "activity", "act-b")],
"20002": [_el("20002", "activity", "act-b")],
}
descriptions = {
"10001": "Rare widget",
"10002": "Rare widget",
"20001": "Rare widget",
"20002": "Rare widget",
}
rows = derive_families(elements, {}, descriptions)
keys = {r.code: r.key for r in rows}
assert keys["10001"] == keys["10002"]
assert keys["20001"] == keys["20002"]
assert keys["10001"] != keys["20001"]
both = {keys["10001"], keys["20001"]}
assert "RARE-WIDGET" in both
other = (both - {"RARE-WIDGET"}).pop()
assert other.startswith("RARE-WIDGET-")
def test_none_description_does_not_raise_and_stands_alone(self): def test_none_description_does_not_raise_and_stands_alone(self):
# #687 live-run regression: a code whose `descriptions` mapping # #687 live-run regression: a code whose `descriptions` mapping
# holds an explicit None (a real NULL pfs.rvu description, not a # holds an explicit None (a real NULL pfs.rvu description, not a
@@ -390,11 +431,42 @@ class TestLoadAndRefresh:
], ],
) )
n = refresh_from(con) n = refresh_from(con)
assert ( # Ruling 17: a hand key the table reaches (CCM) is replaced by
n == 1 # the derived (superset) codes; every other hand key survives
and set(mod.FAMILIES) == {"CCM"} # untouched since `refresh_from` seeds from HAND_FAMILIES
and "G2058" in mod.FAMILIES["CCM"].codes # first instead of clearing the registry outright.
assert n == len(HAND_FAMILIES)
assert set(mod.FAMILIES) >= set(HAND_FAMILIES)
assert set(mod.FAMILIES["CCM"].codes) == {"99490", "G2058"}
assert mod.FAMILIES["CCM"].name == HAND_FAMILIES["CCM"].name
finally:
con.close()
def test_hand_key_absent_from_table_survives(self, restore_families):
# Ruling 17: only CCM appears in the derived table; the other four
# hand families (ACP, PCM, TCM, APCM) must come through unchanged.
mod = restore_families
con = duckdb.connect(":memory:")
try:
ensure_tables(con)
write_families(
con,
[
FamilyRow(
"CCM",
"Chronic Care Management",
"99490",
"base",
None,
None,
"",
0,
)
],
) )
refresh_from(con)
for key in ("ACP", "PCM", "TCM", "APCM"):
assert mod.FAMILIES[key] == HAND_FAMILIES[key]
finally: finally:
con.close() con.close()