fix(cli,pfs): I4/I9/I6/M3 — families/lineage read without --write, bulk element/event reads, --all-payable marked experimental, unknown-family error trimmed
Some checks failed
CI / lint (push) Successful in 30s
CI / notebooks-smoke (push) Successful in 1m25s
Infra CI / notebooks (push) Failing after 1m5s
Infra CI / zotero (push) Successful in 16s
Infra CI / docs (push) Successful in 1m32s
Infra CI / api (push) Successful in 1m5s
Infra CI / llm (push) Successful in 52s
Infra CI / mc (push) Successful in 13s
CI / lint (pull_request) Successful in 31s
CI / test (push) Successful in 17m29s
CI / notebooks-smoke (pull_request) Successful in 1m27s
Infra CI / notebooks (pull_request) Failing after 1m9s
Infra CI / zotero (pull_request) Successful in 14s
Infra CI / docs (pull_request) Successful in 13s
Infra CI / api (pull_request) Successful in 19s
Infra CI / llm (pull_request) Successful in 13s
Infra CI / mc (pull_request) Successful in 13s
CI / test (pull_request) Successful in 28m53s

I4: `stack pfs families` and `stack pfs lineage` opened a RW
`duckdb_batch` connection even without `--write`, so a plain lookup
contended for the DuckDB single-writer lock a notebook may be holding
(#508-#514). Mirror the `elements --dry-run` shape: `_read()` when
`write` is false, `_batch()` + `ensure_tables` only when true. A
replica that hasn't had `--write` run yet (no `pfs.code_element`/
`code_event`/`code_family`) prints "no derived tables yet — run with
--write" and exits 0 instead of raising.

I9: `families --write` ran `read_elements`/`read_events` once per code
(~2xN queries) inside the write lock. Added `read_all_elements`/
`read_all_events` to `pfs.codetables` — one full-table read each,
grouped in Python, same per-code ordering as the existing readers.

I6: `--all-payable` isn't viable as written (the loop isn't inverted in
this slice per the controller's ruling) — help text now says
"experimental: ~20 min of SQL before any model call" and a
`log.warning` logs the target count when it's used.

M3: the unknown-`--family` error listed every key in `FAMILIES`
(~15k once `refresh_from` has run) — list only `HAND_FAMILIES`.

Claude-Session: https://claude.ai/code/session_01Aum3pEMAM3yQVdFSdVe6Gc
This commit is contained in:
kert
2026-09-09 14:11:21 -04:00
parent 7baa789bc0
commit 297eef504b
4 changed files with 265 additions and 42 deletions

View File

