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:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user