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,123 @@
1
+ """The search side of the gate: FTS results that are themselves citable.
2
+
3
+ A searched snippet is minted exactly like a read one. This is the whole point of
4
+ routing search through the gate: in the app that preceded backdraft, search
5
+ returned text with nothing citable attached, so every search was followed by a
6
+ page read purely to obtain an anchor. Here the result *is* the anchor, and the
7
+ read hint below the results is an affordance, not a prerequisite.
8
+
9
+ Consumes the pinned registry surface (SPEC Addendum A) and nothing else.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ from ..kernel.hashing import normalize
17
+
18
+ from .reader import GateError
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterable
22
+
23
+ from ..registry.store import Registry, SearchHit
24
+
25
+ __all__ = ["EXCERPT_CHARS", "PHRASE_FALLBACK_NOTE", "search", "render_search"]
26
+
27
+ EXCERPT_CHARS = 160
28
+ """How much of a snippet a result line shows before deferring to a page read."""
29
+
30
+ PHRASE_FALLBACK_NOTE = "(query retried as a phrase)"
31
+ """Shown when the registry could not parse the query as FTS5 syntax.
32
+
33
+ The retry changes the question: `NOI 1.42x` as a boolean query asks for both
34
+ terms anywhere, as a phrase it asks for those tokens adjacent and in order. A
35
+ reader who is not told cannot distinguish "no such fact" from "not asked that
36
+ way", so the gate says it — once, on its own line, rather than as an error.
37
+ """
38
+
39
+ _ELLIPSIS = "..."
40
+ _DEFAULT_LIMIT = 20
41
+ """Mirrors `Registry.search`'s own default, so the CLI and the store agree."""
42
+
43
+
44
+ def search(
45
+ registry: Registry,
46
+ query: str,
47
+ *,
48
+ slug: str | None = None,
49
+ limit: int = _DEFAULT_LIMIT,
50
+ session: str | None = None,
51
+ ) -> str:
52
+ """Run `query` through the registry's FTS index and render the results.
53
+
54
+ Each hit is one line carrying its token, its document and page, and an
55
+ excerpt of its snippet; a read hint follows for every distinct page matched.
56
+ Every hit's anchor is recorded in `session` before the text is returned — a
57
+ result the writer saw is a result the writer may cite.
58
+
59
+ Raises `GateError` if `slug` names no document.
60
+ """
61
+ if slug is not None and registry.document(slug) is None:
62
+ raise GateError(f"no such document: {slug!r}")
63
+ hits = registry.search(query, slug=slug, limit=limit)
64
+ _mint(registry, session, hits)
65
+ return render_search(query, hits, slug=slug)
66
+
67
+
68
+ def render_search(query: str, hits: Iterable[SearchHit], *, slug: str | None = None) -> str:
69
+ """Render search results. Pure: minting happens in `search`.
70
+
71
+ NOTE: the `phrase_fallback` flag is read *before* `hits` is copied into a
72
+ plain list — it rides on the result object the registry returned.
73
+ """
74
+ retried = bool(getattr(hits, "phrase_fallback", False))
75
+ hits = list(hits)
76
+ scope = f" in {slug}" if slug else ""
77
+ note = [PHRASE_FALLBACK_NOTE] if retried else []
78
+ if not hits:
79
+ return "\n".join(
80
+ [
81
+ f'No results for "{query}"{scope}.',
82
+ *note,
83
+ "",
84
+ "[List documents: backdraft read]",
85
+ ]
86
+ )
87
+
88
+ count = f"{len(hits)} result" if len(hits) == 1 else f"{len(hits)} results"
89
+ lines = [f'{count} for "{query}"{scope}', *note, ""]
90
+ for hit in hits:
91
+ lines.append(f"[{hit.anchor.token}] {hit.slug} p{hit.page_number}")
92
+ lines.append(f" {_excerpt(hit.anchor.receipt.snippet)}")
93
+ lines.append("")
94
+
95
+ seen: list[tuple[str, int]] = []
96
+ for hit in hits:
97
+ if (hit.slug, hit.page_number) not in seen:
98
+ seen.append((hit.slug, hit.page_number))
99
+ lines += [f"[Read the page: backdraft read {s} p{n}]" for s, n in seen]
100
+ return "\n".join(line.rstrip() for line in lines).rstrip("\n")
101
+
102
+
103
+ def _excerpt(snippet: str) -> str:
104
+ """A snippet collapsed onto one line and cut to `EXCERPT_CHARS`.
105
+
106
+ NOTE: the cut is from the start rather than centred on the match. FTS5
107
+ decides what matched (stemming, phrase queries), and the gate does not
108
+ re-derive anything the registry owns; the token on the line above is the
109
+ thing to cite, and the read hint is the way to see the rest.
110
+ """
111
+ text = normalize(snippet)
112
+ return text if len(text) <= EXCERPT_CHARS else text[:EXCERPT_CHARS] + _ELLIPSIS
113
+
114
+
115
+ def _mint(registry: Registry, session: str | None, hits: Iterable[SearchHit]) -> None:
116
+ """Record every result's anchor under the session."""
117
+ if session is None:
118
+ return
119
+ anchor_ids = sorted({hit.anchor.id for hit in hits if hit.anchor.id is not None})
120
+ if not anchor_ids:
121
+ return
122
+ session_id = registry.ensure_session(session)
123
+ registry.record_shown(session_id, anchor_ids)
@@ -0,0 +1,17 @@
1
+ """The kernel: pure, stdlib-only, imports nothing else from the package.
2
+
3
+ Grammar, hashing, chunking, claim parsing, the artifact format and the vocabulary
4
+ of types. No I/O, no SQLite, no configuration. Everything above this layer —
5
+ registry, extract, gate, bind, render — imports the kernel; the kernel imports
6
+ none of them. `tests/test_invariants.py` enforces that by reading this source.
7
+
8
+ **The module paths are the API.** There is deliberately no flat re-export surface
9
+ here: callers write `from backdraft.kernel.tokens import parse`, not
10
+ `from backdraft.kernel import parse_token`. Two reasons. The module a name lives
11
+ in is information — `hashing.normalize` and a bare `normalize` are not equally
12
+ readable — and a flat surface has to invent disambiguating aliases (`parse`, in
13
+ three different modules, became `parse_token` / `parse_locator` /
14
+ `parse_citation`) that name nothing actually defined anywhere. Each submodule's
15
+ own `__all__` is its contract; this package has none.
16
+ """
17
+
@@ -0,0 +1,234 @@
1
+ """The artifact format: its version string, its legend, its file names, its payload.
2
+
3
+ Kernel-owned because the format is shared vocabulary: bind writes the sidecar,
4
+ render reads and re-emits it, and neither may import the other. The prose spec
5
+ is `spec/artifact.md`; this module is its single in-code copy.
6
+
7
+ **Naming is part of the format.** What a bind run and a render run are called on
8
+ disk is how a reader (person, agent, or another implementation) finds the pieces
9
+ of one document's record, so the suffixes and the path math live here rather than
10
+ in whichever layer happens to write each file. `spec/artifact.md` § Naming is the
11
+ normative statement; the constants below are its in-code copy.
12
+
13
+ NOTE on kernel purity: `pathlib` is imported for path *math* only. Nothing here
14
+ touches the filesystem — these functions compute names, they do not read or write
15
+ (`tests/test_invariants.py` enforces that distinction).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .model import BindReport
25
+
26
+ __all__ = [
27
+ "FORMAT",
28
+ "LEGEND",
29
+ "ARTIFACT_SUFFIX",
30
+ "BOUND_SUFFIX",
31
+ "FOOTNOTES_SUFFIX",
32
+ "SIDECAR_SUFFIX",
33
+ "bound_path",
34
+ "sidecar_path",
35
+ "RECORDS_DIR",
36
+ "record_path",
37
+ "sidecar",
38
+ "dumps",
39
+ ]
40
+
41
+ FORMAT = "backdraft/artifact-v1"
42
+ """The artifact format string. Readers match it exactly; see `LEGEND["version"]`."""
43
+
44
+ # ---- the naming family ------------------------------------------------------
45
+ #
46
+ # Every name is `<stem of the authored document>` + one of these suffixes, so a
47
+ # single `memo.md` produces `memo.bound.md`, `memo.backdraft.json`,
48
+ # `memo.backdraft.html` and `memo.footnotes.md`, and a directory listing sorts
49
+ # them together. The `.backdraft.*` pair is the artifact record proper — the same
50
+ # payload, machine-readable and human-readable; the other two are projections.
51
+
52
+ BOUND_SUFFIX = ".bound.md"
53
+ """bind's rewritten document: `memo.md` -> `memo.bound.md`."""
54
+
55
+ SIDECAR_SUFFIX = ".backdraft.json"
56
+ """A document's sidecar sits beside it: `memo.md` -> `memo.backdraft.json`."""
57
+
58
+ ARTIFACT_SUFFIX = ".backdraft.html"
59
+ """The self-contained artifact: `memo.md` -> `memo.backdraft.html`."""
60
+
61
+ FOOTNOTES_SUFFIX = ".footnotes.md"
62
+ """render's markdown projection: `memo.md` -> `memo.footnotes.md`.
63
+
64
+ Named for what it is rather than `.backdraft.md`, which would join the sidecar's
65
+ family without being the record, and rather than `.bound.md`, which is bind's
66
+ output and is a different document.
67
+ """
68
+
69
+
70
+ def bound_path(doc_path: Path) -> Path:
71
+ """Where bind's rewritten document goes."""
72
+ return doc_path.with_name(doc_path.stem + BOUND_SUFFIX)
73
+
74
+
75
+ def sidecar_path(doc_path: Path) -> Path:
76
+ """The beside-the-document record path: `memo.md` -> `memo.backdraft.json`.
77
+
78
+ This is the *portable* location — the one a reader handed a document and
79
+ its record uses. A project with a registry stores records out of sight
80
+ instead; see `record_path`.
81
+ """
82
+ return doc_path.with_name(doc_path.stem + SIDECAR_SUFFIX)
83
+
84
+
85
+ RECORDS_DIR = "records"
86
+ """Records live under `.backdraft/records/`, mirroring each document's path
87
+ relative to the project root — the authored directory shows only the document
88
+ and its artifact."""
89
+
90
+
91
+ def record_path(root: Path, doc_path: Path) -> Path:
92
+ """Where a project stores a document's record: out of the authored directory.
93
+
94
+ `<root>/.backdraft/records/<doc's path relative to root>` with the sidecar
95
+ suffix, so distinct documents can never collide. A document outside `root`
96
+ falls back to the beside-the-document path.
97
+
98
+ Pure path math (the kernel invariant): both arguments must already be
99
+ resolved to comparable absolute forms — callers hold the filesystem.
100
+ """
101
+ try:
102
+ relative = Path(doc_path).relative_to(Path(root))
103
+ except ValueError:
104
+ return sidecar_path(doc_path)
105
+ return (
106
+ Path(root) / ".backdraft" / RECORDS_DIR / relative.parent
107
+ / (relative.stem + SIDECAR_SUFFIX)
108
+ )
109
+
110
+
111
+ LEGEND: dict[str, Any] = {
112
+ "what_this_is": (
113
+ "A bound document: every claim its author cited, recorded with the verbatim "
114
+ "evidence behind it. This legend describes the object around it so that a "
115
+ "reader who has never seen backdraft — person or model — can use the record "
116
+ "without any other file, any registry, and any network access."
117
+ ),
118
+ "how_to_read": [
119
+ "`claims` is the record, in document order. Each claim is a span of the "
120
+ "authored document: `text` is the words the citations support, and "
121
+ "`start`/`end` are character offsets into the authored source, bounding the "
122
+ "whole markdown construct the author wrote.",
123
+ "Each entry in a claim's `citations` names one anchor by its `token` and "
124
+ "carries what binding found: a `status`, an `anchor` when one was found, "
125
+ "`drifted_from` when the source moved, `error` when the token did not parse, "
126
+ "and `verdicts` from whichever verification methods were switched on.",
127
+ "An `anchor` is the receipt: `slug` names the source document, `locator` "
128
+ "names the place inside it, `snippet` is the verbatim text that was quoted, "
129
+ "and `snippet_sha256` is that snippet's hash. The snippet is present in "
130
+ "full — you never need the source document to read the evidence.",
131
+ "A claim with `unmatched` true is one that binding could not anchor at all. "
132
+ "It is listed because it was written, not because it was verified.",
133
+ "Every citation whose `status` is not `resolved` is a failure that was kept "
134
+ "rather than hidden. Read those first; they are the interesting part.",
135
+ "`summary` is derived from `claims` and is never authoritative on its own. "
136
+ "If the two disagree, recount from `claims`.",
137
+ ],
138
+ "token": (
139
+ "`bd:<slug>:<locator>:<hash>` is the textual name of an anchor. `hash` is the "
140
+ "leading 4 to 8 hex characters of sha256 over the normalized snippet, where "
141
+ "normalize is: Unicode NFC, then every whitespace run collapsed to one space, "
142
+ "then strip, case preserved."
143
+ ),
144
+ "locator_forms": {
145
+ "p8": "page or sheet 8, whole",
146
+ "p8.c3": (
147
+ "chunk 3 within page 8 — chunks are deterministic paragraph-scale "
148
+ "subdivisions of a page, numbered from 1"
149
+ ),
150
+ "rent-roll!B10": "sheet `rent-roll`, cell B10, A1 notation",
151
+ "rent-roll!B10:C12": "sheet `rent-roll`, rectangular range",
152
+ },
153
+ "citation_status": {
154
+ "resolved": (
155
+ "the anchor was found in the source document's current extraction; "
156
+ "`anchor.snippet` is what the source says now and what the author cited"
157
+ ),
158
+ "drifted": (
159
+ "the anchor was found only in a superseded extraction — the source changed "
160
+ "after the claim was written. `drifted_from` is the snippet the author saw; "
161
+ "`anchor.snippet` is what stands there now. Compare them before trusting "
162
+ "the claim"
163
+ ),
164
+ "not_shown": (
165
+ "a real anchor, but it was never shown to the writer in the recorded "
166
+ "session: the claim cites something its author did not read"
167
+ ),
168
+ "unresolved": (
169
+ "a well-formed token naming no anchor in any generation of any source. "
170
+ "Treat the claim as uncited"
171
+ ),
172
+ "malformed": (
173
+ "the citation text is not a token; `error` says why. Reported verbatim "
174
+ "rather than dropped"
175
+ ),
176
+ },
177
+ "verdict_status": {
178
+ "pass": "the method ran and the claim survived it",
179
+ "fail": "the method ran and the claim did not survive it",
180
+ "partial": "the method ran and found partial support; `detail` says how much",
181
+ "skip": "the method did not apply to this claim and citation",
182
+ },
183
+ "verdicts_are_evidence": (
184
+ "Verification methods are opt-in switches, never gates. A method missing from "
185
+ "a citation's `verdicts` was not run — that is not a pass. `summary.by_method` "
186
+ "counts only what actually ran, as {method: {verdict status: count}}."
187
+ ),
188
+ "verify_this_record": [
189
+ "1. For every citation carrying an `anchor`, recompute sha256 over the "
190
+ "normalized `snippet`: it must equal `anchor.snippet_sha256`, and the `hash` "
191
+ "segment of `token` must be a prefix of it.",
192
+ "2. The `slug` and `locator` segments of `token` must equal `anchor.slug` and "
193
+ "`anchor.locator`.",
194
+ "3. Recount `summary` from `claims`.",
195
+ "Steps 1 to 3 need nothing but this object. If you also hold the source "
196
+ "documents, look `locator` up in the document named by `slug` and compare its "
197
+ "text against `snippet`; that is the only check this record cannot make of "
198
+ "itself.",
199
+ ],
200
+ "evidence": (
201
+ "Optional. Evidence context for the cited sources, bounded by what is cited "
202
+ "— never the whole corpus. `documents` maps slug to {filename, media_type}. "
203
+ "`pages` maps `slug:pN` to a page image {format, width, height, data} where "
204
+ "`data` is base64; for a vision-model extraction this is the page as the "
205
+ "model was shown it. `pagetexts` maps `slug:pN` to that page's extracted "
206
+ "text. `windows` maps `slug:<locator>` to a small cell grid around a cited "
207
+ "cell: {sheet, cited, cols, rows}. `sheets` maps `slug:<sheet>` to the full "
208
+ "cited sheet's values: {name, nrows, ncols, rows}. A record without "
209
+ "`evidence` is still complete — evidence is context, snippets are the proof. "
210
+ "The HTML artifact may omit `pages[*].data` from its embedded copy of this "
211
+ "object when the same bytes are present in the page as image elements."
212
+ ),
213
+ "version": (
214
+ "The format string in `$format` is matched exactly, as an opaque string. A "
215
+ "reader that does not recognize `backdraft/artifact-v1` must not guess: later "
216
+ "versions may reuse these field names with different meanings. There is no "
217
+ "compatibility range and no minor-version negotiation."
218
+ ),
219
+ }
220
+ """Normative. The artifact teaches its own decoding; `spec/artifact.md` specifies it."""
221
+
222
+
223
+ def sidecar(report: BindReport) -> dict[str, Any]:
224
+ """The sidecar payload: `$format`, `$legend`, then the report, in that order."""
225
+ return {"$format": FORMAT, "$legend": LEGEND, **report.to_dict()}
226
+
227
+
228
+ def dumps(report: BindReport) -> str:
229
+ """The sidecar as text: UTF-8, two-space indent, trailing newline.
230
+
231
+ Deterministic — the same report always produces the same bytes, so artifacts
232
+ diff cleanly.
233
+ """
234
+ return json.dumps(sidecar(report), indent=2, ensure_ascii=False) + "\n"
@@ -0,0 +1,162 @@
1
+ """The deterministic chunker (normative prose copy in spec/chunking.md).
2
+
3
+ `chunk(page_text) -> [Chunk(ordinal, text, start, end)]`. Pure and total: the
4
+ same page text always yields the same chunks, and no input raises.
5
+
6
+ Determinism is the whole point. A chunk's ordinal is half of its anchor identity
7
+ (extraction, page number, ordinal), so a chunker that rebalanced or capped chunk
8
+ counts would silently move every citation on the page. There is deliberately no
9
+ per-page rebalancing and no chunk-count cap.
10
+
11
+ The algorithm:
12
+
13
+ 1. Split on blank lines (``\\n\\s*\\n``).
14
+ 2. Merge forward: a segment shorter than 200 chars merges into the following
15
+ segment; the last segment merges backward instead.
16
+ 3. Split long: a segment longer than 2400 chars splits at the sentence boundary
17
+ nearest each 1200-char multiple. A sentence boundary is a terminal ``.!?``
18
+ followed by whitespace and an uppercase letter or digit — no abbreviation
19
+ table, because splitting slightly wrong only shifts a boundary, and it does so
20
+ deterministically. A final piece under 200 chars merges backward: step 2 ran
21
+ before the split and could not see it.
22
+ 4. Ordinals ``c1..cN`` in order.
23
+
24
+ Offsets are exact: ``page_text[chunk.start:chunk.end] == chunk.text`` for every
25
+ chunk. NOTE: a chunk's text is therefore the verbatim source region, including
26
+ any blank-line separators that merging swallowed; only the outer edges are
27
+ trimmed of whitespace. This keeps offsets meaningful for future span features and
28
+ costs nothing, since `hashing.normalize` collapses interior whitespace anyway.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+
35
+ from .model import Chunk
36
+
37
+ __all__ = [
38
+ "MIN_CHARS",
39
+ "MAX_CHARS",
40
+ "TARGET_CHARS",
41
+ "chunk",
42
+ ]
43
+
44
+ MIN_CHARS = 200
45
+ """Segments shorter than this merge into a neighbour."""
46
+
47
+ MAX_CHARS = 2400
48
+ """Segments longer than this are split."""
49
+
50
+ TARGET_CHARS = 1200
51
+ """Long segments split near each multiple of this."""
52
+
53
+ _BLANK_LINE = re.compile(r"\n\s*\n")
54
+ # NOTE: the spec says "terminal .!? + space"; we accept a run of whitespace so a
55
+ # double space or a wrapped line still reads as a boundary.
56
+ _SENTENCE_BOUNDARY = re.compile(r"[.!?]\s+(?=[A-Z0-9])")
57
+
58
+ type _Span = tuple[int, int]
59
+
60
+
61
+ def chunk(page_text: str) -> list[Chunk]:
62
+ """Chunk one page's text. Returns `[]` for empty or whitespace-only pages."""
63
+ spans: list[_Span] = []
64
+ for span in _merge_short(_split_paragraphs(page_text)):
65
+ spans.extend(_split_long(page_text, span))
66
+ return [
67
+ Chunk(ordinal=ordinal, text=page_text[start:end], start=start, end=end)
68
+ for ordinal, (start, end) in enumerate(spans, start=1)
69
+ ]
70
+
71
+
72
+ def _split_paragraphs(text: str) -> list[_Span]:
73
+ """Blank-line separated regions, edge-trimmed, empties dropped."""
74
+ spans: list[_Span] = []
75
+ cursor = 0
76
+ for separator in _BLANK_LINE.finditer(text):
77
+ spans.append((cursor, separator.start()))
78
+ cursor = separator.end()
79
+ spans.append((cursor, len(text)))
80
+ return [span for span in (_trim(text, *raw) for raw in spans) if span[0] < span[1]]
81
+
82
+
83
+ def _merge_short(spans: list[_Span]) -> list[_Span]:
84
+ """Merge forward while a group is under `MIN_CHARS`; the last merges backward.
85
+
86
+ NOTE: length is measured over the source region (`end - start`), which for a
87
+ single segment is exactly its character count and for a merged group also
88
+ counts the blank line it absorbed.
89
+ """
90
+ groups: list[_Span] = []
91
+ index = 0
92
+ while index < len(spans):
93
+ start, end = spans[index]
94
+ index += 1
95
+ while end - start < MIN_CHARS and index < len(spans):
96
+ end = spans[index][1]
97
+ index += 1
98
+ groups.append((start, end))
99
+ if len(groups) > 1 and groups[-1][1] - groups[-1][0] < MIN_CHARS:
100
+ last = groups.pop()
101
+ start, _ = groups.pop()
102
+ groups.append((start, last[1]))
103
+ return groups
104
+
105
+
106
+ def _split_long(text: str, span: _Span) -> list[_Span]:
107
+ """Split a span over `MAX_CHARS` at sentence boundaries near each target."""
108
+ start, end = span
109
+ length = end - start
110
+ if length <= MAX_CHARS:
111
+ return [span]
112
+ boundaries = [
113
+ start + match.end() for match in _SENTENCE_BOUNDARY.finditer(text[start:end])
114
+ ]
115
+ if not boundaries:
116
+ return [span]
117
+
118
+ cuts: list[int] = []
119
+ previous = start
120
+ target = TARGET_CHARS
121
+ while target < length:
122
+ goal = start + target
123
+ # NOTE: nearest wins; ties go to the earlier boundary.
124
+ candidates = [offset for offset in boundaries if previous < offset < end]
125
+ if candidates:
126
+ best = min(candidates, key=lambda offset: (abs(offset - goal), offset))
127
+ cuts.append(best)
128
+ previous = best
129
+ target += TARGET_CHARS
130
+
131
+ edges = [start, *cuts, end]
132
+ pieces = [
133
+ _trim(text, edges[i], edges[i + 1]) for i in range(len(edges) - 1)
134
+ ]
135
+ return _absorb_tail([piece for piece in pieces if piece[0] < piece[1]])
136
+
137
+
138
+ def _absorb_tail(pieces: list[_Span]) -> list[_Span]:
139
+ """Merge an undersized final piece backward, the way rule 2 merges backward.
140
+
141
+ Merging runs before splitting, so rule 2 cannot see the pieces rule 3 is
142
+ about to create: a 2500-char region splits near 1200 and leaves a ~100-char
143
+ tail that rule 2 would have absorbed had it been a paragraph. That tail is a
144
+ chunk in name only — too small to carry context, and it becomes an anchor
145
+ whose receipt is a sentence fragment. So rule 3 applies rule 2's backward
146
+ merge to its own output, which is where that rule already sends a trailing
147
+ short region. `pieces` is a fresh list and is mutated in place.
148
+ """
149
+ if len(pieces) > 1 and pieces[-1][1] - pieces[-1][0] < MIN_CHARS:
150
+ last = pieces.pop()
151
+ start, _ = pieces.pop()
152
+ pieces.append((start, last[1]))
153
+ return pieces
154
+
155
+
156
+ def _trim(text: str, start: int, end: int) -> _Span:
157
+ """Shrink a span past leading and trailing whitespace."""
158
+ while start < end and text[start].isspace():
159
+ start += 1
160
+ while end > start and text[end - 1].isspace():
161
+ end -= 1
162
+ return (start, end)
@@ -0,0 +1,142 @@
1
+ """Reading claims out of authored markdown.
2
+
3
+ The wire form is one markdown link per claim span — `[claim text](bd:...)` — and
4
+ nothing else: no doc names, no footnotes, no display text. Which words an anchor
5
+ supports can never be recovered later and can always be projected away, so it is
6
+ captured here, at write time.
7
+
8
+ `parse_claims` is total: it never raises. A token that does not parse becomes a
9
+ `Citation` with status `malformed` carrying the reason, because bind must report
10
+ every citation the author wrote — including the ones it cannot use.
11
+
12
+ Scope, deliberately small (NOTE: the spec is silent on each of these):
13
+
14
+ * Link text may contain balanced brackets, other inline formatting, and newlines.
15
+ * Images (`![alt](...)`) are not claims.
16
+ * An href is a citation href if any `;`-separated piece starts with `bd:`; the
17
+ pieces that do not are reported as malformed rather than dropped.
18
+ * Code spans and fenced blocks are not special-cased — a token written inside one
19
+ is still read as a claim. Recognizing fences would require a markdown parser,
20
+ and a document that quotes tokens in prose is not the case worth optimizing.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+
27
+ from .errors import MalformedTokenError, UnsupportedTokenError
28
+ from .model import Citation, CitationStatus, Claim
29
+ from .tokens import is_token_href, parse, split_href
30
+
31
+ __all__ = ["parse_claims", "parse_citation"]
32
+
33
+ _LINK_TITLE = re.compile(r"""\s+(?:"[^"]*"|'[^']*')$""")
34
+
35
+
36
+ def parse_claims(source: str) -> list[Claim]:
37
+ """Every claim in an authored document, in document order.
38
+
39
+ A claim's `start`/`end` bound the whole markdown link, so
40
+ `source[claim.start:claim.end]` is the construct bind rewrites; `claim.text`
41
+ is the link text alone.
42
+ """
43
+ claims: list[Claim] = []
44
+ index = 0
45
+ length = len(source)
46
+ while index < length:
47
+ character = source[index]
48
+ if character == "\\":
49
+ index += 2
50
+ continue
51
+ if character != "[":
52
+ index += 1
53
+ continue
54
+ if index > 0 and source[index - 1] == "!":
55
+ index += 1
56
+ continue
57
+ link = _scan_link(source, index)
58
+ if link is None:
59
+ index += 1
60
+ continue
61
+ text_start, text_end, href, link_end = link
62
+ if not is_token_href(href):
63
+ index += 1
64
+ continue
65
+ claims.append(
66
+ Claim(
67
+ text=source[text_start:text_end],
68
+ start=index,
69
+ end=link_end,
70
+ citations=tuple(parse_citation(piece) for piece in split_href(href)),
71
+ )
72
+ )
73
+ index = link_end
74
+ return claims
75
+
76
+
77
+ def parse_citation(token_text: str) -> Citation:
78
+ """One citation from one token's text. Never raises.
79
+
80
+ Unparseable tokens — including the reserved `bd:calc(...)` derivation form,
81
+ which is recognized but unsupported in v0 — come back with status
82
+ `malformed` and the reason in `error`.
83
+ """
84
+ try:
85
+ parse(token_text)
86
+ except (MalformedTokenError, UnsupportedTokenError) as error:
87
+ return Citation(
88
+ token=token_text, status=CitationStatus.MALFORMED, error=str(error)
89
+ )
90
+ return Citation(token=token_text, status=CitationStatus.UNRESOLVED)
91
+
92
+
93
+ def _scan_link(source: str, open_bracket: int) -> tuple[int, int, str, int] | None:
94
+ """Scan `[text](href)` starting at `open_bracket`.
95
+
96
+ Returns `(text_start, text_end, href, link_end)`, or None if this bracket does
97
+ not open an inline link.
98
+ """
99
+ text_start = open_bracket + 1
100
+ text_end = _scan_balanced(source, text_start, "[", "]")
101
+ if text_end is None:
102
+ return None
103
+ if text_end + 1 >= len(source) or source[text_end + 1] != "(":
104
+ return None
105
+ href_start = text_end + 2
106
+ href_end = _scan_balanced(source, href_start, "(", ")")
107
+ if href_end is None:
108
+ return None
109
+ return (text_start, text_end, _clean_href(source[href_start:href_end]), href_end + 1)
110
+
111
+
112
+ def _scan_balanced(source: str, start: int, opener: str, closer: str) -> int | None:
113
+ """Index of the `closer` matching an already-consumed `opener`, or None."""
114
+ depth = 1
115
+ index = start
116
+ length = len(source)
117
+ while index < length:
118
+ character = source[index]
119
+ if character == "\\":
120
+ index += 2
121
+ continue
122
+ if character == opener:
123
+ depth += 1
124
+ elif character == closer:
125
+ depth -= 1
126
+ if depth == 0:
127
+ return index
128
+ index += 1
129
+ return None
130
+
131
+
132
+ def _clean_href(raw: str) -> str:
133
+ """The href as written, minus markdown's decorations.
134
+
135
+ NOTE: only a trailing quoted link title (`[x](bd:... "why")`) and enclosing
136
+ angle brackets are removed. Interior whitespace is kept, because the reserved
137
+ `bd:calc(...)` form contains it and a citation must be reported verbatim.
138
+ """
139
+ href = raw.strip()
140
+ if href.startswith("<") and href.endswith(">"):
141
+ href = href[1:-1].strip()
142
+ return _LINK_TITLE.sub("", href).strip()