crossfoot 0.1.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.
crossfoot/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """crossfoot: fail-closed numeric grounding checks for LLM output."""
2
+ from .extract import Mention, extract
3
+ from .expressions import find_equations
4
+ from .verify import (
5
+ ARITHMETIC_ERROR,
6
+ WRONG_ROW,
7
+ CONTRADICTED,
8
+ DERIVED,
9
+ EXACT,
10
+ PASSING,
11
+ ROUNDED,
12
+ UNCITED,
13
+ UNKNOWN_CITATION,
14
+ UNSUPPORTED,
15
+ WRONG_RECORD,
16
+ Checker,
17
+ Claim,
18
+ Report,
19
+ check,
20
+ load_sources,
21
+ )
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "check", "Checker", "extract", "load_sources", "Claim", "Report", "Mention", "PASSING",
27
+ "EXACT", "ROUNDED", "DERIVED", "WRONG_RECORD", "CONTRADICTED", "UNSUPPORTED", "UNCITED",
28
+ "UNKNOWN_CITATION", "ARITHMETIC_ERROR", "WRONG_ROW", "find_equations",
29
+ ]
crossfoot/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ """Command line interface.
2
+
3
+ crossfoot check --output answer.txt --sources sources.json [--require-citations]
4
+ crossfoot eval cases.jsonl [--min-accuracy 1.0]
5
+
6
+ ``eval`` runs a labelled set of cases. Each JSONL line is::
7
+
8
+ {"id": "...", "output": "...", "sources": [...] | "sources_file": "x.json",
9
+ "expect": "pass" | "fail", "options": {"require_citations": true}}
10
+
11
+ ``expect`` defaults to "pass", so a set of real model outputs works as a CI gate.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from .verify import check
22
+
23
+ _OPTION_KEYS = {"require_citations", "allow_rounding", "allow_derived", "max_derivation_operands",
24
+ "share_scale_hints", "check_table_rows"}
25
+
26
+
27
+ def _add_options(parser: argparse.ArgumentParser) -> None:
28
+ parser.add_argument("--require-citations", action="store_true",
29
+ help="fail numbers in sentences that cite no source")
30
+ parser.add_argument("--strict", action="store_true",
31
+ help="disallow rounding of directly quoted values")
32
+ parser.add_argument("--no-derived", action="store_true",
33
+ help="disallow derived figures (%% change, share, sums, differences)")
34
+
35
+
36
+ def _options(args: argparse.Namespace) -> Dict[str, Any]:
37
+ return {
38
+ "require_citations": args.require_citations,
39
+ "allow_rounding": not args.strict,
40
+ "allow_derived": not args.no_derived,
41
+ }
42
+
43
+
44
+ def _read_json(path: Path) -> Any:
45
+ return json.loads(path.read_text(encoding="utf-8"))
46
+
47
+
48
+ def _cmd_check(args: argparse.Namespace) -> int:
49
+ output = sys.stdin.read() if args.output == "-" else Path(args.output).read_text(encoding="utf-8")
50
+ report = check(output, _read_json(Path(args.sources)), **_options(args))
51
+ print(json.dumps(report.to_dict(), indent=2) if args.json else report.summary())
52
+ return 0 if report.passed else 1
53
+
54
+
55
+ def _cmd_eval(args: argparse.Namespace) -> int:
56
+ cases_path = Path(args.cases)
57
+ lines = [l for l in cases_path.read_text(encoding="utf-8").splitlines() if l.strip()]
58
+ if not lines:
59
+ print(f"No cases found in {cases_path}")
60
+ return 1
61
+
62
+ agreed = 0
63
+ for number, line in enumerate(lines, start=1):
64
+ case = json.loads(line)
65
+ case_id = case.get("id", f"case-{number}")
66
+ if "sources_file" in case:
67
+ sources = _read_json(cases_path.parent / case["sources_file"])
68
+ else:
69
+ sources = case.get("sources", [])
70
+ options = {k: v for k, v in (case.get("options") or {}).items() if k in _OPTION_KEYS}
71
+ expect = case.get("expect", "pass")
72
+ if expect not in ("pass", "fail"):
73
+ raise ValueError(f"{case_id}: expect must be 'pass' or 'fail', got {expect!r}")
74
+
75
+ report = check(case["output"], sources, **options)
76
+ got = "pass" if report.passed else "fail"
77
+ ok = got == expect
78
+ agreed += ok
79
+ print(f"{'ok ' if ok else 'MISS'} {case_id:<28} expected={expect:<4} got={got}")
80
+ if not ok or args.verbose:
81
+ print(" " + report.summary().replace("\n", "\n "))
82
+
83
+ accuracy = agreed / len(lines)
84
+ print(f"\n{agreed}/{len(lines)} cases as expected (accuracy {accuracy:.2%}, "
85
+ f"threshold {args.min_accuracy:.2%})")
86
+ return 0 if accuracy >= args.min_accuracy else 1
87
+
88
+
89
+ def main(argv: Optional[List[str]] = None) -> int:
90
+ if hasattr(sys.stdout, "reconfigure"):
91
+ sys.stdout.reconfigure(errors="replace") # currency symbols on legacy consoles
92
+
93
+ parser = argparse.ArgumentParser(prog="crossfoot",
94
+ description="Fail-closed numeric grounding checks for LLM output.")
95
+ sub = parser.add_subparsers(dest="command", required=True)
96
+
97
+ p_check = sub.add_parser("check", help="check one output against its sources")
98
+ p_check.add_argument("--output", required=True, help="text file with the LLM output, or - for stdin")
99
+ p_check.add_argument("--sources", required=True, help="JSON file: list of strings or {id, text, fields}")
100
+ p_check.add_argument("--json", action="store_true", help="print a JSON report")
101
+ _add_options(p_check)
102
+ p_check.set_defaults(func=_cmd_check)
103
+
104
+ p_eval = sub.add_parser("eval", help="run a labelled JSONL case set (CI gate)")
105
+ p_eval.add_argument("cases", help="path to a .jsonl file")
106
+ p_eval.add_argument("--min-accuracy", type=float, default=1.0,
107
+ help="fail if fewer than this fraction of cases match expectations")
108
+ p_eval.add_argument("-v", "--verbose", action="store_true", help="print every report")
109
+ p_eval.set_defaults(func=_cmd_eval)
110
+
111
+ args = parser.parse_args(argv)
112
+ return args.func(args)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ sys.exit(main())
@@ -0,0 +1,270 @@
1
+ """Find and re-check arithmetic written out in text.
2
+
3
+ LLM answers often show their working::
4
+
5
+ (7167.1 - 6383.0) / 6383.0 * 100 = 12.28%
6
+ ($81 + $120) / 2 = $201 / 2 = $100.5 million
7
+ $7630.0 million - $6710.4 million = $918.2 million <- wrong: 919.6
8
+
9
+ ``find_equations`` locates ``left = right`` pairs, evaluates the left side with
10
+ a small recursive-descent parser (never ``eval``), and reports whether the
11
+ stated right side agrees at its stated precision.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import List, Optional, Sequence, Tuple
18
+
19
+ from .extract import mask_dates
20
+
21
+ _SCALES = {"thousand": 1e3, "k": 1e3, "million": 1e6, "mn": 1e6, "m": 1e6, "billion": 1e9, "bn": 1e9}
22
+ _UNIT_FACTORS = (1e3, 1e6, 1e9)
23
+ _REL_EPS = 1e-9
24
+
25
+ _TOKEN_RE = re.compile(
26
+ r"(?P<num>(?<![A-Za-z0-9_.])(?:HK\$|US\$|[$€£₹])?\s?"
27
+ r"(?P<digits>(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)(?![\d]|\.\d)"
28
+ r"(?:\s?(?P<scale>thousand|million|billion|mn|bn|m|k)(?![A-Za-z]))?"
29
+ r"(?:\s?(?P<pct>%))?)"
30
+ r"|(?P<op>[+*/×÷]|[-−–]|\bx(?=\s)|\btimes\b)"
31
+ r"|(?P<lp>[(\[])"
32
+ r"|(?P<rp>[)\]])"
33
+ r"|(?P<eq>=|≈|~)"
34
+ r"|(?P<ws>\s+)",
35
+ re.IGNORECASE,
36
+ )
37
+ _ADD = {"+"}
38
+ _SUB = {"-", "−", "–"}
39
+ _MUL = {"*", "×", "x", "times"}
40
+ _DIV = {"/", "÷"}
41
+
42
+
43
+ @dataclass
44
+ class Token:
45
+ kind: str # num | op | lp | rp | eq | ws
46
+ text: str
47
+ start: int
48
+ end: int
49
+ value: float = 0.0
50
+ decimals: int = 0
51
+ scale: float = 1.0
52
+ has_scale: bool = False
53
+ percent: bool = False
54
+
55
+
56
+ @dataclass
57
+ class Equation:
58
+ left_value: float
59
+ left_span: Tuple[int, int]
60
+ left_tokens: List[Token]
61
+ right_value: float
62
+ right_tokens: List[Token]
63
+ right_number: Optional[Token] # set when the right side is a single number
64
+ consistent: bool
65
+ operands: List[Token] = field(default_factory=list)
66
+
67
+
68
+ def _make_token(m: "re.Match[str]") -> Token:
69
+ for kind in ("num", "op", "lp", "rp", "eq", "ws"):
70
+ if m.group(kind) is not None:
71
+ break
72
+ tok = Token(kind=kind, text=m.group(0), start=m.start(), end=m.end())
73
+ if kind == "num":
74
+ digits = m.group("digits").replace(",", "")
75
+ scale_word = (m.group("scale") or "").lower()
76
+ tok.decimals = len(digits.split(".", 1)[1]) if "." in digits else 0
77
+ tok.scale = _SCALES.get(scale_word, 1.0)
78
+ tok.has_scale = bool(scale_word)
79
+ tok.percent = m.group("pct") is not None
80
+ tok.value = float(digits) * tok.scale
81
+ # The span may include a leading space; extract() mentions start inside it.
82
+ tok.text = m.group(0).strip()
83
+ return tok
84
+
85
+
86
+ def tokenize_runs(text: str) -> List[List[Token]]:
87
+ """Maximal runs of arithmetic tokens; any other character ends a run."""
88
+ text = mask_dates(text)
89
+ runs: List[List[Token]] = []
90
+ current: List[Token] = []
91
+ pos = 0
92
+ while pos < len(text):
93
+ m = _TOKEN_RE.match(text, pos)
94
+ if not m or m.end() == pos:
95
+ if current:
96
+ runs.append(current)
97
+ current = []
98
+ pos += 1
99
+ continue
100
+ tok = _make_token(m)
101
+ if tok.kind != "ws":
102
+ current.append(tok)
103
+ pos = m.end()
104
+ if current:
105
+ runs.append(current)
106
+ return runs
107
+
108
+
109
+ class _Parser:
110
+ def __init__(self, tokens: Sequence[Token]) -> None:
111
+ self.tokens = tokens
112
+ self.i = 0
113
+
114
+ def _peek(self) -> Optional[Token]:
115
+ return self.tokens[self.i] if self.i < len(self.tokens) else None
116
+
117
+ def parse(self) -> Optional[float]:
118
+ value = self._expr()
119
+ if value is None or self.i != len(self.tokens):
120
+ return None
121
+ return value
122
+
123
+ def _expr(self) -> Optional[float]:
124
+ value = self._term()
125
+ while value is not None:
126
+ tok = self._peek()
127
+ if tok is None or tok.kind != "op" or tok.text not in _ADD | _SUB:
128
+ break
129
+ self.i += 1
130
+ right = self._term()
131
+ if right is None:
132
+ return None
133
+ value = value + right if tok.text in _ADD else value - right
134
+ return value
135
+
136
+ def _term(self) -> Optional[float]:
137
+ value = self._factor()
138
+ while value is not None:
139
+ tok = self._peek()
140
+ if tok is None or tok.kind != "op" or tok.text.lower() not in _MUL | _DIV:
141
+ break
142
+ self.i += 1
143
+ right = self._factor()
144
+ if right is None:
145
+ return None
146
+ if tok.text in _DIV:
147
+ if right == 0:
148
+ return None
149
+ value = value / right
150
+ else:
151
+ value = value * right
152
+ return value
153
+
154
+ def _factor(self) -> Optional[float]:
155
+ tok = self._peek()
156
+ if tok is None:
157
+ return None
158
+ if tok.kind == "op" and tok.text in _SUB:
159
+ self.i += 1
160
+ inner = self._factor()
161
+ return None if inner is None else -inner
162
+ if tok.kind == "num":
163
+ self.i += 1
164
+ return tok.value
165
+ if tok.kind == "lp":
166
+ self.i += 1
167
+ inner = self._expr()
168
+ close = self._peek()
169
+ if inner is None or close is None or close.kind != "rp":
170
+ return None
171
+ self.i += 1
172
+ return inner
173
+ return None
174
+
175
+
176
+ def _trim(tokens: List[Token]) -> List[Token]:
177
+ """Drop unmatched brackets at either end: '(a - b' / 'a - b)'."""
178
+ tokens = list(tokens)
179
+ while tokens and tokens[-1].kind == "rp" and \
180
+ sum(t.kind == "rp" for t in tokens) > sum(t.kind == "lp" for t in tokens):
181
+ tokens.pop()
182
+ while tokens and tokens[0].kind == "lp" and \
183
+ sum(t.kind == "lp" for t in tokens) > sum(t.kind == "rp" for t in tokens):
184
+ tokens.pop(0)
185
+ return tokens
186
+
187
+
188
+ def _is_expression(tokens: Sequence[Token]) -> bool:
189
+ nums = sum(t.kind == "num" for t in tokens)
190
+ ops = sum(t.kind == "op" and not (t.text in _SUB and i == 0) for i, t in enumerate(tokens))
191
+ return nums >= 2 and ops >= 1
192
+
193
+
194
+ def _evaluate(tokens: List[Token]) -> Optional[float]:
195
+ tokens = _trim(tokens)
196
+ if not _is_expression(tokens):
197
+ return None
198
+ return _Parser(tokens).parse()
199
+
200
+
201
+ def _left_side(tokens: List[Token]) -> Optional[Tuple[List[Token], float]]:
202
+ """Longest suffix of the part before '=' that is a complete expression."""
203
+ for k in range(len(tokens)):
204
+ sub = _trim(tokens[k:])
205
+ value = _evaluate(sub)
206
+ if value is not None:
207
+ return sub, value
208
+ return None
209
+
210
+
211
+ def _right_side(tokens: List[Token]) -> Optional[Tuple[List[Token], float, Optional[Token]]]:
212
+ """Longest prefix that is an expression, else the leading (signed) number."""
213
+ for k in range(len(tokens), 1, -1):
214
+ sub = _trim(tokens[:k])
215
+ value = _evaluate(sub)
216
+ if value is not None:
217
+ return sub, value, None
218
+ rest = [t for t in tokens if t.kind != "lp"]
219
+ if rest and rest[0].kind == "num":
220
+ return [rest[0]], rest[0].value, rest[0]
221
+ if len(rest) >= 2 and rest[0].kind == "op" and rest[0].text in _SUB and rest[1].kind == "num":
222
+ return rest[:2], -rest[1].value, rest[1]
223
+ return None
224
+
225
+
226
+ def _agrees(left: float, left_tokens: Sequence[Token], right: float, right_number: Optional[Token]) -> bool:
227
+ candidates = [left]
228
+ left_scaled = any(t.kind == "num" and t.has_scale for t in left_tokens)
229
+ if right_number is not None:
230
+ tol = 0.5 * (10.0 ** -right_number.decimals) * right_number.scale
231
+ if right_number.percent:
232
+ candidates.append(left * 100) # "(a / b) = 12.3%"
233
+ if right_number.has_scale and not left_scaled:
234
+ candidates.append(left * right_number.scale) # "$201 / 2 = $100.5 million"
235
+ if left_scaled and not right_number.has_scale:
236
+ candidates.extend(left / f for f in _UNIT_FACTORS)
237
+ else:
238
+ tol = 0.005 * max(abs(left), abs(right))
239
+ candidates.extend([left * f for f in _UNIT_FACTORS] + [left / f for f in _UNIT_FACTORS])
240
+ return any(abs(c - right) <= tol + _REL_EPS * max(1.0, abs(right)) for c in candidates)
241
+
242
+
243
+ def find_equations(text: str) -> List[Equation]:
244
+ """All ``left = right`` pairs whose left side is an arithmetic expression."""
245
+ equations: List[Equation] = []
246
+ for run in tokenize_runs(text):
247
+ parts: List[List[Token]] = [[]]
248
+ for tok in run:
249
+ if tok.kind == "eq":
250
+ parts.append([])
251
+ else:
252
+ parts[-1].append(tok)
253
+ for i in range(len(parts) - 1):
254
+ left = _left_side(parts[i])
255
+ right = _right_side(parts[i + 1])
256
+ if left is None or right is None:
257
+ continue
258
+ left_tokens, left_value = left
259
+ right_tokens, right_value, right_number = right
260
+ equations.append(Equation(
261
+ left_value=left_value,
262
+ left_span=(left_tokens[0].start, left_tokens[-1].end),
263
+ left_tokens=left_tokens,
264
+ right_value=right_value,
265
+ right_tokens=right_tokens,
266
+ right_number=right_number,
267
+ consistent=_agrees(left_value, left_tokens, right_value, right_number),
268
+ operands=[t for t in left_tokens if t.kind == "num"],
269
+ ))
270
+ return equations
crossfoot/extract.py ADDED
@@ -0,0 +1,184 @@
1
+ """Extract numeric mentions (amounts, percentages, years) from text.
2
+
3
+ Every mention records the precision it was *stated* with, so later checks can
4
+ tell legitimate rounding ("$3.2bn" for 3,214,000,000) from false precision
5
+ ("$3,214 million" when the source only says "$3.2bn").
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import List, Optional
12
+
13
+ _SCALES = {
14
+ "thousand": 1e3, "k": 1e3,
15
+ "lakh": 1e5,
16
+ "million": 1e6, "mn": 1e6, "m": 1e6,
17
+ "crore": 1e7, "cr": 1e7,
18
+ "billion": 1e9, "bn": 1e9, "b": 1e9,
19
+ "trillion": 1e12, "tn": 1e12,
20
+ }
21
+
22
+ _CURRENCIES = {
23
+ "$": "$", "us$": "USD", "usd": "USD",
24
+ "hk$": "HKD", "hkd": "HKD",
25
+ "€": "EUR", "eur": "EUR",
26
+ "£": "GBP", "gbp": "GBP",
27
+ "₹": "INR", "inr": "INR",
28
+ }
29
+
30
+ _NUMBER_RE = re.compile(
31
+ # "-$3,079" (sign attached). "$5,829 - $5,735" is subtraction, not a negative.
32
+ r"(?P<neg0>(?<![A-Za-z0-9])[-−](?=(?:HK\$|US\$|USD|HKD|EUR|GBP|INR|[$€£₹])))?"
33
+ r"(?:(?P<cur>HK\$|US\$|USD|HKD|EUR|GBP|INR|[$€£₹])\s?)?"
34
+ r"(?P<neg>(?<![A-Za-z0-9])[-−])?"
35
+ # Do not start inside identifiers such as FY2024, Q3, R1 or inside 3.5 / 3,200.
36
+ r"(?<![A-Za-z0-9_.,])"
37
+ r"(?P<num>\d{1,3}(?:,\d{2})+,\d{3}(?:\.\d+)?" # Indian grouping: 1,23,456
38
+ r"|\d{1,3}(?:,\d{3})+(?:\.\d+)?" # Western grouping: 1,234,567
39
+ r"|\d+(?:\.\d+)?)"
40
+ r"(?!\d|\.\d)" # whole numbers only; not part of a version or IP: 8.4.1.1, 10.0.0.1
41
+ r"(?:\s?(?P<scale>trillion|billion|million|thousand|lakh|crore|bn|tn|mn|cr|k|m|b)(?![A-Za-z]))?"
42
+ r"(?:\s?(?P<pct>%|percentage points?|per\s?cent|percent|pp|bps|basis points?)(?![A-Za-z]))?",
43
+ re.IGNORECASE,
44
+ )
45
+
46
+ _LIST_MARKER_RE = re.compile(r"[.)](?:\s|$)")
47
+ # "... settings. 2." / "steps: 1." - a step number after the end of a sentence.
48
+ _AFTER_SENTENCE_END_RE = re.compile(r"(?:^|[.!?:;])\s*$")
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Mention:
53
+ """A number found in text, normalised to a single float value."""
54
+
55
+ raw: str
56
+ value: float
57
+ kind: str # "money" | "number" | "percent" | "pp" | "bps" | "year"
58
+ currency: Optional[str]
59
+ tolerance: float # half a unit of the last stated digit, in value units
60
+ start: int
61
+ end: int
62
+ scaled: bool = False # True if a scale word (k, m, bn, million...) was stated
63
+ scale: float = 1.0 # the stated scale factor (1e6 for "million")
64
+
65
+
66
+ def _parse(match: "re.Match[str]") -> Mention:
67
+ num_text = match.group("num")
68
+ digits = num_text.replace(",", "")
69
+ decimals = len(digits.split(".", 1)[1]) if "." in digits else 0
70
+ base = float(digits)
71
+
72
+ scale_text = (match.group("scale") or "").lower()
73
+ scale = _SCALES.get(scale_text, 1.0)
74
+
75
+ cur_text = match.group("cur")
76
+ currency = _CURRENCIES.get(cur_text.lower()) if cur_text else None
77
+
78
+ pct_text = (match.group("pct") or "").lower()
79
+ if pct_text:
80
+ if pct_text.startswith(("bps", "basis")):
81
+ kind = "bps"
82
+ elif pct_text.startswith(("pp", "percentage")):
83
+ kind = "pp"
84
+ else:
85
+ kind = "percent"
86
+ scale = 1.0 # "12 m%" is not a thing; ignore any scale word
87
+ elif currency:
88
+ kind = "money"
89
+ elif not scale_text and decimals == 0 and "," not in num_text and 1900 <= base <= 2100:
90
+ kind = "year"
91
+ else:
92
+ kind = "number"
93
+
94
+ sign = -1.0 if (match.group("neg") or match.group("neg0")) else 1.0
95
+ return Mention(
96
+ raw=match.group(0).strip(),
97
+ value=sign * base * scale,
98
+ kind=kind,
99
+ currency=currency,
100
+ tolerance=0.5 * (10.0 ** -decimals) * scale,
101
+ start=match.start(),
102
+ end=match.end(),
103
+ scaled=bool(scale_text) and not pct_text,
104
+ scale=scale,
105
+ )
106
+
107
+
108
+ # 11/30/04, 2019-06-30, 30.06.2019: dates, not quantities.
109
+ _DATE_RE = re.compile(
110
+ r"(?<![\d/.-])(?:\d{1,2}/\d{1,2}/\d{2,4}|\d{4}-\d{2}-\d{2}|\d{1,2}\.\d{1,2}\.\d{4})(?![\d/.-]\d)"
111
+ )
112
+
113
+
114
+ def mask_dates(text: str) -> str:
115
+ """Replace numeric dates with spaces, keeping offsets unchanged."""
116
+ return _DATE_RE.sub(lambda m: " " * len(m.group(0)), text)
117
+
118
+
119
+ _MONTH = (r"(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|"
120
+ r"sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)")
121
+ _MONTH_BEFORE_RE = re.compile(r"\b" + _MONTH + r"\.?\s*$", re.IGNORECASE)
122
+ _MONTH_AFTER_RE = re.compile(r"\s*(?:st|nd|rd|th)?\s+(?:of\s+)?" + _MONTH + r"\b", re.IGNORECASE)
123
+ _OBJECT = r"(?:(?:it|this|that|the\s+\w+)\s+)?"
124
+ _MULTIPLY_BEFORE_RE = re.compile(
125
+ r"(?:[*×]|\bx|\btimes|\bmultipl(?:y|ied|ying)\s+" + _OBJECT + r"by)\s*$", re.IGNORECASE)
126
+ _DIVIDE_BEFORE_RE = re.compile(
127
+ r"(?:/|÷|\bdivid(?:e|ed|ing)\s+" + _OBJECT + r"by)\s*$", re.IGNORECASE)
128
+ _OPERATOR_AFTER_RE = re.compile(r"\s*[*×]")
129
+
130
+
131
+ def _is_date_day(text: str, match: "re.Match[str]", mention: Mention) -> bool:
132
+ if mention.kind != "number" or mention.currency or mention.scaled or not (1 <= mention.value <= 31):
133
+ return False
134
+ if mention.value != int(mention.value):
135
+ return False
136
+ before = text[max(0, match.start() - 12):match.start()]
137
+ return bool(_MONTH_BEFORE_RE.search(before) or _MONTH_AFTER_RE.match(text, match.end()))
138
+
139
+
140
+ def _is_formula_constant(text: str, match: "re.Match[str]", mention: Mention) -> bool:
141
+ """Arithmetic notation, not a claim: the 100 in "(a / b) * 100" or
142
+ "multiplying by 100", and the 2 in "/ 2" or "divide by 2"."""
143
+ if mention.kind != "number" or mention.currency or mention.scaled:
144
+ return False
145
+ value = mention.value
146
+ before = text[max(0, match.start() - 40):match.start()]
147
+ if value in (100.0, 1000.0):
148
+ return bool(_MULTIPLY_BEFORE_RE.search(before) or _OPERATOR_AFTER_RE.match(text, match.end()))
149
+ if value == int(value) and 2 <= value <= 12:
150
+ return bool(_DIVIDE_BEFORE_RE.search(before))
151
+ return False
152
+
153
+
154
+ def extract(text: str) -> List[Mention]:
155
+ """Return all numeric mentions in ``text`` in order of appearance.
156
+
157
+ Skipped as non-claims: numbered-list markers ("1. ", "2) ") at the start
158
+ of a line, numbers inside identifiers (FY2024, Q3, R1), the day in dates
159
+ ("December 31, 2007", "31st March"), numeric dates ("11/30/04"),
160
+ version strings ("8.4.1.1") and formula constants ("* 100", "/ 2").
161
+ """
162
+ mentions: List[Mention] = []
163
+ text = mask_dates(text)
164
+ for match in _NUMBER_RE.finditer(text):
165
+ if not match.group("num"):
166
+ continue
167
+ line_start = text.rfind("\n", 0, match.start()) + 1
168
+ plain_marker = (
169
+ not (match.group("cur") or match.group("scale") or match.group("pct"))
170
+ and "." not in match.group("num") and "," not in match.group("num")
171
+ and _LIST_MARKER_RE.match(text, match.end()) is not None
172
+ )
173
+ is_list_marker = plain_marker and (
174
+ text[line_start:match.start()].strip() == ""
175
+ or (int(match.group("num")) <= 99
176
+ and _AFTER_SENTENCE_END_RE.search(text, max(0, match.start() - 3), match.start()) is not None)
177
+ )
178
+ if is_list_marker:
179
+ continue
180
+ mention = _parse(match)
181
+ if _is_date_day(text, match, mention) or _is_formula_constant(text, match, mention):
182
+ continue
183
+ mentions.append(mention)
184
+ return mentions
@@ -0,0 +1 @@
1
+ """Optional integrations with eval frameworks (DeepEval, promptfoo)."""
@@ -0,0 +1,51 @@
1
+ """DeepEval metric wrapper.
2
+
3
+ from deepeval import assert_test
4
+ from deepeval.test_case import LLMTestCase
5
+ from crossfoot.integrations.deepeval_metric import NumericGroundingMetric
6
+
7
+ case = LLMTestCase(input="...", actual_output=answer, retrieval_context=chunks)
8
+ assert_test(case, [NumericGroundingMetric(require_citations=True)])
9
+
10
+ Sources are taken from ``retrieval_context`` (falling back to ``context``);
11
+ chunk N can be cited as ``[N]`` or ``[SN]``.
12
+
13
+ Requires ``pip install crossfoot[deepeval]``. Written against DeepEval's
14
+ ``BaseMetric`` interface; check it against the DeepEval version you use.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from deepeval.metrics import BaseMetric
21
+ from deepeval.test_case import LLMTestCase
22
+
23
+ from ..verify import check
24
+
25
+
26
+ class NumericGroundingMetric(BaseMetric):
27
+ def __init__(self, threshold: float = 1.0, **check_options: Any) -> None:
28
+ self.threshold = threshold
29
+ self.check_options = check_options
30
+ self.score = 0.0
31
+ self.reason = ""
32
+ self.success = False
33
+ self.report = None
34
+
35
+ def measure(self, test_case: LLMTestCase, *args: Any, **kwargs: Any) -> float:
36
+ sources = list(test_case.retrieval_context or test_case.context or [])
37
+ self.report = check(test_case.actual_output or "", sources, **self.check_options)
38
+ self.score = self.report.score
39
+ self.success = self.report.passed and self.score >= self.threshold
40
+ self.reason = self.report.summary()
41
+ return self.score
42
+
43
+ async def a_measure(self, test_case: LLMTestCase, *args: Any, **kwargs: Any) -> float:
44
+ return self.measure(test_case)
45
+
46
+ def is_successful(self) -> bool:
47
+ return self.success
48
+
49
+ @property
50
+ def __name__(self) -> str: # DeepEval reads the metric name from here
51
+ return "Numeric Grounding (crossfoot)"