How a chart is made¶
Most "chat with your data" systems hand a language model a database connection and ask it to write SQL. Groundfact does not, and this page traces exactly what it does instead — using a real run, not an illustrative one. Every artifact below was captured from the working system against the same warehouse the site is serving.
The short version: the model resolves, deterministic code computes, and the server binds the numbers. The model never sees the database, never writes a query, and never supplies a value that reaches the page.
Step 1 — the question¶
How has Nepal's fertility compared to Bangladesh since 2000?
Step 2 — the model returns a specification, not a query¶
The model is given a compact grounding document — roughly 4,800 tokens listing every indicator id, geography id, alias and coverage range the semantic layer contains — and asked for one JSON object. This is the entire output for the question above:
{
"indicators": ["fertility_rate_total"],
"geos": ["NPL", "BGD"],
"period": { "start": 2000, "end": 2024, "latest": false },
"transform": "none",
"compare": "geo",
"source_preference": null
}
131 output tokens. No SQL, no table names, no column names, no numbers.
Every field is then validated against a catalogue built from the warehouse itself.
fertility_rate_total, NPL and BGD must already exist; transform and compare are
closed enumerations. An id the model invented does not become a query that returns nothing —
it fails validation and becomes a clarifying question back to the reader.
This is what makes prompt injection structurally uninteresting here. The widest possible blast radius for anything embedded in a question is selecting a different, valid indicator. There is no instruction that turns into executable anything, because the model's output is never executed — it is parsed into a fixed shape and checked.
Step 3 — deterministic code writes the SQL¶
The validated specification goes to a query builder — ordinary Python, no model involved — which produces this:
WITH base AS (
SELECT
f.indicator_id, f.geo_id, f.period, f.period_year,
f.value, f.unit, f.source_id, f.dataset_id, f.snapshot_date,
ROW_NUMBER() OVER (
PARTITION BY f.indicator_id, f.geo_id, f.period_year
ORDER BY
CASE WHEN f.source_id = i.preferred_source THEN 0 ELSE 1 END,
f.source_id
) AS _src_rank
FROM conformed.fact_observation AS f
LEFT JOIN conformed.dim_indicator AS i USING (indicator_id)
WHERE f.indicator_id IN (?)
AND f.geo_id IN (?, ?)
AND f.period_grain = 'year'
AND f.value IS NOT NULL
AND NOT f.is_projection
AND f.period_year >= ?
AND f.period_year <= ?
),
picked AS (SELECT * FROM base WHERE _src_rank = 1)
SELECT * FROM (SELECT *, value AS display_value FROM picked)
ORDER BY indicator_id, geo_id, period_year
LIMIT 20000
with these parameters:
['fertility_rate_total', 'NPL', 'BGD', 2000, 2024]
Two things in that SQL are worth pausing on, because neither is something a model would reliably remember to do.
Every value influenced by the question is a bound parameter. There is no string interpolation of user-derived content anywhere in the statement. The query's shape is fixed by code; only its parameters vary.
_src_rank resolves multi-source disagreement deterministically. Several sources report
fertility for Nepal. Without that window function, a two-country chart would silently draw
four overlapping lines. The preferred_source it ranks by is a curated editorial decision
recorded in the semantic layer, not a runtime guess — and where sources genuinely disagree,
that disagreement is surfaced separately rather than being quietly resolved here.
NOT f.is_projection keeps IMF forecasts and FAO scenario runs out of a question that
did not ask about the future. Asking for 2000–2024 gets measurements; asking for a range
that reaches past this year gets the projections, and they are labelled.
Step 4 — the model proposes a shape; the server binds the values¶
The rows come back from the warehouse. A second model pass writes the commentary and proposes a chart shape — type, title, axis labels, which series to draw — and then:
def bind_chart_data(chart, rows, spec):
"""Attach real values to the model's chosen shape.
This is the step that makes fabrication structurally impossible rather than
merely discouraged: whatever the model named, the numbers come from `rows`.
A series the model invented simply binds to an empty list and is dropped.
"""
The model can propose that a line for Bangladesh exists. It cannot propose what is in it.
Values are joined onto its proposed series from the query results by (geo_id,
indicator_id); a series with no matching rows binds to nothing and disappears.
So a hallucinated country does not produce a hallucinated line. It produces no line.
Step 5 — the citation is attached to the number, not the page¶
Each row carries the source, dataset id and snapshot date it came from, and those travel with it to the rendered chip. The citation is not a footnote assembled at the end from the model's memory of where things came from — it is a property of the row.
Why there is no agent framework¶
There is no LangGraph, no LangChain, no agent framework of any kind in this project. There
is not even a vendor SDK: the two model calls are plain HTTP POSTs made with httpx. The
whole AI layer is about 1,500 lines of Python across seven files — client, grounding,
interpreter, analyst, schemas, pipeline, and the shared response types.
That is a deliberate decision rather than an omission, and the reason is that the hard part of this problem is not orchestration.
A graph framework helps when control flow is genuinely dynamic — when the model decides what runs next, tools call other tools, and the shape of the computation is not known in advance. Groundfact's flow is not dynamic. It is:
interpret → validate → build → execute → analyse → bind → render
Always those stages, always in that order, with a defined fallback at each one. There is no step where the model chooses what happens next, and that is the point: the safety property comes from the model having no such choice. Wrapping a fixed sequence in a graph engine would add a dependency, a state abstraction and a class of failure modes, in exchange for expressing something a function already expresses.
Framework-shaped questions are also the wrong ones for this problem. What actually mattered was: what is the model allowed to name, how is that checked, what happens when it names something that does not exist, and who binds the numbers. None of that is made easier by an orchestrator — and an orchestrator would have made it easier to not answer them, because the tool-calling loop would have looked like progress.
The fallback ladder is a good illustration. Five rungs, each a plain branch in one
async generator: cache hit; ambiguity or unknown id, which asks rather than guesses; zero
rows, which reports honest nearby coverage instead of an empty chart; model unavailable,
which degrades to the same clarification path and keeps the site serving cited data with no
inference at all; and an unhandled error, which streams a friendly message while the trace
goes to the log. Each rung is a few lines. As graph nodes with a shared state object they
would be considerably harder to read, and no harder to get wrong.
What this costs¶
A question resolved this way costs roughly $0.0009 — two calls to an open-weights instruct model behind an OpenAI-compatible endpoint. The interpreter call is the small one: about 5,900 tokens in, 131 out.
The grounding document is deliberately capped at around 8,000 tokens and currently sits near 4,800. That budget is why the semantic layer is a curated subset rather than the whole warehouse: everything the model is allowed to know must fit in one compact document that is rendered from the same commit as the code and the documentation, and a CI check fails if any of them drift apart.
Further reading¶
- Architecture — the pipeline end to end, and what was deliberately left out
- Sources — what is ingested, under which licence, and how fresh it is
- ADR 004 — why source coverage stops where it does