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.
- backdraft/__init__.py +13 -0
- backdraft/bind/__init__.py +37 -0
- backdraft/bind/binder.py +653 -0
- backdraft/bind/cli.py +133 -0
- backdraft/bind/evidence.py +232 -0
- backdraft/bind/verify/__init__.py +30 -0
- backdraft/bind/verify/base.py +70 -0
- backdraft/bind/verify/entail.py +156 -0
- backdraft/bind/verify/overlap.py +114 -0
- backdraft/bind/verify/recompute.py +45 -0
- backdraft/bind/verify/value_trace.py +352 -0
- backdraft/cli.py +341 -0
- backdraft/cli_context.py +152 -0
- backdraft/credentials.py +106 -0
- backdraft/extract/__init__.py +37 -0
- backdraft/extract/base.py +184 -0
- backdraft/extract/pdf_text.py +124 -0
- backdraft/extract/text.py +44 -0
- backdraft/extract/vlm.py +175 -0
- backdraft/extract/vlm_settings.py +197 -0
- backdraft/extract/xlsx.py +244 -0
- backdraft/gate/__init__.py +39 -0
- backdraft/gate/cli.py +131 -0
- backdraft/gate/reader.py +592 -0
- backdraft/gate/searcher.py +123 -0
- backdraft/kernel/__init__.py +17 -0
- backdraft/kernel/artifact.py +234 -0
- backdraft/kernel/chunking.py +162 -0
- backdraft/kernel/claims.py +142 -0
- backdraft/kernel/errors.py +35 -0
- backdraft/kernel/hashing.py +89 -0
- backdraft/kernel/model.py +336 -0
- backdraft/kernel/tokens.py +291 -0
- backdraft/registry/__init__.py +39 -0
- backdraft/registry/ledger.py +82 -0
- backdraft/registry/schema.sql +90 -0
- backdraft/registry/store.py +915 -0
- backdraft/render/__init__.py +31 -0
- backdraft/render/_text.py +69 -0
- backdraft/render/cli.py +107 -0
- backdraft/render/footnotes.py +172 -0
- backdraft/render/html.py +1211 -0
- backdraft/render/markdown.py +313 -0
- backdraft/render/placement.py +105 -0
- backdraft/render/sidecar.py +164 -0
- backdraft-0.2.0.dist-info/METADATA +253 -0
- backdraft-0.2.0.dist-info/RECORD +50 -0
- backdraft-0.2.0.dist-info/WHEEL +4 -0
- backdraft-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|