backdraft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,592 @@
1
+ """The read side of the gate: document list, table of contents, page read.
2
+
3
+ The gate is the mechanism the whole design rests on: *the set of citable tokens
4
+ is exactly the set the gate emitted*. Two consequences shape every function here.
5
+
6
+ 1. **Recompute nothing.** Tokens are read off `Registry.anchors_for_page`, never
7
+ re-derived from page text. A token printed here is a token that already
8
+ exists as a row, so it resolves later by construction.
9
+ 2. **Emitting is minting.** Every anchor whose token (or whose in-band `[B10]`
10
+ reference) reaches the output is recorded in the session ledger before the
11
+ text is returned. Bind's `not_shown` status is what catches a token the writer
12
+ never saw; that status is only meaningful if this module never under-records.
13
+
14
+ The registry is consumed through the pinned surface in SPEC Addendum A and
15
+ nothing else. Nothing in this module writes to `registry/`.
16
+
17
+ Output is plain ASCII and line-oriented: it is read by an agent in a terminal, so
18
+ it is scannable, and it is a contract, so it is stable enough to diff.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING
26
+
27
+ from ..kernel.errors import BackdraftError
28
+ from ..kernel.hashing import normalize
29
+ from ..kernel.tokens import CellLocator, ChunkLocator
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Iterable, Sequence
33
+
34
+ from ..kernel.model import Anchor, Document, Page
35
+ from ..registry.store import Registry
36
+
37
+ __all__ = [
38
+ "GateError",
39
+ "cells",
40
+ "TOC_PREVIEW_CHARS",
41
+ "Selection",
42
+ "read",
43
+ "render_documents",
44
+ "render_toc",
45
+ "render_page_read",
46
+ "select_pages",
47
+ ]
48
+
49
+
50
+ class GateError(BackdraftError):
51
+ """The gate cannot serve this read: unknown slug, unusable selector."""
52
+
53
+
54
+ TOC_PREVIEW_CHARS = 120
55
+ """How much of a page's text stands in for a missing summary (SPEC § Gate)."""
56
+
57
+ _ELLIPSIS = "..."
58
+ _PAGE_SELECTOR = re.compile(r"p(?P<first>[1-9][0-9]*)(?:-(?P<last>[1-9][0-9]*))?")
59
+ _BARE_NUMBER = re.compile(r"[1-9][0-9]*")
60
+ _MARKDOWN_RULE = re.compile(r"[\s|:-]*-[\s|:-]*")
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # selection
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Selection:
70
+ """The pages one read targets, plus the selector text that targeted them.
71
+
72
+ `text` is echoed back verbatim in continuation hints, so the command the
73
+ reader is told to run next is the command it just ran.
74
+ """
75
+
76
+ numbers: tuple[int, ...]
77
+ text: str
78
+
79
+
80
+ def select_pages(pages: Sequence[Page], selector: str) -> Selection:
81
+ """Resolve `p3`, `p3-5`, a sheet name, or a bare page number to page numbers.
82
+
83
+ Sheet names win over the numeric forms so that a sheet named `p3` or `3` is
84
+ still reachable by name. Matching is on `Page.name`, insensitive to case and
85
+ to the `-`/space/underscore difference between a sheet name and the sheetref
86
+ it was sanitized into at ingest.
87
+
88
+ Raises `GateError` if the selector names nothing.
89
+ """
90
+ wanted = _fold(selector)
91
+ named = [page.number for page in pages if page.name and _fold(page.name) == wanted]
92
+ if named:
93
+ return Selection(numbers=tuple(sorted(named)), text=selector)
94
+
95
+ known = {page.number for page in pages}
96
+ if match := _PAGE_SELECTOR.fullmatch(selector):
97
+ first = int(match["first"])
98
+ last = int(match["last"]) if match["last"] else first
99
+ if last < first:
100
+ raise GateError(f"page range runs backwards: {selector!r}")
101
+ numbers = tuple(number for number in range(first, last + 1) if number in known)
102
+ if not numbers:
103
+ raise GateError(f"no such page: {selector!r}")
104
+ return Selection(numbers=numbers, text=selector)
105
+
106
+ if _BARE_NUMBER.fullmatch(selector) and int(selector) in known:
107
+ # NOTE: the spec shows `p3`; a bare `3` is accepted because it costs
108
+ # nothing and is what a reader types by accident.
109
+ return Selection(numbers=(int(selector),), text=f"p{selector}")
110
+
111
+ raise GateError(f"no page or sheet named {selector!r}")
112
+
113
+
114
+ def _fold(name: str) -> str:
115
+ """Compare sheet names the way a reader types them."""
116
+ return re.sub(r"[\s_-]+", "-", name.strip().lower())
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # dispatch
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def read(
125
+ registry: Registry,
126
+ slug: str | None = None,
127
+ selector: str | None = None,
128
+ *,
129
+ session: str | None = None,
130
+ offset: int = 0,
131
+ limit: int | None = None,
132
+ ) -> str:
133
+ """The unified `backdraft read` dispatch.
134
+
135
+ No arguments lists documents; a slug alone prints its table of contents;
136
+ a slug plus a selector prints token-marked content and mints it into
137
+ `session`. Listing and the table of contents emit no tokens, so they mint
138
+ nothing.
139
+ """
140
+ if slug is None:
141
+ return render_documents(registry)
142
+ if selector is None:
143
+ return render_toc(registry, slug)
144
+ return render_page_read(
145
+ registry, slug, selector, session=session, offset=offset, limit=limit
146
+ )
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # document list
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def render_documents(registry: Registry) -> str:
155
+ """One line per document: slug, filename, media type, page count.
156
+
157
+ Emits no tokens: a document is not evidence, its pages are.
158
+ """
159
+ documents = registry.documents()
160
+ if not documents:
161
+ return _block(
162
+ ["No documents.", "", "[Ingest one with: backdraft ingest <file>]"]
163
+ )
164
+
165
+ rows = []
166
+ for document in documents:
167
+ pages = registry.pages(document.slug)
168
+ # NOTE: page count is derived rather than read off Document, which
169
+ # Addendum A does not carry one on.
170
+ rows.append((document.slug, document.filename, document.media_type, pages))
171
+
172
+ slug_width = max(len(row[0]) for row in rows)
173
+ file_width = max(len(row[1]) for row in rows)
174
+ media_width = max(len(row[2]) for row in rows)
175
+ count_width = max(len(str(len(row[3]))) for row in rows)
176
+
177
+ lines = [_plural(len(documents), "document"), ""]
178
+ lines += [
179
+ " ".join(
180
+ (
181
+ slug.ljust(slug_width),
182
+ filename.ljust(file_width),
183
+ media.ljust(media_width),
184
+ f"{len(pages):>{count_width}} {_unit(pages)}",
185
+ )
186
+ )
187
+ for slug, filename, media, pages in rows
188
+ ]
189
+ lines += ["", "[Table of contents: backdraft read <slug>]"]
190
+ return _block(lines)
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # table of contents
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def render_toc(registry: Registry, slug: str) -> str:
199
+ """One line per page or sheet: number, name, summary or first 120 chars.
200
+
201
+ Emits no tokens: a preview is not a receipt, and a reader that cites from a
202
+ table of contents is citing text it was shown only in part.
203
+ """
204
+ document = _require_document(registry, slug)
205
+ pages = registry.pages(slug)
206
+ if not pages:
207
+ return _block([_document_headline(document, pages), "", "(no pages)"])
208
+
209
+ labels = [f"p{page.number}" for page in pages]
210
+ label_width = max(len(label) for label in labels)
211
+ names = [page.name or "" for page in pages]
212
+ name_width = max(len(name) for name in names)
213
+
214
+ lines = [_document_headline(document, pages), ""]
215
+ for page, label, name in zip(pages, labels, names, strict=True):
216
+ cells = [label.ljust(label_width)]
217
+ if name_width:
218
+ cells.append(name.ljust(name_width))
219
+ cells.append(_preview(page))
220
+ lines.append(" ".join(cells).rstrip())
221
+
222
+ first = pages[0]
223
+ lines += ["", f"[Read one: backdraft read {slug} p{first.number}]"]
224
+ if len(pages) > 1 and first.kind == "page":
225
+ lines.append(
226
+ f"[Read a range: backdraft read {slug} p{first.number}-{pages[-1].number}]"
227
+ )
228
+ if first.name:
229
+ lines.append(f'[Read by name: backdraft read {slug} "{first.name}"]')
230
+ return _block(lines)
231
+
232
+
233
+ def _preview(page: Page) -> str:
234
+ """A page's summary, or its opening text collapsed onto one line."""
235
+ if page.summary:
236
+ return normalize(page.summary)
237
+ text = normalize(page.text)
238
+ if len(text) <= TOC_PREVIEW_CHARS:
239
+ return text
240
+ return text[:TOC_PREVIEW_CHARS] + _ELLIPSIS
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # page read
245
+ # ---------------------------------------------------------------------------
246
+
247
+
248
+ def render_page_read(
249
+ registry: Registry,
250
+ slug: str,
251
+ selector: str,
252
+ *,
253
+ session: str | None = None,
254
+ offset: int = 0,
255
+ limit: int | None = None,
256
+ ) -> str:
257
+ """Token-marked content for the selected pages, minted into `session`.
258
+
259
+ PDF pages render one chunk per token: `[bd:slug:p3.c1:a7f3]` alone on a line
260
+ above the chunk's verbatim snippet, chunks separated by a blank line. Sheets
261
+ render `Page.text` unchanged — the `[B10]` references are already in band —
262
+ under a header line carrying the page token.
263
+
264
+ Windowing shares one budget across the whole request, counted in the unit
265
+ each page kind is native to: characters for `page`, rows for `sheet`.
266
+ `offset` skips that many units and `limit` caps how many are shown; a
267
+ continuation hint reports the window and the command that reads the next one.
268
+ NOTE: windows never cut a chunk or a row in half, so `offset` snaps outward
269
+ to the enclosing unit. A token is never printed above partial text.
270
+
271
+ Every anchor shown is recorded, including the cell anchors a sheet window
272
+ exposes by their in-band references but does not print tokens for.
273
+ """
274
+ if offset < 0:
275
+ raise GateError(f"offset must not be negative: {offset!r}")
276
+ if limit is not None and limit < 0:
277
+ raise GateError(f"limit must not be negative: {limit!r}")
278
+
279
+ document = _require_document(registry, slug)
280
+ pages = registry.pages(slug)
281
+ selection = select_pages(pages, selector)
282
+ by_number = {page.number: page for page in pages}
283
+ total_pages = len(pages)
284
+
285
+ window = _Window(offset=offset, limit=limit)
286
+ blocks: list[str] = []
287
+ minted: list[int] = []
288
+ # NOTE: one unit name for the whole request, taken from the first page. An
289
+ # extraction is all pages or all sheets, so a mixed selection cannot arise.
290
+ unit = "rows" if by_number[selection.numbers[0]].kind == "sheet" else "chars"
291
+ for number in selection.numbers:
292
+ page = by_number[number]
293
+ anchors = registry.anchors_for_page(slug, number)
294
+ if page.kind == "sheet":
295
+ block, shown = _render_sheet(document, page, anchors, total_pages, window)
296
+ else:
297
+ block, shown = _render_page(document, page, anchors, total_pages, window)
298
+ if block:
299
+ blocks.append(block)
300
+ minted.extend(shown)
301
+
302
+ _mint(registry, session, minted)
303
+
304
+ lines = "\n\n".join(blocks).split("\n") if blocks else ["(nothing to show at this offset)"]
305
+ if (hint := window.hint(slug, selection.text, unit)) is not None:
306
+ lines += ["", hint]
307
+ return _block(lines)
308
+
309
+
310
+ @dataclass(slots=True)
311
+ class _Window:
312
+ """The shared offset/limit budget for one read, in page-native units.
313
+
314
+ `consumed` counts units skipped or shown so far; `total` counts every unit
315
+ the request covers, whether shown or not, so the hint can say how much is
316
+ left.
317
+ """
318
+
319
+ offset: int
320
+ limit: int | None
321
+ consumed: int = 0
322
+ shown: int = 0
323
+ start: int | None = None
324
+ total: int = 0
325
+ full: bool = False
326
+
327
+ @property
328
+ def open(self) -> bool:
329
+ """True while the window is neither still skipping nor already closed."""
330
+ return not self.full and self.consumed >= self.offset
331
+
332
+ def take(self, size: int) -> bool:
333
+ """Offer one indivisible group of `size` units. True if it is shown.
334
+
335
+ A group is shown whole or not at all, so `offset` snaps outward to the
336
+ enclosing group and a group larger than `limit` is still shown when it is
337
+ the first one — a read never prints a token above partial text.
338
+
339
+ The first group that will not fit closes the window for good: a window is
340
+ one contiguous run, so `--offset` on the continuation hint resumes exactly
341
+ where this one stopped rather than at a gap.
342
+ """
343
+ self.total += size
344
+ if not self.open:
345
+ self.consumed += size
346
+ return False
347
+ if self.limit is not None and self.shown and self.shown + size > self.limit:
348
+ self.full = True
349
+ self.consumed += size
350
+ return False
351
+ if self.start is None:
352
+ self.start = self.consumed
353
+ self.consumed += size
354
+ self.shown += size
355
+ return True
356
+
357
+ def hint(self, slug: str, selector: str, unit: str) -> str | None:
358
+ """The continuation line, or None when the window covered everything."""
359
+ start = self.start if self.start is not None else min(self.offset, self.total)
360
+ end = start + self.shown
361
+ if start == 0 and end == self.total:
362
+ return None
363
+ line = f"[Showing {start}-{end} of {self.total} {unit}."
364
+ if end < self.total:
365
+ line += f" Continue with: backdraft read {slug} {selector} --offset {end}"
366
+ return line + "]"
367
+
368
+
369
+ def _render_page(
370
+ document: Document,
371
+ page: Page,
372
+ anchors: Sequence[Anchor],
373
+ total_pages: int,
374
+ window: _Window,
375
+ ) -> tuple[str, list[int]]:
376
+ """A PDF/text page as its chunks, each under its own token."""
377
+ header = f"# {document.slug} p{page.number} (page {page.number} of {total_pages})"
378
+ chunks = sorted(
379
+ (a for a in anchors if isinstance(a.locator, ChunkLocator)),
380
+ key=lambda a: a.locator.ordinal, # type: ignore[union-attr]
381
+ )
382
+ parts: list[str] = []
383
+ minted: list[int] = []
384
+ for anchor in chunks:
385
+ if not window.take(len(anchor.receipt.snippet)):
386
+ continue
387
+ parts.append(f"[{anchor.token}]\n{anchor.receipt.snippet}")
388
+ if anchor.id is not None:
389
+ minted.append(anchor.id)
390
+ if not parts:
391
+ if not chunks and window.open:
392
+ # NOTE: a page with no chunk anchors has no citable text; say so
393
+ # rather than printing text no token covers.
394
+ return f"{header}\n\n(no text on this page)", []
395
+ return "", []
396
+ return "\n\n".join([header, *parts]), minted
397
+
398
+
399
+ def _render_sheet(
400
+ document: Document,
401
+ page: Page,
402
+ anchors: Sequence[Anchor],
403
+ total_pages: int,
404
+ window: _Window,
405
+ ) -> tuple[str, list[int]]:
406
+ """A sheet as its markdown table, header row repeated on every window."""
407
+ name = page.name or f"p{page.number}"
408
+ header = f"# {document.slug} p{page.number} (sheet {page.number} of {total_pages}: {name})"
409
+ page_anchor = next((a for a in anchors if a.locator.kind == "page"), None)
410
+ if page_anchor is not None:
411
+ header = f"{header} [{page_anchor.token}]"
412
+
413
+ table_header, rows = _split_table(page.text)
414
+ shown = [row for row in rows if window.take(1)]
415
+ if not shown:
416
+ return "", []
417
+
418
+ body = "\n".join([*table_header, *shown])
419
+ minted = [page_anchor.id] if page_anchor is not None and page_anchor.id is not None else []
420
+ minted += _cells_in(anchors, body)
421
+ return f"{header}\n\n{body}", minted
422
+
423
+
424
+ def _split_table(text: str) -> tuple[list[str], list[str]]:
425
+ """Separate a sheet page's header block from the table's data rows.
426
+
427
+ The header block is *everything above the first data row*: the extractor puts
428
+ a `## Sheet: … - Values View with cell references` title and a blank line
429
+ above the table (`extract/xlsx.py`), then the `| Row | A | B |` column header
430
+ and its `|---|` rule. Repeating only the first line would repeat the title and
431
+ window the column header away, leaving continuation windows with a headerless
432
+ table — SPEC § Gate requires the header row on every window, so the whole
433
+ block travels with every one of them, blank line included.
434
+
435
+ Rows are lines, so a window can never cut one in half.
436
+ """
437
+ lines = text.split("\n")
438
+ header: list[str] = []
439
+ index = 0
440
+ while index < len(lines) and not _is_table_row(lines[index]):
441
+ header.append(lines[index])
442
+ index += 1
443
+ if index < len(lines):
444
+ header.append(lines[index]) # the column header row
445
+ index += 1
446
+ if index < len(lines) and _is_rule(lines[index]):
447
+ header.append(lines[index])
448
+ index += 1
449
+ rows = [line for line in lines[index:] if line.strip()]
450
+ # NOTE: a page whose text is a placeholder rather than a table (the
451
+ # inflated-sheet case, or a sheet with no data) has no rows to repeat a header
452
+ # above; show it whole.
453
+ if not rows:
454
+ return [], [line for line in lines if line.strip()]
455
+ return _trimmed(header), rows
456
+
457
+
458
+ def _is_table_row(line: str) -> bool:
459
+ """True for a markdown table line, which the extractor always leads with `|`."""
460
+ return line.lstrip().startswith("|")
461
+
462
+
463
+ def _is_rule(line: str) -> bool:
464
+ """True for a `|---|---|` separator under a table's column header."""
465
+ return bool(_MARKDOWN_RULE.fullmatch(line))
466
+
467
+
468
+ def _trimmed(header: list[str]) -> list[str]:
469
+ """The header block without leading or trailing blank lines.
470
+
471
+ The blank line *between* the title and the table is content — it is what keeps
472
+ the title from being read as part of the table — so only the edges go.
473
+ """
474
+ while header and not header[0].strip():
475
+ header.pop(0)
476
+ while header and not header[-1].strip():
477
+ header.pop()
478
+ return header
479
+
480
+
481
+ def _cells_in(anchors: Iterable[Anchor], text: str) -> list[int]:
482
+ """Cell anchor ids whose in-band `[B10]` reference appears in `text`.
483
+
484
+ Sheets do not print per-cell tokens — the spec puts the references in band
485
+ and lets bind compose `bd:slug:sheet!B10:hash` from the registry. Those
486
+ composed tokens must still bind `resolved`, so the cells a window exposes are
487
+ minted exactly as if their tokens had been printed. Membership is decided on
488
+ the rendered text itself, which needs no assumption about the table's shape.
489
+ """
490
+ ids: list[int] = []
491
+ for anchor in anchors:
492
+ locator = anchor.locator
493
+ if not isinstance(locator, CellLocator) or locator.end is not None:
494
+ continue
495
+ if anchor.id is not None and f"[{locator.cell.format()}]" in text:
496
+ ids.append(anchor.id)
497
+ return ids
498
+
499
+
500
+ def cells(
501
+ registry: Registry, slug: str, refs: Sequence[str], *, session: str | None = None
502
+ ) -> str:
503
+ """Mint cell tokens directly: `refs` are `sheet!REF` locators.
504
+
505
+ The ergonomic path to citing a cell you are looking at in a windowed read —
506
+ the alternative was searching for the cell's own value and copying the
507
+ token off the hit. Each line mirrors a search hit: the token, then the
508
+ verbatim value. Emitting a token records it as shown, exactly as a read
509
+ would (the gate's whole contract).
510
+ """
511
+ _require_document(registry, slug)
512
+ # Folded like `read`'s sheet selector, so the name a reader types (or the
513
+ # display name a registry stores) both land on the same sheet.
514
+ sheet_pages = {
515
+ _fold(page.name): (page.name, page.number)
516
+ for page in registry.pages(slug)
517
+ if page.kind == "sheet" and page.name
518
+ }
519
+ lines: list[str] = []
520
+ minted: list[int] = []
521
+ for ref in refs:
522
+ sheet, separator, cell_ref = ref.partition("!")
523
+ if not separator or not cell_ref:
524
+ raise GateError(f"expected sheet!REF, got {ref!r} (for example rent-roll!B10)")
525
+ found_sheet = sheet_pages.get(_fold(sheet))
526
+ if found_sheet is None:
527
+ known = ", ".join(sorted(name for name, _ in sheet_pages.values())) or "none"
528
+ raise GateError(f"no sheet {sheet!r} in {slug!r}; sheets: {known}")
529
+ _, number = found_sheet
530
+ found = None
531
+ for anchor in registry.anchors_for_page(slug, number):
532
+ locator = anchor.locator
533
+ if (
534
+ isinstance(locator, CellLocator)
535
+ and locator.end is None
536
+ and locator.cell.format() == cell_ref
537
+ ):
538
+ found = anchor
539
+ break
540
+ if found is None:
541
+ raise GateError(f"no cell {cell_ref!r} on sheet {sheet!r} (empty cells mint nothing)")
542
+ lines.append(f"[{found.token}] {found.receipt.snippet}")
543
+ if found.id is not None:
544
+ minted.append(found.id)
545
+ _mint(registry, session, minted)
546
+ return "\n".join(lines)
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # shared
551
+ # ---------------------------------------------------------------------------
552
+
553
+
554
+ def _mint(registry: Registry, session: str | None, anchor_ids: Sequence[int]) -> None:
555
+ """Record every emitted anchor under the session. The gate's whole contract.
556
+
557
+ Called once per read, on the anchors that reached the output — never on the
558
+ anchors a window skipped.
559
+ """
560
+ if session is None or not anchor_ids:
561
+ return
562
+ session_id = registry.ensure_session(session)
563
+ registry.record_shown(session_id, sorted(set(anchor_ids)))
564
+
565
+
566
+ def _require_document(registry: Registry, slug: str) -> Document:
567
+ document = registry.document(slug)
568
+ if document is None:
569
+ raise GateError(f"no such document: {slug!r}")
570
+ return document
571
+
572
+
573
+ def _document_headline(document: Document, pages: Sequence[Page]) -> str:
574
+ return (
575
+ f"{document.slug} ({document.filename}, {document.media_type}, "
576
+ f"{len(pages)} {_unit(pages)})"
577
+ )
578
+
579
+
580
+ def _unit(pages: Sequence[Page]) -> str:
581
+ """`sheets` when every page is one, else `pages`. Singular when there is one."""
582
+ word = "sheet" if pages and all(page.kind == "sheet" for page in pages) else "page"
583
+ return word if len(pages) == 1 else f"{word}s"
584
+
585
+
586
+ def _plural(count: int, word: str) -> str:
587
+ return f"{count} {word}" if count == 1 else f"{count} {word}s"
588
+
589
+
590
+ def _block(lines: Iterable[str]) -> str:
591
+ """Join rendered lines into the final output: no trailing blank lines."""
592
+ return "\n".join(line.rstrip() for line in lines).rstrip("\n")