docs(spec): FR jump links — page/paragraph deep links for FR citations (P40)

This commit is contained in:
kert
2026-08-17 14:17:28 -04:00
parent c559cc10e2
commit e813eddd14

View File

@@ -0,0 +1,156 @@
# FR Jump Links — Page/Paragraph Deep Links for Every FR Citation (P40)
Date: 2026-08-17. Milestone: P40. Requested as: a utility "able to grab
and place the paragraph link jump links from the web version of the
federal register to any of the pages or paragraphs in the rule,"
context-aware for ANY FR citation instance, with page ↔ paragraph
transmutation in both directions, Zotero child links, and `:pincite:`
integration. Design presented in-session and approved ("yes that does
it").
## Verified ground truth (2026-08-17, CMS-1848-P / doc 2026-14327)
- The FR API's `body_html_url` serves the full rule body as one HTML
file (4.2 MB for this 716-page rule) containing, in document order:
5,079 paragraph anchors `id="p-N"`, 716 page anchors
`id="page-NNNNN"`, and — decisively — an explicit
`data-page="NNNNN"` attribute **on each paragraph element**, so
page association needs no positional inference.
- Deep links `#p-N` and `#page-NNNNN` work against the canonical
`html_url` reader page.
- The API also returns `start_page`/`end_page` per document; bib rule
items already carry `fr_volume`, `fr_page`, `document_number` in
`extra_json`, and ~30 `item_type='rule'` items have
federalregister.gov URLs.
- Sample verified: page 44218's paragraphs (p-3599…) are the
§414.1425 QP passage the CY2027 notebook cites as "91 FR 44218".
## Decisions (user-selected)
- **Consumers: all.** One context-aware resolver used by notebooks,
the `:pincite:` graph/docs, Zotero (as child link attachments), and
a CLI.
- **Addressing: all modes.** Quoted text, page + ordinal ("91 FR
44218 ¶3"), raw anchor id (`p-3600`), and plain page cites; page ↔
paragraph transmutation both directions.
- **Persistence: in bib.** Anchor maps stored in `bib.sqlite`;
offline, reproducible; refresh on demand.
- **Scope: all bib rules with FR URLs**, backfilled.
## Architecture
**Home: new module `src/bib/frlink.py`** — grabber, anchor store
access, resolver, transmutations, markdown helper. Three thin hooks
elsewhere: `bib/pincite.py` (FR locator grammar + URL resolution),
`bib/sync.py` (child link attachments), `src/cli/bib.py` (two
subcommands). Rationale: `bib` already owns the FR API client
(`federalregister.py`), the citation formatter, the pincite pipeline,
and the Zotero sync; `frlink` composes them. (Alternatives considered:
extending `federalregister.py` — blurs a stateless API client with a
stateful store; a new top-level package — overkill for citation
infrastructure.)
### Data model (`bib/schema.sql`, auto-applied via CREATE TABLE IF NOT
EXISTS on Store init)
- `fr_anchors(id, item_key → items.key, p_id INTEGER, page INTEGER,
ordinal INTEGER, text TEXT)` — one row per paragraph anchor;
`ordinal` is 1-based position on its printed page; `text` is the
entity-decoded, tag-stripped paragraph text (for quote search).
Unique on `(item_key, p_id)`; indexed on `(item_key, page)`.
- `fr_anchor_docs(item_key PRIMARY KEY, document_number, html_url,
body_html_url, start_page INTEGER, end_page INTEGER, fr_volume
INTEGER, fetched_at, sha256, n_paragraphs, n_pages)` — one row per
grabbed rule; sha256 of the fetched body makes drift detectable.
- `fr_links(id, item_key → items.key, p_id INTEGER NULL, page
INTEGER, label TEXT, url TEXT, created_at)` — **placed** links (the
curated subset that gets carried into Zotero), unique on
`(item_key, url)`.
### Grabber
`grab(store, item_key, *, force=False) -> dict` — resolves the item's
`document_number` (from `extra_json`, falling back to parsing the FR
URL), fetches `body_html_url` (one request; via the document API when
the body URL must be discovered), parses anchors with a pure function
`parse_anchors(html) -> list[Anchor]` (regex walk over `id="p-N"` /
`data-page` / `id="page-N"`, tag-strip + `html.unescape` for text),
and replaces that item's `fr_anchors` partition + `fr_anchor_docs`
row. Backfill = iterate `item_type='rule'` items whose URL matches
federalregister.gov; skip already-grabbed unless `--force`.
### Resolver (context-aware core)
`resolve(ref, *, store, item_key=None) -> JumpLink` where `JumpLink`
carries `url, item_key, page, p_id, ordinal, snippet`. Accepted
`ref` forms:
1. `"91 FR 44218"` → page link `{html_url}#page-44218`. The rule is
found by volume + page-range containment against `fr_anchor_docs`
(no `item_key` needed); ambiguity (overlapping ranges) raises with
candidates listed.
2. `"91 FR 44218 ¶3"` (also `para 3` / `p.3` spellings) → 3rd
paragraph anchor on that page → `#p-N`.
3. `"p-3600"` (requires `item_key` or an unambiguous single-rule
context) → validated `#p-3600`.
4. A quoted passage ≥ 15 chars (anything not matching 1–3) →
normalized substring search over `fr_anchors.text` (whitespace
collapsed, entities already decoded); exactly-one match required,
zero/many raise with diagnostics.
Transmutations: `page_of(item_key, p_id) -> int` and
`paragraphs_of("91 FR 44218") -> list[Anchor]` (with ordinals +
snippets) — page cites upgrade to paragraph cites and vice versa.
`md_link(ref, *, text=None) -> str` renders `[91 FR 44218 ¶3](url)`
for notebooks.
### Placement
`place(store, ref, *, label="") -> JumpLink` — resolves, then upserts
an `fr_links` row. `bib/sync.py` gains `_sync_fr_links` beside
`_sync_attachments`: each `fr_links` row becomes a Zotero **child
link attachment** (`linkMode=3` linked_url, `contentType text/html`,
itemData `url` + `title` = label), idempotent by existing child URL.
Zotero carries the links actually cited — never all ~5k anchors per
rule (explicit scope decision from the design review).
`:pincite:` integration: the locator grammar learns FR forms
(`91 FR 44218`, `91 FR 44218 ¶3`, `p-3600`); `Pincite` gains a
`jump_url(store)` accessor used by the citation-graph/docs rendering;
unresolvable FR locators degrade to no-URL (never an exception in
graph rendering).
### CLI
- `stack bib fr-grab [--all | --key KEY] [--force]` — grab/backfill.
- `stack bib fr-jump REF [--key KEY] [--md] [--place [--label L]]` —
resolve, print URL (or markdown), optionally place.
### First consumer
The CY2027 NPRM notebook's FR pincites (at minimum the 91 FR 44218
QP-alignment cite and the CAA-2026 revival cites 44218-44219, 44286)
become live jump links via `frlink.md_link`, gated on the
`nb_integration.py` single-notebook run. Placed links for those cites
sync to Zotero as children of `5ITGVDJV`.
## Testing
- `parse_anchors`: synthetic body-HTML fixture (pages, paragraphs,
data-page, entities like `§ `, footnote-free edge
cases); ordinal assignment; idempotent re-grab (partition replace).
- Resolver: every ref form; ambiguity errors (multi-rule page range,
multi-hit quote); transmutations both ways; `md_link` output.
- Placement: tmp bib + tmp zotero db (existing test_sync fixtures) —
linkMode=3 child created once, idempotent on re-sync.
- Live smoke: parse the already-downloaded 4.2 MB CY2027 body HTML —
5,079 paragraphs / 716 pages expected; `resolve("91 FR 44218 ¶2")`
lands inside the §414.1425 passage.
## Out of scope
- Anchors for non-rule item types (manuals, comments).
- Bulk-placing every anchor into Zotero.
- eCFR/edition permalinks (govinfo PDFs) — FR web version only.
- Auto-rewriting existing docstring pincites; the grammar accepts FR
locators, adoption is incremental.