Skip to content
← Selected projects

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.

TOOLSmetricsscoresSQLrowsAPInamesFACTREGISTRY · WRITE-ONCE1Rankingrecord_ranking()Tablerecord_table()Domainrecord_domain()Factvalue + provenanceADAPTERS · OPTIONAL EXTRASLangGraph nodeguarded_node()MCP tool resultrecord_result()as factsModelstructured output3submit()boundary(...)facts= · checks=2factschecksCHECKER · DETERMINISTIC4superlativeranking_prefixaggregate_reconcilesentities_recordedrow_integritycomparisonokpassoutput returnedfailsraiseUnsupportedClaim5
TOOLSmetricsSQLAPIADAPTERSLangGraph nodeguarded_node()MCP tool resultrecord_result()scores · rows · namesFACTREGISTRY · WRITE-ONCE1Rankingrecord_ranking()Tablerecord_table()Domainrecord_domain()Factvalue + provenancefactsModelstructured output3submit()boundary(...)facts= · checks=2checksCHECKER · DETERMINISTIC4superlativeranking_prefixaggregate_reconcilesentities_recordedrow_integritycomparisonokpassoutput returnedfailsraiseUnsupportedClaim5
fig. 1 — the whole library. ① Tool results are recorded as typed facts, each with the tool call that produced it; the two adapters do the same from inside LangGraph or MCP. ② A boundary names which facts a block of output may use and which checks run. ③ The model submits structured fields, never prose. ④ Five deterministic checks resolve each field against the recorded facts. ⑤ The first unsupported claim raises UnsupportedClaim with the provenance in the message; nothing is logged and ignored.

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
fig. 2 — the README before and after. The before trusts whatever the model wrote. The after records the ranking with its provenance, lets the model fill in a winner field, and the boundary refuses it. The error text is what the library actually raises for this input.

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.

Ong Jun Xiong

ENGINEER · BUILDER · SINGAPORE

EmailHobbiesUniversity workGitHubLinkedInSource

© 2026 Ong Jun Xiong