414 lines
29 KiB
Markdown
414 lines
29 KiB
Markdown
# CY2026 PFS Proposed Rule (P36) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Capture, process, and analyze the CY2026 PFS proposed rule (CMS-1832-P, docket CMS-2025-0304, FR doc 2025-13271): files, parameters with provenance logging, comments, rule-text RAG, QPP/Advanced-APM model, and a financial-changes notebook.
|
||
|
||
**Architecture:** Extends existing modules (`bib`, `pfs`, `llm`, `rex.comments`, `cms`) plus one new module (`qpp`). Reference data lands in `aco.duckdb` and the DuckLake `pfs`/`cms` schemas; embeddings land in pgvector collections `comments` and `rules`; analysis is a marimo notebook.
|
||
|
||
**Tech Stack:** Python 3.12, pydantic v2, DuckDB/DuckLake, narwhals, pgvector via langchain-postgres, Ollama (local only), marimo, typer CLI, pytest.
|
||
|
||
**Tracker:** milestone P36 (id 37) — issues #588–#597. Spec: `docs/superpowers/specs/2026-08-12-cy2026-pfs-proposed-rule-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- Branch: all work on `p36-cy2026-rule` off `main`; merge to main at close-out.
|
||
- Conventional commits, milestone refs in subject: `feat(pfs): … (refs #591)`. **No `Co-Authored-By` trailers.**
|
||
- Coverage bar is **99%** (`[ci].coverage_threshold`); mocking is stdlib `unittest.mock` + `monkeypatch` only.
|
||
- Ruff `E,F,I`, line length 88; run `uv run ruff check src tests && uv run ruff format --check src tests` before each commit.
|
||
- Repo convention: **lazy `from conf import …` inside functions**, not module top-level.
|
||
- `.gitea/workflows/*` are generated — never hand-edit; if `stack.toml` changes, run `uv run python dev/scripts/gen_config.py`.
|
||
- Notebooks live flat in `notebooks/` — no subdirectories, no config files there (`tests/test_notebook_layout.py` enforces).
|
||
- The llm module is **local-only** (self-hosted Ollama); never call cloud LLM APIs from it.
|
||
- DuckDB writes go through `conf.connect.duckdb_batch("aco")` (single-writer lock preflight). If it raises naming a marimo kernel PID, kill that kernel worker and re-run.
|
||
- **Never transcribe rule parameters from model memory** — every number added to a registry must be read out of the captured rule text or addenda in `data/fr_downloads/` / bib storage, and carry an FR citation + bib pincite.
|
||
- Rule-parameter values (Tasks 4, 6) may deviate from strict test-first: transcribe from source, then write exact-value tests pinning what was transcribed. All other code is TDD.
|
||
|
||
---
|
||
|
||
### Task 1: Fix CMS-2025-0304 docket↔rule mapping (#588)
|
||
|
||
**Files:**
|
||
- Modify: `src/rex/comments/classify.py` (the `_SYSTEM` prompt, ~line 144)
|
||
- Test: `tests/rex/comments/test_classify.py` (exists — add to it)
|
||
|
||
**Interfaces:**
|
||
- Produces: `rex.comments.classify.DOCKET_RULES: dict[str, str]` — docket id → CMS rule id, used by tests and future docket-scoped tooling.
|
||
|
||
- [ ] **Step 1: Write the failing test** (append to `tests/rex/comments/test_classify.py`):
|
||
|
||
```python
|
||
from rex.comments import classify
|
||
|
||
|
||
def test_docket_rules_maps_cy2026_pfs():
|
||
assert classify.DOCKET_RULES["CMS-2025-0304"] == "CMS-1832-P"
|
||
|
||
|
||
def test_system_prompt_names_the_pfs_rule():
|
||
assert "CMS-1832-P" in classify._SYSTEM
|
||
assert "CMS-1834-P" not in classify._SYSTEM
|
||
assert "PFS proposed rule" in classify._SYSTEM
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**: `uv run pytest tests/rex/comments/test_classify.py -v` — expect FAIL (`DOCKET_RULES` undefined; prompt says OPPS/CMS-1834-P).
|
||
- [ ] **Step 3: Implement.** In `classify.py`, above `_SYSTEM`, add:
|
||
|
||
```python
|
||
# Docket ↔ CMS rule identity for the dockets this classifier targets.
|
||
# Verified against the regulations.gov docket abstract (2026-08-12):
|
||
# CMS-2025-0304 is the CY2026 *PFS* NPRM, not the OPPS rule — skin-substitute
|
||
# payment moved into the PFS rule in CY2026.
|
||
DOCKET_RULES: dict[str, str] = {"CMS-2025-0304": "CMS-1832-P"}
|
||
```
|
||
|
||
Rewrite the `_SYSTEM` opening sentence to: `You classify public comments submitted on CMS's CY2026 PFS proposed rule\n(CMS-1832-P, docket CMS-2025-0304), specifically regarding the proposal to\nreclassify skin substitutes …` (keep the position semantics unchanged; keep the flat-rate description but drop the OPPS-specific `~$127.28/cm²` figure — that number is from the OPPS companion rule).
|
||
- [ ] **Step 4: Run the full comments test dir**: `uv run pytest tests/rex/comments -v` — expect PASS.
|
||
- [ ] **Step 5: Commit**: `git add -A && git commit -m "fix(comments): CMS-2025-0304 is the PFS NPRM (CMS-1832-P), not OPPS (refs #588)"`
|
||
|
||
---
|
||
|
||
### Task 2: Capture CY2026 rule text into fr_downloads + bib (#589)
|
||
|
||
Ops task — no new code, nothing to commit (`data/` is untracked). Run on the host.
|
||
|
||
- [ ] **Step 1: Preview**: `uv run python dev/scripts/fetch_fr_attachments.py --dry-run --key 2KVJ2HKX` — expect it to plan PDF+TXT downloads for doc `2025-13271`. If it reports "no document number", inspect `store.get("2KVJ2HKX").url` and rely on the URL-date fallback already in the script.
|
||
- [ ] **Step 2: Fetch proposed rule**: `uv run python dev/scripts/fetch_fr_attachments.py --key 2KVJ2HKX`
|
||
- [ ] **Step 3: Fetch final rule**: `uv run python dev/scripts/fetch_fr_attachments.py --key NHRGIHGD` (doc `2025-19787`; needed for proposed-vs-final deltas).
|
||
- [ ] **Step 4: Verify**: `ls -la data/fr_downloads/2025-13271.* data/fr_downloads/2025-19787.*` — both `.pdf` and `.txt`, each > 1 MB; and `uv run python -c "from conf import connect; s=connect.bib(); print([a for a in s.get('2KVJ2HKX').attachments])"` shows the two new attachments (check the `Item` model for the exact attachments accessor; `attach_file` registered them).
|
||
- [ ] **Step 5: Sanity-grep the text**: `grep -c "conversion factor" data/fr_downloads/2025-13271.txt` (expect dozens) and `grep -n "CMS-1832-P" data/fr_downloads/2025-13271.txt | head -3`.
|
||
- [ ] **Step 6:** Comment file sizes + verification output on issue #588's sibling: `#589`, but leave the issue open until close-out.
|
||
|
||
---
|
||
|
||
### Task 3: Capture CMS-1832-P proposed addenda (#590)
|
||
|
||
**Files:**
|
||
- Create: `dev/scripts/fetch_nprm_addenda.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: files under `data/cms/pfs_nprm/2026/`, and a bib/Zotero registration: Addendum B attached to rule item `2KVJ2HKX` with tags `sup:2026_PFS_NPRM`, `module:pfs`, `file:rvu`, `year:2026`. The `sup:2026_PFS_NPRM` tag is what Task 5's loader discovers by — final-rule files use `sup:2026_PFS_FR`, so the namespaces cannot collide.
|
||
|
||
- [ ] **Step 1: Locate the addenda.** The NPRM detail page is `https://www.cms.gov/medicare/medicare-fee-service-payment/physicianfeesched/pfs-federal-regulation-notices/cms-1832-p` (if 404, search `https://www.cms.gov/medicare/payment/fee-schedules/physician/federal-regulation-notices` for CMS-1832-P). Find the "CY 2026 PFS Proposed Rule Addenda" ZIP link (contains Addendum B — proposed RVUs by HCPCS).
|
||
- [ ] **Step 2: Write `dev/scripts/fetch_nprm_addenda.py`** modeled on `dev/scripts/download_opps_files.py` (same httpx download + dest-dir pattern): downloads the addenda ZIP to `data/cms/pfs_nprm/2026/`, unzips, prints the file list. Hard-code the resolved URL with a comment naming the page it came from. Include `--dry-run`.
|
||
- [ ] **Step 3: Run it**; verify Addendum B is present (an `.xlsx`/`.csv` whose name matches `(?i)addendum[_ ]?b`) and open the first rows to confirm columns include HCPCS + work/PE/MP RVUs.
|
||
- [ ] **Step 4: Register in bib** (one-off, in the same script behind `--register`): use `conf.connect.bib()`; `store.attach_file("2KVJ2HKX", addendum_b_path, title="CY2026 PFS NPRM Addendum B (proposed RVUs)")` then `store.add_tag("2KVJ2HKX", t)` for each of `sup:2026_PFS_NPRM`, `module:pfs`, `file:rvu`, `year:2026`. Print the attachment storage path.
|
||
- [ ] **Step 5: Zotero visibility.** Run the tag-scoped sync used by the mail pipeline convention: `uv run stack bib sync-zotero --tag sup:2026_PFS_NPRM` (always `--tag`-scoped; check `--help` for the exact flag name before running).
|
||
- [ ] **Step 6: Verify** `store.get("2KVJ2HKX").tags` includes the four tags.
|
||
- [ ] **Step 7: Commit** the script only: `git add dev/scripts/fetch_nprm_addenda.py && git commit -m "feat(pfs): fetch + register CY2026 NPRM addenda (refs #590)"`
|
||
|
||
---
|
||
|
||
### Task 4: ProposedRule registry (#591)
|
||
|
||
**Files:**
|
||
- Modify: `src/pfs/rules/__init__.py`
|
||
- Test: `tests/pfs/test_rules.py` (exists — add to it)
|
||
|
||
**Interfaces:**
|
||
- Produces: `pfs.rules.ProposedRule` (pydantic `BaseModel`) and `RuleYear.proposed: ProposedRule | None = None`; `RULES[2026].proposed` populated. Fields (all read by Task 6 and Task 10):
|
||
|
||
```python
|
||
class ProposedRule(BaseModel):
|
||
"""Parameters as *proposed* in the year's NPRM (pre-final)."""
|
||
|
||
cms_rule_id: str # "CMS-1832-P"
|
||
fr_document_number: str # "2025-13271"
|
||
federal_register_citation: str # e.g. "90 FR NNNNN" — from the txt header
|
||
published: date # NPRM publication date
|
||
comment_close: date # end of comment period
|
||
conversion_factor: float # proposed non-QP standard CF
|
||
cf_qp: float | None = None # proposed QP standard CF
|
||
anesthesia_cf: float | None = None
|
||
anesthesia_cf_qp: float | None = None
|
||
budget_neutrality_adjustor: float = 1.0
|
||
telehealth_originating_site_fee: float | None = None
|
||
notes: str = ""
|
||
```
|
||
|
||
- [ ] **Step 1: Transcribe the numbers from the captured text** (requires Task 2). Work through these greps, reading surrounding context, and record each value with its page/FR cite:
|
||
- `grep -n "proposed CY 2026 conversion factor" data/fr_downloads/2025-13271.txt | head`
|
||
- `grep -n -i "anesthesia conversion factor" data/fr_downloads/2025-13271.txt | head`
|
||
- `grep -n -i "budget neutrality adjustment" data/fr_downloads/2025-13271.txt | head`
|
||
- `grep -n -i "originating site facility fee" data/fr_downloads/2025-13271.txt | head`
|
||
- Citation + dates: first ~40 lines of the txt give the FR volume/page and DATES block (comment close).
|
||
Expected shape (do not trust until read): two standard CFs (QP and non-QP — CY2026 is the first split year), two anesthesia CFs, a BN adjustor near 1.0. If a value genuinely isn't in the NPRM, leave the field `None` and say so in `notes`.
|
||
- [ ] **Step 2: Add the model + field.** Insert `ProposedRule` (exact code above, with docstrings per field following the file's style) before `RuleYear`; add `proposed: ProposedRule | None = None` to `RuleYear` with a docstring noting final fields stay authoritative for payment math. Populate `RULES[2026].proposed = ProposedRule(...)` inline in the 2026 entry with the transcribed values and `notes` citing `:pincite:` to item `2KVJ2HKX`.
|
||
- [ ] **Step 3: Register the pincite**: `store.upsert_pincite(...)` is docstring-driven in this repo — instead add `:pincite:\`2KVJ2HKX\`` to the `ProposedRule` docstring text where the CY2026 values are cited (match how `VVBEVYLC` is used at the top of `RULES`).
|
||
- [ ] **Step 4: Write exact-value tests** in `tests/pfs/test_rules.py`:
|
||
|
||
```python
|
||
def test_cy2026_has_proposed_rule():
|
||
p = RULES[2026].proposed
|
||
assert p is not None
|
||
assert p.cms_rule_id == "CMS-1832-P"
|
||
assert p.fr_document_number == "2025-13271"
|
||
assert p.published.year == 2025
|
||
assert p.comment_close > p.published
|
||
# exact transcribed values — pin them here after Step 1:
|
||
assert p.conversion_factor == <transcribed>
|
||
assert p.cf_qp == <transcribed>
|
||
assert p.conversion_factor != RULES[2026].conversion_factor # proposed ≠ final
|
||
|
||
|
||
def test_pre_2026_years_have_no_proposed_block():
|
||
assert RULES[2025].proposed is None
|
||
```
|
||
|
||
(Replace `<transcribed>` with the actual floats — the test must not compute them from the registry.)
|
||
- [ ] **Step 5: Run**: `uv run pytest tests/pfs/test_rules.py -v` — PASS.
|
||
- [ ] **Step 6: Commit**: `git add -A && git commit -m "feat(pfs): ProposedRule registry — CY2026 NPRM parameters w/ pincite (refs #591)"`
|
||
|
||
---
|
||
|
||
### Task 5: NPRM Addendum B loader → `pfs.rvu_proposed` (#592)
|
||
|
||
**Files:**
|
||
- Create: `src/pfs/nprm.py`
|
||
- Modify: `dev/scripts/ingest_pfs.py` (add `--nprm` step)
|
||
- Test: `tests/pfs/test_nprm.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: bib attachments tagged `sup:2026_PFS_NPRM` (Task 3); `cms.ingest_log.log_ingest` (Task 7); `pfs.rules.RULES[2026].proposed.cms_rule_id` (Task 4).
|
||
- Produces: `pfs.nprm.load_rvu_proposed(con, *, cms_rule_id: str = "CMS-1832-P") -> dict` returning `{"rows": int, "source_file": str}`; DuckDB table `pfs.rvu_proposed`.
|
||
|
||
- [ ] **Step 1: Write failing tests** (`tests/pfs/test_nprm.py`). Use an in-memory DuckDB and a CSV fixture written to `tmp_path` with the Addendum B header shape observed in Task 3 (HCPCS, MOD, description, work RVU, non-fac PE RVU, fac PE RVU, MP RVU, status). Monkeypatch the discovery function to return the fixture path:
|
||
|
||
```python
|
||
import duckdb
|
||
from pfs import nprm
|
||
|
||
|
||
def _fixture(tmp_path):
|
||
p = tmp_path / "CY2026_NPRM_Addendum_B.csv"
|
||
p.write_text(
|
||
"HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU,"
|
||
"NON-FAC PE RVU,FAC PE RVU,MP RVU\n"
|
||
"99213,,Office visit est,A,1.3,1.5,0.55,0.1\n"
|
||
"0001A,,Admin covid,X,0.0,0.0,0.0,0.0\n"
|
||
)
|
||
return p
|
||
|
||
|
||
def test_load_rvu_proposed_loads_and_reloads(tmp_path, monkeypatch):
|
||
path = _fixture(tmp_path)
|
||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
||
con = duckdb.connect()
|
||
out = nprm.load_rvu_proposed(con)
|
||
assert out["rows"] == 2
|
||
row = con.execute(
|
||
"SELECT hcpcs, work_rvu, cms_rule_id FROM pfs.rvu_proposed "
|
||
"WHERE hcpcs='99213'"
|
||
).fetchone()
|
||
assert row == ("99213", 1.3, "CMS-1832-P")
|
||
nprm.load_rvu_proposed(con) # idempotent delete-and-reload
|
||
assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2
|
||
```
|
||
|
||
- [ ] **Step 2: Run** `uv run pytest tests/pfs/test_nprm.py -v` — FAIL (module missing).
|
||
- [ ] **Step 3: Implement `src/pfs/nprm.py`.** `_discover_addendum_b() -> Path` queries the bib sqlite (lazy `from conf import connect`) for attachments of items tagged `sup:2026_PFS_NPRM` whose filename matches `(?i)addendum[_ ]?b` (mirror the SQL join in `llm/source.py::_attachment_text`); raise `FileNotFoundError` naming the tag if absent. `load_rvu_proposed(con, *, cms_rule_id="CMS-1832-P")`: read csv/xlsx via the header-detect helpers in `pfs.pipe` (`_find_header_row` for xlsx; reuse the file readers rather than re-implementing), normalize columns to `hcpcs, modifier, description, status_code, work_rvu, nonfac_pe_rvu, fac_pe_rvu, mp_rvu, cms_rule_id`, then `CREATE SCHEMA IF NOT EXISTS pfs`, `CREATE TABLE IF NOT EXISTS pfs.rvu_proposed (...)`, `DELETE FROM pfs.rvu_proposed WHERE cms_rule_id = ?`, insert, return `{"rows": n, "source_file": str(path)}`.
|
||
- [ ] **Step 4: Run tests** — PASS. Also `uv run pytest tests/pfs -v` (no regression in pipe tests).
|
||
- [ ] **Step 5: Wire `--nprm` into `dev/scripts/ingest_pfs.py`**: new flag; inside the existing `duckdb_batch("aco")` block, when set, call `load_rvu_proposed(con)` and `cms.ingest_log.log_ingest(con, module="pfs", table_name="pfs.rvu_proposed", rows=out["rows"], source_file=out["source_file"], rule_id="CMS-1832-P", fr_citation=RULES[2026].proposed.federal_register_citation, pincite_key="2KVJ2HKX", run_id=run_id)` (import lazily; `run_id = cms.ingest_log.new_run_id()` once per script run; also log the final-rule tables from `summary` with the same run_id). Replica + lake publish already happen downstream; extend the lake call to `_lake.publish_lake(("pfs", "cms"))`.
|
||
- [ ] **Step 6: Host run**: `uv run python dev/scripts/ingest_pfs.py --nprm --no-lake` first (verify counts), then full with lake. Verify: `SELECT count(*) FROM pfs.rvu_proposed` in the replica, and the `cms.ingest_log` rows.
|
||
- [ ] **Step 7: Commit**: `git add -A && git commit -m "feat(pfs): NPRM Addendum B loader -> pfs.rvu_proposed + ingest wiring (refs #592)"`
|
||
|
||
---
|
||
|
||
### Task 6: `qpp` module — QP / Advanced-APM registry (#593)
|
||
|
||
**Files:**
|
||
- Create: `src/qpp/__init__.py`
|
||
- Modify: `pyproject.toml` (add `"qpp"` to `[tool.uv.build-backend] module-name`; add `qpp = ["stack[conf]", "pydantic>=2.0.0"]` to `[project.optional-dependencies]`; add to the dev/all aggregate extra if one exists — check how `pfs` is aggregated)
|
||
- Test: `tests/qpp/__init__.py` (empty), `tests/qpp/test_rules.py`
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by Task 10's notebook):
|
||
|
||
```python
|
||
class QpThresholds(BaseModel):
|
||
payment_amount_pct: float # % of Part B payments through Advanced APMs
|
||
patient_count_pct: float # % of patients through Advanced APMs
|
||
|
||
class RiskStandards(BaseModel):
|
||
revenue_nominal_pct: float # revenue-based nominal amount standard
|
||
benchmark_nominal_pct: float # expenditure/benchmark-based standard
|
||
notes: str = ""
|
||
|
||
class QppProposed(BaseModel):
|
||
cms_rule_id: str
|
||
federal_register_citation: str
|
||
changes: str # sourced prose summary of proposed changes
|
||
qp_thresholds: QpThresholds | None = None
|
||
partial_qp_thresholds: QpThresholds | None = None
|
||
risk_standards: RiskStandards | None = None
|
||
|
||
class QppYear(BaseModel):
|
||
performance_year: int
|
||
payment_year: int # performance_year + 2
|
||
qp_thresholds: QpThresholds
|
||
partial_qp_thresholds: QpThresholds
|
||
risk_standards: RiskStandards
|
||
apm_incentive_pct: float | None # lump-sum incentive % if applicable
|
||
qp_cf_applies: bool # True when payment-year CF splits QP/non-QP
|
||
cehrt_required: bool = True
|
||
citation: str = ""
|
||
proposed: QppProposed | None = None
|
||
|
||
QPP: dict[int, QppYear] # keyed by performance year, ≥ 2023
|
||
def for_payment_year(year: int) -> QppYear
|
||
```
|
||
|
||
- [ ] **Step 1: Transcribe** (requires Task 2). Read the QPP sections of `data/fr_downloads/2025-13271.txt`: `grep -n -i "Qualifying APM Participant" data/fr_downloads/2025-13271.txt | head -20`, `grep -n -i "nominal amount standard" …`, `grep -n -i "partial QP" …`. Record: current-law thresholds for the 2026 performance year, the proposed changes CMS-1832-P makes to Advanced APM requirements (threshold levels, risk standards, CEHRT language), and the final-rule disposition from `2025-19787.txt` for the `changes` narrative. Statutory baseline values for 2023–2025 come from the same sections' recitals (the NPRM restates them) — cite the FR page you read them from, not memory.
|
||
- [ ] **Step 2: Write failing structural tests** (`tests/qpp/test_rules.py`): registry covers 2023–2026; `payment_year == performance_year + 2` for every entry; `QPP[2024].qp_cf_applies is True` (payment year 2026 = first split-CF year) and earlier entries `False`; every entry has a non-empty `citation`; `QPP[2025].proposed` or `QPP[2026].proposed` (whichever performance year CMS-1832-P modifies — determined in Step 1) is non-None with `cms_rule_id == "CMS-1832-P"`. Add exact-value asserts for the transcribed thresholds.
|
||
- [ ] **Step 3: Run** — FAIL (no module). **Step 4: Implement** `src/qpp/__init__.py` with the models above (docstrings + `:pincite:\`2KVJ2HKX\`` citations, style of `pfs/rules/__init__.py`), the `QPP` dict, and `for_payment_year(year)` = `next(q for q in QPP.values() if q.payment_year == year)` raising `KeyError`-equivalent `StopIteration`→ wrap: raise `KeyError(year)`.
|
||
- [ ] **Step 5: Run tests** — PASS; run `uv run pytest tests/qpp tests/pfs -v`.
|
||
- [ ] **Step 6:** `uv run python dev/scripts/gen_config.py` if `stack.toml` untouched it's a no-op; needed only if you added a `[images.*]`/`[ci]` key (you shouldn't). `uv sync` to register the extra.
|
||
- [ ] **Step 7: Commit**: `git add -A && git commit -m "feat(qpp): QP/Advanced-APM registry incl. CMS-1832-P proposed changes (refs #593)"`
|
||
|
||
---
|
||
|
||
### Task 7: `cms.ingest_log` provenance table + writer (#594)
|
||
|
||
**Files:**
|
||
- Create: `src/cms/ingest_log.py`
|
||
- Test: `tests/cms/test_ingest_log.py` (create `tests/cms/__init__.py` if absent)
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by Task 5's wiring):
|
||
|
||
```python
|
||
def new_run_id() -> str # uuid4().hex
|
||
def file_sha256(path: str | Path) -> str
|
||
def ensure_table(con) -> None # CREATE SCHEMA/TABLE IF NOT EXISTS
|
||
def log_ingest(con, *, module: str, table_name: str, rows: int,
|
||
source_file: str = "", rule_id: str = "", fr_citation: str = "",
|
||
pincite_key: str = "", run_id: str = "") -> None
|
||
```
|
||
|
||
`cms.ingest_log` DDL: `run_id VARCHAR, ingested_at TIMESTAMP, module VARCHAR, table_name VARCHAR, rule_id VARCHAR, source_file VARCHAR, sha256 VARCHAR, rows BIGINT, fr_citation VARCHAR, pincite_key VARCHAR`. `log_ingest` calls `ensure_table`, computes `sha256` itself when `source_file` exists on disk (else empty string), stamps `ingested_at` with `datetime.now(timezone.utc)`, appends one row. Append-only — no delete path.
|
||
|
||
- [ ] **Step 1: Write failing tests**:
|
||
|
||
```python
|
||
import duckdb
|
||
from cms import ingest_log
|
||
|
||
|
||
def test_log_ingest_appends_row(tmp_path):
|
||
src = tmp_path / "f.csv"
|
||
src.write_text("a,b\n1,2\n")
|
||
con = duckdb.connect()
|
||
ingest_log.log_ingest(
|
||
con, module="pfs", table_name="pfs.rvu_proposed", rows=2,
|
||
source_file=str(src), rule_id="CMS-1832-P",
|
||
fr_citation="90 FR 1", pincite_key="2KVJ2HKX",
|
||
run_id=ingest_log.new_run_id(),
|
||
)
|
||
row = con.execute(
|
||
"SELECT module, table_name, rows, rule_id, length(sha256) "
|
||
"FROM cms.ingest_log"
|
||
).fetchone()
|
||
assert row == ("pfs", "pfs.rvu_proposed", 2, "CMS-1832-P", 64)
|
||
|
||
|
||
def test_missing_source_file_logs_empty_hash():
|
||
con = duckdb.connect()
|
||
ingest_log.log_ingest(con, module="pfs", table_name="t", rows=0)
|
||
assert con.execute("SELECT sha256 FROM cms.ingest_log").fetchone()[0] == ""
|
||
```
|
||
|
||
- [ ] **Step 2: Run** — FAIL. **Step 3: Implement** (module docstring notes: complements the JSONL logs in `cms.log`, does not replace them). **Step 4: Run** — PASS, plus `uv run pytest tests/cms -v`.
|
||
- [ ] **Step 5: Commit**: `git add -A && git commit -m "feat(cms): ingest_log provenance table + writer (refs #594)"`
|
||
|
||
(Note: Task 5 Step 5 wires it into `ingest_pfs.py`; if Task 7 executes first, fine — Task 5 depends on both.)
|
||
|
||
---
|
||
|
||
### Task 8: Rule-text RAG source + `rules` collection (#595)
|
||
|
||
**Files:**
|
||
- Modify: `src/llm/source.py`, `src/cli/llm.py`
|
||
- Test: `tests/llm/test_source.py`, `tests/cli/test_llm.py` (both exist — extend; check the fake-store fixture pattern already used in `tests/llm/test_source.py` and reuse it)
|
||
|
||
**Interfaces:**
|
||
- Produces: `llm.source.iter_rule_docs(store, *, keys: tuple[str, ...] = (), tag: str = "") -> Iterator[Doc]` and CLI `stack llm index --collection rules [--key KEY …]`.
|
||
|
||
- [ ] **Step 1: Write failing tests** (extend `tests/llm/test_source.py`, mirroring its existing store-fixture style): a rule item with a `.txt` attachment yields one `Doc` whose `text` is the txt content and `metadata == {"doctype": "rule", "cms_rule_id": "CMS-1832-P", "fr_document_number": "2025-13271", "year": "2026", "item_key": <key>}`; a rule with only a PDF falls back to `extract_attachment` text (monkeypatch `rex.comments.combine.extract_attachment`); `keys=` filters; non-rule items are not yielded.
|
||
- [ ] **Step 2: Run** — FAIL. **Step 3: Implement** in `src/llm/source.py`:
|
||
|
||
```python
|
||
def iter_rule_docs(
|
||
store: Store, *, keys: tuple[str, ...] = (), tag: str = ""
|
||
) -> Iterator[Doc]:
|
||
"""One Doc per FR rule item: TXT attachment preferred, PDF-extract fallback."""
|
||
for item in store.list_items(item_type="rule", tag=tag):
|
||
if keys and item.key not in keys:
|
||
continue
|
||
text = _rule_text(store, item.key) # txt attachment else _attachment_text
|
||
if not text.strip():
|
||
continue
|
||
cms_rule = next(
|
||
(t.split(":", 1)[1] for t in item.tags if t.startswith("cms-rule:")), ""
|
||
)
|
||
yield Doc(
|
||
key=item.key,
|
||
text=text,
|
||
metadata={
|
||
"doctype": "rule",
|
||
"cms_rule_id": cms_rule,
|
||
"fr_document_number": item.document_number or "",
|
||
"year": _year_of(store, item.key),
|
||
"item_key": item.key,
|
||
},
|
||
)
|
||
```
|
||
|
||
`_rule_text`: query the attachments join (same SQL as `_attachment_text`) but return the content of the first `storage_path` ending `.txt` via `Path.read_text`; else fall back to `_attachment_text(store, item_key)`. Confirm `list_items` accepts `item_type=` (it does — `fetch_fr_attachments.py:78` uses it).
|
||
- [ ] **Step 4:** CLI: in `src/cli/llm.py::index`, add `key: list[str] = typer.Option([], "--key")`; accept `collection == "rules"` → `docs = iter_rule_docs(store, keys=tuple(key))`; update the `BadParameter` message to `'comments', 'corpus' or 'rules'`. Extend `tests/cli/test_llm.py` accordingly (runner invokes with `--collection rules --key X`, asserting `iter_rule_docs` was called — monkeypatch it).
|
||
- [ ] **Step 5: Run** `uv run pytest tests/llm tests/cli -v` — PASS.
|
||
- [ ] **Step 6: Commit**: `git add -A && git commit -m "feat(llm): rule-text source + rules collection CLI (refs #595)"`
|
||
|
||
---
|
||
|
||
### Task 9: Comment-farm completion + indexing (#596) — ops
|
||
|
||
Long-running host batches; run each under `nohup`/background with logs in `.state/comments/`, sequentially. Record final counts as a comment on #596. Requires Tasks 1, 2, 8 merged to the working branch. GPU note: embedding fan-out uses `LLM_OLLAMA_HOSTS` — check which hosts are up before starting; the 3060 alone works but is slow.
|
||
|
||
- [ ] **Step 1: Backfill**: `uv run stack bib backfill-comments --help` first to confirm the docket-scoping flag, then run it scoped to `CMS-2025-0304`. Resumable (`enriched:*` tag markers); watch `.state/comments/backfill.log`.
|
||
- [ ] **Step 2: Extract**: `uv run stack comments extract` then `uv run stack comments extract-ocr` (check `--help` for docket scoping); monitor `.state/comments/extract.log`.
|
||
- [ ] **Step 3: Verify counts**: `uv run stack comments stats` and `awk -F, '$2=="CMS-2025-0304"' .state/comments/_index.csv | wc -l` — target: extracted count ≈ comments with attachments; note the OCR-failed remainder honestly.
|
||
- [ ] **Step 4: Index comments**: `uv run stack llm index --collection comments --docket CMS-2025-0304` (incremental/resumable — safe to re-run after interruption).
|
||
- [ ] **Step 5: Index rule text**: `uv run stack llm index --collection rules --key 2KVJ2HKX --key NHRGIHGD`.
|
||
- [ ] **Step 6: RAG spot-check**: run a retrieval (python one-liner against `llm.rag.retrieve`) for "conversion factor" over `rules` and "skin substitutes" over `comments` filtered to the docket; confirm CY2026 chunks with correct metadata come back.
|
||
- [ ] **Step 7:** Comment counts (backfilled / extracted / OCR-failed / chunks indexed per collection) on #596.
|
||
|
||
---
|
||
|
||
### Task 10: Financial-changes + Advanced-APM notebook (#597)
|
||
|
||
**Files:**
|
||
- Create: `notebooks/cy2026_pfs_proposed_rule.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `pfs.rules.RULES[2026]` (+ `.proposed`), `qpp.QPP`/`for_payment_year`, lake tables `pfs.rvu`, `pfs.rvu_proposed`, `pfs.gpci`, `cms.ingest_log`.
|
||
|
||
- [ ] **Step 1: Scaffold** from `notebooks/_template.py` (mo import cell, `connect.theme()` + `connect.ducklake()` + `q(sql)` helper, title cell). Narrative style of `skin_sub_budget_neutrality.py`: `mo.md` intro (Mechanism / What this means), numbered `## N.` sections.
|
||
- [ ] **Step 2: Sections** (each chart follows the dataviz skill — read it before writing chart code; altair, theme-aware):
|
||
1. **The CF walk** — CY2025 → CY2026 proposed → CY2026 final, QP and non-QP tracks plus anesthesia; bar/slope chart from `RULES` + `RULES[2026].proposed`; BN-adjustor decomposition; FR citations under each figure.
|
||
2. **RVU-level deltas** — `pfs.rvu_proposed` vs CY2025 `pfs.rvu` and vs CY2026 final `pfs.rvu`: top-20 winners/losers by total non-fac RVU change, joined to payment via the applicable CF; searchable HCPCS detail table.
|
||
3. **Specialty impact** — aggregate RVU-weighted deltas by specialty if a specialty mapping exists in the lake (`reference_data` schemas); otherwise present the NPRM's published specialty-impact table transcribed with citation, clearly labeled as transcription.
|
||
4. **Advanced APM requirements** — from `qpp`: threshold table (2023–2026 + proposed), risk-standard changes, the QP/non-QP CF differential in dollars for 3 example HCPCS (99213, a major procedure, an imaging code), proposed-vs-finalized disposition.
|
||
5. **Provenance** — render `cms.ingest_log` rows for the tables used.
|
||
- [ ] **Step 3: Layout check**: `uv run pytest tests/test_notebook_layout.py -v`.
|
||
- [ ] **Step 4: Headless validation**: run `dev/scripts/nb_integration.py` the way `.gitea/workflows/notebooks-integration.yml` does (read the workflow for the exact invocation) scoped to the new notebook; expect a clean session export, no root-cause errors.
|
||
- [ ] **Step 5: Commit**: `git add notebooks/cy2026_pfs_proposed_rule.py && git commit -m "feat(notebooks): CY2026 PFS proposed-rule financial changes + APM (refs #597)"`
|
||
|
||
---
|
||
|
||
### Task 11: Close-out
|
||
|
||
- [ ] **Step 1:** Full gates: `uv run ruff check src tests && uv run ruff format --check src tests && uv run pytest -n auto` (coverage ≥ 99).
|
||
- [ ] **Step 2:** Merge `p36-cy2026-rule` → `main` (no-ff, subject `Merge P36: CY2026 PFS proposed rule — capture, ingest, APM analysis`), push.
|
||
- [ ] **Step 3:** Append `## P36 build outcomes (<date>)` to the spec with row counts, chunk counts, and any deviations; commit.
|
||
- [ ] **Step 4:** Close #588–#597 with per-issue outcome comments (re-read each issue body first — close only what its body asked for); close milestone P36.
|