fix(cli): pfs families --write prints the summary by default (refs #699)

The per-key dump ("CCM: 99490(base) ...") used to always print — useless
once derived families number in the thousands. --write now prints only
the summary line by default; the full dump is --verbose. refresh_from
and printing moved outside the duckdb_batch block (they don't need the
write lock open) — refresh_from now reads the freshly published
replica, the same pattern stack llm restamp uses.

Also: docs/superpowers/specs/2026-09-09-code-family-longitudinal-design.md
Decision 2 gets one sentence noting lineage's third source (cpt, the
AMA's own change years — effective years) and that a cpt_changed year
anchors an RVU event the way an FR event does.
This commit is contained in:
kert
2026-09-09 21:20:20 -04:00
parent 30db67b7e1
commit 041378b16b
3 changed files with 36 additions and 9 deletions

View File

@@ -34,7 +34,7 @@ The goal of P49 is that the chat, and the notebooks, can build a **longitudinal
## Decisions ## Decisions
1. **Elements are a closed, typed vocabulary**, not free text. Element *types* are fixed in code; element *values* are a curated list that grows by review (P35 pattern: local model proposes from a closed list, humans accept). Types: `actor` (clinical staff directed by / personally by physician or QHP; RHC/FQHC), `time` (threshold minutes, "each additional"), `period` (calendar month, 30 days, per visit, 14/7 days post-discharge), `population` (≥2 chronic conditions ≥12 months; QMB; high risk), `activity` (consent; care plan; 24/7 access; transitions; medication reconciliation; …), `modality` (face-to-face; interactive telecommunications; audio-only; asynchronous), `relation` (add-on to; do-not-report-with; replaces; defined-by-reference-to; crosswalk-valued-to), `setting` (facility/non-facility; place of service), `billing` (one practitioner per month; initiating visit). 1. **Elements are a closed, typed vocabulary**, not free text. Element *types* are fixed in code; element *values* are a curated list that grows by review (P35 pattern: local model proposes from a closed list, humans accept). Types: `actor` (clinical staff directed by / personally by physician or QHP; RHC/FQHC), `time` (threshold minutes, "each additional"), `period` (calendar month, 30 days, per visit, 14/7 days post-discharge), `population` (≥2 chronic conditions ≥12 months; QMB; high risk), `activity` (consent; care plan; 24/7 access; transitions; medication reconciliation; …), `modality` (face-to-face; interactive telecommunications; audio-only; asynchronous), `relation` (add-on to; do-not-report-with; replaces; defined-by-reference-to; crosswalk-valued-to), `setting` (facility/non-facility; place of service), `billing` (one practitioner per month; initiating visit).
2. **Lineage is a table of dated events with anchors**, derived from two independent sources that must agree or be flagged: (a) `pfs.rvu`/`rvu_proposed` (first year, last year, status transitions, descriptor changes, RVU changes beyond a threshold) and (b) FR paragraphs whose text carries the code and an event verb (create/adopt/replace/delete/crosswalk/bundle/telehealth-list). Every event row stores `item_key, p_id, page` so the chat cites a paragraph, never a table. 2. **Lineage is a table of dated events with anchors**, derived from two independent sources that must agree or be flagged: (a) `pfs.rvu`/`rvu_proposed` (first year, last year, status transitions, descriptor changes, RVU changes beyond a threshold) and (b) FR paragraphs whose text carries the code and an event verb (create/adopt/replace/delete/crosswalk/bundle/telehealth-list). Every event row stores `item_key, p_id, page` so the chat cites a paragraph, never a table. Lineage now has a third source, `cpt` — the AMA CPT Changes book's own "Effective January 1, YYYY" statement per code, ingested as `pfs.cpt_reference`/`pfs.cpt_code` change years, which are effective years rather than paragraph-anchored FR events; a `cpt_changed` year anchors an RVU event (an appearance/status change with no FR paragraph nearby) the same way an FR event does.
3. **Families are derived, not hand-listed.** A family = codes sharing a stem and differing in `time`/`actor`/`population` elements, plus CMS's own "code family" language in the FR ("The Chronic Care Management code family will be resurveyed", `YBM4IZUS` ¶1578) and `relation` elements (add-on/replaces). The five hand families become the regression fixture. 3. **Families are derived, not hand-listed.** A family = codes sharing a stem and differing in `time`/`actor`/`population` elements, plus CMS's own "code family" language in the FR ("The Chronic Care Management code family will be resurveyed", `YBM4IZUS` ¶1578) and `relation` elements (add-on/replaces). The five hand families become the regression fixture.
4. **Anchors live in two places, one per consumer.** Items get `code:<hcpcs>` and `family:<key>` tags in bib/Zotero (browsable, syncable); chunks get `codes`, `families`, and `elements` metadata in pgvector (retrievable). `comments` and `corpus` are re-indexed once with `--force` to stamp them, as P48 did for `rules`. 4. **Anchors live in two places, one per consumer.** Items get `code:<hcpcs>` and `family:<key>` tags in bib/Zotero (browsable, syncable); chunks get `codes`, `families`, and `elements` metadata in pgvector (retrievable). `comments` and `corpus` are re-indexed once with `--force` to stamp them, as P48 did for `rules`.
5. **Longitudinal retrieval is era-balanced, not recency-blended.** A "history of X" question retrieves per era (rule year) with a cap per era, so CY2015 and CY2027 both appear; the P34 recency blend stays for ordinary questions. 5. **Longitudinal retrieval is era-balanced, not recency-blended.** A "history of X" question retrieves per era (rule year) with a cap per era, so CY2015 and CY2027 both appear; the P34 recency blend stays for ordinary questions.

View File

@@ -286,10 +286,11 @@ def _derive_rows(con: Any) -> list[Any]:
) )
def _print_families(rows: list[Any]) -> None: def _print_families(rows: list[Any], *, verbose: bool = False) -> None:
by_key: dict[str, list[Any]] = {} by_key: dict[str, list[Any]] = {}
for r in rows: for r in rows:
by_key.setdefault(r.key, []).append(r) by_key.setdefault(r.key, []).append(r)
if verbose:
for key, members in sorted(by_key.items()): for key, members in sorted(by_key.items()):
typer.echo(f"{key}: {' '.join(f'{r.code}({r.role})' for r in members)}") typer.echo(f"{key}: {' '.join(f'{r.code}({r.role})' for r in members)}")
total = len(by_key) total = len(by_key)
@@ -308,16 +309,24 @@ def _print_families(rows: list[Any]) -> None:
@app.command() @app.command()
def families(write: bool = typer.Option(False, "--write")) -> None: def families(
write: bool = typer.Option(False, "--write"),
verbose: bool = typer.Option(
False, "--verbose", help="Print every family's members, not just the summary."
),
) -> None:
"""Derive families from pfs.code_element / pfs.code_event / pfs.rvu.""" """Derive families from pfs.code_element / pfs.code_event / pfs.rvu."""
if write: if write:
with _batch() as con: with _batch() as con:
ensure_tables(con) ensure_tables(con)
rows = _derive_rows(con) rows = _derive_rows(con)
_print_families(rows)
write_families(con, rows) write_families(con, rows)
refresh_from(con)
_publish() _publish()
# refresh_from and printing don't need the write lock open — do
# them after it's released, from the freshly published replica
# (the same pattern `stack llm restamp` uses).
refresh_from(_read())
_print_families(rows, verbose=verbose)
return return
# No --write: derive-and-print never needs the write lock (I4). # No --write: derive-and-print never needs the write lock (I4).
con = _read() con = _read()
@@ -329,7 +338,7 @@ def families(write: bool = typer.Option(False, "--write")) -> None:
typer.echo("no derived tables yet — run with --write") typer.echo("no derived tables yet — run with --write")
return return
raise raise
_print_families(rows) _print_families(rows, verbose=verbose)
finally: finally:
con.close() con.close()