@@ -13,25 +13,37 @@ the read-only replica so the chat and notebooks see them.
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
import typer import typer
from pfs.codetables import ( from pfs.codetables import (
ensure_tables, ensure_tables,
read_elements, read_all_elements,
read_events, read_all_events,
write_elements, write_elements,
write_events, write_events,
write_families, write_families,
) )
from pfs.extract import extract_code from pfs.extract import extract_code
from pfs.families import FAMILIES, derive_families, refresh_from from pfs.families import FAMILIES, HAND_FAMILIES, derive_families, refresh_from
from pfs.lineage import lineage from pfs.lineage import lineage
log = logging.getLogger(__name__)
app = typer.Typer(no_args_is_help=True) app = typer.Typer(no_args_is_help=True)
#: A replica that hasn't had `elements`/`lineage --write` (or an initial
#: `families --write`) run yet is missing `pfs.code_element`/
#: `pfs.code_event`/`pfs.code_family` — not a bug, just nothing derived
#: yet (I4). DuckDB's "table does not exist" errors are Catalog errors.
def _missing_table(exc: Exception) -> bool:
msg = str(exc)
return "Catalog" in msg or "does not exist" in msg
# ── indirections the tests monkeypatch ─────────────────────────────── # ── indirections the tests monkeypatch ───────────────────────────────
def _batch() -> Any: def _batch() -> Any:
from conf.connect import duckdb_batch from conf.connect import duckdb_batch
@@ -85,8 +97,11 @@ def _codes_for(
for family in families: for family in families:
fam = FAMILIES.get(family.upper()) fam = FAMILIES.get(family.upper())
if fam is None: if fam is None:
# List only the hand families here (M3) — FAMILIES can hold
# ~15k derived keys once `refresh_from` has run, and that
# dump is useless in an error message.
raise typer.BadParameter( raise typer.BadParameter(
f"unknown family {family!r}; known: {', '.join(FAMILIES)}" f"unknown family {family!r}; known: {', '.join(HAND_FAMILIES)}"
) )
out.extend(fam.codes) out.extend(fam.codes)
if all_payable: if all_payable:
@@ -94,6 +109,11 @@ def _codes_for(
"SELECT DISTINCT hcpcs FROM pfs.rvu WHERE status_code IN ('A','R','T') " "SELECT DISTINCT hcpcs FROM pfs.rvu WHERE status_code IN ('A','R','T') "
"AND year = (SELECT max(year) FROM pfs.rvu) ORDER BY hcpcs" "AND year = (SELECT max(year) FROM pfs.rvu) ORDER BY hcpcs"
).fetchall() ).fetchall()
log.warning(
"--all-payable is experimental: targeting %d codes (~20 min of SQL "
"before any model call)",
len(rows),
)
out.extend(r[0] for r in rows) out.extend(r[0] for r in rows)
if not out: if not out:
raise typer.BadParameter("pass --code, --family or --all-payable") raise typer.BadParameter("pass --code, --family or --all-payable")
@@ -107,7 +127,12 @@ def elements(
[], "--family", help="Expand a registered family (CCM, APCM, …); repeatable." [], "--family", help="Expand a registered family (CCM, APCM, …); repeatable."
), ),
all_payable: bool = typer.Option( all_payable: bool = typer.Option(
False, "--all-payable", help="Every A/R/T code in the newest RVU year." False,
"--all-payable",
help=(
"Every A/R/T code in the newest RVU year (experimental: ~20 min "
"of SQL before any model call)."
),
), ),
no_llm: bool = typer.Option( no_llm: bool = typer.Option(
False, False,
@@ -139,6 +164,14 @@ def elements(
_publish() _publish()
def _print_events(events: list[Any]) -> None:
for e in events:
arrow = f"{e.from_codes or '·'}→{e.to_codes or '·'}"
anchor = f"{e.item_key}{e.p_id}" if e.item_key else f"rvu {e.note}".strip()
flag = "" if e.anchored else " UNANCHORED"
typer.echo(f"{e.year} {e.kind:<18}{arrow:<16}{anchor}{flag}")
@app.command("lineage") @app.command("lineage")
def lineage_cmd( def lineage_cmd(
code: str = typer.Option(..., "--code"), code: str = typer.Option(..., "--code"),
@@ -148,27 +181,31 @@ def lineage_cmd(
) -> None: ) -> None:
"""Timeline of a code: RVU-file diffs and FR paragraphs, cross-checked.""" """Timeline of a code: RVU-file diffs and FR paragraphs, cross-checked."""
store = _store() store = _store()
if write:
with _batch() as con: with _batch() as con:
ensure_tables(con) ensure_tables(con)
events = lineage(con, store, code.upper()) events = lineage(con, store, code.upper())
for e in events: _print_events(events)
arrow = f"{e.from_codes or '·'}→{e.to_codes or '·'}"
anchor = (
f"{e.item_key}{e.p_id}" if e.item_key else f"rvu {e.note}".strip()
)
flag = "" if e.anchored else " UNANCHORED"
typer.echo(f"{e.year} {e.kind:<18}{arrow:<16}{anchor}{flag}")
if write:
write_events(con, code.upper(), events) write_events(con, code.upper(), events)
if write:
_publish() _publish()
return
# No --write: a plain lookup never needs the DuckDB single-writer lock
# a notebook may be holding (#508-#514) — read the replica (I4).
con = _read()
try:
try:
events = lineage(con, store, code.upper())
except Exception as exc:
if _missing_table(exc):
typer.echo("no derived tables yet — run with --write")
return
raise
_print_events(events)
finally:
con.close()
@app.command() def _derive_rows(con: Any) -> list[Any]:
def families(write: bool = typer.Option(False, "--write")) -> None:
"""Derive families from pfs.code_element / pfs.code_event / pfs.rvu."""
with _batch() as con:
ensure_tables(con)
codes = [ codes = [
r[0] r[0]
for r in con.execute( for r in con.execute(
@@ -176,25 +213,54 @@ def families(write: bool = typer.Option(False, "--write")) -> None:
"UNION SELECT DISTINCT hcpcs FROM pfs.rvu WHERE year = (SELECT max(year) FROM pfs.rvu) AND status_code IN ('A','R','T')" "UNION SELECT DISTINCT hcpcs FROM pfs.rvu WHERE year = (SELECT max(year) FROM pfs.rvu) AND status_code IN ('A','R','T')"
).fetchall() ).fetchall()
] ]
elements_by_code = {c: read_elements(con, c) for c in codes} # Two full-table reads grouped in Python instead of ~2xN per-code
events = {c: read_events(con, c) for c in codes} # queries run inside the write lock (I9).
all_elements = read_all_elements(con)
all_events = read_all_events(con)
elements_by_code = {c: all_elements.get(c, []) for c in codes}
events_by_code = {c: all_events.get(c, []) for c in codes}
descriptions = { descriptions = {
r[0]: r[1] or "" r[0]: r[1] or ""
for r in con.execute( for r in con.execute(
"SELECT hcpcs, arg_max(description, year) FROM pfs.rvu WHERE mod IS NULL OR mod = '' GROUP BY hcpcs" "SELECT hcpcs, arg_max(description, year) FROM pfs.rvu WHERE mod IS NULL OR mod = '' GROUP BY hcpcs"
).fetchall() ).fetchall()
} }
rows = derive_families(elements_by_code, events, descriptions) return derive_families(elements_by_code, events_by_code, descriptions)
def _print_families(rows: list[Any]) -> None:
by_key: dict[str, list[str]] = {} by_key: dict[str, list[str]] = {}
for r in rows: for r in rows:
by_key.setdefault(r.key, []).append(f"{r.code}({r.role})") by_key.setdefault(r.key, []).append(f"{r.code}({r.role})")
for key, members in sorted(by_key.items()): for key, members in sorted(by_key.items()):
typer.echo(f"{key}: {' '.join(members)}") typer.echo(f"{key}: {' '.join(members)}")
@app.command()
def families(write: bool = typer.Option(False, "--write")) -> None:
"""Derive families from pfs.code_element / pfs.code_event / pfs.rvu."""
if write: if write:
with _batch() as con:
ensure_tables(con)
rows = _derive_rows(con)
_print_families(rows)
write_families(con, rows) write_families(con, rows)
refresh_from(con) refresh_from(con)
if write:
_publish() _publish()
return
# No --write: derive-and-print never needs the write lock (I4).
con = _read()
try:
try:
rows = _derive_rows(con)
except Exception as exc:
if _missing_table(exc):
typer.echo("no derived tables yet — run with --write")
return
raise
_print_families(rows)
finally:
con.close()
@app.command() @app.command()

View File

@@ -137,6 +137,35 @@ def read_events(con: Any, code: str) -> list[EventRow]:
return [EventRow(*r) for r in rows] return [EventRow(*r) for r in rows]
def read_all_elements(con: Any) -> dict[str, list[ElementRow]]:
"""Every ``pfs.code_element`` row, grouped by code — one full-table
scan instead of the ~N per-code queries ``families --write`` used to
run inside the DuckDB write lock (I9). Rows within each code are
ordered exactly as ``read_elements`` orders them."""
rows = con.execute(
"SELECT * FROM pfs.code_element ORDER BY code, year, type, value, detail"
).fetchall()
out: dict[str, list[ElementRow]] = {}
for r in rows:
row = ElementRow(*r)
out.setdefault(row.code, []).append(row)
return out
def read_all_events(con: Any) -> dict[str, list[EventRow]]:
"""Every ``pfs.code_event`` row, grouped by code — see
``read_all_elements``. Rows within each code are ordered exactly as
``read_events`` orders them."""
rows = con.execute(
"SELECT * FROM pfs.code_event ORDER BY code, year, kind"
).fetchall()
out: dict[str, list[EventRow]] = {}
for r in rows:
row = EventRow(*r)
out.setdefault(row.code, []).append(row)
return out
def read_families(con: Any) -> list[FamilyRow]: def read_families(con: Any) -> list[FamilyRow]:
rows = con.execute( rows = con.execute(
"SELECT * FROM pfs.code_family ORDER BY key, role, code" "SELECT * FROM pfs.code_family ORDER BY key, role, code"

View File

@@ -211,6 +211,32 @@ class TestLineage:
assert read_events(con, "G2058")[0].to_codes == "99439" assert read_events(con, "G2058")[0].to_codes == "99439"
assert con.published == [True] assert con.published == [True]
def test_without_write_reads_and_never_takes_the_write_lock(self, con, monkeypatch):
# I4: `stack pfs lineage` without --write must read the replica,
# not open a RW duckdb_batch connection (the DuckDB single-writer
# lock a notebook may be holding, #508-#514).
def fail_batch():
raise AssertionError(
"lineage without --write must not open a RW duckdb_batch connection"
)
monkeypatch.setattr(pfs_cli, "_batch", fail_batch)
monkeypatch.setattr(pfs_cli, "lineage", lambda c, s, code: [])
read_calls = []
orig_read = pfs_cli._read
def spy_read():
read_calls.append(True)
return orig_read()
monkeypatch.setattr(pfs_cli, "_read", spy_read)
res = runner.invoke(app, ["pfs", "lineage", "--code", "G2058"])
assert res.exit_code == 0, res.output
assert read_calls == [True]
assert con.published == []
class TestFamilies: class TestFamilies:
def test_derives_from_tables_and_writes(self, con): def test_derives_from_tables_and_writes(self, con):
@@ -251,6 +277,60 @@ class TestFamilies:
res = runner.invoke(app, ["pfs", "families", "--write"]) res = runner.invoke(app, ["pfs", "families", "--write"])
assert res.exit_code == 0, res.output assert res.exit_code == 0, res.output
def test_without_write_reads_and_never_takes_the_write_lock(self, con, monkeypatch):
# I4: `stack pfs families` without --write must read the replica,
# not open a RW duckdb_batch connection.
def fail_batch():
raise AssertionError(
"families without --write must not open a RW duckdb_batch connection"
)
monkeypatch.setattr(pfs_cli, "_batch", fail_batch)
read_calls = []
orig_read = pfs_cli._read
def spy_read():
read_calls.append(True)
return orig_read()
monkeypatch.setattr(pfs_cli, "_read", spy_read)
res = runner.invoke(app, ["pfs", "families"])
assert res.exit_code == 0, res.output
assert read_calls == [True]
assert con.published == []
def test_no_derived_tables_yet_is_a_friendly_no_op(self, monkeypatch):
# I4: a fresh replica with no pfs.code_element/code_event/code_family
# (nothing has run --write yet) is not a bug — print a friendly
# message and exit 0, without ever taking the write lock.
bare = duckdb.connect(":memory:")
monkeypatch.setattr(pfs_cli, "_read", lambda: bare)
def fail_batch():
raise AssertionError("must not open a RW duckdb_batch connection")
monkeypatch.setattr(pfs_cli, "_batch", fail_batch)
try:
res = runner.invoke(app, ["pfs", "families"])
assert res.exit_code == 0, res.output
assert "no derived tables yet" in res.output
finally:
bare.close()
def test_unknown_family_error_lists_hand_families_only(self, con, monkeypatch):
# M3: FAMILIES can hold ~15k derived keys once `refresh_from` has
# run — the error text must stay short and human-scannable.
pfs_cli.FAMILIES["ZZZZ-99999"] = pfs_cli.FAMILIES["CCM"]
res = runner.invoke(
app, ["pfs", "elements", "--family", "NOPE", "--no-llm", "--dry-run"]
)
assert res.exit_code != 0
assert "ZZZZ-99999" not in res.output
for key in pfs_cli.HAND_FAMILIES:
assert key in res.output
class TestReview: class TestReview:
def test_lists_queue(self, con): def test_lists_queue(self, con):

View File

@@ -11,6 +11,8 @@ from pfs.codetables import (
FamilyRow, FamilyRow,
ReviewRow, ReviewRow,
ensure_tables, ensure_tables,
read_all_elements,
read_all_events,
read_elements, read_elements,
read_events, read_events,
read_families, read_families,
@@ -80,6 +82,52 @@ class TestElements:
assert len(read_elements(con, "G0556")) == 1 assert len(read_elements(con, "G0556")) == 1
class TestReadAll:
def test_read_all_elements_matches_per_code_reader(self, con):
write_elements(
con, "99490", [_el(value="24-7-access"), _el(value="consent")], []
)
write_elements(con, "G0556", [_el(code="G0556")], [])
out = read_all_elements(con)
assert set(out) == {"99490", "G0556"}
assert out["99490"] == read_elements(con, "99490")
assert out["G0556"] == read_elements(con, "G0556")
def test_read_all_elements_empty(self, con):
assert read_all_elements(con) == {}
def test_read_all_events_matches_per_code_reader(self, con):
rows = [
EventRow(
"99439",
2021,
"replaces",
"G2058",
"99439",
"YBM4IZUS",
1578,
84639,
"fr",
True,
"",
),
EventRow("99439", 2021, "appeared", "", "", "", 0, 0, "rvu", True, ""),
]
write_events(con, "99439", rows)
write_events(
con,
"G2058",
[EventRow("G2058", 2020, "appeared", "", "", "", 0, 0, "rvu", True, "")],
)
out = read_all_events(con)
assert set(out) == {"99439", "G2058"}
assert out["99439"] == read_events(con, "99439")
assert out["G2058"] == read_events(con, "G2058")
def test_read_all_events_empty(self, con):
assert read_all_events(con) == {}
class TestEvents: class TestEvents:
def test_roundtrip_sorted_by_year(self, con): def test_roundtrip_sorted_by_year(self, con):
rows = [ rows = [