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

29 KiB
Raw Blame History

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):

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:
# 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):
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 theProposedRuledocstring text where the CY2026 values are cited (match howVVBEVYLCis used at the top ofRULES`).
  • Step 4: Write exact-value tests in tests/pfs/test_rules.py:
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:

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):
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 ofpfs/rules/init.py), the QPPdict, andfor_payment_year(year)=next(q for q in QPP.values() if q.payment_year == year)raisingKeyError-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):
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:
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:

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 deltaspfs.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-rulemain (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.