Skip to content
← AI tooling

Groundplane

2026 · open source · python

Groundplane is a small Python library that checks an agent's answer against the tool results it was given. If the model reports the wrong winner or total, it raises an error showing what the data actually said.

Where it came from

I first built this inside an AI platform at TikTok. Some of our summaries had to rank a table of numbers, and a senior stakeholder pointed out that naming the wrong winner wasn't acceptable, even if the model usually got it right. So I moved the ranking into code: code worked out the winner and the model only described it. Groundplane is that idea pulled out into a library, extended beyond picking a winner, and published under MIT.

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. 1 — before, the model's answer is trusted as is. After, the ranking is recorded with where it came from, the model fills a winner field, and Groundplane rejects it. The error is the library's real output for this input.

What it checks, and what it doesn't

It only checks the fields the model declares. If the model names the right winner and then says something misleading around it, that passes. I kept it that narrow on purpose. A judge model could read the prose too, but then the check would be probabilistic, and I wanted the same facts to give the same result every time.

Figure 02 · The whole library

Record the tool facts, then check the fields the agent declares

  • Facts
  • Model output
  • Verdict

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 returnedFAILSraiseUnsupportedClaim5RECORDGENERATECHECK
fig. 2 — tool results are recorded as facts along with the call that produced them, the model fills in structured fields, and the checks compare the two. The first mismatch raises UnsupportedClaim.

Tool results go into a FactRegistry, each tagged with the tool call and arguments that produced it. A fact can't be overwritten; a new value becomes a new fact, so the old one and its source stay around. A boundary wraps the part of the agent's work being checked and says which facts it may use. The model has to submit structured fields there. A plain string is rejected, and leaving the block without submitting also raises, so you know when validation never ran.

The six checks

A normal validator looks at each field on its own. These look at how the values relate, because two valid values can still end up on the wrong rows.

superlative
the named winner and any quoted score match the computed top value
ranking_prefix
a top-k list matches the computed order, including ties
aggregate_reconciles
a stated sum, mean, count, min, max or median matches the recorded rows
entities_recorded
every name the model used came from the recorded data
row_integrity
every field comes from the named row, not its neighbour
comparison
“A beat B by 12%” is recomputed; when it’s wrong, the error says which convention it does match

Numbers are compared exactly unless the caller sets a tolerance.

Errors that explain themselves

The error says which field failed, what the model said, what the data says and which tool call it came from. For a ranking it also shows where the model's pick actually placed. Inside LangGraph, a failed check can go back to the model as a correction instead of crashing the run. A misconfigured check still crashes, since that's a bug for the developer to fix, not something to ask the model about again. An MCP adapter records tool results as facts the same way.

The core has no dependencies, and 169 tests run on five Python versions in CI. Many of them are cases where the plausible answer is wrong.

Package details
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 · install from GitHub

// AI TOOLING

All four layers

Ong Jun Xiong

SOFTWARE ENGINEER · SINGAPORE

ContactHobbiesArchiveNotesUI PackGitHubLinkedInSource

© 2026 Ong Jun Xiong