crossfoot 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ram Vikram Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,244 @@
1
+ Metadata-Version: 2.4
2
+ Name: crossfoot
3
+ Version: 0.1.0
4
+ Summary: Fail-closed numeric grounding checks for LLM output: verifies stated and derived numbers, direction words and citations.
5
+ Author: Ram Vikram Singh
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ramvikrams/crossfoot
8
+ Project-URL: Source, https://github.com/ramvikrams/crossfoot
9
+ Project-URL: Issues, https://github.com/ramvikrams/crossfoot/issues
10
+ Project-URL: Benchmark history, https://github.com/ramvikrams/crossfoot/blob/main/benchmarks/results/HISTORY.md
11
+ Keywords: llm,evaluation,hallucination,grounding,rag,guardrails
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7; extra == "dev"
19
+ Requires-Dist: hypothesis>=6; extra == "dev"
20
+ Provides-Extra: deepeval
21
+ Requires-Dist: deepeval; extra == "deepeval"
22
+ Provides-Extra: bench
23
+ Requires-Dist: pyarrow>=12; extra == "bench"
24
+ Dynamic: license-file
25
+
26
+ # crossfoot
27
+
28
+ **Fail-closed numeric grounding checks for LLM output.**
29
+
30
+ *To crossfoot*, in accounting, is to check that the figures in a ledger agree when they are added up both down the columns and across the rows. crossfoot does the same for an LLM's answer: every number must agree with the source, and every calculation must add up.
31
+
32
+ LLMs that summarise reports, filings or dashboards often get the numbers almost right: a growth rate with the wrong sign, a figure taken from the wrong document, or more precision than the source supports. crossfoot checks every number in a model's answer against the sources it was given. The check is deterministic, needs no model calls, and has no dependencies.
33
+
34
+ Output from `crossfoot eval examples/cases.jsonl -v` (excerpt):
35
+
36
+ ```text
37
+ ok derived-growth expected=pass got=pass
38
+ [derived] 12% - % change revenue_2023 (2,870,000,000) -> revenue_2024 (3,214,000,000)
39
+ [supported_rounded] $3.21bn - [R1] revenue_2024 = 3,214,000,000
40
+ ok wrong-direction expected=fail got=fail
41
+ [contradicted] 9.5% - magnitude matches % change net_income_2023 (455,000,000) -> net_income_2024 (412,000,000), but the wording states the opposite direction
42
+ [supported_exact] $412m - [R1] net_income_2024 = 412,000,000
43
+ ok wrong-record expected=fail got=fail
44
+ [wrong_record] 1,850 - value is in [R2] employees_2024, but the sentence cites [R1]
45
+ ok false-precision expected=fail got=fail
46
+ [unsupported] $3,214 million - no source value or derivation matches at the stated precision
47
+
48
+ 15/15 cases as expected (accuracy 100.00%, threshold 100.00%)
49
+ ```
50
+
51
+ ## What it checks
52
+
53
+ | Status | Meaning | Passes? |
54
+ |---|---|---|
55
+ | `supported_exact` | Number equals a source value | yes |
56
+ | `supported_rounded` | A rounding of a source value at the precision the output states (`$3.2bn` for 3,214,000,000) | yes |
57
+ | `derived` | Equals a % change, share, difference, sum or percentage-point change of source values, and the direction words ("rose", "fell") agree | yes |
58
+ | `contradicted` | Magnitude matches a derivation, but the wording says the opposite direction | **no** |
59
+ | `wrong_record` | The value exists, but in a record the sentence does not cite | **no** |
60
+ | `wrong_row` | The value is in a table, but in a different row from the one the sentence names. For example, "net sales were $473m" when 473 is in the operating profit row | **no** |
61
+ | `arithmetic_error` | The output writes out a calculation whose stated result is wrong at its stated precision. For example, "$7630.0m − $6710.4m = $918.2m", when the answer is $919.6m | **no** |
62
+ | `unsupported` | Nothing matches at the stated precision, including false precision (`$3,214 million` when the source only says `$3.2bn`) | **no** |
63
+ | `uncited` | Grounded, but `require_citations=True` and the sentence cites nothing | **no** |
64
+ | `unknown_citation` | The sentence cites only records that do not exist, such as `[R9]`. crossfoot does not fall back to searching other sources. | **no** |
65
+
66
+ Every unknown citation is also reported as an issue, which fails the report.
67
+
68
+ **Fail-closed by design:** if crossfoot cannot prove a number, the number fails. Expect some false alarms. That trade-off fits the places where numbers matter: finance, compliance, analytics, and regulated reporting.
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ pip install crossfoot # from PyPI
74
+ pip install "crossfoot[deepeval]" # with the DeepEval metric
75
+ pip install -e ".[dev]" # from a clone, for development
76
+ ```
77
+
78
+ ## Python API
79
+
80
+ ```python
81
+ from crossfoot import check
82
+
83
+ sources = [
84
+ {"id": "R1", "fields": {"revenue_2023": 2_870_000_000, "revenue_2024": 3_214_000_000}},
85
+ "In FY2024 Acme shipped 12,400 units, up from 10,000 in FY2023.", # becomes S2
86
+ ]
87
+ report = check("Revenue grew 12% to $3.2bn [R1]. Shipments rose 24% [S2].", sources)
88
+
89
+ report.passed # True
90
+ report.score # 1.0
91
+ print(report.summary())
92
+ ```
93
+
94
+ Sources can be plain strings (ids `S1`, `S2`, ... by position, citable as `[1]` or `[S1]`) or records `{"id", "text", "fields"}`. Numeric fields are exact. Field names ending in `_pct` or `_percent` are percentages.
95
+
96
+ Options: `require_citations`, `allow_rounding` (set it to `False` for strict mode), `allow_derived`.
97
+
98
+ To check many outputs against the same sources, build the sources once:
99
+
100
+ ```python
101
+ from crossfoot import Checker
102
+ checker = Checker(sources, require_citations=True)
103
+ for answer in answers:
104
+ print(checker.check(answer).passed)
105
+ ```
106
+
107
+ ## CLI and CI gate
108
+
109
+ ```bash
110
+ crossfoot check --output answer.txt --sources sources.json [--require-citations] [--strict] [--json]
111
+ crossfoot eval examples/cases.jsonl --min-accuracy 1.0
112
+ ```
113
+
114
+ `eval` runs a labelled JSONL case set: one line per case with `output`, `sources` or `sources_file`, an optional `expect` (`pass`/`fail`, default `pass`) and `options`. The command exits non-zero below the threshold, so you can use it to gate pull requests that change prompts, models or retrieval.
115
+
116
+ ### As a GitHub Action
117
+
118
+ ```yaml
119
+ - uses: ramvikrams/crossfoot@v0.1.0
120
+ with:
121
+ cases: evals/numeric_cases.jsonl
122
+ ```
123
+
124
+ ## Integrations
125
+
126
+ **DeepEval:**
127
+
128
+ ```python
129
+ from crossfoot.integrations.deepeval_metric import NumericGroundingMetric
130
+ assert_test(LLMTestCase(input=q, actual_output=answer, retrieval_context=chunks),
131
+ [NumericGroundingMetric(require_citations=True)])
132
+ ```
133
+
134
+ **promptfoo** (Python assertion):
135
+
136
+ ```yaml
137
+ assert:
138
+ - type: python
139
+ value: file://path/to/crossfoot/integrations/promptfoo_assert.py
140
+ ```
141
+
142
+ The assertion reads `vars.sources` and the optional `vars.crossfoot_options`.
143
+
144
+ ## How it decides
145
+
146
+ 1. Split the output into sentences and resolve citations such as `[R1]`, `[R1, R2]` or `[1]`.
147
+ 2. Extract each number with its kind (money, number, percent, pp, bps, year), currency, scale (k / m / bn / lakh / crore) and **stated precision**.
148
+ 3. Look for a direct match in the cited records, or in all records if the sentence cites nothing. A match must be exact, or within half a unit of the output's last stated digit.
149
+ 4. Otherwise, look for a derivation. Cited sentences can combine values from any of their cited records. Uncited sentences can only combine values within a single record, which limits chance matches. Two more limits apply:
150
+ - Derived amounts must be stated to about 1% precision.
151
+ - Growth wording ("grew", "up") can only be matched by a change, never by a share or a sum.
152
+ 5. If a cited sentence still has no match, check the other records and report `wrong_record` when the value is found there.
153
+ 6. **Step-by-step arithmetic:** a number that is still unsupported can be derived from numbers the output has *already grounded*, as long as they appear in the same sentence or the two before it. For example, "$1.11 − $1.08" followed by "= $0.03". Wrong arithmetic is still caught. "A decrease of $6 million ($5829m − $5735m)" fails, because the real difference is $94m.
154
+
155
+ 7. **Written calculations:** arithmetic the output shows, such as `(7167.1 − 6383.0) / 6383.0 × 100 = 12.28%` or `(76 + 73 + 52) / 3 = 67`, is parsed with a small safe parser (never `eval`) and recomputed. If the result is right and every operand is grounded, the result passes. If the stated result disagrees at its stated precision, it is an `arithmetic_error`. Constants such as 100, or the 3 in `/ 3`, need no grounding.
156
+ 8. **Table rows:** a value found in a table cell is checked against the row label. If the sentence names a different row in full and none of the matching cells sit in a row the sentence mentions, the claim is `wrong_row`. Set `check_table_rows=False` to switch this off.
157
+
158
+ Some cases get special handling:
159
+ - **Restatements:** a later sentence may repeat a number the output already grounded, for example "= 67" followed by "the average is $67 million".
160
+ - **Dropped units:** a number written without its unit inside a calculation ("2.2" for "$2.2 billion") matches directly, but is never used as a derivation operand.
161
+ - **Tables:** JSON-style tables in a source, such as `[["", "2019", "2018"], ["sales", "5", "4"]]`, are read cell by cell. Each value is labelled with its row and column, and gets a year when its column header or row label has one.
162
+ - **Direction needs a known order:** a difference such as "decreased by $29,253" has the same size in both orders. crossfoot therefore takes the order from table years (earlier → later), or from how the answer writes the calculation: "$5,836,000 − $4,657,000", "from A to B" or "subtracting A from B". If the wording contradicts that order, the claim is `contradicted`. With no known order, only the magnitude is checked.
163
+ - **Averages:** the average of two values is accepted, for example "($81 + $120) / 2 = $100.5 million".
164
+ - **Table units:** in a source whose text contains a unit header such as "(in millions)", "( millions )" or "in thousands", unscaled numbers also match their scaled value. For example, "7170" under "(in millions)" supports "$7170 million", and the evidence names the header.
165
+ - **Fractions:** a plain number such as `0.1233` can match a percentage change or share written as a fraction.
166
+ - **Not claims:** these are skipped:
167
+ - formula constants: the `100` in `* 100` or "multiplying by 100", and small divisors such as the `2` in `/ 2` or "divide by 2"
168
+ - the day in dates (`December 31`) and numeric dates (`11/30/04`, `2019-06-30`)
169
+ - version and IP strings (`8.4.1.1`)
170
+
171
+ The derivation search runs backwards from the stated number. For each operand, crossfoot computes the partner value that would produce the stated figure and finds it by binary search. A record with n values therefore costs O(n log n) per claim, with no cap on the number of operands.
172
+
173
+ ## Benchmark
174
+
175
+ [benchmarks/ragbench.py](https://github.com/ramvikrams/crossfoot/blob/main/benchmarks/ragbench.py) measures crossfoot against the sentence-level support labels in [RAGBench](https://huggingface.co/datasets/rungalileo/ragbench) (CC BY 4.0). It uses the numeric-heavy financial QA subsets `finqa` and `tatqa` by default.
176
+
177
+ ```bash
178
+ pip install -e ".[bench]"
179
+ python benchmarks/ragbench.py --subsets finqa tatqa --limit 300
180
+ ```
181
+
182
+ Latest results (run 7: full `test` splits, about 20,000 numeric sentences; full log in [HISTORY.md](https://github.com/ramvikrams/crossfoot/blob/main/benchmarks/results/HISTORY.md)):
183
+
184
+ | | finqa (2,294 rows) | tatqa (3,338 rows) |
185
+ |---|---:|---:|
186
+ | Injected value errors caught (±10–30%) | 89.7% (5841/6509) | 92.6% (5303/5726) |
187
+ | Injected scale errors caught (million ↔ billion) | 96.3% (3120/3240) | 94.3% (1662/1762) |
188
+ | Injected direction errors caught (increase ↔ decrease) | 69.9% (214/306) | 70.0% (243/347) |
189
+ | Swapped values caught (another number from the same source) | 13.4% (815/6097) | 9.8% (539/5525) |
190
+ | False alarms on sentences labelled supported | 20.1% | 19.4% |
191
+
192
+ The first 300 rows of four non-financial subsets give these false-alarm rates: covidqa 2.5%, techqa 16.8%, emanual 26.7% (only 32 numeric sentences) and hagrid 19.0%. On the same subsets, injected value errors are caught 58–91% of the time.
193
+
194
+ The detection rates come from a controlled test. It starts from sentences that are labelled supported and that crossfoot passes, and injects one known error into each. The false-alarm rate is high because crossfoot is fail-closed by design: anything it cannot prove fails, including correct multi-step arithmetic that it does not yet model. Some of these "false alarms" are also genuine numeric errors that the LLM annotator accepted.
195
+
196
+ **Hand-checking.** Run `--review-out benchmarks/results/review-sample.csv` to export a seeded random sample of flagged sentences. Each row includes the documents. Fill in the `verdict` column with `real error`, `false alarm` or `unsure`, then run `python benchmarks/review_summary.py benchmarks/results/review-sample.csv` to get a hand-checked precision that does not depend on LLM labels.
197
+
198
+ The script writes results to [benchmarks/results/](https://github.com/ramvikrams/crossfoot/tree/main/benchmarks/results) and saves example errors under `.cache/ragbench/` for manual review. Keep two caveats in mind when reading the results:
199
+ - RAGBench labels come from an LLM annotator, not from human review.
200
+ - Sentences can be unsupported for non-numeric reasons, so the recall figure is a lower bound.
201
+
202
+ ## Known limitations
203
+
204
+ - **Coincidental derivations.** With many values per record, a wrong figure can match some unrelated derivation by chance. Above `max_derivation_operands` values per scope (default 30), crossfoot stops deriving from the sources alone. The output must then state the operands it used, which step-by-step arithmetic accepts, or cite a narrower record. Below the limit, coincidences are still possible, so the evidence string shows the formula for a reviewer to audit.
205
+ - **Two-operand derivations only.** No sums of three or more values, averages of more than two values, or CAGR yet.
206
+ - **Ratios** written as "a:b" are not understood.
207
+ - **Direction words** are read from a 60-character window before the number, in English only. Negations ("did not rise") are not understood. When the order of two values is unknown (no table years and no written order), changes below about 8% cannot be told apart by direction at whole-number precision. For example, 100 → 105 is +5%, while 105 → 100 is −4.8%, which rounds to 5. The property-based tests document this.
208
+ - **Row matching** uses word overlap with row labels. It only fires when the sentence names another row in full, so a paraphrased row name is not caught.
209
+ - **Swapped values.** A number replaced with another value from the same source is caught only 9-13% of the time on FinQA and TAT-QA. crossfoot checks values, not which row or column a value belongs to.
210
+ - Small integers ("2 threads") are weakly verifiable in long documents.
211
+ - Numbers written as words ("twelve"), fractions, ranges as a single claim ("10-12%"), dates other than four-digit years, and negative numbers in parentheses ("(1,234)") are not parsed.
212
+ - Any bracketed token is treated as a citation, so `[sic]` will be reported as an unknown citation.
213
+ - `$` is treated as compatible with any dollar currency (USD, HKD). Explicit codes are compared strictly.
214
+ - The DeepEval wrapper targets the `BaseMetric` interface. Confirm it against your DeepEval version.
215
+
216
+ ## Production-gap backlog
217
+
218
+ Not done yet, and needed before relying on crossfoot in a production pipeline:
219
+
220
+ - [x] Benchmark harness for a public faithfulness dataset (RAGBench)
221
+ - [ ] Publish full-split benchmark results, plus a hand-reviewed sample of errors
222
+ - [ ] Multi-operand derivations (totals, averages, CAGR) with a bounded search
223
+ - [ ] Unit-aware quantities beyond currency (%, units, km, tonnes) and date parsing
224
+ - [ ] Negation handling and multilingual direction words
225
+ - [ ] Configurable ignore rules (section numbers, ordinals)
226
+ - [ ] Packaged releases on PyPI and the GitHub Marketplace, plus signed tags
227
+ - [x] Sub-quadratic derivation search (no operand cap)
228
+
229
+ ## Related work
230
+
231
+ - [hardfacts](https://github.com/seekerPrice/hardfacts) checks stated numbers, dates and IDs against sources. According to its README, it treats doubtful matches as supported and flags derived figures without confirming them. crossfoot takes the opposite stance: it is fail-closed, and it verifies derived figures and their direction.
232
+ - [DeepEval](https://deepeval.com), [promptfoo](https://promptfoo.dev) and [Ragas](https://docs.ragas.io) are general LLM evaluation frameworks. crossfoot plugs into the first two as a deterministic metric.
233
+
234
+ ## Development
235
+
236
+ ```bash
237
+ pip install -e ".[dev]"
238
+ pytest -q
239
+ crossfoot eval examples/cases.jsonl -v
240
+ ```
241
+
242
+ All example data is fictional.
243
+
244
+ MIT licensed.
@@ -0,0 +1,219 @@
1
+ # crossfoot
2
+
3
+ **Fail-closed numeric grounding checks for LLM output.**
4
+
5
+ *To crossfoot*, in accounting, is to check that the figures in a ledger agree when they are added up both down the columns and across the rows. crossfoot does the same for an LLM's answer: every number must agree with the source, and every calculation must add up.
6
+
7
+ LLMs that summarise reports, filings or dashboards often get the numbers almost right: a growth rate with the wrong sign, a figure taken from the wrong document, or more precision than the source supports. crossfoot checks every number in a model's answer against the sources it was given. The check is deterministic, needs no model calls, and has no dependencies.
8
+
9
+ Output from `crossfoot eval examples/cases.jsonl -v` (excerpt):
10
+
11
+ ```text
12
+ ok derived-growth expected=pass got=pass
13
+ [derived] 12% - % change revenue_2023 (2,870,000,000) -> revenue_2024 (3,214,000,000)
14
+ [supported_rounded] $3.21bn - [R1] revenue_2024 = 3,214,000,000
15
+ ok wrong-direction expected=fail got=fail
16
+ [contradicted] 9.5% - magnitude matches % change net_income_2023 (455,000,000) -> net_income_2024 (412,000,000), but the wording states the opposite direction
17
+ [supported_exact] $412m - [R1] net_income_2024 = 412,000,000
18
+ ok wrong-record expected=fail got=fail
19
+ [wrong_record] 1,850 - value is in [R2] employees_2024, but the sentence cites [R1]
20
+ ok false-precision expected=fail got=fail
21
+ [unsupported] $3,214 million - no source value or derivation matches at the stated precision
22
+
23
+ 15/15 cases as expected (accuracy 100.00%, threshold 100.00%)
24
+ ```
25
+
26
+ ## What it checks
27
+
28
+ | Status | Meaning | Passes? |
29
+ |---|---|---|
30
+ | `supported_exact` | Number equals a source value | yes |
31
+ | `supported_rounded` | A rounding of a source value at the precision the output states (`$3.2bn` for 3,214,000,000) | yes |
32
+ | `derived` | Equals a % change, share, difference, sum or percentage-point change of source values, and the direction words ("rose", "fell") agree | yes |
33
+ | `contradicted` | Magnitude matches a derivation, but the wording says the opposite direction | **no** |
34
+ | `wrong_record` | The value exists, but in a record the sentence does not cite | **no** |
35
+ | `wrong_row` | The value is in a table, but in a different row from the one the sentence names. For example, "net sales were $473m" when 473 is in the operating profit row | **no** |
36
+ | `arithmetic_error` | The output writes out a calculation whose stated result is wrong at its stated precision. For example, "$7630.0m − $6710.4m = $918.2m", when the answer is $919.6m | **no** |
37
+ | `unsupported` | Nothing matches at the stated precision, including false precision (`$3,214 million` when the source only says `$3.2bn`) | **no** |
38
+ | `uncited` | Grounded, but `require_citations=True` and the sentence cites nothing | **no** |
39
+ | `unknown_citation` | The sentence cites only records that do not exist, such as `[R9]`. crossfoot does not fall back to searching other sources. | **no** |
40
+
41
+ Every unknown citation is also reported as an issue, which fails the report.
42
+
43
+ **Fail-closed by design:** if crossfoot cannot prove a number, the number fails. Expect some false alarms. That trade-off fits the places where numbers matter: finance, compliance, analytics, and regulated reporting.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install crossfoot # from PyPI
49
+ pip install "crossfoot[deepeval]" # with the DeepEval metric
50
+ pip install -e ".[dev]" # from a clone, for development
51
+ ```
52
+
53
+ ## Python API
54
+
55
+ ```python
56
+ from crossfoot import check
57
+
58
+ sources = [
59
+ {"id": "R1", "fields": {"revenue_2023": 2_870_000_000, "revenue_2024": 3_214_000_000}},
60
+ "In FY2024 Acme shipped 12,400 units, up from 10,000 in FY2023.", # becomes S2
61
+ ]
62
+ report = check("Revenue grew 12% to $3.2bn [R1]. Shipments rose 24% [S2].", sources)
63
+
64
+ report.passed # True
65
+ report.score # 1.0
66
+ print(report.summary())
67
+ ```
68
+
69
+ Sources can be plain strings (ids `S1`, `S2`, ... by position, citable as `[1]` or `[S1]`) or records `{"id", "text", "fields"}`. Numeric fields are exact. Field names ending in `_pct` or `_percent` are percentages.
70
+
71
+ Options: `require_citations`, `allow_rounding` (set it to `False` for strict mode), `allow_derived`.
72
+
73
+ To check many outputs against the same sources, build the sources once:
74
+
75
+ ```python
76
+ from crossfoot import Checker
77
+ checker = Checker(sources, require_citations=True)
78
+ for answer in answers:
79
+ print(checker.check(answer).passed)
80
+ ```
81
+
82
+ ## CLI and CI gate
83
+
84
+ ```bash
85
+ crossfoot check --output answer.txt --sources sources.json [--require-citations] [--strict] [--json]
86
+ crossfoot eval examples/cases.jsonl --min-accuracy 1.0
87
+ ```
88
+
89
+ `eval` runs a labelled JSONL case set: one line per case with `output`, `sources` or `sources_file`, an optional `expect` (`pass`/`fail`, default `pass`) and `options`. The command exits non-zero below the threshold, so you can use it to gate pull requests that change prompts, models or retrieval.
90
+
91
+ ### As a GitHub Action
92
+
93
+ ```yaml
94
+ - uses: ramvikrams/crossfoot@v0.1.0
95
+ with:
96
+ cases: evals/numeric_cases.jsonl
97
+ ```
98
+
99
+ ## Integrations
100
+
101
+ **DeepEval:**
102
+
103
+ ```python
104
+ from crossfoot.integrations.deepeval_metric import NumericGroundingMetric
105
+ assert_test(LLMTestCase(input=q, actual_output=answer, retrieval_context=chunks),
106
+ [NumericGroundingMetric(require_citations=True)])
107
+ ```
108
+
109
+ **promptfoo** (Python assertion):
110
+
111
+ ```yaml
112
+ assert:
113
+ - type: python
114
+ value: file://path/to/crossfoot/integrations/promptfoo_assert.py
115
+ ```
116
+
117
+ The assertion reads `vars.sources` and the optional `vars.crossfoot_options`.
118
+
119
+ ## How it decides
120
+
121
+ 1. Split the output into sentences and resolve citations such as `[R1]`, `[R1, R2]` or `[1]`.
122
+ 2. Extract each number with its kind (money, number, percent, pp, bps, year), currency, scale (k / m / bn / lakh / crore) and **stated precision**.
123
+ 3. Look for a direct match in the cited records, or in all records if the sentence cites nothing. A match must be exact, or within half a unit of the output's last stated digit.
124
+ 4. Otherwise, look for a derivation. Cited sentences can combine values from any of their cited records. Uncited sentences can only combine values within a single record, which limits chance matches. Two more limits apply:
125
+ - Derived amounts must be stated to about 1% precision.
126
+ - Growth wording ("grew", "up") can only be matched by a change, never by a share or a sum.
127
+ 5. If a cited sentence still has no match, check the other records and report `wrong_record` when the value is found there.
128
+ 6. **Step-by-step arithmetic:** a number that is still unsupported can be derived from numbers the output has *already grounded*, as long as they appear in the same sentence or the two before it. For example, "$1.11 − $1.08" followed by "= $0.03". Wrong arithmetic is still caught. "A decrease of $6 million ($5829m − $5735m)" fails, because the real difference is $94m.
129
+
130
+ 7. **Written calculations:** arithmetic the output shows, such as `(7167.1 − 6383.0) / 6383.0 × 100 = 12.28%` or `(76 + 73 + 52) / 3 = 67`, is parsed with a small safe parser (never `eval`) and recomputed. If the result is right and every operand is grounded, the result passes. If the stated result disagrees at its stated precision, it is an `arithmetic_error`. Constants such as 100, or the 3 in `/ 3`, need no grounding.
131
+ 8. **Table rows:** a value found in a table cell is checked against the row label. If the sentence names a different row in full and none of the matching cells sit in a row the sentence mentions, the claim is `wrong_row`. Set `check_table_rows=False` to switch this off.
132
+
133
+ Some cases get special handling:
134
+ - **Restatements:** a later sentence may repeat a number the output already grounded, for example "= 67" followed by "the average is $67 million".
135
+ - **Dropped units:** a number written without its unit inside a calculation ("2.2" for "$2.2 billion") matches directly, but is never used as a derivation operand.
136
+ - **Tables:** JSON-style tables in a source, such as `[["", "2019", "2018"], ["sales", "5", "4"]]`, are read cell by cell. Each value is labelled with its row and column, and gets a year when its column header or row label has one.
137
+ - **Direction needs a known order:** a difference such as "decreased by $29,253" has the same size in both orders. crossfoot therefore takes the order from table years (earlier → later), or from how the answer writes the calculation: "$5,836,000 − $4,657,000", "from A to B" or "subtracting A from B". If the wording contradicts that order, the claim is `contradicted`. With no known order, only the magnitude is checked.
138
+ - **Averages:** the average of two values is accepted, for example "($81 + $120) / 2 = $100.5 million".
139
+ - **Table units:** in a source whose text contains a unit header such as "(in millions)", "( millions )" or "in thousands", unscaled numbers also match their scaled value. For example, "7170" under "(in millions)" supports "$7170 million", and the evidence names the header.
140
+ - **Fractions:** a plain number such as `0.1233` can match a percentage change or share written as a fraction.
141
+ - **Not claims:** these are skipped:
142
+ - formula constants: the `100` in `* 100` or "multiplying by 100", and small divisors such as the `2` in `/ 2` or "divide by 2"
143
+ - the day in dates (`December 31`) and numeric dates (`11/30/04`, `2019-06-30`)
144
+ - version and IP strings (`8.4.1.1`)
145
+
146
+ The derivation search runs backwards from the stated number. For each operand, crossfoot computes the partner value that would produce the stated figure and finds it by binary search. A record with n values therefore costs O(n log n) per claim, with no cap on the number of operands.
147
+
148
+ ## Benchmark
149
+
150
+ [benchmarks/ragbench.py](https://github.com/ramvikrams/crossfoot/blob/main/benchmarks/ragbench.py) measures crossfoot against the sentence-level support labels in [RAGBench](https://huggingface.co/datasets/rungalileo/ragbench) (CC BY 4.0). It uses the numeric-heavy financial QA subsets `finqa` and `tatqa` by default.
151
+
152
+ ```bash
153
+ pip install -e ".[bench]"
154
+ python benchmarks/ragbench.py --subsets finqa tatqa --limit 300
155
+ ```
156
+
157
+ Latest results (run 7: full `test` splits, about 20,000 numeric sentences; full log in [HISTORY.md](https://github.com/ramvikrams/crossfoot/blob/main/benchmarks/results/HISTORY.md)):
158
+
159
+ | | finqa (2,294 rows) | tatqa (3,338 rows) |
160
+ |---|---:|---:|
161
+ | Injected value errors caught (±10–30%) | 89.7% (5841/6509) | 92.6% (5303/5726) |
162
+ | Injected scale errors caught (million ↔ billion) | 96.3% (3120/3240) | 94.3% (1662/1762) |
163
+ | Injected direction errors caught (increase ↔ decrease) | 69.9% (214/306) | 70.0% (243/347) |
164
+ | Swapped values caught (another number from the same source) | 13.4% (815/6097) | 9.8% (539/5525) |
165
+ | False alarms on sentences labelled supported | 20.1% | 19.4% |
166
+
167
+ The first 300 rows of four non-financial subsets give these false-alarm rates: covidqa 2.5%, techqa 16.8%, emanual 26.7% (only 32 numeric sentences) and hagrid 19.0%. On the same subsets, injected value errors are caught 58–91% of the time.
168
+
169
+ The detection rates come from a controlled test. It starts from sentences that are labelled supported and that crossfoot passes, and injects one known error into each. The false-alarm rate is high because crossfoot is fail-closed by design: anything it cannot prove fails, including correct multi-step arithmetic that it does not yet model. Some of these "false alarms" are also genuine numeric errors that the LLM annotator accepted.
170
+
171
+ **Hand-checking.** Run `--review-out benchmarks/results/review-sample.csv` to export a seeded random sample of flagged sentences. Each row includes the documents. Fill in the `verdict` column with `real error`, `false alarm` or `unsure`, then run `python benchmarks/review_summary.py benchmarks/results/review-sample.csv` to get a hand-checked precision that does not depend on LLM labels.
172
+
173
+ The script writes results to [benchmarks/results/](https://github.com/ramvikrams/crossfoot/tree/main/benchmarks/results) and saves example errors under `.cache/ragbench/` for manual review. Keep two caveats in mind when reading the results:
174
+ - RAGBench labels come from an LLM annotator, not from human review.
175
+ - Sentences can be unsupported for non-numeric reasons, so the recall figure is a lower bound.
176
+
177
+ ## Known limitations
178
+
179
+ - **Coincidental derivations.** With many values per record, a wrong figure can match some unrelated derivation by chance. Above `max_derivation_operands` values per scope (default 30), crossfoot stops deriving from the sources alone. The output must then state the operands it used, which step-by-step arithmetic accepts, or cite a narrower record. Below the limit, coincidences are still possible, so the evidence string shows the formula for a reviewer to audit.
180
+ - **Two-operand derivations only.** No sums of three or more values, averages of more than two values, or CAGR yet.
181
+ - **Ratios** written as "a:b" are not understood.
182
+ - **Direction words** are read from a 60-character window before the number, in English only. Negations ("did not rise") are not understood. When the order of two values is unknown (no table years and no written order), changes below about 8% cannot be told apart by direction at whole-number precision. For example, 100 → 105 is +5%, while 105 → 100 is −4.8%, which rounds to 5. The property-based tests document this.
183
+ - **Row matching** uses word overlap with row labels. It only fires when the sentence names another row in full, so a paraphrased row name is not caught.
184
+ - **Swapped values.** A number replaced with another value from the same source is caught only 9-13% of the time on FinQA and TAT-QA. crossfoot checks values, not which row or column a value belongs to.
185
+ - Small integers ("2 threads") are weakly verifiable in long documents.
186
+ - Numbers written as words ("twelve"), fractions, ranges as a single claim ("10-12%"), dates other than four-digit years, and negative numbers in parentheses ("(1,234)") are not parsed.
187
+ - Any bracketed token is treated as a citation, so `[sic]` will be reported as an unknown citation.
188
+ - `$` is treated as compatible with any dollar currency (USD, HKD). Explicit codes are compared strictly.
189
+ - The DeepEval wrapper targets the `BaseMetric` interface. Confirm it against your DeepEval version.
190
+
191
+ ## Production-gap backlog
192
+
193
+ Not done yet, and needed before relying on crossfoot in a production pipeline:
194
+
195
+ - [x] Benchmark harness for a public faithfulness dataset (RAGBench)
196
+ - [ ] Publish full-split benchmark results, plus a hand-reviewed sample of errors
197
+ - [ ] Multi-operand derivations (totals, averages, CAGR) with a bounded search
198
+ - [ ] Unit-aware quantities beyond currency (%, units, km, tonnes) and date parsing
199
+ - [ ] Negation handling and multilingual direction words
200
+ - [ ] Configurable ignore rules (section numbers, ordinals)
201
+ - [ ] Packaged releases on PyPI and the GitHub Marketplace, plus signed tags
202
+ - [x] Sub-quadratic derivation search (no operand cap)
203
+
204
+ ## Related work
205
+
206
+ - [hardfacts](https://github.com/seekerPrice/hardfacts) checks stated numbers, dates and IDs against sources. According to its README, it treats doubtful matches as supported and flags derived figures without confirming them. crossfoot takes the opposite stance: it is fail-closed, and it verifies derived figures and their direction.
207
+ - [DeepEval](https://deepeval.com), [promptfoo](https://promptfoo.dev) and [Ragas](https://docs.ragas.io) are general LLM evaluation frameworks. crossfoot plugs into the first two as a deterministic metric.
208
+
209
+ ## Development
210
+
211
+ ```bash
212
+ pip install -e ".[dev]"
213
+ pytest -q
214
+ crossfoot eval examples/cases.jsonl -v
215
+ ```
216
+
217
+ All example data is fictional.
218
+
219
+ MIT licensed.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "crossfoot"
7
+ version = "0.1.0"
8
+ description = "Fail-closed numeric grounding checks for LLM output: verifies stated and derived numbers, direction words and citations."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Ram Vikram Singh" }]
14
+ keywords = ["llm", "evaluation", "hallucination", "grounding", "rag", "guardrails"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/ramvikrams/crossfoot"
23
+ Source = "https://github.com/ramvikrams/crossfoot"
24
+ Issues = "https://github.com/ramvikrams/crossfoot/issues"
25
+ "Benchmark history" = "https://github.com/ramvikrams/crossfoot/blob/main/benchmarks/results/HISTORY.md"
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=7", "hypothesis>=6"]
29
+ deepeval = ["deepeval"]
30
+ bench = ["pyarrow>=12"]
31
+
32
+ [project.scripts]
33
+ crossfoot = "crossfoot.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]