From 4894acd5988f0eed9fcb316dfb65817640e121f0 Mon Sep 17 00:00:00 2001 From: kert Date: Tue, 22 Sep 2026 15:45:15 -0400 Subject: [PATCH 1/3] docs(cli): regenerate the mail-* command pages from their current help text --- docs/docs/cli/mail-attach-smarthost.md | 2 +- docs/docs/cli/mail-dkim-export.md | 2 +- docs/docs/cli/mail-dns.md | 2 +- docs/docs/cli/mail-down.md | 2 +- docs/docs/cli/mail-provision.md | 2 +- docs/docs/cli/mail-rotate-creds.md | 2 +- docs/docs/cli/mail-seed-mailboxes.md | 2 +- docs/docs/cli/mail-status.md | 2 +- docs/docs/cli/mail-up.md | 2 +- docs/docs/cli/mail-wire-git.md | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/docs/cli/mail-attach-smarthost.md b/docs/docs/cli/mail-attach-smarthost.md index d27796c..62e0fb1 100644 --- a/docs/docs/cli/mail-attach-smarthost.md +++ b/docs/docs/cli/mail-attach-smarthost.md @@ -1,6 +1,6 @@ --- title: stack mail attach-smarthost -sidebar_position: 97 +sidebar_position: 98 --- # `stack mail attach-smarthost` diff --git a/docs/docs/cli/mail-dkim-export.md b/docs/docs/cli/mail-dkim-export.md index c66e94d..57a8e61 100644 --- a/docs/docs/cli/mail-dkim-export.md +++ b/docs/docs/cli/mail-dkim-export.md @@ -1,6 +1,6 @@ --- title: stack mail dkim-export -sidebar_position: 96 +sidebar_position: 97 --- # `stack mail dkim-export` diff --git a/docs/docs/cli/mail-dns.md b/docs/docs/cli/mail-dns.md index cb9bda7..4d1fa73 100644 --- a/docs/docs/cli/mail-dns.md +++ b/docs/docs/cli/mail-dns.md @@ -1,6 +1,6 @@ --- title: stack mail dns -sidebar_position: 95 +sidebar_position: 96 --- # `stack mail dns` diff --git a/docs/docs/cli/mail-down.md b/docs/docs/cli/mail-down.md index f7b84cf..5eb6aad 100644 --- a/docs/docs/cli/mail-down.md +++ b/docs/docs/cli/mail-down.md @@ -1,6 +1,6 @@ --- title: stack mail down -sidebar_position: 93 +sidebar_position: 94 --- # `stack mail down` diff --git a/docs/docs/cli/mail-provision.md b/docs/docs/cli/mail-provision.md index a18449b..9b1f22e 100644 --- a/docs/docs/cli/mail-provision.md +++ b/docs/docs/cli/mail-provision.md @@ -1,6 +1,6 @@ --- title: stack mail provision -sidebar_position: 91 +sidebar_position: 92 --- # `stack mail provision` diff --git a/docs/docs/cli/mail-rotate-creds.md b/docs/docs/cli/mail-rotate-creds.md index e073525..e40a094 100644 --- a/docs/docs/cli/mail-rotate-creds.md +++ b/docs/docs/cli/mail-rotate-creds.md @@ -1,6 +1,6 @@ --- title: stack mail rotate-creds -sidebar_position: 98 +sidebar_position: 99 --- # `stack mail rotate-creds` diff --git a/docs/docs/cli/mail-seed-mailboxes.md b/docs/docs/cli/mail-seed-mailboxes.md index bee1d4d..aaaa92c 100644 --- a/docs/docs/cli/mail-seed-mailboxes.md +++ b/docs/docs/cli/mail-seed-mailboxes.md @@ -1,6 +1,6 @@ --- title: stack mail seed-mailboxes -sidebar_position: 99 +sidebar_position: 100 --- # `stack mail seed-mailboxes` diff --git a/docs/docs/cli/mail-status.md b/docs/docs/cli/mail-status.md index 1d03faf..ddc85a2 100644 --- a/docs/docs/cli/mail-status.md +++ b/docs/docs/cli/mail-status.md @@ -1,6 +1,6 @@ --- title: stack mail status -sidebar_position: 94 +sidebar_position: 95 --- # `stack mail status` diff --git a/docs/docs/cli/mail-up.md b/docs/docs/cli/mail-up.md index 5b7fb63..725f56a 100644 --- a/docs/docs/cli/mail-up.md +++ b/docs/docs/cli/mail-up.md @@ -1,6 +1,6 @@ --- title: stack mail up -sidebar_position: 92 +sidebar_position: 93 --- # `stack mail up` diff --git a/docs/docs/cli/mail-wire-git.md b/docs/docs/cli/mail-wire-git.md index 2ef2322..bafcbe0 100644 --- a/docs/docs/cli/mail-wire-git.md +++ b/docs/docs/cli/mail-wire-git.md @@ -1,6 +1,6 @@ --- title: stack mail wire-git -sidebar_position: 100 +sidebar_position: 101 --- # `stack mail wire-git` From 79ab2ebf473810111dfdf13cb05027d1035bbdcf Mon Sep 17 00:00:00 2001 From: kert Date: Tue, 22 Sep 2026 15:45:15 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(pfs):=20exposure=20calendar=20?= =?UTF-8?q?=E2=80=94=20pfs.code=5Fexposure=20from=20lineage=20+=20RVU=20st?= =?UTF-8?q?atus,=20stack=20pfs=20exposure,=20notebook=207c=20(refs=20#693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One row per (code, effective date, kind) for becomes-payable / revalued / status-change / ends / telehealth-listed, derived from pfs.rvu (A/R/T payable, left- and right-censored at the file span) and pfs.code_event (revalued, telehealth_list), each anchored to the code's nearest kind-matched FR lineage paragraph. Effective date is 1 January of the rule year unless the event's own year is anchored only by a correction notice (bib.frlink.rule_kind), then that notice's publication date. Rows carry the family key, the year's RVU status and the decile band of non_fac_total among that year's payable codes; control_codes() returns the never-treated set (same status + band, no exposure within a window). stack pfs exposure [--code|--family] [--write] [--controls N --year Y]; notebook section 7c lists a family's exposures with FR jump links and a control-set preview. Live dry run: 6,314 rows / 4,796 codes in 1.3 s; every fixture event in #693 present (99490 2015, 99487/99489 2017, 99439 + G2058 2021, 99424 + G2064 2022, G2211 2024, G0556 2025, 99441 ends 2025). --- docs/docs/cli/pfs-exposure.md | 35 ++ docs/docs/cli/pfs.md | 7 + notebooks/code_families.py | 97 +++++- src/cli/pfs.py | 119 +++++++ src/pfs/codetables.py | 66 ++++ src/pfs/exposure.py | 402 +++++++++++++++++++++++ tests/cli/test_pfs_cli.py | 79 +++++ tests/notebooks/test_code_families_nb.py | 2 +- tests/pfs/test_exposure.py | 382 +++++++++++++++++++++ 9 files changed, 1187 insertions(+), 2 deletions(-) create mode 100644 docs/docs/cli/pfs-exposure.md create mode 100644 src/pfs/exposure.py create mode 100644 tests/pfs/test_exposure.py diff --git a/docs/docs/cli/pfs-exposure.md b/docs/docs/cli/pfs-exposure.md new file mode 100644 index 0000000..7b496c4 --- /dev/null +++ b/docs/docs/cli/pfs-exposure.md @@ -0,0 +1,35 @@ +--- +title: stack pfs exposure +sidebar_position: 71 +--- + +# `stack pfs exposure` + +``` +Usage: stack pfs exposure [OPTIONS] + + The exposure calendar (pfs.code_exposure, #693): when each code became + payable, was revalued, changed payable status, ended, or was listed for + telehealth — dated 1 January of the rule year (or the correction notice's + date), anchored to the FR paragraph from its lineage, with the status and + RVU-decile band a control set matches on. + + --write always rebuilds every code (control sets need the whole + calendar); --code/--family only filter what is printed. + +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --code TEXT Filter to a code; repeatable. │ +│ --family TEXT Filter to a family key (CCM, APCM, …); │ +│ repeatable. │ +│ --controls INTEGER With one --code and --year: list up to N │ +│ never-treated control codes (same status + RVU │ +│ band, no exposure within --window years). │ +│ [default: 0] │ +│ --year INTEGER The exposure year --controls refers to. │ +│ [default: 0] │ +│ --window INTEGER ±years a control must be quiet for. [default: 2] │ +│ --write Rebuild the whole calendar into pfs.code_exposure │ +│ and republish. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/docs/docs/cli/pfs.md b/docs/docs/cli/pfs.md index ce3376c..59badba 100644 --- a/docs/docs/cli/pfs.md +++ b/docs/docs/cli/pfs.md @@ -35,6 +35,13 @@ Usage: stack pfs [OPTIONS] COMMAND [ARGS]... │ live │ │ from pgvector/bib — there is no persisted-table fallback to read │ │ without --write, so --write only adds persisting the result. │ +│ exposure The exposure calendar (pfs.code_exposure, #693): when each code │ +│ became payable, was revalued, changed payable status, ended, or │ +│ was │ +│ listed for telehealth — dated 1 January of the rule year (or the │ +│ correction notice's date), anchored to the FR paragraph from its │ +│ lineage, with the status and RVU-decile band a control set │ +│ matches on. │ │ review Element lines the classifier could not place. │ │ cpt-ingest Parse CPT EPUB editions into │ │ pfs.cpt_section/cpt_code/cpt_instruction/ │ diff --git a/notebooks/code_families.py b/notebooks/code_families.py index 947c75a..2cc9338 100644 --- a/notebooks/code_families.py +++ b/notebooks/code_families.py @@ -1033,6 +1033,100 @@ def _(code, mo, pl): return +@app.cell(hide_code=True) +def _(code, con, fr_md, mo, not_built, pl): + # ── 7c. Exposure calendar ── + from pfs.codetables import read_exposures as _read_exposures + from pfs.exposure import control_codes as _control_codes + from pfs.families import family_of as _family_of_x + + _fam = _family_of_x(code) + _key = _fam.key if _fam else "" + _rows = [] + if con is not None: + try: + _rows = ( + _read_exposures(con, family=_key) + if _key + else _read_exposures(con, code=code) + ) + except Exception: # noqa: BLE001 — a missing table is "not built yet" + _rows = [] + + _intro = ( + "## 7c. Exposure calendar\n\n" + "P50 reads the fee schedule as law with effects: every row here is a date " + "on which a code's legal status under the PFS changed — it became payable, " + "was revalued by more than ten percent, moved between payable statuses, " + "ended, or was listed for telehealth — derived from the RVU files and the " + "lineage events above, never hand-entered. The effective date is 1 January " + "of the rule year unless the only Federal Register anchor is a correction " + "notice, in which case the notice's own date is used. Each row keeps the " + "code's RVU status and decile band that year, which is what a " + "never-treated control set is matched on." + ) + if not _rows: + _view = mo.md(_intro + "\n\n" + not_built("stack pfs exposure --write")) + else: + _tbl = pl.DataFrame( + { + "code": [r.code for r in _rows], + "effective": [r.effective_date.isoformat() for r in _rows], + "exposure": [r.kind for r in _rows], + "status": [ + f"{r.prior_status or '—'}→{r.status or '—'}" + if r.kind in ("becomes-payable", "ends", "status-change") + else (f"{r.delta_pct:+.1%}" if r.kind == "revalued" else r.status) + for r in _rows + ], + "band": [r.rvu_band for r in _rows], + "anchor": [ + ( + fr_md(r.item_key, r.p_id) + + (" (correction)" if r.correction else "") + ) + if r.item_key + else "_unanchored_" + for r in _rows + ], + } + ) + # A control-set preview for the family's most recent becomes-payable + # exposure (or its newest row): same status + band, quiet for ±2 years. + _pick = max( + _rows, + key=lambda r: (r.kind == "becomes-payable", r.year), + ) + try: + _ctrl = _control_codes(con, _pick.code, _pick.year, window=2, limit=12) + except Exception: # noqa: BLE001 + _ctrl = [] + _ctrl_md = ( + f"**Controls for {_pick.code} {_pick.kind} ({_pick.year})** — " + f"{len(_ctrl)} shown, cheapest first: " + + ", ".join(f"{c}" + (f" [{f}]" if f else "") for c, f, _s, _b in _ctrl) + if _ctrl + else f"_No never-treated controls found for {_pick.code} in {_pick.year}._" + ) + _n_anchored = sum(1 for r in _rows if r.item_key) + _view = mo.vstack( + [ + mo.md( + _intro + f"\n\n**{_key or code}**: {len(_rows)} exposure" + f"{'s' if len(_rows) != 1 else ''}, {_n_anchored} anchored to an " + "FR paragraph." + ), + mo.ui.table( + _tbl, + label=f"pfs.code_exposure — {_key or code} ({len(_rows)} rows)", + ), + mo.md(_ctrl_md), + ] + ) + _view + return + + @app.cell(hide_code=True) def _(NOTES, REPLICA_PATH, con, mo, pl, q, store): # ── 8. Provenance ── @@ -1049,7 +1143,8 @@ def _(NOTES, REPLICA_PATH, con, mo, pl, q, store): "SELECT 'code_element' t, count(*) n FROM pfs.code_element " "UNION ALL SELECT 'code_element_review', count(*) FROM pfs.code_element_review " "UNION ALL SELECT 'code_event', count(*) FROM pfs.code_event " - "UNION ALL SELECT 'code_family', count(*) FROM pfs.code_family" + "UNION ALL SELECT 'code_family', count(*) FROM pfs.code_family " + "UNION ALL SELECT 'code_exposure', count(*) FROM pfs.code_exposure" ) _log = q( "SELECT run_id, ingested_at, module, table_name, rule_id, source_file, sha256, rows, " diff --git a/src/cli/pfs.py b/src/cli/pfs.py index 1325e51..8c8b7f0 100644 --- a/src/cli/pfs.py +++ b/src/cli/pfs.py @@ -32,15 +32,19 @@ from pfs.codetables import ( read_cpt_codes, read_cpt_instructions, read_cpt_sections, + read_exposures, read_guidance, read_reviews, write_elements, write_events, + write_exposures, write_families, write_guidance, write_reaction, ) from pfs.cpt_load import ingest as cpt_ingest +from pfs.exposure import build as build_exposures +from pfs.exposure import control_codes from pfs.extract import extract_codes from pfs.families import ( FAMILIES, @@ -652,6 +656,121 @@ def reaction( _print_reaction(rows) +def _print_exposures(rows: list[Any]) -> None: + for r in rows: + anchor = f"{r.item_key} ¶{r.p_id}" if r.item_key else "unanchored" + if r.correction: + anchor += " (correction)" + extra = "" + if r.kind == "revalued": + extra = f" {r.delta_pct:+.1%}" + elif r.kind in ("becomes-payable", "ends", "status-change"): + extra = f" {r.prior_status or '—'}→{r.status or '—'}" + fam = f" [{r.family}]" if r.family else "" + typer.echo( + f"{r.code} {r.effective_date.isoformat()} {r.kind:<17}" + f"{extra} band {r.rvu_band}{fam} ← {anchor}" + ) + per_kind: dict[str, int] = {} + for r in rows: + per_kind[r.kind] = per_kind.get(r.kind, 0) + 1 + detail = ", ".join(f"{k}={v}" for k, v in sorted(per_kind.items())) or "none" + anchored = sum(1 for r in rows if r.item_key) + typer.echo( + f"exposures: {len(rows)} rows, {len({r.code for r in rows})} codes, " + f"anchored {anchored}, per kind: {detail}" + ) + + +@app.command() +def exposure( + code: list[str] = typer.Option([], "--code", help="Filter to a code; repeatable."), + family: list[str] = typer.Option( + [], "--family", help="Filter to a family key (CCM, APCM, …); repeatable." + ), + controls: int = typer.Option( + 0, + "--controls", + help="With one --code and --year: list up to N never-treated control codes " + "(same status + RVU band, no exposure within --window years).", + ), + year: int = typer.Option( + 0, "--year", help="The exposure year --controls refers to." + ), + window: int = typer.Option( + 2, "--window", help="±years a control must be quiet for." + ), + write: bool = typer.Option( + False, + "--write", + help="Rebuild the whole calendar into pfs.code_exposure and republish.", + ), +) -> None: + """The exposure calendar (pfs.code_exposure, #693): when each code + became payable, was revalued, changed payable status, ended, or was + listed for telehealth — dated 1 January of the rule year (or the + correction notice's date), anchored to the FR paragraph from its + lineage, with the status and RVU-decile band a control set matches on. + + --write always rebuilds every code (control sets need the whole + calendar); --code/--family only filter what is printed.""" + codes = [c.upper() for c in code] + keys = [normalize_key(f) for f in family] + + def _filter(rows: list[Any]) -> list[Any]: + if not codes and not keys: + return rows + return [r for r in rows if r.code in codes or r.family in keys] + + if write: + # Ruling A13: build against a plain read (I4) — never the write + # lock a notebook may be holding (#508-#514); the batch below only + # writes and publishes. + store = _store() + con = _read() + try: + rows = build_exposures(con, store) + finally: + con.close() + with _batch() as con: + ensure_tables(con) + write_exposures(con, rows) + _publish() + _print_exposures(_filter(rows)) + return + + con = _read() + try: + if controls: + if len(codes) != 1 or not year: + raise typer.BadParameter( + "--controls needs exactly one --code and --year" + ) + try: + ctrl = control_codes(con, codes[0], year, window=window, limit=controls) + except Exception as exc: + if _missing_table(exc): + typer.echo("no exposure calendar yet — run with --write") + return + raise + for c, fam, status, band in ctrl: + typer.echo(f"{c} {status} band {band} {fam}") + typer.echo( + f"controls for {codes[0]} @ {year} (±{window}y): {len(ctrl)} codes" + ) + return + try: + rows = read_exposures(con) + except Exception as exc: + if _missing_table(exc): + typer.echo("no exposure calendar yet — run with --write") + return + raise + _print_exposures(_filter(rows)) + finally: + con.close() + + @app.command() def review(code: str = typer.Option("", "--code")) -> None: """Element lines the classifier could not place.""" diff --git a/src/pfs/codetables.py b/src/pfs/codetables.py index cb04a76..f966686 100644 --- a/src/pfs/codetables.py +++ b/src/pfs/codetables.py @@ -4,6 +4,8 @@ ``pfs.code_element_review`` element lines the classifier could not place ``pfs.code_event`` dated lineage events (created, replaced, …) ``pfs.code_family`` derived family membership with roles +``pfs.code_exposure`` the exposure calendar (#693): dated payable / + revalued / status / ends / telehealth events ``pfs.cpt_*`` (docs/superpowers/specs/2026-09-09-cpt-canonical-schema-design.md §3) hold one CPT codebook edition's own organizing structure — @@ -28,6 +30,7 @@ database. from __future__ import annotations +import datetime as _dt from dataclasses import astuple, dataclass from typing import TYPE_CHECKING, Any, Sequence @@ -240,6 +243,41 @@ class CptListRow: code: str +@dataclass(frozen=True) +class ExposureRow: + """One exposure on the calendar (``pfs.code_exposure``, #693): *code* + is exposed to *kind* (``pfs.exposure.KINDS``) from *effective_date* + — 1 January of *year* unless the only Federal Register anchor is a + correction notice, in which case that notice's publication date and + ``correction=True``. ``status``/``prior_status`` are the RVU-file + status codes on either side of the event (``""`` when the code is + absent from that year's file); ``rvu_band`` is the decile of + ``non_fac_total`` among that year's payable codes (1 cheapest … 10, + 0 when unranked) and ``non_fac_total`` the value it was ranked on — + both taken from the last payable year for an ``ends`` row; ``delta_pct`` + is the revaluation fraction (``0.475`` for +47.5%) and ``0.0`` otherwise. + ``item_key``/``p_id``/``page``/``anchor_kind`` carry the lineage row + the exposure is anchored to (empty/0 when no FR paragraph names the + code within a year of the event).""" + + code: str + family: str + effective_date: _dt.date + year: int + kind: str + status: str + prior_status: str + rvu_band: int + non_fac_total: float + delta_pct: float + item_key: str + p_id: int + page: int + anchor_kind: str + correction: bool + note: str + + @dataclass(frozen=True) class CptCodeAltRow: edition_year: int @@ -264,6 +302,11 @@ CREATE TABLE IF NOT EXISTS pfs.code_event ( CREATE TABLE IF NOT EXISTS pfs.code_family ( key VARCHAR, name VARCHAR, code VARCHAR, role VARCHAR, since INTEGER, until INTEGER, item_key VARCHAR, p_id INTEGER, note VARCHAR); +CREATE TABLE IF NOT EXISTS pfs.code_exposure ( + code VARCHAR, family VARCHAR, effective_date DATE, year INTEGER, kind VARCHAR, + status VARCHAR, prior_status VARCHAR, rvu_band INTEGER, non_fac_total DOUBLE, + delta_pct DOUBLE, item_key VARCHAR, p_id INTEGER, page INTEGER, anchor_kind VARCHAR, + correction BOOLEAN, note VARCHAR); CREATE TABLE IF NOT EXISTS pfs.code_guidance ( family VARCHAR, code VARCHAR, kind VARCHAR, locator VARCHAR, item_key VARCHAR, item_key_src VARCHAR, p_id_src INTEGER, page_src INTEGER, page INTEGER); @@ -418,6 +461,29 @@ def read_all_events(con: Any) -> dict[str, list[EventRow]]: return out +def write_exposures(con: Any, rows: Sequence[ExposureRow]) -> int: + """Full replace — the calendar is always rebuilt whole (control sets + need every code's exposures, not one family's).""" + ensure_tables(con) + con.execute("DELETE FROM pfs.code_exposure") + return _insert(con, "pfs.code_exposure", rows) + + +def read_exposures(con: Any, *, code: str = "", family: str = "") -> list[ExposureRow]: + where, params = [], [] + if code: + where.append("code = ?") + params.append(code.upper()) + if family: + where.append("family = ?") + params.append(family) + sql = "SELECT * FROM pfs.code_exposure" + if where: + sql += " WHERE " + " AND ".join(where) + rows = con.execute(sql + " ORDER BY code, year, kind", params).fetchall() + return [ExposureRow(*r) for r in rows] + + def read_families(con: Any) -> list[FamilyRow]: rows = con.execute( "SELECT * FROM pfs.code_family ORDER BY key, role, code" diff --git a/src/pfs/exposure.py b/src/pfs/exposure.py new file mode 100644 index 0000000..be790f7 --- /dev/null +++ b/src/pfs/exposure.py @@ -0,0 +1,402 @@ +"""The exposure calendar (#693): when a code became payable under the +fee schedule, was revalued, changed payable status, ended, or was listed +for telehealth — one dated row each, anchored to the lineage paragraph +that says so, with the status and RVU-band context a control set is +matched on. + +Legal-epidemiology framing (spec §P50): the fee schedule is law, each +row here is an exposure date, and ``control_codes`` picks the +never-treated comparison set for a window around it. The calendar is +derived, never hand-entered:: + + rvu_series(con) pfs.rvu → {code: [(year, status, non_fac_total)]} + rvu_bands(series) (year, code) → decile of non_fac_total among + that year's payable codes + derive(...) pure: series + pfs.code_event rows → ExposureRow + build(con, store) gathers the inputs (rule kinds/dates from bib) + control_codes(con, ...) same status + band, no exposure in ±window + +Rules the rows follow: + +* **Payable** means RVU status ``A``/``R``/``T`` (``pfs.valuation + .PAID_STATUSES`` — the same set ``stack pfs lineage --all-payable`` + targets). ``becomes-payable`` is the first year a code is payable after + a year it was not (or was absent); ``ends`` is the first year it is not + (or is absent) after a payable year; ``status-change`` is a move + between two payable statuses (A→R, R→T …). Non-payable ↔ non-payable + moves are not exposures. +* **Left-censoring**: the RVU files start in 2015. A code payable in the + first year only gets a ``becomes-payable`` row when a Federal Register + ``created``/``adopted_cpt``/``replaces`` event for it sits in that + year — otherwise its start is unknown, not 2015. +* **Revalued** rows come from the lineage ``revalued`` events (>10% + change in ``non_fac_total``); ``telehealth-listed`` rows from the FR + ``telehealth_list`` events, once per run of consecutive years — a + code mentioned on the list in 2022, 2023 and 2024 was listed once, + in 2022. +* **Anchors** come from the same code's FR lineage rows: non-correction + rules first, then the nearest year (same year, else ±1 — FR events are + dated by the mentioning rule's payment year), kind-matched + (``created``/``adopted_cpt``/``replaces`` for becomes-payable; + ``deleted``/``replaced_by``/``bundled`` for ends; ``telehealth_list`` + for telehealth) before any other kind, lowest ``p_id`` last. A + correction notice (``bib.frlink.rule_kind``) is chosen only when no + other rule anchors the event — and when it sits in the event's own + year the exposure's effective date is the notice's publication date, + not 1 January. +""" + +from __future__ import annotations + +import datetime as dt +import re +from typing import Any, Callable, Mapping, Sequence + +from pfs.codetables import EventRow, ExposureRow, read_all_events +from pfs.valuation import PAID_STATUSES + +KINDS = ("becomes-payable", "revalued", "status-change", "ends", "telehealth-listed") + +#: Preferred FR event kinds per exposure kind, best first; an empty tuple +#: means any FR event of the code anchors it. +_ANCHOR_PREF: dict[str, tuple[str, ...]] = { + "becomes-payable": ("created", "adopted_cpt", "replaces"), + "ends": ("deleted", "replaced_by", "bundled"), + "telehealth-listed": ("telehealth_list",), + "revalued": (), + "status-change": (), +} +_BIRTH_KINDS = ("created", "adopted_cpt", "replaces") +_PCT = re.compile(r"([+-]?\d+(?:\.\d+)?)%") + +Series = dict[str, list[tuple[int, str, float]]] +#: item_key → (rule kind, publication date) — ``bib.frlink.rule_kind`` +#: plus ``items.date_published``; ``None`` date when unknown. +RuleInfo = Mapping[str, tuple[str, dt.date | None]] + + +def payable(status: str) -> bool: + return (status or "") in PAID_STATUSES + + +# ── inputs ──────────────────────────────────────────────────────────── + + +def rvu_series(con: Any) -> Series: + """One scan of ``pfs.rvu``: every code's ``(year, status, non_fac_total)`` + on its unmodified row (mod NULL/''), sorted by year.""" + out: Series = {} + for code, year, status, total in con.execute( + "SELECT hcpcs, year, status_code, non_fac_total FROM pfs.rvu " + "WHERE mod IS NULL OR mod = '' " + "QUALIFY row_number() OVER (PARTITION BY hcpcs, year ORDER BY mod NULLS FIRST) = 1 " + "ORDER BY hcpcs, year" + ).fetchall(): + out.setdefault(code, []).append((int(year), status or "", float(total or 0.0))) + return out + + +def rvu_bands(series: Series) -> dict[tuple[int, str], int]: + """``(year, code) → 1..10`` — decile of ``non_fac_total`` among the + codes payable that year (ties share the lower rank's decile).""" + per_year: dict[int, list[tuple[float, str]]] = {} + for code, rows in series.items(): + for year, status, total in rows: + if payable(status): + per_year.setdefault(year, []).append((total, code)) + out: dict[tuple[int, str], int] = {} + for year, vals in per_year.items(): + vals.sort() + n = len(vals) + prev_total, prev_band = None, 0 + for i, (total, code) in enumerate(vals): + band = prev_band if total == prev_total else 1 + (i * 10) // n + out[(year, code)] = band + prev_total, prev_band = total, band + return out + + +def series_span(series: Series) -> tuple[int, int]: + years = [y for rows in series.values() for y, _, _ in rows] + return (min(years), max(years)) if years else (0, 0) + + +# ── derivation (pure) ───────────────────────────────────────────────── + + +def _anchor( + kind: str, + year: int, + fr: Sequence[EventRow], + rule_info: RuleInfo, +) -> tuple[EventRow | None, bool]: + """The lineage row anchoring (*kind*, *year*): nearest year, preferred + kind, lowest p_id; non-correction rules before correction notices. + Returns ``(row, is_correction)``.""" + pref = _ANCHOR_PREF.get(kind, ()) + + def rank(e: EventRow) -> tuple[int, int, int, int]: + is_corr = 1 if rule_info.get(e.item_key, ("", None))[0] == "correction" else 0 + k = pref.index(e.kind) if e.kind in pref else len(pref) + return (is_corr, abs(e.year - year), k, e.p_id) + + cands = [e for e in fr if abs(e.year - year) <= 1] + if pref: + matched = [e for e in cands if e.kind in pref] + cands = matched or cands + if not cands: + return None, False + best = min(cands, key=rank) + return best, rule_info.get(best.item_key, ("", None))[0] == "correction" + + +def _telehealth_years(fr: Sequence[EventRow]) -> list[int]: + years = sorted({e.year for e in fr if e.kind == "telehealth_list"}) + starts = [y for i, y in enumerate(years) if i == 0 or y - years[i - 1] > 1] + return starts + + +def derive( + series: Series, + events_by_code: Mapping[str, Sequence[EventRow]], + span: tuple[int, int], + *, + family_of: Callable[[str], str], + rule_info: RuleInfo, + bands: Mapping[tuple[int, str], int] | None = None, +) -> list[ExposureRow]: + """Every exposure for every code in *series* (plus telehealth + listings for codes only the FR knows), sorted by (code, year, kind).""" + lo, hi = span + bands = bands if bands is not None else rvu_bands(series) + out: list[ExposureRow] = [] + codes = sorted(set(series) | set(events_by_code)) + for code in codes: + rows = series.get(code, []) + by_year = {y: (s, t) for y, s, t in rows} + events = events_by_code.get(code, []) + fr = [e for e in events if e.source == "fr"] + found: dict[tuple[int, str], dict[str, Any]] = {} + + def put(year: int, kind: str, **kw: Any) -> None: + found.setdefault((year, kind), kw) + + # payable transitions, year by year across the file span — a code + # still payable in the newest file is right-censored, not ended + for year in range(lo, hi + 1): + prev = by_year.get(year - 1) + cur = by_year.get(year) + ps, pt = prev if prev else ("", 0.0) + cs, ct = cur if cur else ("", 0.0) + if year == lo: + if payable(cs) and any( + e.kind in _BIRTH_KINDS and e.year == lo for e in fr + ): + put( + year, + "becomes-payable", + status=cs, + prior_status="", + total=ct, + band_year=year, + ) + continue + if payable(cs) and not payable(ps): + put( + year, + "becomes-payable", + status=cs, + prior_status=ps, + total=ct, + band_year=year, + ) + elif payable(ps) and not payable(cs): + put( + year, + "ends", + status=cs, + prior_status=ps, + total=pt, + band_year=year - 1, + ) + elif payable(ps) and payable(cs) and ps != cs: + put( + year, + "status-change", + status=cs, + prior_status=ps, + total=ct, + band_year=year, + ) + + for e in events: + if e.source == "rvu" and e.kind == "revalued": + m = _PCT.search(e.note or "") + delta = float(m.group(1)) / 100.0 if m else 0.0 + s, t = by_year.get(e.year, ("", 0.0)) + ps, _ = by_year.get(e.year - 1, ("", 0.0)) + put( + e.year, + "revalued", + status=s, + prior_status=ps, + total=t, + band_year=e.year, + delta=delta, + ) + + for year in _telehealth_years(fr): + s, t = by_year.get(year, ("", 0.0)) + ps, _ = by_year.get(year - 1, ("", 0.0)) + put( + year, + "telehealth-listed", + status=s, + prior_status=ps, + total=t, + band_year=year, + ) + + fam = family_of(code) or "" + for (year, kind), kw in found.items(): + anchor, is_corr = _anchor(kind, year, fr, rule_info) + eff = dt.date(year, 1, 1) + # A correction notice only moves the effective date when it is + # the anchor for the event's own year — a neighbouring year's + # correction is evidence of the code, not of this event's date. + if anchor is not None and is_corr and anchor.year == year: + pub = rule_info.get(anchor.item_key, ("", None))[1] + if pub is not None: + eff = pub + out.append( + ExposureRow( + code=code, + family=fam, + effective_date=eff, + year=year, + kind=kind, + status=kw["status"], + prior_status=kw["prior_status"], + rvu_band=bands.get((kw["band_year"], code), 0), + non_fac_total=float(kw["total"] or 0.0), + delta_pct=float(kw.get("delta", 0.0)), + item_key=anchor.item_key if anchor else "", + p_id=anchor.p_id if anchor else 0, + page=anchor.page if anchor else 0, + anchor_kind=anchor.kind if anchor else "", + correction=bool(anchor is not None and is_corr), + note=anchor.note if anchor else "", + ) + ) + return sorted(out, key=lambda r: (r.code, r.year, r.kind)) + + +# ── gathering ───────────────────────────────────────────────────────── + + +def _rule_info( + store: Any, item_keys: Sequence[str] +) -> dict[str, tuple[str, dt.date | None]]: + from bib.frlink import rule_kind + + out: dict[str, tuple[str, dt.date | None]] = {} + for key in sorted(set(k for k in item_keys if k)): + title, pub = "", None + try: + item = store.get(key) + title = item.title or "" + raw = (item.date_published or "")[:10] + pub = dt.date.fromisoformat(raw) if raw else None + except Exception: # noqa: BLE001 — unknown item: no kind, no date + pass + try: + kind = rule_kind(store, key, title=title) + except Exception: # noqa: BLE001 + kind = "rule" + out[key] = (kind, pub) + return out + + +def build( + con: Any, + store: Any, + *, + family_of: Callable[[str], str] | None = None, +) -> list[ExposureRow]: + """The whole calendar from the replica (``pfs.rvu`` + ``pfs.code_event``) + and the bibliography (rule kinds and dates). *family_of* defaults to + the live family registry (``pfs.families.family_of``) after + ``refresh_from(con)``.""" + series = rvu_series(con) + events = read_all_events(con) + if family_of is None: + from pfs.families import family_of as _family_of + from pfs.families import refresh_from + + refresh_from(con) + + def family_of(code: str) -> str: + fam = _family_of(code) + return fam.key if fam else "" + + keys = [e.item_key for rows in events.values() for e in rows if e.source == "fr"] + info = _rule_info(store, keys) + return derive( + series, events, series_span(series), family_of=family_of, rule_info=info + ) + + +# ── controls ────────────────────────────────────────────────────────── + + +def control_codes( + con: Any, code: str, year: int, *, window: int = 2, limit: int = 0 +) -> list[tuple[str, str, str, int]]: + """Never-treated comparison codes for *code*'s exposure in *year*: + payable that year with the same RVU status and decile band, and no + exposure of their own within ``year ± window``. Returns + ``(code, family, status, band)`` rows, cheapest first. Empty when the + (code, year) has no exposure row.""" + target = con.execute( + "SELECT status, rvu_band FROM pfs.code_exposure WHERE code = ? AND year = ? " + "ORDER BY CASE kind WHEN 'becomes-payable' THEN 0 WHEN 'ends' THEN 1 ELSE 2 END LIMIT 1", + [code.upper(), year], + ).fetchone() + if not target: + return [] + status, band = target + band_year = year + if ( + not status + ): # an `ends` row: the code is absent in *year*; match on the prior year + band_year = year - 1 + row = con.execute( + "SELECT prior_status FROM pfs.code_exposure WHERE code = ? AND year = ? AND kind = 'ends'", + [code.upper(), year], + ).fetchone() + status = row[0] if row else status + series = rvu_series(con) + bands = rvu_bands(series) + exposed = { + r[0] + for r in con.execute( + "SELECT DISTINCT code FROM pfs.code_exposure WHERE year BETWEEN ? AND ?", + [year - window, year + window], + ).fetchall() + } + fam = { + r[0]: r[1] + for r in con.execute( + "SELECT code, min(key) FROM pfs.code_family GROUP BY code" + ).fetchall() + } + out: list[tuple[float, str, str, str, int]] = [] + for c, rows in series.items(): + if c == code.upper() or c in exposed: + continue + for y, s, t in rows: + if y == band_year and s == status and bands.get((y, c)) == band: + out.append((t, c, fam.get(c, ""), s, band)) + out.sort() + result = [(c, f, s, b) for _, c, f, s, b in out] + return result[:limit] if limit else result diff --git a/tests/cli/test_pfs_cli.py b/tests/cli/test_pfs_cli.py index a424bf3..b0a8cac 100644 --- a/tests/cli/test_pfs_cli.py +++ b/tests/cli/test_pfs_cli.py @@ -1017,3 +1017,82 @@ class TestReview: res = runner.invoke(app, ["pfs", "review", "--code", "g0556"]) assert res.exit_code == 0, res.output assert "Odd line" in res.output and "Other line" not in res.output + + +class TestExposure: + def test_write_builds_whole_calendar_and_publishes(self, con, monkeypatch): + from pfs.codetables import read_exposures + + res = runner.invoke(app, ["pfs", "exposure", "--write"]) + assert res.exit_code == 0, res.output + rows = read_exposures(con) + got = {(r.code, r.year, r.kind) for r in rows} + # 99490 appears (payable) in 2026 after being absent; G2058's only year is + # 2020 so it ends 2021; 99999 is B-status → never payable → no rows. + assert ("99490", 2026, "becomes-payable") in got + assert ("G2058", 2021, "ends") in got + assert not any(r.code == "99999" for r in rows) + assert con.published == [True] + assert "99490 2026-01-01 becomes-payable" in res.output + assert "exposures:" in res.output + + def test_filters_only_affect_printing(self, con, monkeypatch): + from pfs.codetables import read_exposures + + res = runner.invoke(app, ["pfs", "exposure", "--write", "--code", "g2058"]) + assert res.exit_code == 0, res.output + assert len({r.code for r in read_exposures(con)}) == 2 # whole calendar written + assert "G2058" in res.output and "99490" not in res.output + + def test_without_write_reads_and_never_takes_the_write_lock(self, con, monkeypatch): + runner.invoke(app, ["pfs", "exposure", "--write"]) + con.published.clear() + + def fail_batch(): + raise AssertionError("must not open a RW duckdb_batch connection") + + monkeypatch.setattr(pfs_cli, "_batch", fail_batch) + res = runner.invoke(app, ["pfs", "exposure", "--code", "99490"]) + assert res.exit_code == 0, res.output + assert "99490 2026-01-01 becomes-payable" in res.output + assert con.published == [] + + def test_controls_lists_quiet_codes_in_the_same_band(self, con, monkeypatch): + # two more A-status codes in 2026 in the same (single) band, one of + # which is itself exposed that year → only the quiet one is a control + con.executemany( + "INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)", + [ + # 11111 is payable across the whole file span → never treated + *[("11111", None, "quiet", "A", 1.0, y) for y in range(2020, 2027)], + ("22222", None, "new", "A", 1.0, 2026), # becomes payable 2026 itself + ], + ) + runner.invoke(app, ["pfs", "exposure", "--write"]) + res = runner.invoke( + app, + ["pfs", "exposure", "--code", "99490", "--year", "2026", "--controls", "5"], + ) + assert res.exit_code == 0, res.output + assert "11111 A band" in res.output + assert "22222" not in res.output + assert "controls for 99490 @ 2026" in res.output + + def test_controls_requires_one_code_and_year(self, con): + res = runner.invoke(app, ["pfs", "exposure", "--controls", "5"]) + assert res.exit_code != 0 + + def test_no_calendar_yet_is_a_friendly_no_op(self, monkeypatch): + 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", "exposure"]) + assert res.exit_code == 0, res.output + assert "no exposure calendar yet" in res.output + finally: + bare.close() diff --git a/tests/notebooks/test_code_families_nb.py b/tests/notebooks/test_code_families_nb.py index f995711..62c79d5 100644 --- a/tests/notebooks/test_code_families_nb.py +++ b/tests/notebooks/test_code_families_nb.py @@ -39,7 +39,7 @@ def test_cells_are_anonymous_and_banners_present(): names = [n.name for n in tree.body if isinstance(n, ast.FunctionDef)] assert names and set(names) == {"_"} banners = re.findall(r"# ── (\S+)\. ", src) - assert banners == ["0", "1", "2", "3", "4", "5", "6", "7", "7b", "8"] + assert banners == ["0", "1", "2", "3", "4", "5", "6", "7", "7b", "7c", "8"] def test_headless_run_degrades_without_data(monkeypatch, tmp_path): diff --git a/tests/pfs/test_exposure.py b/tests/pfs/test_exposure.py new file mode 100644 index 0000000..72083c7 --- /dev/null +++ b/tests/pfs/test_exposure.py @@ -0,0 +1,382 @@ +"""pfs.exposure — the exposure calendar derived from lineage + RVU status (#693).""" + +from __future__ import annotations + +import datetime as dt +import sqlite3 + +import duckdb +import pytest + +from pfs.codetables import ( + EventRow, + ExposureRow, + ensure_tables, + read_exposures, + write_events, + write_exposures, +) +from pfs.exposure import ( + KINDS, + build, + control_codes, + derive, + rvu_bands, + rvu_series, +) + +RVU_COLS = "hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, non_fac_total DOUBLE, year INTEGER" + + +def _fr(code, year, kind, item_key, p_id, page=1, from_codes="", to_codes=""): + return EventRow( + code, year, kind, from_codes, to_codes, item_key, p_id, page, "fr", True, "" + ) + + +def _rvu(code, year, kind, note=""): + return EventRow(code, year, kind, "", "", "", 0, 0, "rvu", True, note) + + +@pytest.fixture +def con(): + c = duckdb.connect(":memory:") + ensure_tables(c) + c.execute(f"CREATE TABLE pfs.rvu ({RVU_COLS})") + rows = [ + # span 2015..2026; ten filler payable codes give the deciles something to rank + *[ + (f"F{i:04d}", None, "filler", "A", float(i), y) + for i in range(1, 11) + for y in range(2015, 2027) + ], + # 99490 payable from the first year (left-censored) — FR says created 2015 + *[("99490", None, "ccm", "A", 1.2, y) for y in range(2015, 2027)], + # 99487: B 2015-16, A from 2017; +47% revalue 2022 + ("99487", None, "cplx", "B", 0.0, 2015), + ("99487", None, "cplx", "B", 0.0, 2016), + *[("99487", None, "cplx", "A", 2.6, y) for y in range(2017, 2022)], + *[("99487", None, "cplx", "A", 3.9, y) for y in range(2022, 2027)], + # G2058: 2020 only → ends 2021 (replaced by 99439) + ("G2058", None, "ccm addl", "A", 1.05, 2020), + *[("99439", "", "ccm addl", "A", 1.1, y) for y in range(2021, 2027)], + # 99441: N through 2020, A 2021-2024, gone 2025 + *[("99441", None, "phone", "N", 0.4, y) for y in range(2015, 2021)], + *[("99441", None, "phone", "A", 1.6, y) for y in range(2021, 2025)], + # 12345: A → R (both payable) in 2019, R → C (non-payable) in 2023 + *[("12345", None, "x", "A", 5.0, y) for y in range(2015, 2019)], + *[("12345", None, "x", "R", 5.0, y) for y in range(2019, 2023)], + *[("12345", None, "x", "C", 0.0, y) for y in range(2023, 2027)], + # modifier rows are ignored + ("99487", "26", "modifier row ignored", "A", 99.0, 2022), + ] + c.executemany("INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)", rows) + write_events( + c, + "99490", + [ + _fr("99490", 2015, "adopted_cpt", "DE2VH9PD", 1251, 67716), + _fr("99490", 2015, "created", "6N8FPFHF", 49, 14854), + _fr("99490", 2016, "created", "VZW3HXDA", 1181), + ], + ) + write_events( + c, + "99487", + [ + _rvu("99487", 2017, "status_change", "B→A"), + _rvu("99487", 2022, "revalued", "+47.5%"), + _fr("99487", 2022, "crosswalk", "JE7KYBW3", 900), + ], + ) + write_events( + c, + "G2058", + [ + _rvu("G2058", 2021, "disappeared"), + _fr("G2058", 2021, "replaced_by", "YBM4IZUS", 1578, to_codes="99439"), + ], + ) + write_events( + c, + "99439", + [ + _rvu("99439", 2021, "appeared"), + _fr("99439", 2021, "created", "YBM4IZUS", 686), + _fr("99439", 2021, "replaces", "YBM4IZUS", 1578, from_codes="G2058"), + ], + ) + write_events( + c, + "99441", + [ + _fr("99441", 2022, "telehealth_list", "JE7KYBW3", 555), + _fr("99441", 2023, "telehealth_list", "MZ24MX5S", 336), + _fr("99441", 2023, "telehealth_list", "PETM225D", 458), + _fr("99441", 2025, "deleted", "XFGGRBDH", 489), + _fr("99441", 2025, "bundled", "JJ6AM5HJ", 765), + # a lone correction-only anchor: only a correction notice mentions this + _fr("99441", 2021, "created", "CORR2021", 7), + ], + ) + write_events(c, "12345", []) + yield c + c.close() + + +class _Store: + def __init__(self): + self.con = sqlite3.connect(":memory:") + self.con.row_factory = sqlite3.Row + self.con.executescript( + "CREATE TABLE items (key TEXT PRIMARY KEY, title TEXT, date_published TEXT);" + "CREATE TABLE fr_anchors (item_key TEXT, p_id INTEGER, page INTEGER, ordinal INTEGER, text TEXT);" + ) + self.con.executemany( + "INSERT INTO items VALUES (?,?,?)", + [ + ("DE2VH9PD", "Medicare Program; CY 2015 PFS Final Rule", "2014-11-13"), + ("6N8FPFHF", "Medicare Program; CY 2015 PFS; Correction", "2015-03-20"), + ("VZW3HXDA", "CY 2016 PFS Proposed Rule", "2015-07-15"), + ("JE7KYBW3", "CY 2022 PFS Final Rule", "2021-11-19"), + ("YBM4IZUS", "CY 2021 PFS Final Rule", "2020-12-28"), + ("MZ24MX5S", "CY 2023 PFS Final Rule", "2022-11-18"), + ("PETM225D", "CY 2023 PFS Proposed Rule", "2022-07-29"), + ("XFGGRBDH", "CY 2025 PFS Proposed Rule", "2024-07-31"), + ("JJ6AM5HJ", "CY 2025 PFS Final Rule", "2024-12-09"), + ("CORR2021", "CY 2021 PFS Final Rule; Correction", "2021-01-19"), + ], + ) + + def _con(self): + return self.con + + def get(self, key): + row = self.con.execute("SELECT * FROM items WHERE key=?", (key,)).fetchone() + if row is None: + raise KeyError(key) + + class _I: + title = row["title"] + date_published = row["date_published"] + + return _I() + + def close(self): + self.con.close() + + +@pytest.fixture +def store(): + s = _Store() + yield s + s.close() + + +def _by(rows, code, kind=None): + return [r for r in rows if r.code == code and (kind is None or r.kind == kind)] + + +class TestSeriesAndBands: + def test_series_ignores_modifier_rows(self, con): + s = rvu_series(con) + assert [y for y, _, _ in s["99487"]] == list(range(2015, 2027)) + assert all(t != 99.0 for _, _, t in s["99487"]) + + def test_bands_are_deciles_among_payable_codes(self, con): + s = rvu_series(con) + b = rvu_bands(s) + # ten fillers 1..10 plus the real codes; the cheapest is band 1, dearest band 10 + assert b[(2020, "F0001")] == 1 + # 14 payable codes in 2020; 12345 (5.0) sorts 8th (ties break on code) → 1 + 7*10//14 + assert b[(2020, "12345")] == 6 + assert b[(2020, "F0010")] == 10 + assert (2016, "99487") not in b # B status is not payable → no band + + def test_ties_share_a_band(self): + series = {c: [(2020, "A", 1.0)] for c in ("A0001", "A0002", "A0003")} + series["B0001"] = [(2020, "A", 2.0)] + b = rvu_bands(series) + assert b[(2020, "A0001")] == b[(2020, "A0002")] == b[(2020, "A0003")] == 1 + assert b[(2020, "B0001")] == 8 # index 3 of 4 → 1 + 30//4 + + +class TestDerive: + def test_kinds_are_closed(self): + assert KINDS == ( + "becomes-payable", + "revalued", + "status-change", + "ends", + "telehealth-listed", + ) + + def test_becomes_payable_from_status_flip_and_appearance(self, con, store): + rows = build(con, store) + r = _by(rows, "99487", "becomes-payable") + assert ( + len(r) == 1 + and r[0].year == 2017 + and r[0].prior_status == "B" + and r[0].status == "A" + ) + assert r[0].effective_date == dt.date(2017, 1, 1) + r = _by(rows, "99439", "becomes-payable") + assert len(r) == 1 and r[0].year == 2021 and r[0].prior_status == "" + # anchor: kind-matched (created/replaces) from the same year, lowest p_id + assert (r[0].item_key, r[0].p_id, r[0].anchor_kind) == ( + "YBM4IZUS", + 686, + "created", + ) + + def test_left_censored_first_year_needs_fr_evidence(self, con, store): + rows = build(con, store) + r = _by(rows, "99490", "becomes-payable") + assert len(r) == 1 and r[0].year == 2015 + # the final rule's adopted_cpt anchor wins over the correction notice + assert r[0].item_key == "DE2VH9PD" and r[0].correction is False + assert r[0].effective_date == dt.date(2015, 1, 1) + # filler codes are payable in 2015 too but have no FR evidence → no row + assert not _by(rows, "F0001") + + def test_ends_on_disappearance_and_on_non_payable_flip(self, con, store): + rows = build(con, store) + r = _by(rows, "G2058", "ends") + assert ( + len(r) == 1 + and r[0].year == 2021 + and r[0].prior_status == "A" + and r[0].status == "" + ) + assert (r[0].item_key, r[0].anchor_kind, r[0].p_id) == ( + "YBM4IZUS", + "replaced_by", + 1578, + ) + r = _by(rows, "99441", "ends") + assert len(r) == 1 and r[0].year == 2025 and r[0].anchor_kind == "deleted" + r = _by(rows, "12345", "ends") + assert ( + len(r) == 1 + and r[0].year == 2023 + and r[0].prior_status == "R" + and r[0].status == "C" + ) + assert r[0].item_key == "" and r[0].p_id == 0 # no FR mention at all + + def test_status_change_only_between_payable_statuses(self, con, store): + rows = build(con, store) + r = _by(rows, "12345", "status-change") + assert [(x.year, x.prior_status, x.status) for x in r] == [(2019, "A", "R")] + assert not _by( + rows, "99441", "status-change" + ) # N→A is becomes-payable, not status-change + + def test_revalued_carries_delta_and_nearest_anchor(self, con, store): + rows = build(con, store) + r = _by(rows, "99487", "revalued") + assert len(r) == 1 and r[0].year == 2022 + assert r[0].delta_pct == pytest.approx(0.475) + assert r[0].item_key == "JE7KYBW3" and r[0].anchor_kind == "crosswalk" + + def test_telehealth_listed_once_per_run_of_years(self, con, store): + rows = build(con, store) + r = _by(rows, "99441", "telehealth-listed") + assert [x.year for x in r] == [2022] # 2023 continues the 2022 run + assert r[0].item_key == "JE7KYBW3" and r[0].p_id == 555 + + def test_correction_only_anchor_moves_the_effective_date(self, con, store): + rows = build(con, store) + r = _by(rows, "99441", "becomes-payable") + assert len(r) == 1 and r[0].year == 2021 + assert r[0].correction is True and r[0].item_key == "CORR2021" + assert r[0].effective_date == dt.date(2021, 1, 19) + + def test_neighbouring_year_correction_keeps_january_first(self, con, store): + # 20982-style: a 2016 revalue whose only FR mention is the CY2015 + # correction notice (±1 year) stays dated 2016-01-01. + write_events( + con, + "55555", + [ + _rvu("55555", 2016, "revalued", "+20.0%"), + _fr("55555", 2015, "created", "6N8FPFHF", 3), + ], + ) + con.executemany( + "INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)", + [ + ("55555", None, "x", "A", 1.0, 2015), + ("55555", None, "x", "A", 1.2, 2016), + ], + ) + rows = build(con, store) + r = _by(rows, "55555", "revalued")[0] + assert r.item_key == "6N8FPFHF" and r.correction is True + assert r.effective_date == dt.date(2016, 1, 1) + # and its 2015 becomes-payable IS dated by the correction (same year) + assert _by(rows, "55555", "becomes-payable")[0].effective_date == dt.date( + 2015, 3, 20 + ) + + def test_bands_and_family_carried(self, con, store): + rows = build( + con, + store, + family_of=lambda c: ( + "CCM" if c in {"99490", "99487", "99439", "G2058"} else "" + ), + ) + r = _by(rows, "99487", "becomes-payable")[0] + assert r.family == "CCM" and r.rvu_band >= 1 and r.non_fac_total == 2.6 + e = _by(rows, "G2058", "ends")[0] + assert e.non_fac_total == 1.05 and e.rvu_band >= 1 # from the last payable year + assert _by(rows, "12345", "ends")[0].family == "" + + def test_rows_sorted_and_unique(self, con, store): + rows = build(con, store) + keys = [(r.code, r.year, r.kind) for r in rows] + assert len(keys) == len(set(keys)) + assert keys == sorted(keys) + + def test_derive_is_pure(self): + series = {"X1234": [(2015, "A", 1.0), (2016, "A", 1.0), (2017, "N", 0.0)]} + rows = derive(series, {}, (2015, 2017), family_of=lambda c: "", rule_info={}) + assert [(r.kind, r.year) for r in rows] == [("ends", 2017)] + + +class TestPersist: + def test_write_and_read_roundtrip(self, con, store): + rows = build(con, store) + n = write_exposures(con, rows) + assert n == len(rows) > 0 + back = read_exposures(con) + assert back == rows + assert read_exposures(con, code="99487") == _by(rows, "99487") + assert all(isinstance(r, ExposureRow) for r in back) + write_exposures(con, rows) # idempotent full replace + assert len(read_exposures(con)) == n + + +class TestControls: + def test_controls_match_status_and_band_outside_window(self, con, store): + rows = build(con, store) + write_exposures(con, rows) + # 99487 becomes payable 2017 (A, band by 2.6). Controls: A-status codes in the + # same band that year with no exposure in 2015..2019. + ctrl = control_codes(con, "99487", 2017, window=2) + codes = {c for c, *_ in ctrl} + assert "99487" not in codes + assert ( + "99490" not in codes or True + ) # 99490's only exposure is 2015 → outside window, allowed + assert all(status == "A" for _, _, status, _ in ctrl) + band = _by(rows, "99487", "becomes-payable")[0].rvu_band + assert all(b == band for *_, b in ctrl) + # 12345 has a 2019 status-change → inside the window → excluded even if same band + assert "12345" not in codes + + def test_unknown_code_year_is_empty(self, con, store): + write_exposures(con, build(con, store)) + assert control_codes(con, "ZZZZZ", 2017) == [] From 57ed2c70098cdccc9b7b743ff5007f740fb2df40 Mon Sep 17 00:00:00 2001 From: kert Date: Tue, 22 Sep 2026 15:46:24 -0400 Subject: [PATCH 3/3] docs(cli): renumber sidebar positions after the pfs-exposure page (refs #693) --- docs/docs/cli/mail.md | 2 +- docs/docs/cli/pfs-cpt-ingest.md | 2 +- docs/docs/cli/pfs-review.md | 2 +- docs/docs/cli/prisma-eligible.md | 2 +- docs/docs/cli/prisma-export.md | 2 +- docs/docs/cli/prisma-extract.md | 2 +- docs/docs/cli/prisma-fetch.md | 2 +- docs/docs/cli/prisma-flow.md | 2 +- docs/docs/cli/prisma-init.md | 2 +- docs/docs/cli/prisma-ping-llm.md | 2 +- docs/docs/cli/prisma-run.md | 2 +- docs/docs/cli/prisma-screen.md | 2 +- docs/docs/cli/prisma-vpn.md | 2 +- docs/docs/cli/prisma.md | 2 +- docs/docs/cli/zot-dump-schema.md | 2 +- docs/docs/cli/zot-fix-dates.md | 2 +- docs/docs/cli/zot-fix-fields.md | 2 +- docs/docs/cli/zot-fix-keys.md | 2 +- docs/docs/cli/zot-verify-parity.md | 2 +- docs/docs/cli/zot.md | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/docs/cli/mail.md b/docs/docs/cli/mail.md index 90d997f..79af001 100644 --- a/docs/docs/cli/mail.md +++ b/docs/docs/cli/mail.md @@ -1,6 +1,6 @@ --- title: stack mail -sidebar_position: 90 +sidebar_position: 91 --- # `stack mail` diff --git a/docs/docs/cli/pfs-cpt-ingest.md b/docs/docs/cli/pfs-cpt-ingest.md index c21c8e3..1c8cffd 100644 --- a/docs/docs/cli/pfs-cpt-ingest.md +++ b/docs/docs/cli/pfs-cpt-ingest.md @@ -1,6 +1,6 @@ --- title: stack pfs cpt-ingest -sidebar_position: 72 +sidebar_position: 73 --- # `stack pfs cpt-ingest` diff --git a/docs/docs/cli/pfs-review.md b/docs/docs/cli/pfs-review.md index fa10404..0e5a1b0 100644 --- a/docs/docs/cli/pfs-review.md +++ b/docs/docs/cli/pfs-review.md @@ -1,6 +1,6 @@ --- title: stack pfs review -sidebar_position: 71 +sidebar_position: 72 --- # `stack pfs review` diff --git a/docs/docs/cli/prisma-eligible.md b/docs/docs/cli/prisma-eligible.md index 6e92d1a..86a021b 100644 --- a/docs/docs/cli/prisma-eligible.md +++ b/docs/docs/cli/prisma-eligible.md @@ -1,6 +1,6 @@ --- title: stack prisma eligible -sidebar_position: 84 +sidebar_position: 85 --- # `stack prisma eligible` diff --git a/docs/docs/cli/prisma-export.md b/docs/docs/cli/prisma-export.md index a54e4a3..53bdabe 100644 --- a/docs/docs/cli/prisma-export.md +++ b/docs/docs/cli/prisma-export.md @@ -1,6 +1,6 @@ --- title: stack prisma export -sidebar_position: 81 +sidebar_position: 82 --- # `stack prisma export` diff --git a/docs/docs/cli/prisma-extract.md b/docs/docs/cli/prisma-extract.md index 0b2549c..16142f2 100644 --- a/docs/docs/cli/prisma-extract.md +++ b/docs/docs/cli/prisma-extract.md @@ -1,6 +1,6 @@ --- title: stack prisma extract -sidebar_position: 85 +sidebar_position: 86 --- # `stack prisma extract` diff --git a/docs/docs/cli/prisma-fetch.md b/docs/docs/cli/prisma-fetch.md index 6969fb1..5585803 100644 --- a/docs/docs/cli/prisma-fetch.md +++ b/docs/docs/cli/prisma-fetch.md @@ -1,6 +1,6 @@ --- title: stack prisma fetch -sidebar_position: 87 +sidebar_position: 88 --- # `stack prisma fetch` diff --git a/docs/docs/cli/prisma-flow.md b/docs/docs/cli/prisma-flow.md index 309dfd3..a1bac12 100644 --- a/docs/docs/cli/prisma-flow.md +++ b/docs/docs/cli/prisma-flow.md @@ -1,6 +1,6 @@ --- title: stack prisma flow -sidebar_position: 86 +sidebar_position: 87 --- # `stack prisma flow` diff --git a/docs/docs/cli/prisma-init.md b/docs/docs/cli/prisma-init.md index 2d0a71d..d0f9feb 100644 --- a/docs/docs/cli/prisma-init.md +++ b/docs/docs/cli/prisma-init.md @@ -1,6 +1,6 @@ --- title: stack prisma init -sidebar_position: 80 +sidebar_position: 81 --- # `stack prisma init` diff --git a/docs/docs/cli/prisma-ping-llm.md b/docs/docs/cli/prisma-ping-llm.md index 6f64f18..0e8f655 100644 --- a/docs/docs/cli/prisma-ping-llm.md +++ b/docs/docs/cli/prisma-ping-llm.md @@ -1,6 +1,6 @@ --- title: stack prisma ping-llm -sidebar_position: 82 +sidebar_position: 83 --- # `stack prisma ping-llm` diff --git a/docs/docs/cli/prisma-run.md b/docs/docs/cli/prisma-run.md index 1fc9e8e..1c9ba9d 100644 --- a/docs/docs/cli/prisma-run.md +++ b/docs/docs/cli/prisma-run.md @@ -1,6 +1,6 @@ --- title: stack prisma run -sidebar_position: 88 +sidebar_position: 89 --- # `stack prisma run` diff --git a/docs/docs/cli/prisma-screen.md b/docs/docs/cli/prisma-screen.md index a43bdfb..206f6ce 100644 --- a/docs/docs/cli/prisma-screen.md +++ b/docs/docs/cli/prisma-screen.md @@ -1,6 +1,6 @@ --- title: stack prisma screen -sidebar_position: 83 +sidebar_position: 84 --- # `stack prisma screen` diff --git a/docs/docs/cli/prisma-vpn.md b/docs/docs/cli/prisma-vpn.md index 1223041..c6edb1e 100644 --- a/docs/docs/cli/prisma-vpn.md +++ b/docs/docs/cli/prisma-vpn.md @@ -1,6 +1,6 @@ --- title: stack prisma vpn -sidebar_position: 89 +sidebar_position: 90 --- # `stack prisma vpn` diff --git a/docs/docs/cli/prisma.md b/docs/docs/cli/prisma.md index 291c2be..98ac801 100644 --- a/docs/docs/cli/prisma.md +++ b/docs/docs/cli/prisma.md @@ -1,6 +1,6 @@ --- title: stack prisma -sidebar_position: 79 +sidebar_position: 80 --- # `stack prisma` diff --git a/docs/docs/cli/zot-dump-schema.md b/docs/docs/cli/zot-dump-schema.md index 1af948b..5d872a8 100644 --- a/docs/docs/cli/zot-dump-schema.md +++ b/docs/docs/cli/zot-dump-schema.md @@ -1,6 +1,6 @@ --- title: stack zot dump-schema -sidebar_position: 74 +sidebar_position: 75 --- # `stack zot dump-schema` diff --git a/docs/docs/cli/zot-fix-dates.md b/docs/docs/cli/zot-fix-dates.md index 7503c89..1bb7067 100644 --- a/docs/docs/cli/zot-fix-dates.md +++ b/docs/docs/cli/zot-fix-dates.md @@ -1,6 +1,6 @@ --- title: stack zot fix-dates -sidebar_position: 75 +sidebar_position: 76 --- # `stack zot fix-dates` diff --git a/docs/docs/cli/zot-fix-fields.md b/docs/docs/cli/zot-fix-fields.md index 1a468b1..b55b051 100644 --- a/docs/docs/cli/zot-fix-fields.md +++ b/docs/docs/cli/zot-fix-fields.md @@ -1,6 +1,6 @@ --- title: stack zot fix-fields -sidebar_position: 77 +sidebar_position: 78 --- # `stack zot fix-fields` diff --git a/docs/docs/cli/zot-fix-keys.md b/docs/docs/cli/zot-fix-keys.md index 5838fa4..cd10bc5 100644 --- a/docs/docs/cli/zot-fix-keys.md +++ b/docs/docs/cli/zot-fix-keys.md @@ -1,6 +1,6 @@ --- title: stack zot fix-keys -sidebar_position: 76 +sidebar_position: 77 --- # `stack zot fix-keys` diff --git a/docs/docs/cli/zot-verify-parity.md b/docs/docs/cli/zot-verify-parity.md index d1b3cda..0924b84 100644 --- a/docs/docs/cli/zot-verify-parity.md +++ b/docs/docs/cli/zot-verify-parity.md @@ -1,6 +1,6 @@ --- title: stack zot verify-parity -sidebar_position: 78 +sidebar_position: 79 --- # `stack zot verify-parity` diff --git a/docs/docs/cli/zot.md b/docs/docs/cli/zot.md index af60546..4ca8eb0 100644 --- a/docs/docs/cli/zot.md +++ b/docs/docs/cli/zot.md @@ -1,6 +1,6 @@ --- title: stack zot -sidebar_position: 73 +sidebar_position: 74 --- # `stack zot`