fix(llm): lineage collapse to first occurrence per kind, CPT labels, label cache and prompt-only URL resolution (refs #691)

This commit is contained in:
kert
2026-09-10 10:42:09 -04:00
parent 6924b9b6c3
commit c0ba1a9b69
2 changed files with 379 additions and 83 deletions

View File

@@ -12,15 +12,22 @@ per turn — that path re-derives events from the RVU files and a live
scan of ``fr_anchors``, too slow to run for every code on every turn). scan of ``fr_anchors``, too slow to run for every code on every turn).
Events for the same ``(code, year, kind, from_codes, to_codes)`` are Events for the same ``(code, year, kind, from_codes, to_codes)`` are
collapsed to one representative row, preferring an anchored FR collapsed to one representative row, preferring an anchored FR
paragraph. ``pfs.code_element`` supplies element differences across the paragraph; "point in time" kinds (created, adopted_cpt, appeared,
detected codes' newest year, and ``pfs.code_guidance`` supplies CFR/IOM/ deleted, disappeared, cpt_deleted) collapse further to their single
MLN references for the detected families. Like ``llm.evidence``, this earliest occurrence per code — a code isn't "created" once per rule
degrades to "no evidence" on any failure — the chat never fails because that happens to mention it again later. ``pfs.code_element`` supplies
of it. element differences across the detected codes' newest year, and
``pfs.code_guidance`` supplies CFR/IOM/MLN references for the detected
families. FR jump links are resolved only for the collapsed rows that
will actually reach the prompt block (the SSE payload still carries
every collapsed row — the rest just show a plain label with no link).
Like ``llm.evidence``, this degrades to "no evidence" on any failure —
the chat never fails because of it.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses
import logging import logging
import threading import threading
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
@@ -53,6 +60,18 @@ _PRIORITY_KINDS = frozenset(
{"created", "adopted_cpt", "replaces", "replaced_by", "deleted", "disappeared"} {"created", "adopted_cpt", "replaces", "replaced_by", "deleted", "disappeared"}
) )
#: Ruling B5: "point in time" kinds — a code is created/adopted/deleted
#: once, not once per rule that happens to mention it again in a later
#: year — collapse further to a single row per (code, kind) at the
#: earliest surviving year. Directional/repeatable kinds (replaces,
#: replaced_by, crosswalk, revalued, status_change, descriptor_change,
#: cpt_changed, telehealth_list, bundled) keep one row per year — a
#: code can legitimately be replaced by different things in different
#: years, or crosswalked/revalued repeatedly.
_EARLIEST_ONLY_KINDS = frozenset(
{"created", "adopted_cpt", "appeared", "deleted", "disappeared", "cpt_deleted"}
)
#: Sort order for collapsed events sharing a year and code: FR event #: Sort order for collapsed events sharing a year and code: FR event
#: kinds (their own internal order), then RVU-file kinds, then the two #: kinds (their own internal order), then RVU-file kinds, then the two
#: CPT-codebook kinds. Anything unrecognized sorts last. #: CPT-codebook kinds. Anything unrecognized sorts last.
@@ -148,12 +167,32 @@ def _guidance_line(g: GuidanceRef) -> str:
return f"[{g.label}] {g.locator} — {g.kind.upper()}" return f"[{g.label}] {g.locator} — {g.kind.upper()}"
def _select_for_prompt(
events: Sequence[LineageEvent], max_rows: int
) -> list[LineageEvent]:
"""Every priority-kind event, plus the earliest-sorted non-priority
events, up to *max_rows* total — priority rows are never dropped
even past the cap. Selection is by identity (``id()``), not value
equality, so it stays correct before FR urls are resolved (several
collapsed rows can otherwise compare equal on every other field).
Shared by ``LineageEvidence.prompt_block`` (final render) and
``lineage_evidence`` (deciding which rows are worth an FR url
lookup — ruling: resolve links only for what the model will see)."""
priority = [e for e in events if e.kind in _PRIORITY_KINDS]
rest = [e for e in events if e.kind not in _PRIORITY_KINDS]
budget = max(max_rows - len(priority), 0)
keep = {id(e) for e in priority} | {id(e) for e in rest[:budget]}
return [e for e in events if id(e) in keep]
@dataclass(frozen=True) @dataclass(frozen=True)
class LineageEvidence: class LineageEvidence:
codes: tuple[str, ...] codes: tuple[str, ...]
families: tuple[str, ...] families: tuple[str, ...]
#: Every collapsed event row, sorted (year, code, kind order) — the #: Every collapsed event row, sorted (year, code, kind order) — the
#: SSE payload carries all of these; ``prompt_block`` trims them. #: SSE payload carries all of these; ``prompt_block`` trims them.
#: Only the rows ``prompt_block`` actually renders carry a resolved
#: FR ``url`` — the rest are ``""`` (the UI shows a plain label).
events: tuple[LineageEvent, ...] events: tuple[LineageEvent, ...]
element_diffs: tuple[ElementDiff, ...] element_diffs: tuple[ElementDiff, ...]
guidance: tuple[GuidanceRef, ...] guidance: tuple[GuidanceRef, ...]
@@ -165,11 +204,7 @@ class LineageEvidence:
max_prompt_rows: int = 25 max_prompt_rows: int = 25
def _prompt_events(self) -> list[LineageEvent]: def _prompt_events(self) -> list[LineageEvent]:
priority = [e for e in self.events if e.kind in _PRIORITY_KINDS] return _select_for_prompt(self.events, self.max_prompt_rows)
rest = [e for e in self.events if e.kind not in _PRIORITY_KINDS]
budget = max(self.max_prompt_rows - len(priority), 0)
keep = set(priority) | set(rest[:budget])
return [e for e in self.events if e in keep]
def prompt_block(self) -> str: def prompt_block(self) -> str:
lines = [_HEADER] lines = [_HEADER]
@@ -221,31 +256,74 @@ def _store() -> Any:
return _STORE return _STORE
def _source_label(store: Any, item_key: str, p_id: int) -> str: # ── Process-global caches, all reset together whenever the replica is
"""``rule_label`` for the FR rule at *item_key* — falls back to the # republished (a new mtime): resolved bib items (Store.get), FR jump
bare item key when the item can't be fetched (unresolvable/missing # links, and on-demand lineage() results. Three call sites, one
item must never break the chat over a label).""" # "forget everything on a new mtime, per key" policy. ───────────────────
_MISSING = object()
class _ReplicaCache:
def __init__(self) -> None:
self._data: dict[Any, Any] = {}
self._mtime: int | None = None
self._lock = threading.Lock()
def get(self, key: Any, mtime: int) -> Any:
with self._lock:
if self._mtime != mtime:
self._data.clear()
self._mtime = mtime
return self._data.get(key, _MISSING)
def put(self, key: Any, mtime: int, value: Any) -> None:
with self._lock:
if self._mtime == mtime: # a reset raced us — don't resurrect a stale value
self._data[key] = value
def reset(self) -> None:
with self._lock:
self._data.clear()
self._mtime = None
_ITEM_CACHE = _ReplicaCache() # item_key -> resolved bib Item (or None)
_URL_CACHE = _ReplicaCache() # (item_key, p_id) -> FR jump-link url
_ON_DEMAND_CACHE = _ReplicaCache() # code -> on-demand EventRow list
def _cached_item(store: Any, item_key: str, mtime: int) -> Any:
cached = _ITEM_CACHE.get(item_key, mtime)
if cached is not _MISSING:
return cached
try: try:
item = store.get(item_key) item = store.get(item_key)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.warning("lineage label unresolved (%s): %s", item_key, e) log.warning("lineage item unresolved (%s): %s", item_key, e)
item = None
_ITEM_CACHE.put(item_key, mtime, item)
return item
def _source_label(store: Any, item_key: str, p_id: int, mtime: int) -> str:
"""``rule_label`` for the FR rule at *item_key* — falls back to the
bare item key when the item can't be fetched (unresolvable/missing
item must never break the chat over a label). The resolved
``Store.get`` item is cached per ``item_key`` — the same handful of
rules back dozens of events/diffs/guidance rows in one turn."""
item = _cached_item(store, item_key, mtime)
if item is None:
return item_key or "?" return item_key or "?"
return rule_label(item.title, item.date_published, p_id) return rule_label(item.title, item.date_published, p_id)
# ── FR jump links, cached per (item_key, p_id) ────────────────────────── def _fr_url(store: Any, item_key: str, p_id: int, mtime: int) -> str:
_URL_CACHE: dict[tuple[str, int], str] = {}
_URL_LOCK = threading.Lock()
def _fr_url(store: Any, item_key: str, p_id: int) -> str:
if not item_key or not p_id: if not item_key or not p_id:
return "" return ""
key = (item_key, p_id) key = (item_key, p_id)
with _URL_LOCK: cached = _URL_CACHE.get(key, mtime)
cached = _URL_CACHE.get(key) if cached is not _MISSING:
if cached is not None:
return cached return cached
url = "" url = ""
try: try:
@@ -254,23 +332,22 @@ def _fr_url(store: Any, item_key: str, p_id: int) -> str:
url = frlink.resolve(f"p-{p_id}", store=store, item_key=item_key).url url = frlink.resolve(f"p-{p_id}", store=store, item_key=item_key).url
except Exception as e: # noqa: BLE001 — a bad anchor must not break the chat except Exception as e: # noqa: BLE001 — a bad anchor must not break the chat
log.warning("lineage FR url unresolved (%s p-%s): %s", item_key, p_id, e) log.warning("lineage FR url unresolved (%s p-%s): %s", item_key, p_id, e)
with _URL_LOCK: _URL_CACHE.put(key, mtime, url)
_URL_CACHE[key] = url
return url return url
def _to_lineage_event(store: Any, r: EventRow) -> LineageEvent: def _to_lineage_event(store: Any, r: EventRow, mtime: int) -> LineageEvent:
"""*r* as a ``LineageEvent`` with its label resolved but ``url=""``
— FR urls are resolved afterwards, only for the rows selected for
the prompt block (``_resolve_prompt_urls``)."""
from_codes = tuple(r.from_codes.split()) if r.from_codes else () from_codes = tuple(r.from_codes.split()) if r.from_codes else ()
to_codes = tuple(r.to_codes.split()) if r.to_codes else () to_codes = tuple(r.to_codes.split()) if r.to_codes else ()
if r.source == "fr": if r.source == "fr":
label = _source_label(store, r.item_key, r.p_id) label = _source_label(store, r.item_key, r.p_id, mtime)
url = _fr_url(store, r.item_key, r.p_id)
elif r.source == "cpt": elif r.source == "cpt":
label = r.note or f"CPT Changes {r.year}" label = f"CPT Changes {r.year}"
url = ""
else: # "rvu" else: # "rvu"
label = f"PFS CY{r.year} RVU file" label = f"PFS CY{r.year} RVU file"
url = ""
return LineageEvent( return LineageEvent(
code=r.code, code=r.code,
year=r.year, year=r.year,
@@ -281,47 +358,73 @@ def _to_lineage_event(store: Any, r: EventRow) -> LineageEvent:
item_key=r.item_key, item_key=r.item_key,
p_id=r.p_id, p_id=r.p_id,
page=r.page, page=r.page,
url=url, url="",
source=r.source, source=r.source,
anchored=r.anchored, anchored=r.anchored,
note=r.note, note=r.note,
) )
def _resolve_prompt_urls(
store: Any, events: list[LineageEvent], mtime: int, max_rows: int
) -> tuple[LineageEvent, ...]:
"""FR jump links, resolved only for the rows ``_select_for_prompt``
would actually render — every other collapsed row keeps ``url=""``
in the payload (the UI shows a plain label for those)."""
selected_ids = {id(e) for e in _select_for_prompt(events, max_rows)}
out: list[LineageEvent] = []
for e in events:
if id(e) in selected_ids and e.source == "fr":
url = _fr_url(store, e.item_key, e.p_id, mtime)
if url:
e = dataclasses.replace(e, url=url)
out.append(e)
return tuple(out)
def _pref_key(r: EventRow) -> tuple[int, int]:
"""Collapse preference within a group: an anchored FR row first,
then the lowest ``p_id`` — shared by both ``_collapse`` passes."""
return (0 if (r.source == "fr" and r.anchored) else 1, r.p_id)
def _collapse(rows: Sequence[EventRow]) -> list[EventRow]: def _collapse(rows: Sequence[EventRow]) -> list[EventRow]:
"""One representative row per ``(code, year, kind, from_codes, """One representative row per ``(code, year, kind, from_codes,
to_codes)`` — prefer an anchored FR row, then the lowest ``p_id`` — to_codes)`` — prefer an anchored FR row, then the lowest ``p_id``.
sorted by ``(year, code, kind order)``."""
Ruling B5: "point in time" kinds (see ``_EARLIEST_ONLY_KINDS``)
collapse further to a single row per ``(code, kind)`` at the
earliest surviving year, applying the same preference among that
year's rows when more than one survives. Directional/repeatable
kinds keep one row per year.
Sorted by ``(year, code, kind order)``.
"""
groups: dict[tuple[str, int, str, str, str], list[EventRow]] = {} groups: dict[tuple[str, int, str, str, str], list[EventRow]] = {}
for r in rows: for r in rows:
key = (r.code, r.year, r.kind, r.from_codes, r.to_codes) key = (r.code, r.year, r.kind, r.from_codes, r.to_codes)
groups.setdefault(key, []).append(r) groups.setdefault(key, []).append(r)
reps = [ reps = [min(grp, key=_pref_key) for grp in groups.values()]
min(
grp, singleton: dict[tuple[str, str], list[EventRow]] = {}
key=lambda r: (0 if (r.source == "fr" and r.anchored) else 1, r.p_id), other: list[EventRow] = []
) for r in reps:
for grp in groups.values() if r.kind in _EARLIEST_ONLY_KINDS:
singleton.setdefault((r.code, r.kind), []).append(r)
else:
other.append(r)
earliest = [
min((r for r in grp if r.year == min(x.year for x in grp)), key=_pref_key)
for grp in singleton.values()
] ]
return sorted(reps, key=lambda r: (r.year, r.code, _kind_rank(r.kind)))
final = other + earliest
# ── On-demand fallback for codes with no precomputed rows, cached by return sorted(final, key=lambda r: (r.year, r.code, _kind_rank(r.kind)))
# (replica mtime, code) — see the module docstring. ─────────────────────
_ON_DEMAND_CACHE: dict[str, list[EventRow]] = {}
_ON_DEMAND_MTIME: int | None = None
_ON_DEMAND_LOCK = threading.Lock()
def _on_demand_events(cur: Any, store: Any, mtime: int, code: str) -> list[EventRow]: def _on_demand_events(cur: Any, store: Any, mtime: int, code: str) -> list[EventRow]:
global _ON_DEMAND_MTIME cached = _ON_DEMAND_CACHE.get(code, mtime)
with _ON_DEMAND_LOCK: if cached is not _MISSING:
if _ON_DEMAND_MTIME != mtime:
_ON_DEMAND_CACHE.clear()
_ON_DEMAND_MTIME = mtime
cached = _ON_DEMAND_CACHE.get(code)
if cached is not None:
return cached return cached
try: try:
import pfs.lineage as pfs_lineage import pfs.lineage as pfs_lineage
@@ -330,9 +433,7 @@ def _on_demand_events(cur: Any, store: Any, mtime: int, code: str) -> list[Event
except Exception as e: # noqa: BLE001 — on-demand fallback must not break the chat except Exception as e: # noqa: BLE001 — on-demand fallback must not break the chat
log.warning("on-demand lineage skipped for %s: %s", code, e) log.warning("on-demand lineage skipped for %s: %s", code, e)
rows = [] rows = []
with _ON_DEMAND_LOCK: _ON_DEMAND_CACHE.put(code, mtime, rows)
if _ON_DEMAND_MTIME == mtime:
_ON_DEMAND_CACHE[code] = rows
return rows return rows
@@ -355,21 +456,25 @@ def _collect_events(
return out return out
def _element_label(store: Any, row: ElementRow) -> str: def _element_label(store: Any, row: ElementRow, mtime: int) -> str:
if row.item_key and row.p_id: if row.item_key and row.p_id:
return _source_label(store, row.item_key, row.p_id) return _source_label(store, row.item_key, row.p_id, mtime)
if row.source == "cpt": if row.source == "cpt":
return f"CPT Changes {row.year}" return f"CPT Changes {row.year}"
return f"PFS CY{row.year} RVU file" return f"PFS CY{row.year} RVU file"
def _element_diffs( def _element_diffs(
cur: Any, codes: Sequence[str], store: Any cur: Any, codes: Sequence[str], store: Any, mtime: int
) -> tuple[tuple[ElementDiff, ...], str]: ) -> tuple[tuple[ElementDiff, ...], str]:
"""Element values present for some detected codes and absent for """Element values present for some detected codes and absent for
others, from each code's newest ``pfs.code_element`` year. Needs others, from each code's newest ``pfs.code_element`` year. Needs
>= 2 detected codes with any element rows at all — a single code's >= 2 detected codes with any element rows at all — a single code's
elements have nothing to diff against (ruling 18).""" elements have nothing to diff against (ruling 18). ``not_in_codes``
is relative to the codes that have *any* element rows extracted,
not to every code in *codes* — a code with no elements at all is
absent from both ``in_codes`` and ``not_in_codes``, not silently
counted as "not having" a value it was simply never extracted for."""
by_code: dict[str, list[ElementRow]] = {} by_code: dict[str, list[ElementRow]] = {}
for code in codes: for code in codes:
try: try:
@@ -414,7 +519,7 @@ def _element_diffs(
value=value_, value=value_,
in_codes=in_codes, in_codes=in_codes,
not_in_codes=not_in_codes, not_in_codes=not_in_codes,
label=_element_label(store, best), label=_element_label(store, best, mtime),
item_key=best.item_key, item_key=best.item_key,
p_id=best.p_id, p_id=best.p_id,
) )
@@ -424,7 +529,7 @@ def _element_diffs(
def _collect_guidance( def _collect_guidance(
cur: Any, store: Any, families: Sequence[str], wide: Sequence[str] cur: Any, store: Any, families: Sequence[str], wide: Sequence[str], mtime: int
) -> tuple[GuidanceRef, ...]: ) -> tuple[GuidanceRef, ...]:
wide_set = set(wide) wide_set = set(wide)
rows: list[GuidanceRow] = [] rows: list[GuidanceRow] = []
@@ -457,7 +562,7 @@ def _collect_guidance(
kind=r.kind, kind=r.kind,
locator=r.locator, locator=r.locator,
url=url, url=url,
label=_source_label(store, r.item_key_src, r.p_id_src), label=_source_label(store, r.item_key_src, r.p_id_src, mtime),
item_key_src=r.item_key_src, item_key_src=r.item_key_src,
p_id_src=r.p_id_src, p_id_src=r.p_id_src,
) )
@@ -491,9 +596,12 @@ def lineage_evidence(question: str, cfg: LlmConfig) -> LineageEvidence | None:
raw_events = _collect_events( raw_events = _collect_events(
cur, store, mtime, det.codes, cfg.lineage_on_demand_max cur, store, mtime, det.codes, cfg.lineage_on_demand_max
) )
events = tuple(_to_lineage_event(store, r) for r in _collapse(raw_events)) events_list = [
element_diffs, elements_note = _element_diffs(cur, det.codes, store) _to_lineage_event(store, r, mtime) for r in _collapse(raw_events)
guidance = _collect_guidance(cur, store, det.families, det.wide) ]
events = _resolve_prompt_urls(store, events_list, mtime, cfg.lineage_max_rows)
element_diffs, elements_note = _element_diffs(cur, det.codes, store, mtime)
guidance = _collect_guidance(cur, store, det.families, det.wide, mtime)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.warning("lineage evidence skipped (query): %s", e) log.warning("lineage evidence skipped (query): %s", e)
return None return None

View File

@@ -60,9 +60,9 @@ def _clear_caches():
def _reset(): def _reset():
evidence._REPLICA = None evidence._REPLICA = None
lineage._STORE = None lineage._STORE = None
lineage._URL_CACHE.clear() lineage._ITEM_CACHE.reset()
lineage._ON_DEMAND_CACHE.clear() lineage._URL_CACHE.reset()
lineage._ON_DEMAND_MTIME = None lineage._ON_DEMAND_CACHE.reset()
_reset() _reset()
yield yield
@@ -282,16 +282,82 @@ class TestCollapse:
] ]
assert len(lineage._collapse(rows)) == 2 assert len(lineage._collapse(rows)) == 2
def test_created_across_three_years_collapses_to_the_earliest(self):
"""Ruling B5: G2058 is mentioned as "created" by three different
rules (2021, 2022, 2023 — each rule re-establishing the G-code
after it was crosswalked away) but it was only actually created
once."""
rows = [
_ev("G2058", 2021, "created", item_key="A", p_id=100),
_ev("G2058", 2022, "created", item_key="B", p_id=200),
_ev("G2058", 2023, "created", item_key="C", p_id=300),
]
collapsed = lineage._collapse(rows)
assert [(e.year, e.code, e.kind) for e in collapsed] == [
(2021, "G2058", "created")
]
assert collapsed[0].item_key == "A"
def test_deleted_twice_keeps_only_the_first(self):
rows = [
_ev("99213", 2019, "deleted", item_key="A", p_id=1),
_ev("99213", 2024, "deleted", item_key="B", p_id=2),
]
collapsed = lineage._collapse(rows)
assert [(e.year, e.kind) for e in collapsed] == [(2019, "deleted")]
def test_earliest_only_picks_the_preferred_row_within_that_year(self):
"""Two rows tie on the earliest year — the anchored-FR/lowest-p_id
preference still applies among them."""
rows = [
_ev(
"G2058",
2021,
"created",
item_key="LATE",
p_id=999,
source="fr",
anchored=True,
),
_ev(
"G2058",
2021,
"created",
item_key="EARLY",
p_id=100,
source="fr",
anchored=True,
),
_ev("G2058", 2022, "created", item_key="OTHER", p_id=1),
]
collapsed = lineage._collapse(rows)
assert len(collapsed) == 1
assert collapsed[0].year == 2021 and collapsed[0].item_key == "EARLY"
def test_replaced_by_rows_in_two_years_both_survive(self):
"""replaced_by is directional/repeatable, not a point-in-time
kind — a code can legitimately be replaced by different things
in different years, so both rows must survive collapse."""
rows = [
_ev("99439", 2021, "replaced_by", to="G2058", p_id=1),
_ev("99439", 2022, "replaced_by", to="G2214", p_id=2),
]
collapsed = lineage._collapse(rows)
assert [(e.year, e.to_codes) for e in collapsed] == [
(2021, "G2058"),
(2022, "G2214"),
]
class TestElementDiffs: class TestElementDiffs:
def test_no_diffs_when_fewer_than_two_codes_have_elements(self, con): def test_no_diffs_when_fewer_than_two_codes_have_elements(self, con):
c, _ = con c, _ = con
assert lineage._element_diffs(c, ["99490"], None) == ((), "") assert lineage._element_diffs(c, ["99490"], None, 1) == ((), "")
def test_notes_when_exactly_one_code_has_elements(self, con): def test_notes_when_exactly_one_code_has_elements(self, con):
c, _ = con c, _ = con
write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], []) write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], [])
diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], None) diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], None, 1)
assert diffs == () assert diffs == ()
assert note == "elements extracted for 1 of 3 codes" assert note == "elements extracted for 1 of 3 codes"
@@ -305,7 +371,7 @@ class TestElementDiffs:
) )
write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], []) write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], [])
write_elements(c, "G0556", [_el("G0556", 2026, "telehealth", "yes")], []) write_elements(c, "G0556", [_el("G0556", 2026, "telehealth", "yes")], [])
diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], store) diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], store, 1)
assert note == "" assert note == ""
by_pair = {(d.type, d.value): d for d in diffs} by_pair = {(d.type, d.value): d for d in diffs}
assert set(by_pair) == {("consent", "required"), ("telehealth", "yes")} assert set(by_pair) == {("consent", "required"), ("telehealth", "yes")}
@@ -321,7 +387,7 @@ class TestElementDiffs:
c, _ = con c, _ = con
write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], []) write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], [])
write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], []) write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], [])
diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None) diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None, 1)
assert diffs == () assert diffs == ()
def test_only_the_newest_year_per_code_is_compared(self, con): def test_only_the_newest_year_per_code_is_compared(self, con):
@@ -336,7 +402,7 @@ class TestElementDiffs:
[], [],
) )
write_elements(c, "99491", [_el("99491", 2026, "consent", "verbal")], []) write_elements(c, "99491", [_el("99491", 2026, "consent", "verbal")], [])
diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None) diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None, 1)
# 2020's "verbal" for 99490 must not be compared — only 2026's # 2020's "verbal" for 99490 must not be compared — only 2026's
# "required" — so the diff is required (99490) vs verbal (99491). # "required" — so the diff is required (99490) vs verbal (99491).
values = {d.value: d for d in diffs} values = {d.value: d for d in diffs}
@@ -354,7 +420,7 @@ class TestElementDiffs:
# a fourth code with none of the "t*" values, so every t* pair # a fourth code with none of the "t*" values, so every t* pair
# differs (present in the three, absent in the fourth) # differs (present in the three, absent in the fourth)
write_elements(c, "A0004", [_el("A0004", 2026, "shared", "no")], []) write_elements(c, "A0004", [_el("A0004", 2026, "shared", "no")], [])
diffs, _ = lineage._element_diffs(c, codes + ["A0004"], None) diffs, _ = lineage._element_diffs(c, codes + ["A0004"], None, 1)
assert len(diffs) == 12 assert len(diffs) == 12
assert diffs[0].in_codes == ("A0001", "A0002", "A0003") assert diffs[0].in_codes == ("A0001", "A0002", "A0003")
@@ -371,7 +437,7 @@ class TestGuidance:
for i in range(6) for i in range(6)
] ]
write_guidance(c, "CCM", rows) write_guidance(c, "CCM", rows)
out = lineage._collect_guidance(c, store, ["CCM"], []) out = lineage._collect_guidance(c, store, ["CCM"], [], 1)
assert len(out) == 8 assert len(out) == 8
assert [g.kind for g in out[:6]] == ["cfr"] * 6 assert [g.kind for g in out[:6]] == ["cfr"] * 6
assert out[0].url == "https://www.ecfr.gov/current/title-42/section-410.0" assert out[0].url == "https://www.ecfr.gov/current/title-42/section-410.0"
@@ -386,7 +452,7 @@ class TestGuidance:
_gd("CCM", "99491", "cfr", "42 CFR 410.78(a)(3)", "YBM4IZUS", 686), _gd("CCM", "99491", "cfr", "42 CFR 410.78(a)(3)", "YBM4IZUS", 686),
], ],
) )
out = lineage._collect_guidance(c, store, ["CCM"], []) out = lineage._collect_guidance(c, store, ["CCM"], [], 1)
assert len(out) == 1 assert len(out) == 1
def test_wide_families_are_skipped(self, con, store): def test_wide_families_are_skipped(self, con, store):
@@ -394,7 +460,7 @@ class TestGuidance:
write_guidance( write_guidance(
c, "APCM", [_gd("APCM", "G0556", "mln", "MLN 907166", "YBM4IZUS", 686)] c, "APCM", [_gd("APCM", "G0556", "mln", "MLN 907166", "YBM4IZUS", 686)]
) )
out = lineage._collect_guidance(c, store, ["APCM"], ["APCM"]) out = lineage._collect_guidance(c, store, ["APCM"], ["APCM"], 1)
assert out == () assert out == ()
def test_iom_mln_have_no_url(self, con, store): def test_iom_mln_have_no_url(self, con, store):
@@ -404,7 +470,7 @@ class TestGuidance:
"CCM", "CCM",
[_gd("CCM", "99490", "iom", "100-04 ch.12 §30.6.4", "YBM4IZUS", 686)], [_gd("CCM", "99490", "iom", "100-04 ch.12 §30.6.4", "YBM4IZUS", 686)],
) )
out = lineage._collect_guidance(c, store, ["CCM"], []) out = lineage._collect_guidance(c, store, ["CCM"], [], 1)
assert out[0].url == "" assert out[0].url == ""
assert out[0].label == "CY2021 PFS final ¶686" assert out[0].label == "CY2021 PFS final ¶686"
@@ -754,6 +820,128 @@ class TestLineageEvidence:
assert ev.guidance[0].locator == "42 CFR 410.78(a)(3)" assert ev.guidance[0].locator == "42 CFR 410.78(a)(3)"
class TestCptLabel:
def test_cpt_label_is_always_cpt_changes_year_note_holds_the_detail(self):
"""CPT-sourced events (cpt_changed, cpt_deleted) always label as
"CPT Changes {year}" — the note (e.g. "absent from CPT 2024")
is a separate field, not folded into the label."""
row = _ev(
"99213",
2024,
"cpt_deleted",
item_key="CPTED2024",
p_id=0,
source="cpt",
note="absent from CPT 2024",
)
event = lineage._to_lineage_event(None, row, 1)
assert event.label == "CPT Changes 2024"
assert event.note == "absent from CPT 2024"
line = lineage._event_line(event)
assert (
line == "[CPT Changes 2024] 2024 cpt_deleted 99213 — absent from CPT 2024"
)
assert (
line.count("absent from CPT 2024") == 1
) # not duplicated into the label too
def test_cpt_changed_label_ignores_any_note(self):
row = _ev(
"99213",
2022,
"cpt_changed",
item_key="CPTED2022",
p_id=0,
source="cpt",
note="some other detail",
)
event = lineage._to_lineage_event(None, row, 1)
assert event.label == "CPT Changes 2022"
class TestSourceLabelAndUrlDegrade:
def test_source_label_degrades_to_item_key_when_store_get_fails(self):
store = SimpleNamespace(get=lambda k: (_ for _ in ()).throw(KeyError(k)))
assert lineage._source_label(store, "UNKNOWN1", 5, 1) == "UNKNOWN1"
def test_source_label_degrades_to_question_mark_when_item_key_empty(self):
store = SimpleNamespace(get=lambda k: (_ for _ in ()).throw(KeyError(k)))
assert lineage._source_label(store, "", 5, 1) == "?"
def test_fr_url_degrades_to_empty_string_on_resolve_failure(self, store):
with patch("bib.frlink.resolve", side_effect=RuntimeError("boom")):
assert lineage._fr_url(store, "YBM4IZUS", 686, 1) == ""
def test_fr_url_empty_when_item_key_or_p_id_missing(self):
assert lineage._fr_url(None, "", 5, 1) == ""
assert lineage._fr_url(None, "K", 0, 1) == ""
class TestCachingAndScopedUrlResolution:
"""Ruling: Store.get results are cached per item_key (not just per
(item_key, p_id) — the same rule backs many events/diffs/guidance
rows), and FR urls are only resolved for the rows that actually
reach the prompt block."""
def test_urls_resolved_only_for_prompt_rows_labels_cached_per_item(
self, con, store
):
c, path = con
n_items = 10
rows = [
_ev(
"99999",
2000 + i,
"crosswalk", # non-priority, not an _EARLIEST_ONLY kind
item_key=f"K{i % n_items:02d}",
p_id=1000 + i,
source="fr",
anchored=True,
)
for i in range(60)
]
write_events(c, "99999", rows)
store.con.executemany(
"INSERT INTO items VALUES (?,?,?)",
[
(
f"K{i:02d}",
f"Medicare Program; CY {2000 + i} PFS Final Rule",
f"{1999 + i}-11-01",
)
for i in range(n_items)
],
)
c.close() # release the write handle before lineage_evidence opens read-only
cfg = replace(CFG, duckdb_replica=str(path), lineage_max_rows=25)
det = Detection(codes=("99999",), families=(), explicit=(), wide=())
real_get = store.get
seen_keys: list[str] = []
def counting_get(key):
seen_keys.append(key)
return real_get(key)
with patch("llm.lineage._store", return_value=store):
with patch("llm.lineage.detect_codes", return_value=det):
with patch.object(store, "get", side_effect=counting_get) as get_spy:
with patch("bib.frlink.resolve") as resolve_mock:
resolve_mock.return_value = SimpleNamespace(url="http://x")
ev = lineage_evidence("irrelevant", cfg)
assert ev is not None
assert len(ev.events) == 60 # payload carries every collapsed row
# exactly the 25 prompt-selected rows (budget: no priority kinds
# here, so the first 25 by year) get an FR url resolved.
assert resolve_mock.call_count == 25
with_url = [e for e in ev.events if e.url]
assert len(with_url) == 25
# every referenced item_key is fetched at most once, total calls
# bounded by the number of distinct item_keys (10), not events (60).
assert get_spy.call_count <= n_items
assert len(seen_keys) == len(set(seen_keys)) # never re-fetched
class TestLineageEvidenceLive: class TestLineageEvidenceLive:
@pytest.mark.live @pytest.mark.live
def test_median_under_300ms_on_live_replica(self): def test_median_under_300ms_on_live_replica(self):