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:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user