@demystify/grounding
v0.3.1
Published
Deterministic numeric grounding guard for LLM output: drops any sentence asserting a figure its own citations do not support. Indian digit grouping and scale words, Unicode digit folding, invisible-character stripping, fail-closed on unreadable digit scri
Maintainers
Readme
@demystify/grounding — numeric grounding guard for LLM output
Drops any sentence asserting a figure its own citations do not support.
Deterministic, pure, zero dependencies, no network, no clock, no model call. The same input always yields the same verdict, so it is safe in a serverless request path and testable without fixtures.
What it is / when to use it
Every product that lets a model speak about numbers needs this. A model that has been handed the right facts will still, occasionally, state a figure that is not in them — transposed, averaged, invented, or borrowed from an adjacent fact. Schema validation does not catch it (the shape is fine) and a second model call is neither cheap nor deterministic.
This is a post-validator, not a critic. It does not judge whether a claim is sensible — only whether every figure in it traces to a fact that sentence cited.
Install
pnpm add @demystify/grounding # npm / yarn / bun all fineNode ≥ 22, ESM.
Quickstart
import { checkGrounding, INDIA_ACCOUNTING } from "@demystify/grounding";
const facts = [
{ id: "inv-1", text: "Invoice INV-1 dated 2026-08-04 for ₹1,23,456 including GST." },
{ id: "inv-2", text: "Invoice INV-2 for ₹7,500." },
];
const answer =
"Invoice INV-1 was for ₹1,23,456. [inv-1] " +
"Invoice INV-2 was for ₹9,999. [inv-2] " +
"Both were settled on time.";
const result = checkGrounding(answer, facts, INDIA_ACCOUNTING);
result.allGrounded; // false
result.droppedCount; // 1
result.text;
// "Invoice INV-1 was for ₹1,23,456. [inv-1] Both were settled on time."
result.sentences[1];
// { kept: false, reason: "ungrounded_number", ungrounded: ["9,999"], … }Sentences containing no figures are always kept — prose is not the target.
The four defeats
This package is shaped by four adversarial reviews that beat an earlier version:
each returned "grounded" for output containing a fabricated figure. Every one is
pinned by a regression test in test/adversarial.test.ts.
| # | The attack | The fix |
|---|---|---|
| 1 | A figure in Devanagari digits produced zero ASCII tokens — and "no tokens" trivially satisfies "all tokens grounded", so the claim was vacuously true | Fold every digit script we can enumerate; fail closed (UnfoldableDigitError) on any Nd codepoint we cannot |
| 2 | Zero-width characters split 1,23,456 into digit runs that were each individually whitelisted | Strip invisibles before tokenizing, never after |
| 3 | The report's own date 2026-08 leaked month 8 into the numeric whitelist, grounding a fabricated "8 invoices" | Dates are a separate token kind, not interchangeable with numerics |
| 4 | A sentence borrowed a real figure from fact A to dress up a claim citing only fact B | Scope every check to (this sentence, the facts this sentence cites) |
Defeat 1 is worth dwelling on: folding is strictly better than rejecting, because it lets a correct figure written in Devanagari verify. We fail closed only on what is left over — an unreadable digit must never be mistaken for the absence of a digit.
Cr means Credit
Under INDIA_ACCOUNTING, Cr and Dr are never read as scale words. In a book of
accounts Cr is Credit and Dr is Debit; reading "₹5,000 Cr" as five thousand crore
turns a routine ledger line into a fabricated ₹50bn claim. A caller who genuinely
means crore writes it out.
lakh, lac, crore (and their plurals) are honoured, so 5 lakh grounds
₹5,00,000.
Presets
| Preset | Use for |
|---|---|
| INDIA_ACCOUNTING | Indic scale words + the Cr/Dr denylist. Finance, ERP, GST. |
| GENERIC | Western scale words only, nothing denied. |
The core is script-agnostic and domain-free; everything true of Indian accounting but
not of arithmetic in general lives in presets.ts. Callers may pass their own
scaleWords / scaleDenylist / citationPattern.
Citation markers
Default [fact-id]. Citations are written after the claim they support and after its
full stop, so a citation stranded at the head of the next chunk binds backward to
the sentence that earned it. Override with citationPattern (the global flag is added
for you if you forget it).
API
checkGrounding(answer: string, facts: Fact[], options?: GroundingOptions): GroundingResultDrop reasons: ungrounded_number · no_citation · unknown_citation ·
unreadable_digits.
uncited: "keep" relaxes the default, under which a figure with no citation is
unverifiable and therefore dropped.
Lower-level pieces are exported too and are useful on their own: normalizeDigits,
tokenizeNumbers, segment, splitSentences, extractCitations.
Testing
pnpm test # 29 tests
pnpm exec vitest --coverage99% statements / 95% branches. The adversarial suite is the one that matters — it must never go green by accident.
Status
Contributed to the Demystify substrate by Finocket, extracted and generalized here so
ASHR.work (payroll figures) and Miatz (grades, progress) get the same guard. A Python
twin (demystify-grounding) is the next step — the extraction worker is Python and
generates numeric prose too.
MIT.
