Groundplane
2026 · open source · python
An agent calls tools, gets ground truth, then writes prose. The prose usually matches. When it does not, nothing throws and the customer reads it. Groundplane records the tool results as typed facts, lets the model fill in fields, and raises the moment a field says something the facts do not support. Code on GitHub.
Where it came from
I first built this shape inside a production AI platform at TikTok. Summaries there rank things, and a ranking a model writes from a table of numbers is right most of the time. Most of the time is not a guarantee, and a senior stakeholder was not going to sign off on output that could quietly name the wrong winner. The pushback was right, and it is what produced the design.
The fix was to compute the ranking in code and constrain the model to phrasing it. The model could describe the winner. It could no longer choose one. Groundplane is that idea taken out of the platform, generalised past the argmax case, and published under MIT.
Declared facts, not detected hallucinations
You cannot prompt this away. “Only state what is in the data” is an instruction to the component that failed. You also cannot grade it with a second model, because an LLM judge is the same class of component making the same class of mistake with a rubber stamp. So the library does neither. Tool results go into a FactRegistry as typed facts, each carrying the tool call and arguments that produced it. Facts are write-once. If a later call changes the value, that is a new fact with a new name, so provenance never lies.
A boundary wraps the block of model output and names the facts it may lean on. The model emits structured fields, never prose; submitting a plain string is a TypeError. Leaving the block without submitting anything also raises, because a check that silently never ran is worse than no check.
before
rows = warehouse.query("select campaign, ctr from campaign_daily")summary = llm(f"Which campaign performed best?\n{rows}")# says "north". It was "harbour".
after
from groundplane import FactRegistry, boundary, superlative reg = FactRegistry()reg.record_ranking( "campaign_ctr", {"north": 0.0412, "harbour": 0.0455, "delta": 0.0301}, key="ctr", tool="warehouse.query", args={"table": "campaign_daily", "window": "7d"},) with boundary(reg, facts=["campaign_ctr"], checks=[superlative(fact="campaign_ctr")]) as b: b.submit(llm_structured(facts=b.facts())) # {"winner": "north", ...}
raised
UnsupportedClaim: unsupported claim in field 'winner':model said 'north', registered facts support 'harbour'| fact='campaign_ctr'| provenance=warehouse.query(table='campaign_daily', window='7d')| computed argmax on 'ctr' is 'harbour' (0.0455); 'north' ranked #2 at 0.0412
Six checks, one question each
Each check asks how the recorded facts relate to each other, which is what a per-field validator cannot see. Every value in a swapped row is a real value, and a wrong argmax is spelled the same as the right one.
superlative checks that the named winner is the computed argmax and any quoted score is the computed score. ranking_prefix checks a top-k list against the computed order, including a cut that falls inside a block of tied scores. aggregate_reconciles recomputes a stated sum, mean, count, min, max or median over the recorded rows and refuses to do it over a truncated table. entities_recorded checks that every name the model used came from a recorded set. row_integrity resolves the named row first and reads every other field off that one row, which catches the neighbour's value in the wrong column. comparison recomputes “A beat B by 12%” in code and, when the number is wrong, says which convention it does match: percentage points, a ratio, or the two entities reversed.
Numeric comparisons are exact by default. Every check takes a tolerance, but it starts at zero rather than the usual nine digits of forgiveness, because a checker built to catch a wrong number should not quietly wave one through.
- check families
- 6
- fact types
- 4 · Fact, Ranking, Table, Domain
- tests
- 169, all deterministic
- python
- 3.10 – 3.14 in CI
- runtime dependencies
- 0
- adapters
- 2 · LangGraph, MCP
- typing
- mypy, disallow_untyped_defs
- library
- ~2.6k lines · tests ~2.2k
- status
- v0.1.0 · MIT · PyPI release pending
Failing loudly
The error message is most of the product. It carries the field, what the model said, what the facts support, the tool call with its arguments, and where the model's pick actually ranked. Whoever is reading it at 2am can tell at once whether the data or the prose was wrong, without opening a trace.
Inside a LangGraph graph the same failure can become a state update instead of a crash, so the graph routes back to the model with the checker's message as the correction. A misconfigured check still propagates, because a developer bug is not something to reask the model about. The MCP adapter records a tool result as a fact with the call as provenance, and prefers the structured payload over the text blocks, since text is a rendering and reading it is parsing prose again.
What it is not
It is not a hallucination detector. It validates declared fields against declared facts, and if the model names the right winner and then editorialises misleadingly around it, that passes. I kept the scope that narrow on purpose. Every system I looked at that tried to verify open prose ended up handing the verdict to embeddings or a judge model, which brings back the probabilistic answer this exists to remove.
The core has no dependencies and the adapters import neither framework they adapt, so the whole thing is readable and testable with a plain interpreter. A hundred and sixty-nine tests run on five Python versions in CI, many of them adversarial cases where the plausible model answer is provably wrong.