Deterministically verify how grounded an AI-generated text is in its source — highlight verbatim and verbatim-paraphrased reuse word by word, count differing tokens, and gate pipelines on fabricated quotations. No judge model, no network.
Project description
Deterministic verification of groundedness for AI-generated text
Getting started · Usage · Enforcement · Performance · Design · Contributing · License
verbatimeter delivers deterministic verification of groundedness: LLMs
fabricate — fluently — so an answer that claims to be grounded in a source
cannot be taken on trust. It measures how grounded a text is in its source as
verbatim reuse (contiguous matching) or verbatim paraphrasing (LCS), showing
word for word what came from the source and what the model introduced. No
judge model, no embeddings, no sampling — the same inputs produce the same
numbers every time, in research or in production.
For RAG agents, verification is one decorator line. Every answer — streamed or not — comes back highlighted and measured:
@verify(source_arg="context", scope="quotes")
def generate(question, context): ...
The same check is available as library functions and as a CLI: the numbers in an evaluation script, a CI pipeline, and a terminal are identical.
The animation above replays a real captured run. A minimal RAG agent
(examples/openai_rag_streaming_example.py)
retrieved four passages from the Attention Is All You Need abstract, asked
gpt-4o-mini "What architecture does the paper propose, and why is it faster
to train?", and instructed it to reuse the context's exact wording. The
@verify decorator checks the stream as it arrives and prints each word in
its final color:
- green — reproduced verbatim from the retrieved context, in contiguous runs of ≥ 3 words: an 18-word lift opens the answer and a 19-word run closes it;
- red — the model's own wording. Note
experiments: the word appears in the source, but not inside any run of three consecutive shared words, so it is not credited — matching is contiguous runs, not isolated words; - the stats line prints when the stream completes: 79% of the answer's words are verbatim reuse, and 10 of its 55 tokens differ from the source.
The model never sees verbatimeter — it just answers the question; every
measurement is post-hoc, deterministic, and judge-free. Replay it yourself
with python examples/openai_rag_streaming_example.py (needs
pip install openai python-dotenv and OPENAI_API_KEY in the environment or
a .env file).
One word-level alignment, two readings:
- Verbatim reuse (default) — word-for-word copying, in contiguous runs.
- Verbatim paraphrasing (
--subsequence) — the source's own words reused in order but not contiguously: split up, rearranged, interleaved with new words.
What it's built for:
- Grounding verification — how much of an answer, summary, or report comes directly from its source, and how much is verbatim-paraphrased from it.
- Quotation verification (
--quotes) — have the model support its answer with verbatim quotations and verify each one; a quotation that differs from the source is a caught fabrication. Add--failto gate a pipeline on it. This is lexical matching, not semantic: it helps detect hallucinations — a deterministic lower bound on grounding failures — but it does not judge unquoted claims or faithful paraphrase. - Multilingual — any language that separates words with spaces, with
Unicode-normalized, case-folded matching. Validated on eleven languages,
from English and French to Hindi, Urdu, and Arabic; quotation extraction
understands
"…",“…”,« … », and„…“. Unsegmented scripts (Chinese, Japanese, Thai) are not yet supported. - Lightweight and offline — one runtime dependency (
tiktoken), with the vocabulary bundled: no network access, ever.
Scope is deliberately narrow: it verifies, highlights, and collects statistics on text you provide. Extracting text from PDFs, Word documents, HTML, and other formats is out of scope — perform the extraction with the library of your choice, then pass the resulting text in.
Getting started
pip install verbatimeter
tiktoken is the only runtime dependency, and it is only needed for the default
token counter (see Tokenizer).
No files, no quotation marks — paste this from any directory (--source /
--answer take literal text; --source-file / --answer-file take paths):
verbatimeter \
--source "We propose a new simple network architecture, the Transformer, based solely on attention mechanisms, dispensing with recurrence and convolutions entirely. Experiments on two machine translation tasks show these models to be superior in quality while being more parallelizable and requiring significantly less time to train." \
--answer "We propose a new simple network architecture, the Transformer, based solely on attention mechanisms, dispensing with recurrence and convolutions entirely"
Every word is verbatim, so it reports matched=100% differing_tokens=0. Change a
word in --answer — say solely to partly — and rerun: only that word turns
up in the differing color. (PowerShell: use backtick line-continuation and the
same quoting.)
To try the quotation check instead, add --quotes and put a
"…" span in the answer. Runnable samples live in
examples/basics/.
Usage
The same check runs on three surfaces; pick by context.
CLI
verbatimeter --source-file source.txt --answer-file answer.txt
verbatimeter --source "raw source text" --answer-file - < answer.txt
verbatimeter --source-file source.txt --answer-file answer.txt --quotes
verbatimeter --source-file source.txt --answer-file answer.txt --ngram 5
--source/--answertake literal text;--source-file/--answer-filetake UTF-8 file paths, with-for stdin. Each pair is mutually exclusive, so interpretation never depends on what happens to exist in the working directory.- Default scope is the whole text (green = verbatim, red = not in the source).
--quotesrestricts the check to quoted spans (the quotation-verification use) — straight"…", curly“…”, French guillemets« … »(inner padding spaces stripped), and low-9„…“. - Matching is contiguous by default: a word counts as verbatim only inside
a run of at least
--ngramconsecutive words copied from the source. The default and minimum are 3 — shorter runs are coincidence-prone. A quotation shorter than--ngramwords cannot contain such a run and therefore fails the gate; instruct your model to quote at least three consecutive words. --subsequenceswitches to order-only (LCS) matching, which measures verbatim paraphrasing. It deliberately has no run floor — single shared words in order are the signal — so--ngramis ignored in this mode.--quotesis a measurement like every other scope and exits 0. Add--failto use it as a pipeline gate: the exit code becomes non-zero when any quotation contains differing tokens — or when no quotations are found at all (an answer that stops quoting must not pass a gate silently).--no-colorfor plain output;--palettepicks the highlight colors (see Accessibility);--jsonemits machine-readable results.
Library
from verbatimeter import check, check_answer, render_result
# check one text against a source (verbatim n-gram overlap):
r = check(candidate_text, source_text, ngram=3)
print(r.matched_ratio, r.longest_fragment, r.fragments)
# or scope over an answer — the whole thing, or just its "…" quotations:
result = check_answer(answer, source, scope="all") # or scope="quotes"
print(render_result(result))
print(result.total_differing_tokens)
check(...) returns a Result:
| Field | Meaning |
|---|---|
words |
per-word verbatim flags — drives the highlighting |
fragments / longest_fragment |
the verbatim runs, and the longest run's word count |
matched_ratio |
fraction of the text's words matched in the source |
differing_tokens / total_tokens |
tokens that differ, against the text's size |
source_segment |
the smallest source window containing every matched word |
rouge_l |
ROUGE-L F1 between the text and source_segment — fidelity to the region it drew from |
Decorator
from verbatimeter import verify
@verify(source_arg="context", scope="quotes")
def generate(prompt, context=None):
return call_your_llm(prompt, context)
generate("...", context=retrieved_passages)
The wrapped function's return value is the answer. The source is resolved from
a static source= and/or a runtime argument — the runtime value wins.
Highlighting and stats print as a side effect (pass print_stats=False for a
quiet mode), and the answer comes back as a str subclass with the full
measurement attached as .result: existing string-handling code is unaffected,
and the numbers are one attribute away. Omit scope="quotes" to check the
whole answer.
Streaming works automatically: if the decorated function returns an
iterator of text chunks (yield delta from your provider's stream), the
chunks pass through unchanged for your own UI while each word is printed in
its final color as it arrives — green verbatim, red not — with the full
CheckResult attached as .result once the stream completes. See
examples/openai_rag_streaming_example.py.
Integrating with a retrieval-augmented-generation agent? See docs/rag-agent-integration.md.
Tokenizer
The differing-token count uses tiktoken (encoding cl100k_base) by default.
The vocabulary is bundled with the package (see
THIRD_PARTY_NOTICES.md), so token counting works fully
offline — the package never touches the network. To count with a different
tokenizer — or avoid tiktoken entirely — pass a count_tokens callable
(str -> int) to check, check_answer, or verify:
check_answer(answer, source, count_tokens=lambda text: len(text.split()))
When count_tokens is supplied, tiktoken is never imported.
Accessibility
The highlight colors are configurable on every surface — --palette on the
CLI, and a palette= keyword on render_words, render_result, and
verify (including streamed output):
| Preset | Matched / differing | For |
|---|---|---|
classic |
green / red | the default |
colorblind |
blue / orange | red–green color-vision deficiency |
neon |
bright green / magenta | dark terminals and screen recordings |
mono |
bold / inverse video | no color reliance at all |
Color is never load-bearing: the stats lines carry every number as plain text,
and --json exposes the full result for machine consumption. Rendering honors
the NO_COLOR convention, auto-disables color when
output is not a terminal, and --no-color forces plain text.
Enforcement
Measure by default — enforce when asked. Every surface reports and exits 0 unless a verdict is requested; the exit code then becomes a branch point, and the consequence is yours to choose:
# hard gate — a fabricated quotation blocks the pipeline
verbatimeter --source-file ctx.txt --answer-file ans.txt --quotes --fail && publish
# fallback — retry generation until the quotations check out
until verbatimeter --source-file ctx.txt --answer-file ans.txt --quotes --fail; do
regenerate_answer > ans.txt
done
# route — clean answers ship, dirty ones go to human review
verbatimeter --source-file ctx.txt --answer-file ans.txt --quotes --fail || mv ans.txt review-queue/
The same verdict is available in Python without exit codes:
answer = generate(question, context=ctx)
if answer.result.total_differing_tokens > 0:
answer = regenerate_with_warning(question, ctx)
Because the check is deterministic, the gate never flakes: the same answer produces the same verdict every time, and when it fails, the highlighted words identify exactly which quotation broke and which words were fabricated.
Performance
verbatimeter is a word-level dynamic-programming alignment. Let S be the
number of words in the source and C the number of words in the text being
checked (the whole answer with scope="all", or the total quoted words with
scope="quotes").
| Step | Time | Memory |
|---|---|---|
| Clean / tokenize | O(S + C) | O(S + C) |
| Contiguous match (default) | O(S · C) | O(C) — two rolling DP rows |
Subsequence match (--subsequence) |
O(S · C) | O(S · C) — full DP table |
| ROUGE-L score | O(S · C) | O(C) — two rolling DP rows |
| Token counting (tiktoken) | O(C) | — |
End-to-end time is O(S · C), dominated by the alignment. Contiguous keeps only
the current and previous DP rows (O(C) memory) and runs a few times faster than
subsequence, which materializes the whole S × C table.
Measured with profiling/benchmark.py (random text,
best of 5; absolute times are hardware-dependent):
S (source) C (cand) S*C contiguous subsequence ns/(S*C)
500 100 50000 2.2ms 8.1ms 43.7
1000 100 100000 3.8ms 19.0ms 37.7
2000 100 200000 8.0ms 33.4ms 40.1
4000 100 400000 15.3ms 65.8ms 38.3
1000 400 400000 14.9ms 65.9ms 37.4
1000 800 800000 29.9ms 135.5ms 37.4
4000 400 1600000 53.4ms 263.6ms 33.4
The ns/(S·C) column stays flat as both S and C grow — the O(S·C) time confirmed
in practice. For typical inputs (a few-thousand-word context, a few-hundred-word
answer) that is single-digit to tens of milliseconds. Very large sources grow
quadratically; contiguous n-gram matching could be reduced to O(S + C) with
rolling hashes or a suffix automaton (noted as a future option in
ADR-0010).
Design
Architecture decisions are recorded as ADRs under docs/, alongside the
RAG integration guide. The core alignment is a
longest-common-subsequence (LCS) algorithm ported from the
Canada-Labour-Research-Assistant
project.
All example text in this README and throughout examples/ is quoted
verbatim from:
Vaswani, A., Shazeer, N., Parmar, N., Uszkoreit, J., Jones, L., Gomez, A. N., Kaiser, Ł., & Polosukhin, I. (2017). Attention Is All You Need. Advances in Neural Information Processing Systems 30. arXiv:1706.03762.
Contributing
Bug reports with runnable reproductions and bug-fix PRs are encouraged; the scope is deliberately narrow and defended. See CONTRIBUTING.md before proposing features.
Citation
If you use verbatimeter in your research, please cite it (CITATION.cff has the authoritative metadata, and GitHub's Cite this repository button generates APA and BibTeX from it):
@software{bonin_verbatimeter,
author = {Bonin, Pierre-Olivier},
title = {verbatimeter: deterministic verification of groundedness
in AI-generated text},
url = {https://github.com/pierreolivierbonin/verbatimeter},
year = {2026}
}
License
MIT. Bundled third-party material is credited in THIRD_PARTY_NOTICES.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file verbatimeter-0.1.1.tar.gz.
File metadata
- Download URL: verbatimeter-0.1.1.tar.gz
- Upload date:
- Size: 793.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4714eee5c818ec4c0218abd3606d803c0a227080ab23cd9dbee2750e5961c500
|
|
| MD5 |
5cf95c11717d2df73624602dc8726065
|
|
| BLAKE2b-256 |
b1e3b567bb8309718a67ba9a2424656a824098780bb7c1731fd639b39a2881e1
|
Provenance
The following attestation bundles were made for verbatimeter-0.1.1.tar.gz:
Publisher:
publish.yml on pierreolivierbonin/verbatimeter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
verbatimeter-0.1.1.tar.gz -
Subject digest:
4714eee5c818ec4c0218abd3606d803c0a227080ab23cd9dbee2750e5961c500 - Sigstore transparency entry: 2138996138
- Sigstore integration time:
-
Permalink:
pierreolivierbonin/verbatimeter@7e49aa88094270fa10d59f61659e114af6d403ca -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/pierreolivierbonin
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7e49aa88094270fa10d59f61659e114af6d403ca -
Trigger Event:
release
-
Statement type:
File details
Details for the file verbatimeter-0.1.1-py3-none-any.whl.
File metadata
- Download URL: verbatimeter-0.1.1-py3-none-any.whl
- Upload date:
- Size: 793.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e9b7ab4ced512f5868ae3630c8ee8f518f7b1d20b08ecf5a37b8db00203b308d
|
|
| MD5 |
76d3adddf29830ac3076230f24e028ec
|
|
| BLAKE2b-256 |
a6c828c2c6084d736b23910abce66e8daa098cfc2676f5d41c250398f0567be8
|
Provenance
The following attestation bundles were made for verbatimeter-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on pierreolivierbonin/verbatimeter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
verbatimeter-0.1.1-py3-none-any.whl -
Subject digest:
e9b7ab4ced512f5868ae3630c8ee8f518f7b1d20b08ecf5a37b8db00203b308d - Sigstore transparency entry: 2138996187
- Sigstore integration time:
-
Permalink:
pierreolivierbonin/verbatimeter@7e49aa88094270fa10d59f61659e114af6d403ca -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/pierreolivierbonin
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7e49aa88094270fa10d59f61659e114af6d403ca -
Trigger Event:
release
-
Statement type: