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:
@@ -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).
|
||||
Events for the same ``(code, year, kind, from_codes, to_codes)`` are
|
||||
collapsed to one representative row, preferring an anchored FR
|
||||
paragraph. ``pfs.code_element`` supplies element differences across the
|
||||
detected codes' newest year, and ``pfs.code_guidance`` supplies CFR/IOM/
|
||||
MLN references for the detected families. Like ``llm.evidence``, this
|
||||
degrades to "no evidence" on any failure — the chat never fails because
|
||||
of it.
|
||||
paragraph; "point in time" kinds (created, adopted_cpt, appeared,
|
||||
deleted, disappeared, cpt_deleted) collapse further to their single
|
||||
earliest occurrence per code — a code isn't "created" once per rule
|
||||
that happens to mention it again later. ``pfs.code_element`` supplies
|
||||
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
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass
|
||||
@@ -53,6 +60,18 @@ _PRIORITY_KINDS = frozenset(
|
||||
{"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
|
||||
#: kinds (their own internal order), then RVU-file kinds, then the two
|
||||
#: 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()}"
|
||||
|
||||
|
||||
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)
|
||||
class LineageEvidence:
|
||||
codes: tuple[str, ...]
|
||||
families: tuple[str, ...]
|
||||
#: Every collapsed event row, sorted (year, code, kind order) — the
|
||||
#: 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, ...]
|
||||
element_diffs: tuple[ElementDiff, ...]
|
||||
guidance: tuple[GuidanceRef, ...]
|
||||
@@ -165,11 +204,7 @@ class LineageEvidence:
|
||||
max_prompt_rows: int = 25
|
||||
|
||||
def _prompt_events(self) -> list[LineageEvent]:
|
||||
priority = [e for e in self.events if e.kind in _PRIORITY_KINDS]
|
||||
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]
|
||||
return _select_for_prompt(self.events, self.max_prompt_rows)
|
||||
|
||||
def prompt_block(self) -> str:
|
||||
lines = [_HEADER]
|
||||
@@ -221,31 +256,74 @@ def _store() -> Any:
|
||||
return _STORE
|
||||
|
||||
|
||||
def _source_label(store: Any, item_key: str, p_id: 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)."""
|
||||
# ── Process-global caches, all reset together whenever the replica is
|
||||
# republished (a new mtime): resolved bib items (Store.get), FR jump
|
||||
# links, and on-demand lineage() results. Three call sites, one
|
||||
# "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:
|
||||
item = store.get(item_key)
|
||||
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 rule_label(item.title, item.date_published, p_id)
|
||||
|
||||
|
||||
# ── FR jump links, cached per (item_key, p_id) ──────────────────────────
|
||||
|
||||
_URL_CACHE: dict[tuple[str, int], str] = {}
|
||||
_URL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _fr_url(store: Any, item_key: str, p_id: int) -> str:
|
||||
def _fr_url(store: Any, item_key: str, p_id: int, mtime: int) -> str:
|
||||
if not item_key or not p_id:
|
||||
return ""
|
||||
key = (item_key, p_id)
|
||||
with _URL_LOCK:
|
||||
cached = _URL_CACHE.get(key)
|
||||
if cached is not None:
|
||||
cached = _URL_CACHE.get(key, mtime)
|
||||
if cached is not _MISSING:
|
||||
return cached
|
||||
url = ""
|
||||
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
|
||||
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)
|
||||
with _URL_LOCK:
|
||||
_URL_CACHE[key] = url
|
||||
_URL_CACHE.put(key, mtime, 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 ()
|
||||
to_codes = tuple(r.to_codes.split()) if r.to_codes else ()
|
||||
if r.source == "fr":
|
||||
label = _source_label(store, r.item_key, r.p_id)
|
||||
url = _fr_url(store, r.item_key, r.p_id)
|
||||
label = _source_label(store, r.item_key, r.p_id, mtime)
|
||||
elif r.source == "cpt":
|
||||
label = r.note or f"CPT Changes {r.year}"
|
||||
url = ""
|
||||
label = f"CPT Changes {r.year}"
|
||||
else: # "rvu"
|
||||
label = f"PFS CY{r.year} RVU file"
|
||||
url = ""
|
||||
return LineageEvent(
|
||||
code=r.code,
|
||||
year=r.year,
|
||||
@@ -281,47 +358,73 @@ def _to_lineage_event(store: Any, r: EventRow) -> LineageEvent:
|
||||
item_key=r.item_key,
|
||||
p_id=r.p_id,
|
||||
page=r.page,
|
||||
url=url,
|
||||
url="",
|
||||
source=r.source,
|
||||
anchored=r.anchored,
|
||||
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]:
|
||||
"""One representative row per ``(code, year, kind, from_codes,
|
||||
to_codes)`` — prefer an anchored FR row, then the lowest ``p_id`` —
|
||||
sorted by ``(year, code, kind order)``."""
|
||||
to_codes)`` — prefer an anchored FR row, then the lowest ``p_id``.
|
||||
|
||||
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]] = {}
|
||||
for r in rows:
|
||||
key = (r.code, r.year, r.kind, r.from_codes, r.to_codes)
|
||||
groups.setdefault(key, []).append(r)
|
||||
reps = [
|
||||
min(
|
||||
grp,
|
||||
key=lambda r: (0 if (r.source == "fr" and r.anchored) else 1, r.p_id),
|
||||
)
|
||||
for grp in groups.values()
|
||||
reps = [min(grp, key=_pref_key) for grp in groups.values()]
|
||||
|
||||
singleton: dict[tuple[str, str], list[EventRow]] = {}
|
||||
other: list[EventRow] = []
|
||||
for r in reps:
|
||||
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)))
|
||||
|
||||
|
||||
# ── On-demand fallback for codes with no precomputed rows, cached by
|
||||
# (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()
|
||||
final = other + earliest
|
||||
return sorted(final, key=lambda r: (r.year, r.code, _kind_rank(r.kind)))
|
||||
|
||||
|
||||
def _on_demand_events(cur: Any, store: Any, mtime: int, code: str) -> list[EventRow]:
|
||||
global _ON_DEMAND_MTIME
|
||||
with _ON_DEMAND_LOCK:
|
||||
if _ON_DEMAND_MTIME != mtime:
|
||||
_ON_DEMAND_CACHE.clear()
|
||||
_ON_DEMAND_MTIME = mtime
|
||||
cached = _ON_DEMAND_CACHE.get(code)
|
||||
if cached is not None:
|
||||
cached = _ON_DEMAND_CACHE.get(code, mtime)
|
||||
if cached is not _MISSING:
|
||||
return cached
|
||||
try:
|
||||
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
|
||||
log.warning("on-demand lineage skipped for %s: %s", code, e)
|
||||
rows = []
|
||||
with _ON_DEMAND_LOCK:
|
||||
if _ON_DEMAND_MTIME == mtime:
|
||||
_ON_DEMAND_CACHE[code] = rows
|
||||
_ON_DEMAND_CACHE.put(code, mtime, rows)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -355,21 +456,25 @@ def _collect_events(
|
||||
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:
|
||||
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":
|
||||
return f"CPT Changes {row.year}"
|
||||
return f"PFS CY{row.year} RVU file"
|
||||
|
||||
|
||||
def _element_diffs(
|
||||
cur: Any, codes: Sequence[str], store: Any
|
||||
cur: Any, codes: Sequence[str], store: Any, mtime: int
|
||||
) -> tuple[tuple[ElementDiff, ...], str]:
|
||||
"""Element values present for some detected codes and absent for
|
||||
others, from each code's newest ``pfs.code_element`` year. Needs
|
||||
>= 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]] = {}
|
||||
for code in codes:
|
||||
try:
|
||||
@@ -414,7 +519,7 @@ def _element_diffs(
|
||||
value=value_,
|
||||
in_codes=in_codes,
|
||||
not_in_codes=not_in_codes,
|
||||
label=_element_label(store, best),
|
||||
label=_element_label(store, best, mtime),
|
||||
item_key=best.item_key,
|
||||
p_id=best.p_id,
|
||||
)
|
||||
@@ -424,7 +529,7 @@ def _element_diffs(
|
||||
|
||||
|
||||
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, ...]:
|
||||
wide_set = set(wide)
|
||||
rows: list[GuidanceRow] = []
|
||||
@@ -457,7 +562,7 @@ def _collect_guidance(
|
||||
kind=r.kind,
|
||||
locator=r.locator,
|
||||
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,
|
||||
p_id_src=r.p_id_src,
|
||||
)
|
||||
@@ -491,9 +596,12 @@ def lineage_evidence(question: str, cfg: LlmConfig) -> LineageEvidence | None:
|
||||
raw_events = _collect_events(
|
||||
cur, store, mtime, det.codes, cfg.lineage_on_demand_max
|
||||
)
|
||||
events = tuple(_to_lineage_event(store, r) for r in _collapse(raw_events))
|
||||
element_diffs, elements_note = _element_diffs(cur, det.codes, store)
|
||||
guidance = _collect_guidance(cur, store, det.families, det.wide)
|
||||
events_list = [
|
||||
_to_lineage_event(store, r, mtime) for r in _collapse(raw_events)
|
||||
]
|
||||
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
|
||||
log.warning("lineage evidence skipped (query): %s", e)
|
||||
return None
|
||||
|
||||
@@ -60,9 +60,9 @@ def _clear_caches():
|
||||
def _reset():
|
||||
evidence._REPLICA = None
|
||||
lineage._STORE = None
|
||||
lineage._URL_CACHE.clear()
|
||||
lineage._ON_DEMAND_CACHE.clear()
|
||||
lineage._ON_DEMAND_MTIME = None
|
||||
lineage._ITEM_CACHE.reset()
|
||||
lineage._URL_CACHE.reset()
|
||||
lineage._ON_DEMAND_CACHE.reset()
|
||||
|
||||
_reset()
|
||||
yield
|
||||
@@ -282,16 +282,82 @@ class TestCollapse:
|
||||
]
|
||||
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:
|
||||
def test_no_diffs_when_fewer_than_two_codes_have_elements(self, 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):
|
||||
c, _ = con
|
||||
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 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, "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 == ""
|
||||
by_pair = {(d.type, d.value): d for d in diffs}
|
||||
assert set(by_pair) == {("consent", "required"), ("telehealth", "yes")}
|
||||
@@ -321,7 +387,7 @@ class TestElementDiffs:
|
||||
c, _ = con
|
||||
write_elements(c, "99490", [_el("99490", 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 == ()
|
||||
|
||||
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")], [])
|
||||
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
|
||||
# "required" — so the diff is required (99490) vs verbal (99491).
|
||||
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
|
||||
# differs (present in the three, absent in the fourth)
|
||||
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 diffs[0].in_codes == ("A0001", "A0002", "A0003")
|
||||
|
||||
@@ -371,7 +437,7 @@ class TestGuidance:
|
||||
for i in range(6)
|
||||
]
|
||||
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 [g.kind for g in out[:6]] == ["cfr"] * 6
|
||||
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),
|
||||
],
|
||||
)
|
||||
out = lineage._collect_guidance(c, store, ["CCM"], [])
|
||||
out = lineage._collect_guidance(c, store, ["CCM"], [], 1)
|
||||
assert len(out) == 1
|
||||
|
||||
def test_wide_families_are_skipped(self, con, store):
|
||||
@@ -394,7 +460,7 @@ class TestGuidance:
|
||||
write_guidance(
|
||||
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 == ()
|
||||
|
||||
def test_iom_mln_have_no_url(self, con, store):
|
||||
@@ -404,7 +470,7 @@ class TestGuidance:
|
||||
"CCM",
|
||||
[_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].label == "CY2021 PFS final ¶686"
|
||||
|
||||
@@ -754,6 +820,128 @@ class TestLineageEvidence:
|
||||
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:
|
||||
@pytest.mark.live
|
||||
def test_median_under_300ms_on_live_replica(self):
|
||||
|
||||
Reference in New Issue
Block a user