19 KiB
comment pipeline — docket seals and cheap skips: never redo what is known complete
Tracker: new milestone (P47) with one issue per component; refs #615 (re-farm), #662 (mirror backfill), #253 (OCR stub, out of scope).
Goal
A default run of any stage of the regulations.gov comment pipeline — bib fetch-pfs-comments, bib backfill-comments, comments extract, llm index — must do no work for a docket that is known complete, and must decide per item whether there is work before touching the network, the filesystem, or a PDF. --force is the only way to redo known-complete work, and it applies to one run.
Success criteria, measured on the live data (2026-09-08: 12 dockets, 201,874 comments, 28,493 PDFs / 13.3 GB, 224,351 bib items):
stack llm index --collection allwith nothing changed finishes in under a minute, opens zero PDFs, and does not copyzotero.sqlite.stack bib fetch-pfs-commentswith every docket sealed makes at most 4 API calls (the Federal Register rule search) and writes zero rows.stack comments extractwith nothing changed reads zerocombined.mdfiles.- Re-running any stage twice produces byte-identical
bib.sqlitecontent (noupdated_at/access_datechurn, no tag rewrites, no duplicate attachment rows).
Current state (measured 2026-09-08)
What a default run redoes today, per stage. File refs are to the tree at 832bfd5.
| Stage | Skips | Redoes | Cost |
|---|---|---|---|
fetch (cli/bib.py:189-298, bib/regulations_gov.py:198-260) |
nothing | rediscovers every rule, one FR GET per rule, one rate-limited reg.gov call per CMS id (even with --docket, which filters after the resolve call), re-walks every docket from page 1 (the lastModifiedDate cursor is intra-call only), re-upserts every comment |
~170 API calls per large docket; Store.upsert (bib/store.py:207-245) stamps access_date, bumps updated_at, deletes and rewrites every tag row (store.py:429-453); attach_file (store.py:502-533) mints a duplicate row and copies the file again — 33,379 duplicated (item_id, filename) groups covering 66,917 of 78,017 rows in the live store |
backfill API (regulations_gov.py:378-544) |
enriched:ok / enriched:gone |
nothing significant | one anti-join scan — already correct |
backfill mirror (regulations_gov.py:657-776) |
enriched:ok |
lists the whole S3 prefix for comments and attachments before filtering | 40–80 LIST requests per docket |
extract (rex/comments/walker.py:79-118, combine.py:42-132) |
dirs with a combined.md (stat only) |
reads + YAML-parses every skipped combined.md for sibling derivation and fires the attach callback; loads every comment abstract from bib at startup (cli/comments.py:52-63); never detects a stale extraction |
28,228 files / 757 MB parsed per run |
index comments (llm/index.py:90-143, llm/source.py:104-132, llm/pages.py:61-83) |
sha256(text) match in index_state |
chunks and opens every PDF page (enrich_pdf_pages) before the hash check; one _year_of query per comment; reads every combined.md; store.get per comment without one |
full PyMuPDF decode of 13.3 GB per no-op run |
index corpus (source.py:265-357, cli/llm.py:24-27) |
same | hydrates all 224,351 items (3 queries each) to discard 201,874 comments; re-extracts every attachment; copies the 1.95 GB zotero.sqlite unconditionally |
~670k queries + 1.95 GB copy per run |
The one signal that would let a stage skip a whole docket — the reg.gov commentEndDate — is fetched every run (cli/bib.py:170, :272) and used only as a filter predicate. No docket-level state exists anywhere.
Decisions
- Two-level skip. A docket-level seal removes whole dockets from every walk; a per-item fingerprint (cheap facts only) is checked before any load. Content hashes stay as the third line.
- Seal = auto after close + quiet period. A docket seals when its comment close date is ≥
seal_quiet_days(default 30) in the past and the most recent completed pull after that date found zero new comments. Manualseal/unsealexist. Sealing happens at the end of a fetch walk because fetch is the only stage that learns about new comments. - Docket state lives in a
docketstable inbib.sqlite, notbib_metaJSON, not tags, not.state/(cache-only by convention). Index-side docket completion lives in the llm Postgres DB next toindex_state. - Fix every default-path redo for open dockets too (fetch watermark, no-op upsert, idempotent attachments, extract skip cost, index ordering, bulk queries, lazy Zotero snapshot).
- Attachment duplicates are cleaned up now — guard plus a one-time migration — because the guard alone leaves 66k dead rows that every later stage pays for.
--forcesemantics unchanged and uniform: bypasses seal, fingerprint, hash, and docket-completion for that run; never unseals.
Architecture
dockets (bib.sqlite) index_state(+fingerprint), index_docket_state (llm pg)
id, close date, watermark, sealed_at (item_key, collection) → fingerprint, content_hash
│ │
fetch ──► sealed? ──skip──┐ backfill ──► sealed? ──skip──┐ extract ──► sealed? ──skip──┐ index ──► sealed & complete? ──skip──┐
│ no │ │ no │ │ no │ │ no │
▼ │ ▼ │ ▼ │ ▼ │
walk from watermark │ pending set from tags │ fingerprint per dir │ fingerprint per DocRef │
upsert → unchanged? no-op│ (mirror: list S3, open only) │ differs? extract │ differs? load → hash differs? embed │
walk clean → advance │ │ │ clean over sealed docket → completion │
watermark, maybe seal ───┘ └──────────────────────────────┴──────────────────────────────────────────┘
Components
bib/schema.sql + bib/store.py — dockets table and store API
CREATE TABLE IF NOT EXISTS dockets (
id TEXT PRIMARY KEY, -- reg.gov docket id, e.g. CMS-2026-2377
rule_cms_id TEXT, -- CMS-1848-P
fr_document_id TEXT, -- reg.gov document id carrying the comments
fr_object_id TEXT, -- attributes.objectId (what /comments filters on)
comment_end_date TEXT, -- YYYY-MM-DD from document attributes
pull_watermark TEXT, -- max lastModifiedDate seen on a completed walk
last_pull_at TEXT,
last_pull_new INTEGER, -- comments created by that walk
sealed_at TEXT,
seal_reason TEXT, -- 'auto' | 'manual'
counts_json TEXT -- {"comments":n,"enriched":n,"extracted":n} at seal
);
CREATE UNIQUE INDEX IF NOT EXISTS attachments_item_filename ON attachments(item_id, filename);
The unique index is created by Store.connect only when no duplicates remain (see migration); fresh databases get it from the schema.
Store methods (all thin SQL, unit-tested against an in-memory store):
docket_get(id) -> Docket | None,docket_upsert(Docket),dockets() -> list[Docket],sealed_docket_ids() -> set[str].docket_seal(id, reason, counts),docket_unseal(id).docket_of_item(key) -> str | Nonereads thereg-docket:tag.upsert(item) -> keykeeps its signature; newupsert_status(item) -> tuple[key, Literal["created","updated","unchanged"]]compares the normalized row (all columns exceptaccess_date,updated_at,created_at) and the tag/collection sets, and returnsunchangedwithout writing anything.stamp_accessruns only on create/update.attach_file(key, path, title)returns the existing attachment key when(item_id, filename)already exists; no copy, no insert.
Docket is a frozen dataclass in new bib/dockets.py, together with the pure should_seal(docket, today, quiet_days) -> bool.
bib/dockets.py (new, pure)
should_seal:comment_end_datepresent,today >= end + quiet_days,last_pull_at >= end + quiet_days,last_pull_new == 0, not already sealed.fingerprint_files(paths) -> str: sha256 over sorted(name, size, mtime_ns)— shared by extract and index.
bib/regulations_gov.py — watermark walk, sealed skips
iter_comments(object_id, since: str | None = None): initial cursor =since. Boundary rows re-yield (>=filter); the no-op upsert absorbs them.walk_docket(store, api, docket: Docket, *, attachments, limit) -> WalkResult(new, updated, unchanged, watermark, clean)extracted from the CLI loop so both fetch commands share it. Advancespull_watermark/last_pull_at/last_pull_newonly whenclean(no HTTPStatusError break, no exception). Callsshould_sealanddocket_seal(..., reason="auto")when true.backfill_comments(..., docket=...)andbackfill_from_mirror(...): return early with a log line when the docket is sealed andforceis false. Mirror listing is unchanged for open dockets.
cli/bib.py — fetch commands
fetch-pfs-comments per proposed rule: split CMS ids → for each, store.docket_get. Sealed → echo " <docket>: sealed <date>, skipping" and continue before the federal_register() rule fetch and the resolve_docket call. Unknown → resolve_docket + find_documents_in_docket once, insert the Docket row with comment_end_date, fr_document_id, fr_object_id. Known and open → walk_docket from the watermark using the stored object id (no resolve, no document listing). --docket filters before any API call (the current help text becomes true). fetch-docket-comments takes the same path for one docket. Both accept --force (walk from page 1, ignore seal, never unseal).
dev/scripts/dedupe_attachments.py (one-time migration)
Groups attachments by (item_id, filename); keeps the row with the smallest rowid; deletes the others' rows and their files under the attachment store when no other row references the same path. Prints a report (groups, rows removed, bytes freed, and the list of removed keys that carry a Zotero mapping, for a separate Zotero-side pass — Zotero itself is not touched). --dry-run default; --apply executes inside one transaction. After a clean apply, Store.connect creates the unique index on next open.
rex/comments/walker.py + combine.py + cli/comments.py — extract
walk_and_extract(root, ..., sealed: set[str], force, reattach): docket dirs insealedare skipped at the loop head (walker.py:95) unlessforce.- Per dir, "up to date" =
combined.mdexists and its frontmattersourcesfingerprint equalsfingerprint_filesover the dir's current attachment/body files.extract_commentwritessourcesinto the frontmatter. - Skipped dirs no longer go through
derive_siblings_from_combinedoron_extracted;--reattachrestores the old behaviour for repair runs. _build_bib_helpersloads abstracts in batches keyed by the todo list instead of the whole table.- New commands:
stack comments dockets(table: id, rule, close date, comments/enriched/extracted, watermark, sealed),stack comments seal <id> [--reason],stack comments unseal <id>,stack comments dockets --discover(one-time populate of the 12 known dockets fromreg-docket:tags, resolving close dates with one API call each).
llm/source.py — lazy DocRef
@dataclass(frozen=True)
class DocRef:
key: str
collection: str
docket: str | None
fingerprint: str
load: Callable[[], Doc] # builds the Doc (reads combined.md / attachments / fr_anchors)
iter_comment_refs(store, root, docket=None, sealed_complete: set[str]): one SQL query per docket for(key, comment_id, year, updated_at, has_combined); skips dockets insealed_complete; fingerprint =fingerprint_files(combined.md + attachment files)+updated_at.loaddoes today'siter_comment_docsbody for one item.iter_corpus_refs: keys selected in SQL (tag = corpus tag AND NOT source:regulations-gov), no hydration; fingerprint =updated_at+ attachment file stats.loadhydrates and extracts attachments.iter_rule_refs: fingerprint =fr_anchor_docs.sha256.ZoteroPdfIndexbecomes lazy: constructed with the source path;snapshot()runs on firstlookup()miss of a local PDF, and only copies when the source mtime is newer than the existing.state/llm/zotero.sqlite.
llm/migrate.py + llm/index.py — fingerprint-first loop
- Migration:
ALTER TABLE index_state ADD COLUMN IF NOT EXISTS fingerprint TEXT;CREATE TABLE IF NOT EXISTS index_docket_state (collection TEXT, docket TEXT, sealed_at TIMESTAMPTZ, indexed_at TIMESTAMPTZ, PRIMARY KEY (collection, docket)). index_refs(refs, collection, *, force, sealed: dict[str, sealed_at]), per ref:not force and state[key].fingerprint == ref.fingerprint→ skip (no load).doc = ref.load();h = content_hash(doc.text);not force and state[key].content_hash == h→ update fingerprint only, skip.chunks = enrich_pdf_pages(doc, chunk_doc(doc))→ embed → write chunks + state row (fingerprint, hash, chunk_count).
- After a run over a docket whose seal is set and which ended with zero errors, upsert
index_docket_state(collection, docket, sealed_at). On the next runiter_*_refsreceivessealed_complete = {docket for rows where sealed_at == dockets.sealed_at}; an unseal/reseal changessealed_atand invalidates the row. statsoutput addsfingerprint_skipped,hash_skipped,docket_skipped.
cli/llm.py
index builds the ref iterators, passes store.sealed_docket_ids() joined with index_docket_state, honours --docket for comments, --key for rules, and --force. The Zotero snapshot line moves into the lazy index. --collection all runs the three in sequence as today.
Config
stack.toml: [comments] seal_quiet_days = 30. No new env.
dev/scripts/refarm_cms_2026_2377.sh
Collapses to the plain chain (mirror backfill → extract → index → API fetch → API backfill → extract → index) with no special ordering justification: every step is now cheap when there is nothing to do. The 2026-09-15 crontab line stays.
Data flow for one incremental run (open docket)
fetch-pfs-comments: FR search (≤4 calls) → 11 dockets sealed, skipped with no calls → CMS-2026-2377 open: walk from watermark2026-09-08T12:00:00→ 40 new, 3 updated, 12 unchanged boundary rows → watermark advanced,last_pull_new=40,should_sealfalse (close date 2026-09-14 not past).backfill --mirror --docket CMS-2026-2377: not sealed → list S3 → 40 pending → enrich, attach (idempotent).comments extract --docket CMS-2026-2377: 40 dirs lackcombined.md, 2 have a changed fingerprint → 42 extracted; the rest are skipped without a read.llm index --collection comments: 11 dockets inindex_docket_state→ not iterated; CMS-2026-2377: 18,691 refs, 42 fingerprints differ → 42 loaded, hashed, PDF-paged, embedded; 18,649 skipped without a load.
Error handling
- A walk that breaks on an HTTP error leaves the watermark untouched; the next run resumes from the old watermark (re-yields are absorbed by the no-op upsert).
- Sealing never happens on a run that had errors (
clean=False). - A fingerprint mismatch caused by a touch (mtime only) costs one load and hash, then settles.
index_docket_stateis written only after a zero-error run; any embed failure leaves the docket iterable.- The dedupe migration is dry-run by default and transactional on
--apply; it refuses to run if any duplicate group's files differ in size (reports them instead). - A sealed docket named by
--docketwithout--forceprints a one-line notice and exits 0.
Testing
Pure units first (TDD): should_seal (boundary days, missing close date, unclean pull), fingerprint_files (order-independent, mtime-sensitive), upsert_status (created/updated/unchanged, tag-set change → updated, access stamp only on change), attach_file idempotency, iter_comments(since=) passes the filter and re-yields the boundary row, walk_docket watermark advance only when clean, index_refs ordering (a ref whose fingerprint matches never has load called; hash match updates fingerprint without embedding; PDFs opened only on the embed path — inverts tests/llm/test_index.py::test_enriches_chunks_before_add).
CLI: extend tests/cli/test_bib_exercise.py (TestFetchPfsComments: sealed docket makes no reg.gov call; --docket filters before resolve), tests/bib/test_regulations_gov_mirror.py::test_idempotent_rerun (sealed → no S3 listing), tests/rex/comments/test_walker.py (skipped dirs not read, --reattach), tests/cli/test_comments.py (dockets, seal, unseal), tests/cli/test_llm_exercise.py (corpus run without Zotero copy when no fallback needed), tests/llm/test_migrate.py (new column/table idempotent).
Migration: fixture DB with duplicate groups → dry-run report → apply → unique index creatable → second apply is a no-op.
Acceptance on live data (recorded in the tracker): the four success criteria above, timed.
Rollout
- Deploy code;
Store.connectcreatesdockets; llm migration adds the column and table on container start. stack comments dockets --discoverpopulates 12 rows (12 API calls, once).dev/scripts/dedupe_attachments.py --dry-run, review,--apply; reopen the store → unique index created.stack comments seal <id> --reason manualfor the 11 historical dockets (CMS-2017-0092 … CMS-2025-0304); CMS-2026-2377 stays open and auto-seals after 2026-10-14 once a pull finds nothing new.- One last full walk:
stack llm index --collection allstamps fingerprints for all 196k items (stat-only, no PDF decode, no embedding) and writesindex_docket_statefor the 11 sealed dockets. Then a second run must meet the sub-minute criterion. stack comments extractonce to stampsourcesfingerprints into existingcombined.mdfrontmatter (read-only pass for unchanged dirs; rewrites frontmatter only).- Re-farm script simplified; crontab unchanged.
Out of scope
- OCR for image-only attachments (#253);
extract-ocrstays a stub. - Zotero-side removal of already-synced duplicate attachments (listed by the migration report; separate pass like #665).
- Reducing mirror S3 listing cost for open dockets.
- Chunking, embedding model, or retrieval changes.