Files
stack/docs/superpowers/plans/2026-08-12-cy2026-pfs-proposed-rule-p36.md

414 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 20232025 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 20232026; `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 (20232026 + 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.