fix(pfs,llm): trie alternation groups optional continuations; wide families do not expand codes; warm the replica before detection (refs #699)

This commit is contained in:
kert
2026-09-10 09:51:48 -04:00
parent e80b179f8c
commit d62480c3e1
6 changed files with 245 additions and 6 deletions

View File

@@ -11,13 +11,28 @@ from __future__ import annotations
import json
import re
from contextlib import asynccontextmanager
from importlib import resources
from typing import Iterator
from typing import AsyncIterator, Iterator
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Warm the DuckDB replica (and refresh the derived families from
it) at boot instead of on the chat's first code-valuation lookup —
refs #699 ruling B3. Never raises: ``llm.evidence.warm`` is a quiet
no-op when the replica file isn't there yet."""
from llm import config as llm_config
from llm.evidence import warm
warm(llm_config.load())
yield
app = FastAPI(
title="llm chat",
description=(
@@ -25,6 +40,7 @@ app = FastAPI(
"reference corpus."
),
version="0.2.0",
lifespan=_lifespan,
)
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

View File

@@ -149,7 +149,26 @@ def _replica_path(cfg: LlmConfig) -> str:
return str(ROOT / p)
def warm(cfg: LlmConfig) -> None:
"""Open (and cache) the DuckDB replica connection ahead of the first
real request (refs #699 ruling B3) — called once at API startup
(``llm.api``'s startup hook) and again, defensively, at the top of
every ``valuation_evidence`` call, where it is a no-op once cached
(``_connect`` keys on path + mtime, so a second call in the same
process just returns the cached handle without reopening). A missing
replica file is a quiet no-op — never raises, so it can never fail
startup or a chat turn."""
path = _replica_path(cfg)
if not Path(path).exists():
return
try:
_connect(path)
except Exception as e: # noqa: BLE001 — duckdb raises several types
log.warning("replica warm-up skipped (%s): %s", path, e)
def valuation_evidence(question: str, cfg: LlmConfig) -> ValuationEvidence | None:
warm(cfg)
det = detect_codes(question)
if not det.codes:
return None

View File

@@ -168,7 +168,18 @@ def _trie_alternation(phrases: Sequence[str]) -> str:
return "" # end() only — caller makes it optional
alts = [re.escape(ch) + _compile(node[ch]) for ch in chars]
body = alts[0] if len(alts) == 1 else "(?:" + "|".join(alts) + ")"
return f"{body}?" if end else body
if not end:
return body
# Bug (fix round 2): a single-branch continuation's `body` can be
# a multi-character string ("s" from `alts[0]`, not grouped) —
# `f"{body}?"` then made only the *last character* optional, not
# the whole continuation, so any phrase that is a proper prefix
# of another (e.g. "cardiac catheterization" inside "cardiac
# catheterization for congenital heart defects") silently
# stopped matching. Always parenthesize before the `?`, even
# when `body` is already a single grouped alternation (harmless
# double-wrap) — this is the only case that's provably correct.
return f"(?:{body})?"
return _compile(root)
@@ -254,13 +265,27 @@ class Detection:
codes: tuple[str, ...] # sorted unique: explicit + family-expanded
families: tuple[str, ...] # family keys, sorted
explicit: tuple[str, ...] # codes literally present in the text
#: Ruling B2: family keys detected (by name or member code) whose
#: ``len(codes) > MAX_FAMILY_EXPAND`` — still listed in ``families``,
#: but their member codes are *not* folded into ``codes`` (a wide
#: family's whole code list would crowd a valuation prompt).
wide: tuple[str, ...] = ()
#: Ruling B2: a family this big is a real grouping worth naming in the
#: answer, but not worth pricing every member code for — a 378-code
#: heading (e.g. a broad CPT chapter-level rollup) would otherwise blow
#: the valuation prompt budget for one mention.
MAX_FAMILY_EXPAND = 20
def detect_codes(text: str) -> Detection:
"""Codes a question is about: explicit codes plus every code of any
family named (by synonym, or by a qualifying derived name) or touched
(by one member code). Runs off the precompiled ``_PHRASE_RE``/
``_CODE_INDEX`` — no per-family scan."""
*narrow* (``len(codes) <= MAX_FAMILY_EXPAND``) family named (by
synonym, or by a qualifying derived name) or touched (by one member
code); a wide family is still named in ``families``/``wide`` but its
codes are not expanded into ``codes``. Runs off the precompiled
``_PHRASE_RE``/``_CODE_INDEX`` — no per-family scan."""
explicit = find_codes(text)
lowered = text.lower()
families: set[str] = set()
@@ -269,14 +294,20 @@ def detect_codes(text: str) -> Detection:
for code in explicit:
families.update(_CODE_INDEX.get(code, ()))
codes = set(explicit)
wide: set[str] = set()
for key in families:
fam = FAMILIES.get(key)
if fam is not None:
if fam is None:
continue
if len(fam.codes) <= MAX_FAMILY_EXPAND:
codes.update(fam.codes)
else:
wide.add(key)
return Detection(
codes=tuple(sorted(codes)),
families=tuple(sorted(families)),
explicit=explicit,
wide=tuple(sorted(wide)),
)

View File

@@ -113,6 +113,22 @@ def _cfg(**kw):
return LlmConfig(**base)
class TestStartup:
"""#699 ruling B3: the replica is warmed at boot, not on the chat's
first valuation lookup. ``with TestClient(app) as c:`` is required to
actually trigger FastAPI's startup event — a bare ``TestClient(app)``
(as ``client`` above, module-level) never sends the ASGI lifespan
``startup`` message."""
@patch("llm.config.load")
def test_startup_event_warms_the_replica(self, mock_load):
mock_load.return_value = _cfg()
with patch("llm.evidence.warm") as mock_warm:
with TestClient(app):
pass
mock_warm.assert_called_once_with(mock_load.return_value)
class TestHosts:
@patch("llm.pool.pick_model", return_value="big")
@patch("llm.pool.HostPool.check", return_value=["http://h2:11434"])

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import duckdb
@@ -16,6 +17,7 @@ from llm.evidence import (
code_cited_sources,
merge_sources,
valuation_evidence,
warm,
)
from pfs.valuation import ValuationRow
@@ -228,6 +230,52 @@ class TestValuationEvidence:
assert mock_val.call_args.kwargs == {"years": 2}
class TestWarm:
"""#699 ruling B3: warm the replica connection ahead of the first
real request — at API startup and again (a no-op once cached) at the
top of every ``valuation_evidence`` call."""
def test_warm_opens_once_and_is_a_noop_on_replay(self, tmp_path):
path = tmp_path / "aco.ro.duckdb"
path.touch() # warm() only needs the file to exist, not be a real db
cfg = replace(CFG, duckdb_replica=str(path))
with patch("llm.evidence.duckdb.connect") as mock_connect:
mock_connect.return_value = MagicMock()
warm(cfg)
warm(cfg)
mock_connect.assert_called_once_with(str(path), read_only=True)
def test_warm_is_a_noop_when_the_replica_file_is_missing(self):
with patch("llm.evidence.duckdb.connect") as mock_connect:
warm(CFG) # CFG.duckdb_replica ("/nonexistent/...") doesn't exist
mock_connect.assert_not_called()
def test_warm_swallows_connect_failures(self, tmp_path, caplog):
path = tmp_path / "aco.ro.duckdb"
path.touch()
cfg = replace(CFG, duckdb_replica=str(path))
with patch("llm.evidence.duckdb.connect", side_effect=OSError("boom")):
warm(cfg) # must not raise
assert "replica warm-up skipped" in caplog.text
@patch("llm.evidence.detect_codes")
@patch("llm.evidence.warm")
def test_valuation_evidence_warms_before_detecting_codes(
self, mock_warm, mock_detect
):
order: list[str] = []
mock_warm.side_effect = lambda cfg: order.append("warm")
def _detect(question):
order.append("detect")
return SimpleNamespace(codes=())
mock_detect.side_effect = _detect
assert valuation_evidence("anything", CFG) is None
assert order == ["warm", "detect"]
mock_warm.assert_called_once_with(CFG)
class TestConnectRefreshesFamilies:
"""#699: derived families (``pfs.code_family``) live only on the
replica — ``_connect`` is the chat's one hook to pick them up, on

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
import time
import duckdb
@@ -21,9 +22,11 @@ from pfs.codetables import (
from pfs.families import (
FAMILIES,
HAND_FAMILIES,
MAX_FAMILY_EXPAND,
Detection,
Family,
_cpt_edges,
_trie_alternation,
cpt_groups,
derive_families,
detect_codes,
@@ -125,6 +128,77 @@ class TestDetectCodes:
)
class TestTrieAlternation:
"""Fix round 2, item 1: ``_trie_alternation``'s optional-continuation
bug. When a node has exactly one continuation, ``body`` was an
unwrapped (possibly multi-character) string, and a trailing ``?``
applied to it bound to only its *last character* — so any phrase
that is a proper prefix of a longer registered phrase (e.g.
"cardiac catheterization" inside "cardiac catheterization for
congenital heart defects") silently stopped matching at all. The
task-1 report's "verified identical" claim was based on running one
sample question through the live registry, which happened not to
exercise a prefix-of-another-phrase pair — it did not prove
equivalence in general, and was wrong."""
#: A two-way prefix collision ("cardiac catheterization" is a strict
#: prefix of the "... for congenital heart defects" phrase), a
#: three-way share (both "... services" and "... program" continue
#: the same "chronic care management" prefix, so that node has an
#: end marker *and* two children), plus the five phrases the report
#: named as broken live.
PHRASES = (
"cardiac catheterization",
"cardiac catheterization for congenital heart defects",
"chronic care management",
"chronic care management services",
"chronic care management program",
"tricuspid valve",
"tricuspid valve repair",
"adaptive behavior assessments",
"repair and/or reconstruction",
"subcutaneous cardiac rhythm monitor",
)
SENTENCES = (
"Discuss cardiac catheterization for congenital heart defects in infants.",
"A cardiac catheterization was performed without complication.",
"Chronic care management services are billed monthly.",
"Our chronic care management program includes personalized outreach.",
"We provide chronic care management for our patients, full stop.",
"Tricuspid valve repair is a common cardiac procedure.",
"The tricuspid valve was evaluated by echo.",
"Adaptive behavior assessments are conducted for autism evaluations.",
"This code covers repair and/or reconstruction of the tendon.",
"A subcutaneous cardiac rhythm monitor was implanted last week.",
"None of these words appear in this sentence at all.",
) + PHRASES # each phrase alone too
@staticmethod
def _flat_re(phrases) -> re.Pattern[str]:
ordered = sorted(phrases, key=len, reverse=True)
alt = "|".join(re.escape(p) for p in ordered)
return re.compile(rf"\b(?:{alt})\b", re.IGNORECASE)
@staticmethod
def _trie_re(phrases) -> re.Pattern[str]:
alt = _trie_alternation(sorted(phrases))
return re.compile(rf"\b(?:{alt})\b", re.IGNORECASE)
def test_trie_matches_flat_alternation(self):
flat_re = self._flat_re(self.PHRASES)
trie_re = self._trie_re(self.PHRASES)
for s in self.SENTENCES:
lowered = s.lower()
flat = [m.group(0) for m in flat_re.finditer(lowered)]
trie = [m.group(0) for m in trie_re.finditer(lowered)]
assert trie == flat, (s, flat, trie)
# And directly: every prefix-collision phrase, embedded in its
# longer sibling, must still match on its own.
assert self._trie_re(self.PHRASES).search("cardiac catheterization today")
assert self._trie_re(self.PHRASES).search("chronic care management today")
class TestDetectDerivedFamilies:
"""#699: the chat must see derived (``pfs.code_family``) families,
not only the five hand families — by member code always, by name
@@ -188,6 +262,41 @@ class TestDetectDerivedFamilies:
elapsed = time.perf_counter() - start
assert elapsed < 0.2, elapsed
def test_wide_family_does_not_expand_codes(self, restore_families):
# Ruling B2: a family with more than MAX_FAMILY_EXPAND (20) codes
# is still named (in `families`/`wide`) but its member codes are
# not folded into `codes` — only explicit codes survive.
wide_codes = tuple(f"{20000 + i}" for i in range(378))
assert len(wide_codes) > MAX_FAMILY_EXPAND
restore_families.FAMILIES["WIDE-FAMILY"] = Family(
"WIDE-FAMILY",
"Comprehensive Ambulatory Service Bundle",
wide_codes,
(),
cpt=True,
)
rebuild_index()
d = detect_codes("How is the Comprehensive Ambulatory Service Bundle valued?")
assert d.explicit == ()
assert d.codes == d.explicit # nothing expanded
assert "WIDE-FAMILY" in d.families
assert "WIDE-FAMILY" in d.wide
def test_narrow_family_still_expands_under_the_cap(self, restore_families):
codes = tuple(f"{30000 + i}" for i in range(MAX_FAMILY_EXPAND))
restore_families.FAMILIES["NARROW-FAMILY"] = Family(
"NARROW-FAMILY",
"Narrow Bundled Service Package",
codes,
(),
cpt=True,
)
rebuild_index()
d = detect_codes("How is the Narrow Bundled Service Package valued?")
assert d.codes == codes
assert "NARROW-FAMILY" in d.families
assert d.wide == ()
def test_hand_families_regression(self):
# Unchanged from TestDetectCodes — the phrase/index refactor must
# not alter a single hand-family detection.