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,313 @@
1
+ """A small markdown subset, rendered to HTML with stdlib only.
2
+
3
+ The artifact must be one file with no dependency at read time and none at build
4
+ time either, so the document body goes through this renderer rather than a
5
+ markdown library. The subset is exactly what an authored memo uses: headings,
6
+ paragraphs, lists, tables, blockquotes, fenced code, thematic breaks, and the
7
+ inline forms bold / italic / code / link.
8
+
9
+ Claims are not parsed here. `to_html` takes the source ranges bind already
10
+ located and splices caller-supplied HTML into them, so the claim spans in the
11
+ artifact are the spans the report recorded — never a second, disagreeing parse.
12
+
13
+ NOTE (deliberate omissions, the spec is silent on all of them): reference links,
14
+ setext headings, HTML passthrough, footnote syntax, and autolinks are not
15
+ recognized and render as literal text. Images render as their alt text: an
16
+ `<img>` would be an external request, which the artifact does not make.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import html
22
+ import re
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass
25
+
26
+ __all__ = ["Span", "to_html", "inline"]
27
+
28
+ _SPLICE = "\x00s{index}\x00"
29
+ _ESCAPED = "\x01e{index}\x01"
30
+
31
+ _HEADING_RE = re.compile(r"(?P<level>#{1,6})\s+(?P<text>.*?)\s*#*\s*$")
32
+ _RULE_RE = re.compile(r"(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,}")
33
+ _FENCE_RE = re.compile(r"(?P<indent>\s*)(?P<fence>```+|~~~+)(?P<info>.*)$")
34
+ _BULLET_RE = re.compile(r"(?P<indent>[ \t]*)(?P<marker>[-*+])[ \t]+(?P<text>.*)$")
35
+ _ORDERED_RE = re.compile(r"(?P<indent>[ \t]*)(?P<marker>\d{1,9})[.)][ \t]+(?P<text>.*)$")
36
+ _QUOTE_RE = re.compile(r"[ \t]*>[ \t]?(?P<text>.*)$")
37
+ _ALIGN_RE = re.compile(r":?-{1,}:?")
38
+ _BACKSLASH_RE = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~])")
39
+ _SAFE_HREF_RE = re.compile(r"\s*javascript\s*:", re.IGNORECASE)
40
+
41
+ _INLINE_RE = re.compile(
42
+ r"""
43
+ (?P<fence>`+)(?P<code>.+?)(?P=fence)
44
+ | !\[(?P<alt>[^\]]*)\]\([^)]*\)
45
+ | \[(?P<label>(?:[^\[\]]|\[[^\]]*\])*)\]\((?P<href>[^)\s]*)(?:\s+"[^"]*")?\)
46
+ | (?P<strong_mark>\*\*|__)(?P<strong>.+?)(?P=strong_mark)
47
+ | (?P<em_mark>[*_])(?P<em>.+?)(?P=em_mark)
48
+ """,
49
+ re.VERBOSE | re.DOTALL,
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class Span:
55
+ """A source range whose rendered form the caller supplies.
56
+
57
+ `start`/`end` are character offsets into the source; `html` replaces that
58
+ range verbatim and is never escaped or re-parsed.
59
+ """
60
+
61
+ start: int
62
+ end: int
63
+ html: str
64
+
65
+
66
+ def to_html(source: str, spans: Sequence[Span] = ()) -> str:
67
+ """Render `source` as HTML, splicing each span's HTML into its source range.
68
+
69
+ Spans must not overlap; they are applied in source order.
70
+ """
71
+ masked, splices = _mask_spans(source, spans)
72
+ rendered = _blocks(masked.expandtabs(4).splitlines())
73
+ for index, markup in enumerate(splices):
74
+ rendered = rendered.replace(_SPLICE.format(index=index), markup)
75
+ return rendered
76
+
77
+
78
+ def inline(text: str) -> str:
79
+ """Render one run of inline markdown — the form used inside a claim's text."""
80
+ escapes: list[str] = []
81
+
82
+ def stash(match: re.Match[str]) -> str:
83
+ escapes.append(html.escape(match.group(1), quote=False))
84
+ return _ESCAPED.format(index=len(escapes) - 1)
85
+
86
+ rendered = _inline(_BACKSLASH_RE.sub(stash, text))
87
+ for index, literal in enumerate(escapes):
88
+ rendered = rendered.replace(_ESCAPED.format(index=index), literal)
89
+ return rendered
90
+
91
+
92
+ def _mask_spans(source: str, spans: Sequence[Span]) -> tuple[str, list[str]]:
93
+ """Replace each span's source range with a splice marker."""
94
+ ordered = sorted(spans, key=lambda span: span.start)
95
+ pieces: list[str] = []
96
+ splices: list[str] = []
97
+ cursor = 0
98
+ for span in ordered:
99
+ if span.start < cursor:
100
+ raise ValueError(f"overlapping span at {span.start}")
101
+ pieces.append(source[cursor : span.start])
102
+ pieces.append(_SPLICE.format(index=len(splices)))
103
+ splices.append(span.html)
104
+ cursor = span.end
105
+ pieces.append(source[cursor:])
106
+ return "".join(pieces), splices
107
+
108
+
109
+ def _blocks(lines: Sequence[str]) -> str:
110
+ """Render a sequence of lines as block-level HTML."""
111
+ out: list[str] = []
112
+ index = 0
113
+ total = len(lines)
114
+ while index < total:
115
+ line = lines[index]
116
+ if not line.strip():
117
+ index += 1
118
+ continue
119
+ if fence := _FENCE_RE.fullmatch(line):
120
+ index = _code_block(lines, index, fence, out)
121
+ continue
122
+ if heading := _HEADING_RE.fullmatch(line.strip()):
123
+ level = len(heading["level"])
124
+ out.append(f"<h{level}>{inline(heading['text'])}</h{level}>")
125
+ index += 1
126
+ continue
127
+ if _RULE_RE.fullmatch(line.strip()):
128
+ out.append("<hr>")
129
+ index += 1
130
+ continue
131
+ if _QUOTE_RE.fullmatch(line):
132
+ index = _quote_block(lines, index, out)
133
+ continue
134
+ if _BULLET_RE.fullmatch(line) or _ORDERED_RE.fullmatch(line):
135
+ index = _list_block(lines, index, out)
136
+ continue
137
+ if index + 1 < total and _is_table_rule(lines[index + 1]) and "|" in line:
138
+ index = _table_block(lines, index, out)
139
+ continue
140
+ index = _paragraph(lines, index, out)
141
+ return "\n".join(out)
142
+
143
+
144
+ def _code_block(lines: Sequence[str], index: int, fence: re.Match[str], out: list[str]) -> int:
145
+ """Consume a fenced code block. Its content is never inline-rendered."""
146
+ marker = fence["fence"]
147
+ body: list[str] = []
148
+ index += 1
149
+ while index < len(lines) and lines[index].strip() != marker:
150
+ body.append(lines[index])
151
+ index += 1
152
+ escaped = html.escape("\n".join(body), quote=False)
153
+ out.append(f"<pre><code>{escaped}</code></pre>")
154
+ return index + 1 if index < len(lines) else index
155
+
156
+
157
+ def _quote_block(lines: Sequence[str], index: int, out: list[str]) -> int:
158
+ body: list[str] = []
159
+ while index < len(lines) and (match := _QUOTE_RE.fullmatch(lines[index])):
160
+ body.append(match["text"])
161
+ index += 1
162
+ out.append(f"<blockquote>{_blocks(body)}</blockquote>")
163
+ return index
164
+
165
+
166
+ def _list_block(lines: Sequence[str], index: int, out: list[str]) -> int:
167
+ """Consume one list, including indented continuation lines and sublists."""
168
+ ordered = _ORDERED_RE.fullmatch(lines[index]) is not None
169
+ items: list[list[str]] = []
170
+ loose = False
171
+ pending_blank = False
172
+ while index < len(lines):
173
+ line = lines[index]
174
+ item = _ORDERED_RE.fullmatch(line) if ordered else _BULLET_RE.fullmatch(line)
175
+ if item is not None and len(item["indent"]) < 2:
176
+ items.append([item["text"]])
177
+ loose = loose or (pending_blank and len(items) > 1)
178
+ pending_blank = False
179
+ index += 1
180
+ continue
181
+ if not line.strip():
182
+ if not items:
183
+ break
184
+ pending_blank = True
185
+ index += 1
186
+ continue
187
+ if items and line.startswith(" "):
188
+ if pending_blank:
189
+ items[-1].append("")
190
+ loose = True
191
+ pending_blank = False
192
+ items[-1].append(line[2:])
193
+ index += 1
194
+ continue
195
+ break
196
+ rendered = [f"<li>{_item(body, loose)}</li>" for body in items]
197
+ tag = "ol" if ordered else "ul"
198
+ out.append(f"<{tag}>{''.join(rendered)}</{tag}>")
199
+ return index
200
+
201
+
202
+ def _item(body: Sequence[str], loose: bool) -> str:
203
+ """One list item: bare inline when it is a single simple line, blocks otherwise."""
204
+ if not loose and len(body) == 1:
205
+ return inline(body[0])
206
+ return _blocks(body)
207
+
208
+
209
+ def _table_block(lines: Sequence[str], index: int, out: list[str]) -> int:
210
+ header = _row(lines[index])
211
+ alignments = [_alignment(cell) for cell in _row(lines[index + 1])]
212
+ index += 2
213
+ rows: list[list[str]] = []
214
+ while index < len(lines) and "|" in lines[index] and lines[index].strip():
215
+ rows.append(_row(lines[index]))
216
+ index += 1
217
+ head = "".join(
218
+ f"<th{_align_attr(alignments, column)}>{inline(cell)}</th>"
219
+ for column, cell in enumerate(header)
220
+ )
221
+ body = "".join(
222
+ "<tr>"
223
+ + "".join(
224
+ f"<td{_align_attr(alignments, column)}>{inline(cell)}</td>"
225
+ for column, cell in enumerate(row)
226
+ )
227
+ + "</tr>"
228
+ for row in rows
229
+ )
230
+ out.append(
231
+ '<div class="table-wrap"><table>'
232
+ f"<thead><tr>{head}</tr></thead><tbody>{body}</tbody>"
233
+ "</table></div>"
234
+ )
235
+ return index
236
+
237
+
238
+ def _paragraph(lines: Sequence[str], index: int, out: list[str]) -> int:
239
+ body: list[str] = []
240
+ while index < len(lines) and lines[index].strip():
241
+ line = lines[index]
242
+ if body and (
243
+ _HEADING_RE.fullmatch(line.strip())
244
+ or _FENCE_RE.fullmatch(line)
245
+ or _QUOTE_RE.fullmatch(line)
246
+ or _BULLET_RE.fullmatch(line)
247
+ or _ORDERED_RE.fullmatch(line)
248
+ ):
249
+ break
250
+ body.append(line.strip())
251
+ index += 1
252
+ out.append(f"<p>{inline(' '.join(body))}</p>")
253
+ return index
254
+
255
+
256
+ def _is_table_rule(line: str) -> bool:
257
+ stripped = line.strip()
258
+ if "-" not in stripped or not stripped:
259
+ return False
260
+ cells = _row(line)
261
+ return bool(cells) and all(_ALIGN_RE.fullmatch(cell) for cell in cells)
262
+
263
+
264
+ def _row(line: str) -> list[str]:
265
+ stripped = line.strip()
266
+ if stripped.startswith("|"):
267
+ stripped = stripped[1:]
268
+ if stripped.endswith("|"):
269
+ stripped = stripped[:-1]
270
+ return [cell.strip() for cell in stripped.split("|")]
271
+
272
+
273
+ def _alignment(cell: str) -> str:
274
+ left, right = cell.startswith(":"), cell.endswith(":")
275
+ if left and right:
276
+ return "center"
277
+ if right:
278
+ return "right"
279
+ return "left"
280
+
281
+
282
+ def _align_attr(alignments: Sequence[str], column: int) -> str:
283
+ align = alignments[column] if column < len(alignments) else "left"
284
+ return "" if align == "left" else f' class="t-{align}"'
285
+
286
+
287
+ def _inline(text: str) -> str:
288
+ """Inline rendering over text whose backslash escapes are already stashed."""
289
+ out: list[str] = []
290
+ cursor = 0
291
+ for match in _INLINE_RE.finditer(text):
292
+ out.append(html.escape(text[cursor : match.start()], quote=False))
293
+ out.append(_inline_match(match))
294
+ cursor = match.end()
295
+ out.append(html.escape(text[cursor:], quote=False))
296
+ return "".join(out)
297
+
298
+
299
+ def _inline_match(match: re.Match[str]) -> str:
300
+ if (code := match["code"]) is not None:
301
+ return f"<code>{html.escape(code.strip(), quote=False)}</code>"
302
+ if (alt := match["alt"]) is not None:
303
+ # NOTE: images are not embedded in v0 (no page images, no external
304
+ # requests), so an image renders as its alt text, marked as such.
305
+ return f'<span class="bd-image" title="image omitted">{_inline(alt)}</span>'
306
+ if (label := match["label"]) is not None:
307
+ href = match["href"] or ""
308
+ if _SAFE_HREF_RE.match(href):
309
+ return _inline(label)
310
+ return f'<a href="{html.escape(href, quote=True)}">{_inline(label)}</a>'
311
+ if (strong := match["strong"]) is not None:
312
+ return f"<strong>{_inline(strong)}</strong>"
313
+ return f"<em>{_inline(match['em'])}</em>"
@@ -0,0 +1,105 @@
1
+ """Finding each claim's span in the document render receives.
2
+
3
+ Render's only inputs are an authored document and a sidecar; the registry may be
4
+ gone. A claim carries `start`/`end` into the document bind read, which is usually
5
+ the document render is handed — but bind also rewrites citations into readable
6
+ form, so the file on disk may be the rewritten one and the offsets may have
7
+ moved. Rather than guess, we try three locators in order and record which claims
8
+ could not be placed at all; an unplaced claim still appears in the artifact, in
9
+ its own section, because nothing drops silently.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+
17
+ from ..kernel.model import Claim
18
+
19
+ __all__ = ["Placement", "locate"]
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Placement:
24
+ """One claim and where it sits in the document being rendered.
25
+
26
+ `number` is the claim's 1-based position in document order — the number the
27
+ artifact labels it with. `start`/`end` are None when the claim's text could
28
+ not be found in the document at all.
29
+ """
30
+
31
+ number: int
32
+ claim: Claim
33
+ start: int | None = None
34
+ end: int | None = None
35
+
36
+ @property
37
+ def placed(self) -> bool:
38
+ """True when this claim has a span in the document body."""
39
+ return self.start is not None and self.end is not None
40
+
41
+
42
+ def locate(source: str, claims: Sequence[Claim]) -> list[Placement]:
43
+ """Place every claim in `source`, in document order.
44
+
45
+ Three locators, in order of confidence:
46
+
47
+ 1. the recorded offsets, when the construct they bound is still there;
48
+ 2. the first `[claim text](` link at or after the previous claim — the shape
49
+ bind leaves behind when it rewrites tokens into readable citations;
50
+ 3. the first bare occurrence of the claim's text, for a document whose
51
+ citations were projected away entirely.
52
+
53
+ A claim that matches none of the three is returned unplaced.
54
+ """
55
+ placements: list[Placement] = []
56
+ cursor = 0
57
+ for number, claim in enumerate(claims, start=1):
58
+ span = _at_offsets(source, claim) or _as_link(source, claim, cursor)
59
+ if span is None:
60
+ span = _as_text(source, claim, cursor)
61
+ if span is None:
62
+ placements.append(Placement(number=number, claim=claim))
63
+ continue
64
+ start, end = span
65
+ placements.append(Placement(number=number, claim=claim, start=start, end=end))
66
+ cursor = end
67
+ return placements
68
+
69
+
70
+ def _at_offsets(source: str, claim: Claim) -> tuple[int, int] | None:
71
+ """The recorded span, if the document still holds the construct bind saw."""
72
+ if not 0 <= claim.start < claim.end <= len(source):
73
+ return None
74
+ construct = source[claim.start : claim.end]
75
+ if construct == claim.text:
76
+ return (claim.start, claim.end)
77
+ if construct.startswith(f"[{claim.text}](") and construct.endswith(")"):
78
+ return (claim.start, claim.end)
79
+ return None
80
+
81
+
82
+ def _as_link(source: str, claim: Claim, cursor: int) -> tuple[int, int] | None:
83
+ """The first `[claim text](...)` link at or after `cursor`.
84
+
85
+ NOTE: the closing parenthesis is the first one after the opener. Tokens
86
+ cannot contain parentheses, and the readable citations bind writes are
87
+ fragment references, so this is exact for every href render can meet.
88
+ """
89
+ start = source.find(f"[{claim.text}](", cursor)
90
+ if start < 0:
91
+ return None
92
+ close = source.find(")", start + len(claim.text) + 3)
93
+ if close < 0:
94
+ return None
95
+ return (start, close + 1)
96
+
97
+
98
+ def _as_text(source: str, claim: Claim, cursor: int) -> tuple[int, int] | None:
99
+ """The first bare occurrence of the claim's text at or after `cursor`."""
100
+ if not claim.text:
101
+ return None
102
+ start = source.find(claim.text, cursor)
103
+ if start < 0:
104
+ return None
105
+ return (start, start + len(claim.text))
@@ -0,0 +1,164 @@
1
+ """The sidecar: one bind run, written as a standalone self-describing JSON file.
2
+
3
+ The sidecar is the machine-readable half of the artifact. It is exactly a
4
+ `BindReport` payload with two reserved keys in front of it:
5
+
6
+ * ``$format`` — ``backdraft/artifact-v1``, matched exactly (never parsed, never
7
+ range-checked); and
8
+ * ``$legend`` — prose that teaches a reader who has never seen backdraft how to
9
+ decode the rest of the object.
10
+
11
+ The HTML artifact embeds this same payload, byte for byte, in its JSON island:
12
+ ``render --to json`` and the island of ``render --to html`` are the same bytes,
13
+ so a reader can treat either as the record.
14
+
15
+ The format itself — `FORMAT`, `LEGEND`, `SIDECAR_SUFFIX`, `sidecar_path`, and the
16
+ `sidecar` / `dumps` writers — lives in `kernel/artifact.py`, because bind writes
17
+ it and render reads it and neither owns it; the file's *name* is part of the
18
+ format for the same reason. This module is the render-side door onto it: it
19
+ re-exports those names and adds the reader that turns a sidecar file back into a
20
+ `BindReport`. `spec/artifact.md` is the prose specification; where it and the
21
+ kernel's legend disagree, the spec file decides.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from ..kernel.artifact import ( # noqa: F401 (re-exported: the format is kernel-owned)
31
+ FORMAT,
32
+ LEGEND,
33
+ SIDECAR_SUFFIX,
34
+ dumps,
35
+ record_path,
36
+ sidecar,
37
+ sidecar_path,
38
+ )
39
+ from ..kernel.model import (
40
+ Anchor,
41
+ BindReport,
42
+ Citation,
43
+ CitationStatus,
44
+ Claim,
45
+ Receipt,
46
+ Verdict,
47
+ VerdictStatus,
48
+ )
49
+ from ..kernel.tokens import parse_locator
50
+
51
+ __all__ = [
52
+ "FORMAT",
53
+ "LEGEND",
54
+ "SIDECAR_SUFFIX",
55
+ "sidecar",
56
+ "dumps",
57
+ "write",
58
+ "read",
59
+ "to_report",
60
+ "sidecar_path",
61
+ "find_sidecar",
62
+ ]
63
+
64
+
65
+ def write(report: BindReport, path: Path) -> Path:
66
+ """Write the sidecar to `path`. Returns the path written."""
67
+ path.write_text(dumps(report), encoding="utf-8")
68
+ return path
69
+
70
+
71
+ def read(path: Path) -> BindReport:
72
+ """Read a sidecar file back into a `BindReport`.
73
+
74
+ Raises `ValueError` if the file is not a payload of this exact format.
75
+ """
76
+ return to_report(json.loads(path.read_text(encoding="utf-8")))
77
+
78
+
79
+ def to_report(payload: dict[str, Any]) -> BindReport:
80
+ """Rebuild the `BindReport` a sidecar payload carries.
81
+
82
+ `$format` is matched exactly; a payload carrying anything else is refused
83
+ rather than interpreted. Round-trip is stable at the payload level:
84
+ `sidecar(to_report(payload)) == payload` for any payload this accepts.
85
+
86
+ NOTE: `Anchor.extraction_id`, `start` and `end` are registry-side fields the
87
+ sidecar deliberately does not carry, so they come back `None`; `page_number`
88
+ is recovered from the locator when the locator has one.
89
+ """
90
+ if not isinstance(payload, dict):
91
+ raise ValueError(f"sidecar payload must be an object, not {type(payload).__name__}")
92
+ found = payload.get("$format")
93
+ if found != FORMAT:
94
+ raise ValueError(f"unknown artifact format {found!r}; this reader speaks {FORMAT!r}")
95
+ return BindReport(
96
+ doc_path=payload["doc_path"],
97
+ mode=payload["mode"],
98
+ bound_at=payload["bound_at"],
99
+ session_id=payload.get("session_id"),
100
+ claims=tuple(_claim(entry) for entry in payload.get("claims", ())),
101
+ evidence=payload.get("evidence"),
102
+ )
103
+
104
+
105
+ def find_sidecar(doc_path: Path) -> Path | None:
106
+ """The document's record, or None.
107
+
108
+ Looked for in order: beside the document (`<stem>.backdraft.json` — the
109
+ portable form a reader is handed), the whole-filename variant a person
110
+ types (`memo.md.backdraft.json`), then the project's records store —
111
+ `.backdraft/records/` under the nearest ancestor holding a `.backdraft`
112
+ directory, which is where a rooted bind writes.
113
+ """
114
+ for candidate in (sidecar_path(doc_path), doc_path.with_name(doc_path.name + SIDECAR_SUFFIX)):
115
+ if candidate.is_file():
116
+ return candidate
117
+ resolved = doc_path.resolve()
118
+ for ancestor in resolved.parents:
119
+ if (ancestor / ".backdraft").is_dir():
120
+ candidate = record_path(ancestor, resolved)
121
+ return candidate if candidate.is_file() else None
122
+ return None
123
+
124
+
125
+ def _claim(entry: dict[str, Any]) -> Claim:
126
+ return Claim(
127
+ text=entry["text"],
128
+ start=entry["start"],
129
+ end=entry["end"],
130
+ unmatched=bool(entry.get("unmatched", False)),
131
+ citations=tuple(_citation(item) for item in entry.get("citations", ())),
132
+ )
133
+
134
+
135
+ def _citation(entry: dict[str, Any]) -> Citation:
136
+ token = entry["token"]
137
+ anchor = entry.get("anchor")
138
+ return Citation(
139
+ token=token,
140
+ status=CitationStatus(entry["status"]),
141
+ anchor=_anchor(anchor, token) if anchor is not None else None,
142
+ drifted_from=entry.get("drifted_from"),
143
+ error=entry.get("error"),
144
+ verdicts=tuple(_verdict(item) for item in entry.get("verdicts", ())),
145
+ )
146
+
147
+
148
+ def _anchor(entry: dict[str, Any], token: str) -> Anchor:
149
+ locator = parse_locator(entry["locator"])
150
+ return Anchor(
151
+ slug=entry["slug"],
152
+ locator=locator,
153
+ receipt=Receipt(snippet=entry["snippet"], snippet_sha256=entry["snippet_sha256"]),
154
+ token=token,
155
+ page_number=getattr(locator, "page", None),
156
+ )
157
+
158
+
159
+ def _verdict(entry: dict[str, Any]) -> Verdict:
160
+ return Verdict(
161
+ method=entry["method"],
162
+ status=VerdictStatus(entry["status"]),
163
+ detail=entry.get("detail", ""),
164
+ )