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,653 @@
1
+ """bind: resolve → verify → rewrite → report.
2
+
3
+ The postprocess. It takes the document as written — claim spans carrying raw
4
+ tokens — and turns every token back into the receipt it names: a doc name, a
5
+ locator, and the verbatim quote the writer saw. What it cannot resolve it
6
+ reports; what it reports it never drops.
7
+
8
+ Three rules hold everywhere in this module:
9
+
10
+ * **Never edit a claim's text.** Bind rewrites the citation *around* a claim
11
+ (`[claim](bd:…)` → `[claim](#cite-1)`); the words are the author's.
12
+ * **Never drop a citation.** Every token the author wrote gets a number, a
13
+ References entry, and a line in the report — malformed ones included.
14
+ * **Verification never gates.** The exit-code contract keys off citation
15
+ resolution alone; verdicts are evidence recorded beside it.
16
+
17
+ Outputs, per bind run over `notes.md`:
18
+
19
+ * `notes.bound.md` — the rewritten document plus a generated References section.
20
+ A separate file, not an in-place edit: the authored document with its tokens
21
+ is the source of truth and bind is re-runnable against it.
22
+ * `notes.backdraft.json` — the sidecar: the self-describing artifact payload
23
+ (`$format` + `$legend` wrapped around `BindReport.to_dict()`), which is what a
24
+ reader who has only this file needs. The row saved to `bindings.report_json`
25
+ is the bare `BindReport.to_dict()` instead: the registry already knows what
26
+ format it stores, so the legend would be noise in a database column.
27
+
28
+ NOTE: both filenames belong to the artifact format, not to this module —
29
+ `kernel/artifact.py` owns the suffixes and the path math (spec/artifact.md
30
+ § Naming), so bind, render and any other implementation agree on where a bound
31
+ document's pieces live. They are re-exported here because this module's callers
32
+ already ask bind where it wrote.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import re
39
+ from collections.abc import Sequence
40
+ from datetime import UTC, datetime
41
+ from pathlib import Path
42
+ from typing import TYPE_CHECKING
43
+
44
+ from ..kernel.artifact import ( # noqa: F401 (re-exported: naming is kernel-owned)
45
+ BOUND_SUFFIX,
46
+ SIDECAR_SUFFIX,
47
+ bound_path,
48
+ record_path,
49
+ sidecar_path,
50
+ )
51
+ from ..kernel.artifact import dumps as artifact_dumps
52
+ from ..kernel.claims import parse_claims
53
+ from ..kernel.model import (
54
+ Anchor,
55
+ BindMode,
56
+ BindReport,
57
+ Citation,
58
+ CitationStatus,
59
+ Claim,
60
+ )
61
+ from ..kernel.tokens import format_locator
62
+ from . import evidence as evidence_module
63
+ from .verify.base import Verifier, selected
64
+ from .verify.value_trace import extract_values
65
+
66
+ if TYPE_CHECKING: # pragma: no cover - bind consumes the registry, never extends it
67
+ from ..registry.store import Registry
68
+
69
+ __all__ = [
70
+ "bind",
71
+ "bound_path",
72
+ "record_path",
73
+ "record_target",
74
+ "sidecar_path",
75
+ "propose_anchors",
76
+ "search_query",
77
+ "BOUND_SUFFIX",
78
+ "SIDECAR_SUFFIX",
79
+ "PROPOSAL_LIMIT",
80
+ ]
81
+
82
+ # BOUND_SUFFIX, SIDECAR_SUFFIX, `bound_path` and `sidecar_path` come from
83
+ # `kernel.artifact`: the names of a bind run's outputs, like the format inside
84
+ # them, are shared vocabulary between bind (the writer) and render (the reader).
85
+
86
+ PROPOSAL_LIMIT = 3
87
+ """Search hits proposed per unmatched claim in backfill mode."""
88
+
89
+ QUERY_TERMS = 5
90
+ """Distinctive words kept from a claim when searching for its anchor."""
91
+
92
+ QUERY_VALUES = 2
93
+ """Numbers from a claim carried into the query as terms of their own."""
94
+
95
+ _REFERENCES_HEADING = "## References"
96
+ _UNMATCHED_HEADING = "## Unmatched claims"
97
+
98
+ _WORD = re.compile(r"[A-Za-z][A-Za-z'’]*")
99
+ _VALUE = re.compile(r"\d[\d,]*(?:\.\d+)?")
100
+
101
+ _STOPWORDS = frozenset(
102
+ """
103
+ a an and are as at be been but by can could did do does for from had has
104
+ have how in into is it its may might must no not of off on one only or other
105
+ our out over per shall should since so some such than that the their them
106
+ then there these they this those through to under until up upon was were
107
+ what when where which while who whom why will with within would you your
108
+ also being both each either every less more most much nor same still
109
+ thus very
110
+ """.split()
111
+ )
112
+ """Words that carry no discriminating power in a corpus of analyst prose.
113
+
114
+ Deliberately not the full NLTK list: a term dropped here is a term the proposal
115
+ cannot rank on, and words like `net`, `total` and `gross` earn their place in
116
+ this domain even though a general-purpose list would bin them.
117
+ """
118
+
119
+
120
+ def bind(
121
+ doc_path: Path,
122
+ registry: Registry,
123
+ *,
124
+ mode: BindMode = "frontwalk",
125
+ session_id: str | None = None,
126
+ checks: Sequence[str] = (),
127
+ write: bool = True,
128
+ lean: bool = False,
129
+ bound: bool = False,
130
+ ) -> BindReport:
131
+ """Bind one authored document. Returns the report; writes the artifacts.
132
+
133
+ `checks` names the verification methods to run (`--check`); empty means no
134
+ verifier runs and the report carries no verdict rows. `session_id` is what
135
+ `not_shown` is judged against — without one, frontwalk mode cannot tell a
136
+ cited-what-you-saw token from a valid token that was never shown, so it
137
+ does not claim to. `write=False` computes the report without touching the
138
+ filesystem or the registry. `lean` skips page images in the evidence block
139
+ (text windows and sheet values are always included). `bound` also writes
140
+ the rewritten-markdown projection (`memo.bound.md`) — off by default, so
141
+ the standard working set is three files: the authored document, the
142
+ record, the artifact.
143
+ """
144
+ source = doc_path.read_text(encoding="utf-8")
145
+ verifiers = selected(checks)
146
+ claims = [
147
+ _resolve_claim(claim, registry, mode=mode, session_id=session_id)
148
+ for claim in parse_claims(source)
149
+ ]
150
+ proposals: dict[int, tuple[Anchor, ...]] = {}
151
+ if mode == "backfill":
152
+ claims = _backfill(claims, source, registry, proposals)
153
+ claims = _verify(claims, verifiers)
154
+ report = BindReport(
155
+ doc_path=str(doc_path),
156
+ mode=mode,
157
+ bound_at=_now(),
158
+ claims=tuple(claims),
159
+ session_id=session_id,
160
+ evidence=evidence_module.assemble(registry, claims, lean=lean),
161
+ )
162
+ if write:
163
+ if bound:
164
+ bound_path(doc_path).write_text(
165
+ rewrite(source, report, registry, proposals), encoding="utf-8"
166
+ )
167
+ # The record is the self-describing artifact payload ($format + $legend
168
+ # around the report). With a rooted registry it lives out of the
169
+ # authored directory (.backdraft/records/); a fake or rootless registry
170
+ # keeps the portable beside-the-document form. The registry row carries
171
+ # the bare report — the evidence block is heavy and reproducible.
172
+ target = record_target(doc_path, registry)
173
+ target.parent.mkdir(parents=True, exist_ok=True)
174
+ target.write_text(artifact_dumps(report), encoding="utf-8")
175
+ registry.save_binding(
176
+ doc_path=str(doc_path),
177
+ session_id=session_id,
178
+ mode=mode,
179
+ report_json=json.dumps(
180
+ report.to_dict(include_evidence=False), indent=2, ensure_ascii=False
181
+ ),
182
+ )
183
+ return report
184
+
185
+
186
+ def record_target(doc_path: Path, registry) -> Path: # noqa: ANN001
187
+ """Where this bind writes its record: the registry's records dir, or beside."""
188
+ root = getattr(registry, "root", None)
189
+ if root is None:
190
+ return sidecar_path(doc_path)
191
+ return record_path(Path(root).resolve(), doc_path.resolve())
192
+
193
+
194
+ def propose_anchors(
195
+ registry: Registry, text: str, *, limit: int = PROPOSAL_LIMIT
196
+ ) -> tuple[Anchor, ...]:
197
+ """Search hits bind offers for an unanchored claim. Proposals only.
198
+
199
+ Backfill proposes; it never attaches. An anchor bind chose for a claim the
200
+ author did not cite would be a citation nobody made.
201
+ """
202
+ query = search_query(text)
203
+ if not query:
204
+ return ()
205
+ try:
206
+ hits = registry.search(query, limit=limit)
207
+ except Exception: # noqa: BLE001 - a search that fails proposes nothing
208
+ return ()
209
+ return tuple(hit.anchor for hit in hits[:limit])
210
+
211
+
212
+ def search_query(text: str) -> str:
213
+ """The FTS5 query a claim's own words make. Empty when it has none to give.
214
+
215
+ A claim sentence is not a query. Handed to FTS5 verbatim, an ordinary one —
216
+ *"Real estate taxes of $412,300 are the largest single expense line."* —
217
+ fails to parse on the `$`, the `,` and the `.`, falls through to the quoted-
218
+ phrase retry, and matches nothing at all, because no snippet contains that
219
+ exact sentence. Backfill's proposals were therefore always empty or driven by
220
+ stopword overlap.
221
+
222
+ So the claim is reduced to what distinguishes it:
223
+
224
+ * stopwords and one/two-letter fragments go;
225
+ * an all-caps acronym (`NOI`, `DSCR`, `LTV`) outranks everything, because a
226
+ corpus of prose has few of them and analysts write in them;
227
+ * otherwise longer words rank first — a crude stand-in for rarity, but the
228
+ right crudeness, since it prefers `reassessment` over `year`;
229
+ * the top `QUERY_TERMS` survive, joined with `OR` so ranking sorts by how
230
+ many matched rather than requiring all of them;
231
+ * up to `QUERY_VALUES` numbers ride along as their own terms — `412,300` is
232
+ the single most distinctive thing in that sentence.
233
+
234
+ Every term is quoted, which both keeps punctuation out of FTS5's grammar and
235
+ makes it impossible for a claim containing the word "or" to become an
236
+ operator.
237
+ """
238
+ # Deduplicated on case, but the first spelling is kept: `NOI` has to still
239
+ # look like an acronym when the sentence later says `noi`.
240
+ seen: dict[str, str] = {}
241
+ for word in _WORD.findall(text):
242
+ if _is_content(word):
243
+ seen.setdefault(word.lower(), word)
244
+ # `sorted` is stable, so words of equal rank stay in the order they were
245
+ # written — the query is a pure function of the claim, as proposals must be.
246
+ ranked = sorted(seen.values(), key=_distinctiveness)
247
+ values = sorted(dict.fromkeys(_VALUE.findall(text)), key=len, reverse=True)
248
+ terms = [*ranked[:QUERY_TERMS], *values[:QUERY_VALUES]]
249
+ return " OR ".join(f'"{term.lower()}"' for term in terms)
250
+
251
+
252
+ def _is_content(word: str) -> bool:
253
+ """A word worth searching for: not a stopword, not a two-letter fragment."""
254
+ return len(word) > 2 and word.lower() not in _STOPWORDS
255
+
256
+
257
+ def _distinctiveness(word: str) -> tuple[int, int]:
258
+ """Sort key: acronyms first, then longest first."""
259
+ return (0 if word.isupper() else 1, -len(word))
260
+
261
+
262
+ # --- resolution ------------------------------------------------------------
263
+
264
+
265
+ def _resolve_claim(
266
+ claim: Claim, registry: Registry, *, mode: BindMode, session_id: str | None
267
+ ) -> Claim:
268
+ citations = tuple(
269
+ _resolve_citation(citation, registry, mode=mode, session_id=session_id)
270
+ for citation in claim.citations
271
+ )
272
+ return Claim(
273
+ text=claim.text,
274
+ start=claim.start,
275
+ end=claim.end,
276
+ citations=citations,
277
+ unmatched=claim.unmatched,
278
+ )
279
+
280
+
281
+ def _current_counterpart(registry: Registry, anchor: Anchor) -> Anchor | None:
282
+ """The current generation's anchor at `anchor`'s locator, if it survived."""
283
+ if anchor.page_number is None:
284
+ return None
285
+ for candidate in registry.anchors_for_page(anchor.slug, anchor.page_number):
286
+ if candidate.locator == anchor.locator:
287
+ return candidate
288
+ return None
289
+
290
+
291
+ def _resolve_citation(
292
+ citation: Citation, registry: Registry, *, mode: BindMode, session_id: str | None
293
+ ) -> Citation:
294
+ """One citation's status, from the closed set.
295
+
296
+ `malformed` is already decided by the kernel and is never revisited: a
297
+ token that does not parse cannot be looked up.
298
+ """
299
+ if citation.status is CitationStatus.MALFORMED:
300
+ return citation
301
+ resolution = registry.resolve(citation.token)
302
+ if resolution is None:
303
+ return Citation(token=citation.token, status=CitationStatus.UNRESOLVED)
304
+ anchor = resolution.anchor
305
+ if not resolution.current:
306
+ # The drift contract (kernel fixture, spec/artifact.md): `drifted_from`
307
+ # is the snippet the writer cited; `anchor` is what stands at that
308
+ # locator now, found via `anchors_for_page` on the current generation.
309
+ # When the locator itself is gone, the cited anchor stands in and the
310
+ # two sides of the diff agree — still `drifted`, because the token no
311
+ # longer resolves against the current generation.
312
+ current = _current_counterpart(registry, anchor)
313
+ return Citation(
314
+ token=citation.token,
315
+ status=CitationStatus.DRIFTED,
316
+ anchor=current or anchor,
317
+ drifted_from=anchor.receipt.snippet,
318
+ )
319
+ if mode == "frontwalk" and session_id is not None:
320
+ if not registry.was_shown(session_id, citation.token):
321
+ return Citation(
322
+ token=citation.token, status=CitationStatus.NOT_SHOWN, anchor=anchor
323
+ )
324
+ return Citation(token=citation.token, status=CitationStatus.RESOLVED, anchor=anchor)
325
+
326
+
327
+ # --- backfill --------------------------------------------------------------
328
+
329
+
330
+ def _backfill(
331
+ claims: list[Claim],
332
+ source: str,
333
+ registry: Registry,
334
+ proposals: dict[int, tuple[Anchor, ...]],
335
+ ) -> list[Claim]:
336
+ """Mark and propose for every claim backfill could not anchor.
337
+
338
+ Two kinds land here. A claim the author cited whose citations all failed —
339
+ the token is there, the anchor is not. And a factual sentence carrying no
340
+ citation at all, which is the whole backfill case: an existing document
341
+ plus ingested sources, where the report has to name what is still
342
+ unattributed rather than say nothing.
343
+ """
344
+ marked: list[Claim] = []
345
+ for claim in claims:
346
+ if any(citation.anchor is not None for citation in claim.citations):
347
+ marked.append(claim)
348
+ continue
349
+ marked.append(
350
+ Claim(
351
+ text=claim.text,
352
+ start=claim.start,
353
+ end=claim.end,
354
+ citations=claim.citations,
355
+ unmatched=True,
356
+ )
357
+ )
358
+ covered = [(claim.start, claim.end) for claim in claims]
359
+ for start, end in _candidate_spans(source, covered):
360
+ marked.append(Claim(text=source[start:end], start=start, end=end, unmatched=True))
361
+ marked.sort(key=lambda claim: claim.start)
362
+ for index, claim in enumerate(marked):
363
+ if claim.unmatched:
364
+ proposals[index] = propose_anchors(registry, claim.text)
365
+ return marked
366
+
367
+
368
+ def _candidate_spans(source: str, covered: Sequence[tuple[int, int]]) -> list[tuple[int, int]]:
369
+ """Sentences that look like factual claims and carry no citation.
370
+
371
+ NOTE: the spec does not say what an uncited claim *is*, so this takes the
372
+ narrowest reading that still makes backfill useful: a sentence, outside a
373
+ fenced code block or a heading, that contains at least one value. A claim
374
+ with a number or a date is the claim class backfill can actually search
375
+ for; treating every sentence as a claim would bury the report it is meant
376
+ to be.
377
+ """
378
+ spans: list[tuple[int, int]] = []
379
+ for start, end in _sentences(source):
380
+ if any(start < c_end and c_start < end for c_start, c_end in covered):
381
+ continue
382
+ if not extract_values(source[start:end]):
383
+ continue
384
+ spans.append((start, end))
385
+ return spans
386
+
387
+
388
+ def _sentences(source: str) -> list[tuple[int, int]]:
389
+ """Sentence spans in prose lines, as `(start, end)` offsets into `source`."""
390
+ spans: list[tuple[int, int]] = []
391
+ offset = 0
392
+ fenced = False
393
+ for line in source.splitlines(keepends=True):
394
+ stripped = line.strip()
395
+ if stripped.startswith("```"):
396
+ fenced = not fenced
397
+ offset += len(line)
398
+ continue
399
+ if fenced or not stripped or stripped.startswith("#"):
400
+ offset += len(line)
401
+ continue
402
+ for start, end in _split_sentences(line):
403
+ spans.append((offset + start, offset + end))
404
+ offset += len(line)
405
+ return spans
406
+
407
+
408
+ def _split_sentences(line: str) -> list[tuple[int, int]]:
409
+ """Sentence spans within one line: terminal `.!?` followed by a space."""
410
+ spans: list[tuple[int, int]] = []
411
+ start = 0
412
+ index = 0
413
+ length = len(line)
414
+ while index < length:
415
+ if line[index] in ".!?" and (index + 1 >= length or line[index + 1].isspace()):
416
+ end = index + 1
417
+ if line[start:end].strip():
418
+ spans.append((start + _lead(line[start:end]), end))
419
+ start = end
420
+ index += 1
421
+ tail = line[start:]
422
+ if tail.strip():
423
+ spans.append((start + _lead(tail), start + len(tail.rstrip())))
424
+ return spans
425
+
426
+
427
+ def _lead(text: str) -> int:
428
+ return len(text) - len(text.lstrip())
429
+
430
+
431
+ # --- verification ----------------------------------------------------------
432
+
433
+
434
+ def _verify(claims: list[Claim], verifiers: Sequence[Verifier]) -> list[Claim]:
435
+ """Run enabled verifiers over every citation that reached an anchor.
436
+
437
+ A citation with no anchor has no snippet to check against, so no method
438
+ applies to it — `unresolved` and `malformed` carry no verdicts. A verifier
439
+ whose `applies` is False writes no row: the report never implies a method
440
+ looked at a claim it never read.
441
+ """
442
+ if not verifiers:
443
+ return claims
444
+ pairs = [
445
+ (claim, citation, citation.anchor)
446
+ for claim in claims
447
+ for citation in claim.citations
448
+ if citation.anchor is not None
449
+ ]
450
+ for verifier in verifiers:
451
+ prepare = getattr(verifier, "prepare", None)
452
+ if callable(prepare):
453
+ applicable = [
454
+ (claim, citation, anchor)
455
+ for claim, citation, anchor in pairs
456
+ if verifier.applies(claim, citation)
457
+ ]
458
+ if applicable:
459
+ prepare(applicable)
460
+ verified: list[Claim] = []
461
+ for claim in claims:
462
+ citations = tuple(
463
+ _verify_citation(claim, citation, verifiers) for citation in claim.citations
464
+ )
465
+ verified.append(
466
+ Claim(
467
+ text=claim.text,
468
+ start=claim.start,
469
+ end=claim.end,
470
+ citations=citations,
471
+ unmatched=claim.unmatched,
472
+ )
473
+ )
474
+ return verified
475
+
476
+
477
+ def _verify_citation(
478
+ claim: Claim, citation: Citation, verifiers: Sequence[Verifier]
479
+ ) -> Citation:
480
+ if citation.anchor is None:
481
+ return citation
482
+ verdicts = tuple(
483
+ verifier.verify(claim, citation, citation.anchor)
484
+ for verifier in verifiers
485
+ if verifier.applies(claim, citation)
486
+ )
487
+ return Citation(
488
+ token=citation.token,
489
+ status=citation.status,
490
+ anchor=citation.anchor,
491
+ drifted_from=citation.drifted_from,
492
+ verdicts=verdicts,
493
+ error=citation.error,
494
+ )
495
+
496
+
497
+ # --- rewrite ---------------------------------------------------------------
498
+
499
+
500
+ def rewrite(
501
+ source: str,
502
+ report: BindReport,
503
+ registry: Registry,
504
+ proposals: dict[int, tuple[Anchor, ...]] | None = None,
505
+ ) -> str:
506
+ """The authored document with tokens replaced by readable citations.
507
+
508
+ Citations are numbered 1..N in document order, **one number per distinct
509
+ token**: three claims citing the same anchor share number 4 and the anchor's
510
+ quote appears once. Numbering per citation instead would repeat a 1200-char
511
+ snippet down the page once per citing claim, which is how a References
512
+ section stops being readable — and a reader who sees `[4]` twice has learned
513
+ something true, that both claims rest on the same evidence.
514
+
515
+ A claim's span becomes `[claim](#cite-n)`; a claim carrying more than one
516
+ citation gets the extra numbers appended as their own links, so no citation
517
+ loses its anchor and the claim's own words stay exactly as written. Uncited
518
+ backfill claims are left untouched — bind has nothing to attach to them.
519
+
520
+ NOTE: this is a rendering rule only. The report and the sidecar still carry
521
+ every citation the author wrote, individually.
522
+ """
523
+ proposals = proposals or {}
524
+ numbers, entries = _numbering(report)
525
+ pieces: list[str] = []
526
+ cursor = 0
527
+ for index, claim in enumerate(report.claims):
528
+ if not claim.citations:
529
+ continue
530
+ pieces.append(source[cursor : claim.start])
531
+ pieces.append(_rewrite_claim(claim, numbers[index]))
532
+ cursor = claim.end
533
+ pieces.append(source[cursor:])
534
+ body = "".join(pieces).rstrip("\n")
535
+ sections = [body, _references(report, entries, registry)]
536
+ unmatched = _unmatched_section(report, proposals, registry)
537
+ if unmatched:
538
+ sections.append(unmatched)
539
+ return "\n\n".join(sections) + "\n"
540
+
541
+
542
+ def _numbering(report: BindReport) -> tuple[dict[int, list[int]], list[Citation]]:
543
+ """Citation numbers per claim index, and the citation each number names.
544
+
545
+ Numbers are 1-based over the whole document and keyed on the token, so a
546
+ token cited from three places is numbered once, at its first appearance.
547
+ Returns `({claim index: [numbers]}, [citation for 1, citation for 2, ...])`.
548
+ """
549
+ assigned: dict[str, int] = {}
550
+ entries: list[Citation] = []
551
+ numbers: dict[int, list[int]] = {}
552
+ for index, claim in enumerate(report.claims):
553
+ row: list[int] = []
554
+ for citation in claim.citations:
555
+ if citation.token not in assigned:
556
+ assigned[citation.token] = len(entries) + 1
557
+ entries.append(citation)
558
+ number = assigned[citation.token]
559
+ # A claim that cites one token twice links to it once; the report
560
+ # still carries both citations.
561
+ if number not in row:
562
+ row.append(number)
563
+ numbers[index] = row
564
+ return numbers, entries
565
+
566
+
567
+ def _rewrite_claim(claim: Claim, numbers: Sequence[int]) -> str:
568
+ first, *rest = numbers
569
+ out = f"[{claim.text}](#cite-{first})"
570
+ for number in rest:
571
+ out += f"[{number}](#cite-{number})"
572
+ return out
573
+
574
+
575
+ def _references(report: BindReport, entries: Sequence[Citation], registry: Registry) -> str:
576
+ """The generated References section: doc name, locator, verbatim quote.
577
+
578
+ One entry per distinct token — `entries` is already deduplicated by
579
+ `_numbering`, and its index is the citation number.
580
+ """
581
+ lines = [_REFERENCES_HEADING, ""]
582
+ if report.summary["citations"] == 0:
583
+ lines.append("_No citations._")
584
+ for number, citation in enumerate(entries, start=1):
585
+ lines.append(f'<a id="cite-{number}"></a>**[{number}]** {_entry(citation, registry)}')
586
+ lines.append("")
587
+ if citation.anchor is not None:
588
+ lines.append(_quote(citation.anchor.receipt.snippet))
589
+ lines.append("")
590
+ return "\n".join(lines).rstrip("\n")
591
+
592
+
593
+ def _entry(citation: Citation, registry: Registry) -> str:
594
+ """One reference line: what the citation names, and how it resolved."""
595
+ status = str(citation.status)
596
+ if citation.anchor is None:
597
+ detail = f" — {citation.error}" if citation.error else ""
598
+ return f"`{citation.token}` — {status}{detail}"
599
+ anchor = citation.anchor
600
+ name = _doc_name(registry, anchor.slug)
601
+ locator = format_locator(anchor.locator)
602
+ note = ""
603
+ if citation.status is CitationStatus.DRIFTED:
604
+ note = " (snippet from a superseded extraction)"
605
+ elif citation.status is CitationStatus.NOT_SHOWN:
606
+ note = " (not in the session ledger)"
607
+ return f"{name} — `{locator}` — {status}{note}"
608
+
609
+
610
+ def _unmatched_section(
611
+ report: BindReport, proposals: dict[int, tuple[Anchor, ...]], registry: Registry
612
+ ) -> str:
613
+ """Backfill's open list: claims bind could not anchor, and what it found.
614
+
615
+ NOTE: `BindReport` carries the `unmatched` flag but has no field for
616
+ proposals, and the kernel is not bind's to extend — so the proposals live
617
+ here, in the document a human reads, while the sidecar carries the flag.
618
+ """
619
+ entries = [
620
+ (index, claim) for index, claim in enumerate(report.claims) if claim.unmatched
621
+ ]
622
+ if not entries:
623
+ return ""
624
+ lines = [_UNMATCHED_HEADING, ""]
625
+ for index, claim in entries:
626
+ lines.append(f"- {claim.text.strip()}")
627
+ for anchor in proposals.get(index, ()):
628
+ name = _doc_name(registry, anchor.slug)
629
+ lines.append(f" - proposed: `{anchor.token}` — {name}")
630
+ if not proposals.get(index):
631
+ lines.append(" - proposed: none")
632
+ lines.append("")
633
+ return "\n".join(lines).rstrip("\n")
634
+
635
+
636
+ def _doc_name(registry: Registry, slug: str) -> str:
637
+ """The document's human handle: its filename, or the slug if unknown."""
638
+ try:
639
+ document = registry.document(slug)
640
+ except Exception: # noqa: BLE001 - a name lookup never fails a bind
641
+ return slug
642
+ return document.filename if document is not None else slug
643
+
644
+
645
+ def _quote(snippet: str) -> str:
646
+ """The verbatim snippet as a markdown blockquote, line for line."""
647
+ lines = snippet.splitlines() or [""]
648
+ return "\n".join(f"> {line}".rstrip() for line in lines)
649
+
650
+
651
+ def _now() -> str:
652
+ """ISO-8601 UTC, matching the DDL's `TEXT` timestamps."""
653
+ return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")