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