View File

@@ -285,7 +285,9 @@ class TestFamilies:
assert res.exit_code == 0, res.output assert res.exit_code == 0, res.output
fams = read_families(con) fams = read_families(con)
assert {r.code for r in fams if r.key == "CCM"} >= {"99490", "99439", "G2058"} assert {r.code for r in fams if r.key == "CCM"} >= {"99490", "99439", "G2058"}
assert "CCM" in res.output # Minor: --write prints the summary line by default; the
# per-family dump ("CCM" among them) is --verbose only.
assert "CCM" not in res.output
# No pfs.cpt_* rows seeded in this test — the summary line must # No pfs.cpt_* rows seeded in this test — the summary line must
# still print, with cpt-named at 0 (I4: read-only-tolerant path # still print, with cpt-named at 0 (I4: read-only-tolerant path
# also applies to the write path when the CPT tables are empty). # also applies to the write path when the CPT tables are empty).
@@ -293,6 +295,22 @@ class TestFamilies:
res.output res.output
) )
def test_verbose_prints_the_per_family_dump(self, con):
write_elements(
con,
"99439",
[
ElementRow(
"99439", 2021, "relation", "addon-of", "99490", "", "K", 1, 1, "fr"
)
],
[],
)
res = runner.invoke(app, ["pfs", "families", "--write", "--verbose"])
assert res.exit_code == 0, res.output
assert "CCM" in res.output
assert "families: total" in res.output
def _ccm_cpt_edition(self, year=2024): def _ccm_cpt_edition(self, year=2024):
section = CptSection( section = CptSection(
sec_id="sec_ccm", sec_id="sec_ccm",