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,114 @@
1
+ """overlap: how much of the claim's wording is actually in the snippet.
2
+
3
+ Report-only, by construction: this method never returns `fail`. Word overlap is
4
+ a signal about paraphrase, not a decision about truth — a faithful summary can
5
+ share almost no vocabulary with its source, and a plagiarised sentence shares
6
+ all of it. The worst verdict is `partial`, and the ratio travels in `detail` so
7
+ a reader can judge it.
8
+
9
+ Two behaviours, one method:
10
+
11
+ * **Quoted spans** — if the claim quotes (`"…"`, `“…”`, `'…'`), each quoted span
12
+ must appear verbatim in the snippet. A quotation is a checkable assertion, so
13
+ this is an exact substring test over normalized text; a case-only difference
14
+ is `partial`, as is a quote that is not there at all.
15
+ * **Everything else** — the fraction of the claim's word tokens that occur in
16
+ the snippet, at or above `PASS_RATIO` ⇒ `pass`.
17
+
18
+ NOTE: no stopword list and no stemming. Both are tuning knobs on a heuristic
19
+ whose output is advisory, and a stopword list is a language-specific table this
20
+ codebase should not start carrying.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+
27
+ from ...kernel.hashing import normalize
28
+ from ...kernel.model import Anchor, Citation, Claim, Verdict, VerdictStatus
29
+ from ...kernel.tokens import CellLocator
30
+ from .base import register
31
+
32
+ __all__ = ["Overlap", "overlap", "PASS_RATIO"]
33
+
34
+ PASS_RATIO = 0.6
35
+ """Share of the claim's word tokens that must occur in the snippet to `pass`."""
36
+
37
+ _QUOTED = re.compile(r"\"([^\"]{2,})\"|“([^”]{2,})”|'([^']{3,})'")
38
+ _TOKEN = re.compile(r"[0-9a-z]+(?:[.,][0-9]+)*")
39
+
40
+
41
+ def quoted_spans(text: str) -> list[str]:
42
+ """Every quoted span in `text`, normalized, in order."""
43
+ spans: list[str] = []
44
+ for match in _QUOTED.finditer(normalize(text)):
45
+ span = next(group for group in match.groups() if group is not None)
46
+ spans.append(normalize(span))
47
+ return spans
48
+
49
+
50
+ def word_tokens(text: str) -> list[str]:
51
+ """Lowercased word tokens: letters, digits, and numerals kept whole."""
52
+ return _TOKEN.findall(normalize(text).lower())
53
+
54
+
55
+ class Overlap:
56
+ """Span overlap between a claim and its anchor snippet. Never fails."""
57
+
58
+ method = "overlap"
59
+
60
+ def applies(self, claim: Claim, citation: Citation) -> bool:
61
+ """True whenever the claim has words to compare."""
62
+ return bool(word_tokens(claim.text))
63
+
64
+ def verify(self, claim: Claim, citation: Citation, anchor: Anchor) -> Verdict:
65
+ """Exact-substring for quoted spans; token overlap ratio otherwise."""
66
+ if isinstance(anchor.locator, CellLocator):
67
+ # A cell's snippet is one value; measuring how much of a sentence's
68
+ # wording appears in it says nothing. Recorded as skip, not noise.
69
+ return Verdict(
70
+ method=self.method,
71
+ status=VerdictStatus.SKIP,
72
+ detail="wording overlap does not apply to a single cell",
73
+ )
74
+ snippet = normalize(anchor.receipt.snippet)
75
+ spans = quoted_spans(claim.text)
76
+ if spans:
77
+ return self._verify_quotes(spans, snippet)
78
+ tokens = word_tokens(claim.text)
79
+ present = [token for token in tokens if token in set(word_tokens(snippet))]
80
+ ratio = len(present) / len(tokens)
81
+ detail = f"{len(present)}/{len(tokens)} claim tokens in snippet (ratio {ratio:.2f})"
82
+ status = VerdictStatus.PASS if ratio >= PASS_RATIO else VerdictStatus.PARTIAL
83
+ return Verdict(method=self.method, status=status, detail=detail)
84
+
85
+ def _verify_quotes(self, spans: list[str], snippet: str) -> Verdict:
86
+ missing: list[str] = []
87
+ miscased: list[str] = []
88
+ for span in spans:
89
+ if span in snippet:
90
+ continue
91
+ if span.lower() in snippet.lower():
92
+ miscased.append(span)
93
+ else:
94
+ missing.append(span)
95
+ if missing:
96
+ return Verdict(
97
+ method=self.method,
98
+ status=VerdictStatus.PARTIAL,
99
+ detail="quoted span not in snippet: " + ", ".join(repr(s) for s in missing),
100
+ )
101
+ if miscased:
102
+ return Verdict(
103
+ method=self.method,
104
+ status=VerdictStatus.PARTIAL,
105
+ detail="quoted span differs in case: " + ", ".join(repr(s) for s in miscased),
106
+ )
107
+ return Verdict(
108
+ method=self.method,
109
+ status=VerdictStatus.PASS,
110
+ detail=f"{len(spans)} quoted span(s) verbatim in snippet",
111
+ )
112
+
113
+
114
+ overlap = register(Overlap())
@@ -0,0 +1,45 @@
1
+ """recompute: deterministic re-execution of a declared derivation.
2
+
3
+ Registered and inert. `applies` returns False for every pair, so enabling
4
+ `--check recompute` is legal, changes nothing, and writes no verdict rows.
5
+
6
+ The method exists because the citation grammar reserves a derivation form —
7
+ `bd:calc(<expr over tokens>)`, see `kernel/tokens.py` and `spec/tokens.md` —
8
+ whose whole point is that "the numbers tie" becomes a deterministic check
9
+ instead of a slogan. v0 does not implement that grammar: `kernel.tokens.parse`
10
+ raises `UnsupportedTokenError` for it and bind reports such a citation as
11
+ `malformed`, which means no citation reaching a verifier can ever carry a
12
+ derivation. When the grammar lands, `applies` becomes "the citation is a
13
+ derivation" and `verify` evaluates the expression over its operand anchors.
14
+
15
+ Registering the stub now keeps the method name in the closed set from day one,
16
+ so a report's `by_method` table has a stable shape across versions.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from ...kernel.model import Anchor, Citation, Claim, Verdict, VerdictStatus
22
+ from .base import register
23
+
24
+ __all__ = ["Recompute", "recompute"]
25
+
26
+
27
+ class Recompute:
28
+ """Re-executes declared derivations. Applies to nothing until `bd:calc`."""
29
+
30
+ method = "recompute"
31
+
32
+ def applies(self, claim: Claim, citation: Citation) -> bool:
33
+ """Always False: no v0 citation can carry a derivation."""
34
+ return False
35
+
36
+ def verify(self, claim: Claim, citation: Citation, anchor: Anchor) -> Verdict:
37
+ """Unreachable in v0; `skip`, never an exception, if ever called."""
38
+ return Verdict(
39
+ method=self.method,
40
+ status=VerdictStatus.SKIP,
41
+ detail="derivations (bd:calc) are reserved and not implemented in v0",
42
+ )
43
+
44
+
45
+ recompute = register(Recompute())
@@ -0,0 +1,352 @@
1
+ """value-trace: does the claim's value occur at the cited location?
2
+
3
+ The deterministic check. A claim says "$1.4M"; the snippet says "1,400,000".
4
+ Those are the same value, and a verifier that cannot see that is useless on real
5
+ documents, so the unit/scale/format equivalences live here — and *only* here.
6
+ `kernel.hashing.normalize` is whitespace and Unicode form; this module is the
7
+ system's one authority on when two written values are the same value.
8
+
9
+ Equivalences implemented (both directions — the side carrying the marker is the
10
+ side that expands):
11
+
12
+ * **Thousands separators / currency / whitespace** — `$1,400,000` ≡ `1400000`.
13
+ * **Scale suffixes** — `K` `M` `MM` `B` `BN` `T` attached, and the words
14
+ `thousand` `million` `billion` `trillion` spaced: `$1.4M` ≡ `1.4 million` ≡
15
+ `1,400,000`.
16
+ * **Percent** — `12%` and `12 percent` each stand for both `12` and `0.12`, so a
17
+ percent on either side matches a bare decimal on the other. A bare `0.12` does
18
+ *not* expand to `12`: that direction is the ambiguous one.
19
+ * **Multipliers** — `1.42x` ≡ `1.42` (the `x` is a unit, not a scale).
20
+ * **Accounting negatives** — `(1,234)` ≡ `-1234`.
21
+ * **Trailing zeros** — `4.10` ≡ `4.1` (values compare as numbers, not strings).
22
+ * **Dates** — `2025-03-31` ≡ `March 31, 2025` ≡ `Mar. 31 2025` ≡
23
+ `31 March 2025` ≡ `3/31/2025`. A slash date whose first field could be either
24
+ field expands to both readings; `2025-03` ≡ `March 2025`.
25
+
26
+ Rounding is a `partial`, never a `pass`: a claim written as `$1.4M` against a
27
+ snippet reading `1,412,000` is a rounded match — the claim's own precision sets
28
+ the quantum. A date written to month precision against a day-precision date in
29
+ the snippet is the same case.
30
+
31
+ Verdict rule: every value in the claim must be found in the snippet. Any value
32
+ with no match at all ⇒ `fail`, naming the values that were not found. All found,
33
+ at least one only by rounding ⇒ `partial`. All found exactly ⇒ `pass`.
34
+
35
+ NOTE: the spec's claim class for value-trace is "numbers, dates, names"; names
36
+ are not extracted here. A name is a span of words, which is what `overlap`
37
+ already measures, and a name extractor is a guessing machine this deterministic
38
+ check should not contain.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ from dataclasses import dataclass
45
+ from decimal import Decimal, InvalidOperation
46
+
47
+ from ...kernel.hashing import normalize
48
+ from ...kernel.model import Anchor, Citation, Claim, Verdict, VerdictStatus
49
+ from .base import register
50
+
51
+ __all__ = ["ValueTrace", "value_trace"]
52
+
53
+ _SCALES: dict[str, Decimal] = {
54
+ "k": Decimal(10) ** 3,
55
+ "thousand": Decimal(10) ** 3,
56
+ "m": Decimal(10) ** 6,
57
+ "mm": Decimal(10) ** 6,
58
+ "million": Decimal(10) ** 6,
59
+ "b": Decimal(10) ** 9,
60
+ "bn": Decimal(10) ** 9,
61
+ "billion": Decimal(10) ** 9,
62
+ "t": Decimal(10) ** 12,
63
+ "trillion": Decimal(10) ** 12,
64
+ }
65
+
66
+ _CURRENCY = "$€£¥"
67
+
68
+ _MONTHS: dict[str, int] = {}
69
+ for _index, _name in enumerate(
70
+ (
71
+ "january",
72
+ "february",
73
+ "march",
74
+ "april",
75
+ "may",
76
+ "june",
77
+ "july",
78
+ "august",
79
+ "september",
80
+ "october",
81
+ "november",
82
+ "december",
83
+ ),
84
+ start=1,
85
+ ):
86
+ _MONTHS[_name] = _index
87
+ _MONTHS[_name[:3]] = _index
88
+ _MONTHS["sept"] = 9
89
+
90
+ _MONTH_NAMES = "|".join(sorted(_MONTHS, key=len, reverse=True))
91
+
92
+ # Dates are extracted first and their spans masked, so `2025-03-31` never
93
+ # decomposes into the numbers 2025, 3 and 31.
94
+ _DATE_PATTERNS = (
95
+ re.compile(r"\b(?P<y>\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})\b"),
96
+ re.compile(
97
+ rf"\b(?P<mon>{_MONTH_NAMES})\.?\s+(?P<d>\d{{1,2}})(?:st|nd|rd|th)?,?\s+(?P<y>\d{{4}})\b",
98
+ re.IGNORECASE,
99
+ ),
100
+ re.compile(
101
+ rf"\b(?P<d>\d{{1,2}})(?:st|nd|rd|th)?\s+(?P<mon>{_MONTH_NAMES})\.?,?\s+(?P<y>\d{{4}})\b",
102
+ re.IGNORECASE,
103
+ ),
104
+ re.compile(r"\b(?P<a>\d{1,2})/(?P<b>\d{1,2})/(?P<y>\d{4})\b"),
105
+ re.compile(r"\b(?P<a>\d{1,2})/(?P<b>\d{1,2})/(?P<y2>\d{2})\b"),
106
+ re.compile(rf"\b(?P<mon>{_MONTH_NAMES})\.?\s+(?P<y>\d{{4}})\b", re.IGNORECASE),
107
+ re.compile(r"\b(?P<y>\d{4})-(?P<m>\d{1,2})\b"),
108
+ )
109
+
110
+ _DIGITS = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+"
111
+ _ATTACHED_SCALE = r"MM|mm|[Bb][Nn]|[KkMmBbTt]"
112
+ _WORD_SCALE = r"thousand|million|billion|trillion"
113
+ _NUMBER = re.compile(
114
+ rf"""
115
+ (?P<open>\()?
116
+ (?P<sign>-)?
117
+ (?P<currency>[{_CURRENCY}])?\s?
118
+ (?P<sign2>-)?
119
+ (?P<digits>{_DIGITS})
120
+ (?:
121
+ (?P<percent>\s?%|\s+percent\b)
122
+ | (?P<multiplier>[xX×](?![A-Za-z0-9]))
123
+ | (?P<attached>(?:{_ATTACHED_SCALE})(?![A-Za-z0-9]))
124
+ | \s(?P<word>{_WORD_SCALE})\b
125
+ )?
126
+ (?P<close>\))?
127
+ """,
128
+ re.VERBOSE | re.IGNORECASE,
129
+ )
130
+
131
+ _WORD = re.compile(r"[A-Za-z0-9]")
132
+
133
+ _ORDINAL_LABEL = re.compile(
134
+ r"\b(?:year|yr|quarter|qtr|month|week|day|phase|tier|fy|q)[\s\-#]{0,2}\d{1,2}\b",
135
+ re.IGNORECASE,
136
+ )
137
+ """Structural labels, not claimed values: the `1` in `Year 1 debt yield` names a
138
+ column, and hunting for a bare `1` in the source produced false fails on every
139
+ proforma claim. Masked like dates, before number extraction, on both sides."""
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class _Reading:
144
+ """One numeric reading of a written value: the number and its precision.
145
+
146
+ `quantum` is the size of the last written digit — `1.4M` is precise to
147
+ 100000, `1,412,000` to 1 — and is what rounding comparisons round to.
148
+ """
149
+
150
+ number: Decimal
151
+ quantum: Decimal
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class _Value:
156
+ """One value as written, with every reading it could stand for."""
157
+
158
+ text: str
159
+ kind: str
160
+ readings: tuple[_Reading, ...] = ()
161
+ dates: tuple[tuple[int, int, int | None], ...] = ()
162
+
163
+
164
+ def extract_values(text: str) -> list[_Value]:
165
+ """Every number and date written in `text`, in order.
166
+
167
+ Dates are matched first and their spans removed from number scanning, so a
168
+ date's fields are never also read as three separate numbers.
169
+ """
170
+ source = normalize(text)
171
+ masked = list(source)
172
+ values: list[_Value] = []
173
+ spans: list[tuple[int, int, _Value]] = []
174
+ for match in _ORDINAL_LABEL.finditer(source):
175
+ for index in range(match.start(), match.end()):
176
+ masked[index] = "\x00"
177
+ for pattern in _DATE_PATTERNS:
178
+ for match in pattern.finditer(source):
179
+ if any(masked[index] == "\x00" for index in range(match.start(), match.end())):
180
+ continue
181
+ date = _read_date(match)
182
+ if date is None:
183
+ continue
184
+ for index in range(match.start(), match.end()):
185
+ masked[index] = "\x00"
186
+ spans.append((match.start(), match.end(), date))
187
+ remainder = "".join(masked)
188
+ for match in _NUMBER.finditer(remainder):
189
+ if "\x00" in match.group(0):
190
+ continue
191
+ number = _read_number(match)
192
+ if number is not None:
193
+ spans.append((match.start(), match.end(), number))
194
+ for _start, _end, value in sorted(spans, key=lambda item: item[0]):
195
+ values.append(value)
196
+ return values
197
+
198
+
199
+ def _read_number(match: re.Match[str]) -> _Value | None:
200
+ """One `_Value` from a number match, or None if the digits do not parse."""
201
+ digits = match["digits"].replace(",", "")
202
+ try:
203
+ magnitude = Decimal(digits)
204
+ except InvalidOperation: # pragma: no cover - the regex admits only numerals
205
+ return None
206
+ exponent = -magnitude.as_tuple().exponent
207
+ quantum = Decimal(1).scaleb(-exponent) # type: ignore[operator]
208
+ negative = bool(match["sign"] or match["sign2"]) or bool(match["open"] and match["close"])
209
+ scale = Decimal(1)
210
+ if match["attached"]:
211
+ scale = _SCALES[match["attached"].lower()]
212
+ elif match["word"]:
213
+ scale = _SCALES[match["word"].lower()]
214
+ value = magnitude * scale
215
+ quantum = quantum * scale
216
+ if negative:
217
+ value = -value
218
+ readings = [_Reading(number=value, quantum=quantum)]
219
+ if match["percent"]:
220
+ readings.append(_Reading(number=value / 100, quantum=quantum / 100))
221
+ return _Value(text=match.group(0).strip(), kind="number", readings=tuple(readings))
222
+
223
+
224
+ def _read_date(match: re.Match[str]) -> _Value | None:
225
+ """One `_Value` from a date match, or None if the fields are not a date."""
226
+ groups = match.groupdict()
227
+ candidates: list[tuple[int, int, int | None]] = []
228
+ if "mon" in groups and groups.get("mon"):
229
+ month = _MONTHS[groups["mon"].lower().rstrip(".")]
230
+ year = int(groups["y"])
231
+ day = int(groups["d"]) if groups.get("d") else None
232
+ if day is not None and not 1 <= day <= 31:
233
+ return None
234
+ candidates.append((year, month, day))
235
+ elif groups.get("a") is not None:
236
+ first, second = int(groups["a"]), int(groups["b"])
237
+ year = int(groups["y"]) if groups.get("y") else 2000 + int(groups["y2"])
238
+ # NOTE: month-first is the primary reading; the day-first reading is
239
+ # added whenever both fields could be a month, so an ambiguous slash
240
+ # date matches either convention rather than half of them.
241
+ if 1 <= first <= 12 and 1 <= second <= 31:
242
+ candidates.append((year, first, second))
243
+ if 1 <= second <= 12 and 1 <= first <= 31 and (year, second, first) not in candidates:
244
+ candidates.append((year, second, first))
245
+ else:
246
+ year = int(groups["y"])
247
+ month = int(groups["m"])
248
+ day = int(groups["d"]) if groups.get("d") else None
249
+ if not 1 <= month <= 12:
250
+ return None
251
+ if day is not None and not 1 <= day <= 31:
252
+ return None
253
+ candidates.append((year, month, day))
254
+ if not candidates:
255
+ return None
256
+ return _Value(text=match.group(0).strip(), kind="date", dates=tuple(candidates))
257
+
258
+
259
+ def matches(claimed: _Value, found: _Value) -> str | None:
260
+ """`"exact"`, `"rounded"`, or None — how `found` satisfies `claimed`."""
261
+ if claimed.kind != found.kind:
262
+ return None
263
+ if claimed.kind == "date":
264
+ return _match_dates(claimed, found)
265
+ for reading in claimed.readings:
266
+ for other in found.readings:
267
+ if reading.number == other.number:
268
+ return "exact"
269
+ for reading in claimed.readings:
270
+ for other in found.readings:
271
+ if _rounds_to(other.number, reading) or _rounds_to(reading.number, other):
272
+ return "rounded"
273
+ return None
274
+
275
+
276
+ def _match_dates(claimed: _Value, found: _Value) -> str | None:
277
+ if set(claimed.dates) & set(found.dates):
278
+ return "exact"
279
+ for year, month, day in claimed.dates:
280
+ for other_year, other_month, other_day in found.dates:
281
+ if (year, month) != (other_year, other_month):
282
+ continue
283
+ if day is None or other_day is None:
284
+ return "rounded"
285
+ return None
286
+
287
+
288
+ def _rounds_to(number: Decimal, target: _Reading) -> bool:
289
+ """True when `number` rounds to `target` at `target`'s written precision.
290
+
291
+ A zero target is excluded: everything small "rounds to 0" at an integer's
292
+ precision, which once matched every percentage in a claim against a bare
293
+ `0` in a proforma row and reported `7.6% ~ 0`. Nothing nonzero is evidence
294
+ for zero.
295
+ """
296
+ if target.quantum <= 0:
297
+ return False # pragma: no cover - quanta are powers of ten
298
+ if target.number == 0 and number != 0:
299
+ return False
300
+ steps = (number / target.quantum).quantize(Decimal(1))
301
+ return steps * target.quantum == target.number
302
+
303
+
304
+ class ValueTrace:
305
+ """Deterministic value equivalence between a claim and its anchor snippet."""
306
+
307
+ method = "value-trace"
308
+
309
+ def applies(self, claim: Claim, citation: Citation) -> bool:
310
+ """True when the claim text contains at least one number or date."""
311
+ return bool(extract_values(claim.text))
312
+
313
+ def verify(self, claim: Claim, citation: Citation, anchor: Anchor) -> Verdict:
314
+ """Every claimed value, sought in the anchor's verbatim snippet."""
315
+ claimed = extract_values(claim.text)
316
+ found = extract_values(anchor.receipt.snippet)
317
+ missing: list[str] = []
318
+ rounded: list[str] = []
319
+ for value in claimed:
320
+ outcome = None
321
+ partner = None
322
+ for candidate in found:
323
+ result = matches(value, candidate)
324
+ if result == "exact":
325
+ outcome, partner = result, candidate
326
+ break
327
+ if result == "rounded" and outcome is None:
328
+ outcome, partner = result, candidate
329
+ if outcome is None:
330
+ missing.append(value.text)
331
+ elif outcome == "rounded" and partner is not None:
332
+ rounded.append(f"{value.text} ~ {partner.text}")
333
+ if missing:
334
+ return Verdict(
335
+ method=self.method,
336
+ status=VerdictStatus.FAIL,
337
+ detail="not found in snippet: " + ", ".join(missing),
338
+ )
339
+ if rounded:
340
+ return Verdict(
341
+ method=self.method,
342
+ status=VerdictStatus.PARTIAL,
343
+ detail="rounded match: " + ", ".join(rounded),
344
+ )
345
+ return Verdict(
346
+ method=self.method,
347
+ status=VerdictStatus.PASS,
348
+ detail=f"{len(claimed)} value(s) found in snippet",
349
+ )
350
+
351
+
352
+ value_trace = register(ValueTrace())