fix(pfs): family edges — add-on/by-reference/single-target replacement/stem only; split components spanning hand families (refs #687)

Claude-Session: https://claude.ai/code/session_01Aum3pEMAM3yQVdFSdVe6Gc
This commit is contained in:
kert
2026-09-09 13:35:22 -04:00
parent e4fc8b453a
commit fbd3bc4bcf
2 changed files with 206 additions and 48 deletions

View File

@@ -169,21 +169,102 @@ def stem_tokens(description: str | None) -> frozenset[str]:
) )
_LINK_RELATIONS = { # Deliberately narrow (#687 over-merge incident): a family-membership edge
"addon-of", # is only ever a direct, unambiguous statement that two codes belong
"not-with", # together. Dropped from the relation set: ``not-with`` (an explicit
"defined-by-reference-to", # *exclusion*, the opposite of membership), ``replaces``/``replaced-by``/
"replaces", # ``crosswalk-valued-to`` relation *elements* (these overlap the lineage
"replaced-by", # *event* signal below, which already carries year/anchor precision).
"crosswalk-valued-to", # ``crosswalk`` events are dropped entirely — a rule's "codes ... are
} # crosswalked to X for valuation" sentence routinely lists a whole
_LINK_EVENTS = {"replaces", "replaced_by", "crosswalk"} # family's worth of codes together and is evidence, not a merge signal.
_LINK_RELATIONS = {"addon-of", "defined-by-reference-to"}
#: ``replaces``/``replaced_by`` events still count, but only when the
#: relevant field names exactly one code — a multi-code list (a blanket
#: "these codes are replaced by ..." sentence spanning several families,
#: see #687) is ambiguous and is ignored as a merge edge.
_LINK_EVENTS = {"replaces", "replaced_by"}
def _jaccard(a: frozenset[str], b: frozenset[str]) -> float: def _jaccard(a: frozenset[str], b: frozenset[str]) -> float:
return len(a & b) / len(a | b) if a or b else 0.0 return len(a & b) / len(a | b) if a or b else 0.0
def _name_group(
members: Sequence[str],
stems: Mapping[str, frozenset[str]],
descriptions: Mapping[str, str],
tenure: Any,
) -> tuple[str, str]:
"""The stem-derived ``(key, name)`` for a group with no hand family —
shared by ungrouped components and by the leftover codes a hand-family
split (see ``derive_families``) doesn't reach."""
rep = sorted(members, key=lambda c: (-tenure(c), c))[0]
key = (
"-".join(
t
for t in _RE_TOKEN.findall((descriptions.get(rep) or "").lower())
if t in stems[rep]
).upper()
or rep
)
name = descriptions.get(rep) or rep
return key, name
def _group_rows(
key: str,
name: str,
members: Sequence[str],
elements: Mapping[str, Sequence[Any]],
events: Mapping[str, Sequence[Any]],
) -> list[Any]:
"""``FamilyRow`` per code in *members*, labeled ``key``/``name``."""
from pfs.codetables import FamilyRow
out = []
for c in sorted(members):
els = elements.get(c, ())
evs = events.get(c, ())
kinds = {ev.kind for ev in evs}
if "replaced_by" in kinds:
role = "predecessor"
elif any(e.type == "relation" and e.value == "addon-of" for e in els):
role = "add-on"
elif "replaces" in kinds:
role = "successor"
else:
role = "base"
since = next((ev.year for ev in evs if ev.kind == "appeared"), None)
until = next((ev.year for ev in evs if ev.kind == "disappeared"), None)
anchor = next(((ev.item_key, ev.p_id) for ev in evs if ev.item_key), ("", 0))
out.append(FamilyRow(key, name, c, role, since, until, anchor[0], anchor[1]))
return out
def _connected(nodes: Sequence[str], adj: Mapping[str, set[str]]) -> list[list[str]]:
"""Connected components of *nodes* using only edges between members of
*nodes* itself (an edge to a node outside the set doesn't count)."""
node_set = set(nodes)
seen: set[str] = set()
out: list[list[str]] = []
for start in sorted(nodes):
if start in seen:
continue
stack = [start]
seen.add(start)
comp = []
while stack:
c = stack.pop()
comp.append(c)
for n in adj.get(c, ()):
if n in node_set and n not in seen:
seen.add(n)
stack.append(n)
out.append(comp)
return out
def derive_families( def derive_families(
elements: Mapping[str, Sequence[Any]], elements: Mapping[str, Sequence[Any]],
events: Mapping[str, Sequence[Any]], events: Mapping[str, Sequence[Any]],
@@ -196,11 +277,19 @@ def derive_families(
predecessor (has a ``replaced_by`` event) > add-on (has an predecessor (has a ``replaced_by`` event) > add-on (has an
``addon-of`` relation element) > successor (has a ``replaces`` ``addon-of`` relation element) > successor (has a ``replaces``
event) > base. event) > base.
"""
from pfs.codetables import FamilyRow
A component that reaches into two or more hand families (#687: the
real corpus's blanket multi-code lineage sentences used to merge ACP,
CCM and PCM into one blob even with the narrowed edges above still
occasionally bridging two families) is split: each hand family's own
codes seed a BFS over the component's edges, run in ``HAND_FAMILIES``
order, and every code the BFS reaches joins that hand family. Codes no
BFS reaches keep their own stem-keyed family, same as an ungrouped
component.
"""
codes = sorted(set(elements) | set(events) | set(descriptions)) codes = sorted(set(elements) | set(events) | set(descriptions))
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}
def find(c: str) -> str: def find(c: str) -> str:
while parent[c] != c: while parent[c] != c:
@@ -210,6 +299,8 @@ def derive_families(
def union(a: str, b: str) -> None: def union(a: str, b: str) -> None:
if a in parent and b in parent: if a in parent and b in parent:
adj[a].add(b)
adj[b].add(a)
parent[find(a)] = find(b) parent[find(a)] = find(b)
for code, els in elements.items(): for code, els in elements.items():
@@ -218,9 +309,12 @@ def derive_families(
union(code, e.detail.upper()) union(code, e.detail.upper())
for code, evs in events.items(): for code, evs in events.items():
for ev in evs: for ev in evs:
if ev.kind in _LINK_EVENTS: if ev.kind not in _LINK_EVENTS:
for other in (ev.from_codes + " " + ev.to_codes).split(): continue
union(code, other.upper()) field = ev.from_codes if ev.kind == "replaces" else ev.to_codes
targets = field.split()
if len(targets) == 1:
union(code, targets[0].upper())
stems = {c: stem_tokens(descriptions.get(c) or "") for c in codes} stems = {c: stem_tokens(descriptions.get(c) or "") for c in codes}
acts = { acts = {
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")
@@ -260,44 +354,49 @@ def derive_families(
) )
return until - since return until - since
rows: list[FamilyRow] = [] rows: list[Any] = []
for members in groups.values(): for members in groups.values():
hand = next( member_set = set(members)
(k for k, f in HAND_FAMILIES.items() if set(f.codes) & set(members)), None hands = [k for k in HAND_FAMILIES if set(HAND_FAMILIES[k].codes) & member_set]
if len(hands) >= 2:
_logger.warning(
"family component of %d codes spans hand families %s — "
"splitting by hand-family BFS",
len(members),
hands,
) )
assigned: dict[str, str] = {}
for hk in hands:
for seed in HAND_FAMILIES[hk].codes:
if seed in member_set:
assigned[seed] = hk
for hk in hands:
frontier = [c for c in HAND_FAMILIES[hk].codes if c in member_set]
while frontier:
nxt = []
for c in frontier:
for n in adj.get(c, ()):
if n in member_set and n not in assigned:
assigned[n] = hk
nxt.append(n)
frontier = nxt
for hk in hands:
grp = [c for c, v in assigned.items() if v == hk]
if grp:
rows.extend(
_group_rows(hk, HAND_FAMILIES[hk].name, grp, elements, events)
)
unreached = [c for c in members if c not in assigned]
for sub in _connected(unreached, adj):
key, name = _name_group(sub, stems, descriptions, tenure)
rows.extend(_group_rows(key, name, sub, elements, events))
continue
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:
rep = sorted(members, key=lambda c: (-tenure(c), c))[0] key, name = _name_group(members, stems, descriptions, tenure)
key = ( rows.extend(_group_rows(key, name, members, elements, events))
"-".join(
t
for t in _RE_TOKEN.findall((descriptions.get(rep) or "").lower())
if t in stems[rep]
).upper()
or rep
)
name = descriptions.get(rep) or rep
for c in sorted(members):
els = elements.get(c, ())
evs = events.get(c, ())
kinds = {ev.kind for ev in evs}
if "replaced_by" in kinds:
role = "predecessor"
elif any(e.type == "relation" and e.value == "addon-of" for e in els):
role = "add-on"
elif "replaces" in kinds:
role = "successor"
else:
role = "base"
since = next((ev.year for ev in evs if ev.kind == "appeared"), None)
until = next((ev.year for ev in evs if ev.kind == "disappeared"), None)
anchor = next(
((ev.item_key, ev.p_id) for ev in evs if ev.item_key), ("", 0)
)
rows.append(
FamilyRow(key, name, c, role, since, until, anchor[0], anchor[1])
)
return sorted(rows, key=lambda r: (r.key, r.code)) return sorted(rows, key=lambda r: (r.key, r.code))

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
import duckdb import duckdb
@@ -144,6 +145,11 @@ class TestStemTokens:
class TestDerive: class TestDerive:
def test_ccm_reproduced_with_predecessor_and_addon_roles(self): def test_ccm_reproduced_with_predecessor_and_addon_roles(self):
# #687 ruling 16: `not-with` is an exclusion, not a membership
# signal, and was dropped from _LINK_RELATIONS. The `not-with`
# element below is now inert as a merge edge — 99487 joins CCM
# through the stem-Jaccard + shared "comprehensive-care-plan"
# activity rule instead (its stem overlaps 99490's >= 0.5).
elements = { elements = {
"99490": [ "99490": [
_el("99490", "actor", "clinical-staff-directed"), _el("99490", "actor", "clinical-staff-directed"),
@@ -206,6 +212,59 @@ class TestDerive:
r.name == HAND_FAMILIES[r.key].name for r in rows if r.key in HAND_FAMILIES r.name == HAND_FAMILIES[r.key].name for r in rows if r.key in HAND_FAMILIES
) )
def test_multi_code_replaced_by_does_not_merge(self):
# #687 ruling 16: a `replaced_by` event naming more than one
# to_codes is ambiguous (a blanket "these codes are replaced by
# ..." sentence spanning several families in the real corpus) and
# must not become a merge edge.
elements = {"11111": [], "22222": []}
events = {"11111": [_ev("11111", 2021, "replaced_by", to="22222 33333")]}
descriptions = {"11111": "Alpha widget", "22222": "Zeta gadget"}
rows = derive_families(elements, events, descriptions)
keys = {r.code: r.key for r in rows}
assert keys["11111"] != keys["22222"]
def test_not_with_relation_does_not_merge(self):
# #687 ruling 16: `not-with` is an explicit exclusion (cannot be
# billed together), the opposite of family membership.
elements = {
"99490": [_el("99490", "relation", "not-with", "G0556")],
"G0556": [],
}
descriptions = {
"99490": "Chrnc care mgmt staff 1st 20",
"G0556": "Adv prim care mgmt lvl 1",
}
rows = derive_families(elements, {}, descriptions)
keys = {r.code: r.key for r in rows}
assert keys["99490"] != keys["G0556"]
def test_bridging_addon_chain_splits_by_hand_family_and_warns(self, caplog):
# #687 ruling 16 guard: a component that reaches into two or more
# hand families is split by a BFS seeded from each hand family's
# own codes, run in HAND_FAMILIES order (ACP, CCM, PCM, TCM,
# APCM). Here "77777" is addon-of both a CCM seed (99490) and an
# ACP seed (99497), joining all three into one component; ACP
# comes first in HAND_FAMILIES, so its BFS reaches — and claims —
# the bridging code before CCM's BFS runs.
elements = {
"99490": [],
"99497": [],
"77777": [
_el("77777", "relation", "addon-of", "99490"),
_el("77777", "relation", "addon-of", "99497"),
],
}
with caplog.at_level(logging.WARNING, logger="pfs.families"):
rows = derive_families(elements, {}, {})
by_code = {r.code: r for r in rows}
assert by_code["99490"].key == "CCM"
assert by_code["99497"].key == "ACP"
assert by_code["77777"].key == "ACP"
assert any("spans hand families" in rec.message for rec in caplog.records), (
caplog.text
)
def test_unknown_family_gets_stem_key(self): def test_unknown_family_gets_stem_key(self):
elements = { elements = {
"99453": [_el("99453", "activity", "device-data-review")], "99453": [_el("99453", "activity", "device-data-review")],