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,31 @@
1
+ """render — the artifact and its projections.
2
+
3
+ Three renderings of one bind run, from an authored document plus its sidecar and
4
+ nothing else (the registry is never opened here):
5
+
6
+ * `html` — the single-file artifact: the document, a receipt behind every claim,
7
+ a visible Unresolved section, and a JSON island that teaches its own decoding.
8
+ * `footnotes` — the plain-markdown projection.
9
+ * `sidecar` — the machine-readable record alone, `backdraft/artifact-v1`.
10
+
11
+ Export style (shared by every package above the kernel): **names**, never
12
+ submodules. The three renderers each spell their entry point `render`, so they
13
+ are reached by module path — `from backdraft.render import html`, then
14
+ `html.render(...)` — which also means importing this package does not drag all
15
+ three in. `render.cli` is not imported here either: it needs typer; the library
16
+ does not.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .placement import Placement, locate
22
+ from .sidecar import FORMAT, LEGEND, SIDECAR_SUFFIX, sidecar_path
23
+
24
+ __all__ = [
25
+ "Placement",
26
+ "locate",
27
+ "FORMAT",
28
+ "LEGEND",
29
+ "SIDECAR_SUFFIX",
30
+ "sidecar_path",
31
+ ]
@@ -0,0 +1,69 @@
1
+ """Presentation helpers the renderers share: status prose, elision, quoting.
2
+
3
+ Both renderers project the same report, so both have to say the same things about
4
+ it. When the artifact and the markdown projection each carried their own copy of
5
+ these, they drifted — the same `not_shown` citation was "a valid anchor the
6
+ writer was never shown" in one and "A valid anchor, but the writer was never
7
+ shown it." in the other, which is two answers to one question.
8
+
9
+ The wording here is checked against `kernel/artifact.py`'s `LEGEND`
10
+ ("citation_status"), which is the normative statement of what each status means;
11
+ these are its one-line renderings, not a second definition.
12
+
13
+ Private to `render/`: the dependency rule forbids sideways imports, so bind keeps
14
+ its own quoting even where the code looks alike.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ..kernel.model import CitationStatus
20
+
21
+ __all__ = ["STATUS_NOTE", "quote_lines", "sentence", "short", "status_note"]
22
+
23
+ STATUS_NOTE: dict[CitationStatus, str] = {
24
+ CitationStatus.DRIFTED: "the source changed after this claim was written",
25
+ CitationStatus.NOT_SHOWN: "a valid anchor, but the writer was never shown it",
26
+ CitationStatus.UNRESOLVED: "no anchor for this token in any generation",
27
+ CitationStatus.MALFORMED: "the citation text is not a token",
28
+ }
29
+ """Why a citation did not resolve, as a lowercase fragment.
30
+
31
+ `resolved` is deliberately absent: a resolved citation needs no explanation, and
32
+ `status_note` returning "" is what the callers test for.
33
+ """
34
+
35
+
36
+ def status_note(status: CitationStatus) -> str:
37
+ """The one-line reason for a status, or "" when there is nothing to say."""
38
+ return STATUS_NOTE.get(status, "")
39
+
40
+
41
+ def sentence(text: str) -> str:
42
+ """A fragment as a standalone sentence: capitalized, terminally punctuated.
43
+
44
+ The markdown projection puts these after a colon, where a fragment reads
45
+ correctly; the artifact shows them on their own, where it does not.
46
+ """
47
+ if not text:
48
+ return ""
49
+ return text[0].upper() + text[1:] + ("" if text.endswith((".", "!", "?")) else ".")
50
+
51
+
52
+ def short(text: str, *, limit: int) -> str:
53
+ """A claim's text, collapsed to one line and elided at `limit` characters.
54
+
55
+ `limit` is the caller's, because the two renderers have different room: an
56
+ artifact's failure list is a styled block, a markdown list item is a line.
57
+ """
58
+ flat = " ".join(text.split())
59
+ return flat if len(flat) <= limit else flat[: limit - 1].rstrip() + "…"
60
+
61
+
62
+ def quote_lines(snippet: str) -> list[str]:
63
+ """A verbatim snippet as markdown blockquote lines, line for line.
64
+
65
+ NOTE: `bind.binder` has its own, on purpose — `bind` may not import `render`,
66
+ and its References section quotes with slightly different blank-line handling
67
+ besides.
68
+ """
69
+ return [f"> {line}" if line.strip() else ">" for line in snippet.splitlines() or [""]]
@@ -0,0 +1,107 @@
1
+ """`backdraft render` — the artifact, the markdown projection, or the sidecar.
2
+
3
+ Render's inputs are an authored document and the sidecar bind wrote beside it.
4
+ The registry is never opened here: an artifact must be reproducible from the two
5
+ files a reader was handed, on a machine that has never seen the sources.
6
+
7
+ Mounted by the top-level CLI as `app`, per SPEC Addendum B:
8
+
9
+ from backdraft.render import cli as render_cli
10
+ app.registered_commands.extend(render_cli.app.registered_commands)
11
+
12
+ which puts this module's one command at `backdraft render <doc.md>`.
13
+
14
+ NOTE: typer collapses a single-command app into a bare command, so invoking this
15
+ module's `app` on its own takes the document as its first argument and does not
16
+ want the word `render`. The command name only exists once it is mounted next to
17
+ the top level's other commands.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+ from enum import StrEnum
24
+ from pathlib import Path
25
+ from typing import Annotated
26
+
27
+ import typer
28
+
29
+ from ..cli_context import UsageError, guard
30
+ from ..kernel.artifact import (
31
+ ARTIFACT_SUFFIX,
32
+ FOOTNOTES_SUFFIX,
33
+ SIDECAR_SUFFIX,
34
+ )
35
+ from . import footnotes, html, sidecar
36
+
37
+ __all__ = ["app", "Target"]
38
+
39
+ app = typer.Typer(help="Render a bound document as a self-contained artifact.")
40
+
41
+
42
+ class Target(StrEnum):
43
+ """What to render. `html` is the artifact; the others are projections of it."""
44
+
45
+ HTML = "html"
46
+ FOOTNOTES = "footnotes"
47
+ JSON = "json"
48
+
49
+
50
+ _SUFFIX = {
51
+ Target.HTML: ARTIFACT_SUFFIX,
52
+ Target.FOOTNOTES: FOOTNOTES_SUFFIX,
53
+ Target.JSON: SIDECAR_SUFFIX,
54
+ }
55
+ """The naming family is the format's, so it comes from `kernel/artifact.py`."""
56
+
57
+
58
+ @app.command()
59
+ def render(
60
+ doc: Annotated[Path, typer.Argument(help="The authored markdown document.")],
61
+ to: Annotated[Target, typer.Option("--to", help="Output form.")] = Target.HTML,
62
+ out: Annotated[
63
+ Path | None,
64
+ typer.Option("-o", "--out", help="Output file; '-' writes to stdout."),
65
+ ] = None,
66
+ ) -> None:
67
+ """Render `doc` and its sidecar.
68
+
69
+ The sidecar is found beside the document as `<doc>.backdraft.json` (see
70
+ spec/artifact.md). Exit codes: 0 on success, 1 when the document or its
71
+ sidecar is missing or unreadable.
72
+
73
+ NOTE: the spec's exit code 2 belongs to `bind` — render reports unresolved
74
+ citations in the artifact, which is the whole point, and does not fail on
75
+ them.
76
+ """
77
+ with guard():
78
+ if not doc.is_file():
79
+ raise UsageError(f"no such document: {doc}")
80
+ found = sidecar.find_sidecar(doc)
81
+ if found is None:
82
+ raise UsageError(
83
+ f"no sidecar beside {doc.name}: expected {sidecar.sidecar_path(doc).name}. "
84
+ "Run `backdraft bind` first."
85
+ )
86
+ try:
87
+ report = sidecar.read(found)
88
+ except (ValueError, KeyError, OSError) as error:
89
+ raise UsageError(f"unreadable sidecar {found.name}: {error}") from error
90
+
91
+ source = doc.read_text(encoding="utf-8")
92
+ if to is Target.HTML:
93
+ text = html.render(source, report)
94
+ elif to is Target.FOOTNOTES:
95
+ text = footnotes.render(source, report)
96
+ else:
97
+ text = sidecar.dumps(report)
98
+
99
+ # NOTE: `--to json` writes to stdout unless told otherwise — its default file
100
+ # name is the sidecar it just read, and rewriting an input in place is not a
101
+ # thing a render command should do quietly.
102
+ if (out is not None and str(out) == "-") or (out is None and to is Target.JSON):
103
+ sys.stdout.write(text)
104
+ return
105
+ target = out or doc.with_name(doc.stem + _SUFFIX[to])
106
+ target.write_text(text, encoding="utf-8")
107
+ typer.secho(str(target), fg=typer.colors.GREEN)
@@ -0,0 +1,172 @@
1
+ """The markdown projection: claims become footnote refs, receipts become notes.
2
+
3
+ The lossy, portable rendering — for a pull request, an email, a wiki, anywhere
4
+ HTML cannot go. Every receipt still travels: document, locator, verbatim quote,
5
+ snippet hash, statuses and verdicts. What it gives up is the click.
6
+
7
+ The projection is total. Every claim gets a ref, every citation gets a note, and
8
+ every non-resolved citation is repeated in a visible Unresolved section, because
9
+ failures are data.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ..kernel.model import BindReport, Citation, CitationStatus
15
+ from ._text import quote_lines as _quote
16
+ from ._text import short, status_note
17
+ from .placement import Placement, locate
18
+ from .sidecar import FORMAT, SIDECAR_SUFFIX
19
+
20
+ __all__ = ["render", "LABEL_PREFIX"]
21
+
22
+ LABEL_PREFIX = "bd"
23
+ """Footnote labels are `bd1`, `bd2`, ... in document order."""
24
+
25
+ CLAIM_ECHO_CHARS = 72
26
+ """How much of a claim a list entry echoes: one markdown line's worth."""
27
+
28
+ _Note = tuple[str, list[str]]
29
+
30
+
31
+ def render(source: str, report: BindReport) -> str:
32
+ """Render `source` and its bind report as plain markdown with footnotes."""
33
+ placements = locate(source, report.claims)
34
+ notes: list[_Note] = []
35
+ body = _body(source, placements, notes)
36
+ sections = [body.rstrip(), "---", _receipts(report, notes), _unresolved(placements)]
37
+ return "\n\n".join(section for section in sections if section) + "\n"
38
+
39
+
40
+ def _body(source: str, placements: list[Placement], notes: list[_Note]) -> str:
41
+ """The document with each claim's citation construct replaced by refs."""
42
+ pieces: list[str] = []
43
+ cursor = 0
44
+ for placement in placements:
45
+ claim_notes = _notes_for(placement, len(notes))
46
+ notes.extend(claim_notes)
47
+ if placement.start is None or placement.end is None:
48
+ continue
49
+ pieces.append(source[cursor : placement.start])
50
+ pieces.append(
51
+ placement.claim.text + "".join(f"[^{label}]" for label, _ in claim_notes)
52
+ )
53
+ cursor = placement.end
54
+ pieces.append(source[cursor:])
55
+ return "".join(pieces)
56
+
57
+
58
+ def _notes_for(placement: Placement, offset: int) -> list[_Note]:
59
+ """One note per citation; a claim with no citation still gets one."""
60
+ claim = placement.claim
61
+ if not claim.citations:
62
+ return [(f"{LABEL_PREFIX}{offset + 1}", _unanchored_note(placement))]
63
+ return [
64
+ (f"{LABEL_PREFIX}{offset + position}", _note(citation, placement))
65
+ for position, citation in enumerate(claim.citations, start=1)
66
+ ]
67
+
68
+
69
+ def _note(citation: Citation, placement: Placement) -> list[str]:
70
+ """The lines of one footnote: source, quote, token, drift, verdicts.
71
+
72
+ Groups are separated by blank lines so that the quote is a blockquote in the
73
+ footnote and not a lazy continuation of the line above it.
74
+ """
75
+ anchor = citation.anchor
76
+ if anchor is not None:
77
+ groups = [
78
+ [f"**{anchor.slug}** · `{anchor.locator}` · {citation.status}"],
79
+ _quote(anchor.receipt.snippet),
80
+ [f"Token `{citation.token}` · sha256 `{anchor.receipt.snippet_sha256}`"],
81
+ ]
82
+ else:
83
+ groups = [[f"unanchored · {citation.status}"], [f"Token `{citation.token}`"]]
84
+ if citation.drifted_from is not None:
85
+ groups.append(["As cited, before the source changed:"])
86
+ groups.append(_quote(citation.drifted_from))
87
+ if citation.error:
88
+ groups.append([f"Error: {citation.error}"])
89
+ groups.append(
90
+ ["Verdicts: " + (_verdicts(citation) if citation.verdicts else "none run.")]
91
+ )
92
+ groups.append(_claim_notes(placement))
93
+ return _joined(groups)
94
+
95
+
96
+ def _unanchored_note(placement: Placement) -> list[str]:
97
+ """A claim bind carried but could not cite at all."""
98
+ head = [f"unmatched · no citation — claim: “{_echo(placement.claim.text)}”"]
99
+ return _joined([head, _claim_notes(placement)])
100
+
101
+
102
+ def _claim_notes(placement: Placement) -> list[str]:
103
+ """What is true of the claim itself, rather than of one citation."""
104
+ lines: list[str] = []
105
+ if placement.claim.unmatched:
106
+ lines.append("This claim is unmatched: bind could not anchor it.")
107
+ if not placement.placed:
108
+ lines.append("This claim was not located in the document text above.")
109
+ return lines
110
+
111
+
112
+ def _joined(groups: list[list[str]]) -> list[str]:
113
+ """Non-empty groups, separated by one blank line."""
114
+ out: list[str] = []
115
+ for group in groups:
116
+ if not group:
117
+ continue
118
+ if out:
119
+ out.append("")
120
+ out.extend(group)
121
+ return out
122
+
123
+
124
+ def _verdicts(citation: Citation) -> str:
125
+ return "; ".join(
126
+ f"{verdict.method} {verdict.status}" + (f" — {verdict.detail}" if verdict.detail else "")
127
+ for verdict in citation.verdicts
128
+ )
129
+
130
+
131
+ def _receipts(report: BindReport, notes: list[_Note]) -> str:
132
+ """The footnote definitions, under a provenance line."""
133
+ session = f", session `{report.session_id}`" if report.session_id else ""
134
+ stem = report.doc_path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
135
+ lines = [
136
+ "## Receipts",
137
+ "",
138
+ f"Bound from `{report.doc_path}` — {report.mode}{session}, {report.bound_at}. "
139
+ f"Machine-readable record: `{stem}{SIDECAR_SUFFIX}` (`{FORMAT}`).",
140
+ "",
141
+ ]
142
+ for label, note in notes:
143
+ first, *rest = note
144
+ lines.append(f"[^{label}]: {first}")
145
+ lines.extend(f" {line}" if line else "" for line in rest)
146
+ lines.append("")
147
+ return "\n".join(lines).rstrip()
148
+
149
+
150
+ def _unresolved(placements: list[Placement]) -> str:
151
+ """Every non-resolved citation and every unmatched claim, visibly."""
152
+ items: list[str] = []
153
+ for placement in placements:
154
+ for citation in placement.claim.citations:
155
+ if citation.status is CitationStatus.RESOLVED:
156
+ continue
157
+ items.append(
158
+ f"- **{citation.status}** — `{citation.token}` "
159
+ f"in claim {placement.number}, “{_echo(placement.claim.text)}”: "
160
+ f"{citation.error or status_note(citation.status)}."
161
+ )
162
+ if placement.claim.unmatched:
163
+ items.append(
164
+ f"- **unmatched** — claim {placement.number}, "
165
+ f"“{_echo(placement.claim.text)}”: bind could not anchor it."
166
+ )
167
+ return "\n".join(["## Unresolved", "", *(items or ["Every citation resolved."])])
168
+
169
+
170
+ def _echo(text: str) -> str:
171
+ """A claim's text, collapsed to one line for a list entry."""
172
+ return short(text, limit=CLAIM_ECHO_CHARS)