Kanzo UI
The design of Kanzo UI

Asking within a context

Why @kanzo-tech/ai/data describes each type once, why the answer's schema names a field list once, and why the linker, not a token count, keeps a question inside a small model's context — measured on five graphs, and what would reverse each.

Asking data settled what the model is told: the types it may answer about, with their fields. This page is about how much that costs, measured, and how to say the same in a fraction of it. The measurements were taken on the agent Ask was before it was a Task, which offered every relation; they are why it stopped.

What was measured

Keasy's Discover sent its two calls on five graphs, captured at the BFF and replayed to Docker Model Runner with max_tokens: 1. The counts are exact: the model's own tokenizer and chat template answer usage.prompt_tokens, or refuse with request (N tokens) exceeds the available context. A part's cost is the request's tokens less the request's without it.

GraphRelations (hops)Starters, Hermes 3BAgent, Hermes 8BAgent, Qwen3
e2e corpus4 (2)7431,0332,485
OpenFlights3 (2)1,2901,5803,813
Nobel15 (10)1,5231,8133,787
CORDIS12 (8)3,5693,8597,397
LDBC SNB37 (28)10,46610,75616,565

The dev stack serves complete and chat with an 8,192-token context, and make demo's Qwen3 with 16,384. SNB's starters and SNB's first question both overflow before the model writes a word: the 10,466 in the logs at 09:46, 12:39 and 13:48 is SNB's starters each time, and snb-ask on Qwen3 is refused at 16,565 of 16,384 with no history yet.

Where the tokens go, SNB:

PartHermes 3B / 8BQwen3
QUESTIONS (starters' instructions)170—
The relations, each with its fields10,292≈ 12,900 in the agent's instructions
The answer tool's schema0 — see below3,597
The output schema (response_format)0 — a grammar, not prompt—

The relations are nearly all of it, and most of them are repeats. 37 relations list 300 field lines, of only 44 distinct fields: each type's fields are written again in every relation that reaches it, values and all — Person.firstName: category (John, Carlos, A., Rahul, Shweta, Jun) seven times. Category values are 72% of the characters. Each relation also carries its literal, {"root":"Place","path":[{"edge":"Place_isPartOf_Place","direction":"out"}]}, which its heading, Place>isPartOf>Place, already says.

The tool's schema repeats too. The field enum — every field name of every relation — is written ten times, once for each place a field goes (a measure's field twice, each condition's, trend, x, color, facet, columns), and the relation's description lists every relation key once more.

Hermes in Model Runner counts the tool at 0: its template there does not put tools into the prompt, so the 8B is never told the answer tool exists. That is the behaviour e2e/demos/models.yml records — Hermes writes a query of its own instead of calling the tool — and it is the template, not the model. A separate finding; the services' compose is where it is fixed.

Say each thing once

Types are described once, with their fields; a relation is one line, its key.

### Types

#### Person
- firstName: category (John, Carlos, A., Rahul, Shweta, Jun)
- gender: category (female, male)
- birthday: time (1980-02-06 – 1990-01-28)
…

### Relations

A relation is a type and the hops taken from it. Its fields are those of every type it reaches,
named Type.field; a type met twice is numbered, Person2.

- Person
- Person>knows>Person
- Person<hasCreator<Comment
…

This is LangChain's SQLDatabase.get_table_info: each table's columns once, with a few sample rows (sample_rows_in_table_info), and the joins as foreign keys rather than as a table per join. Vanna and LlamaIndex's SQLDatabase describe a schema the same way. A type's values are read from the relation over the type alone, which is what a reader sees of it; the values a hop narrows to (Place2.name over countries only) are a detail the answer's rows say, not the description.

The model writes no relation. It names the type it counts, of, and the relation is the resolver's — so the prompt lists no relation at all, which was most of what remained. An answer still carries its relation as a key, "Person>knows>Person", through the same relationKey that reads it back; Answer, what AnswerCard and a dashboard read, keeps the object.

The field list is one definition. The schema names it once under $defs and every place a field goes is { "$ref": "#/$defs/field" }. JSON Schema's own reuse; OpenAI's and Anthropic's tools resolve local $refs, and so does llama.cpp's JSON-schema grammar — checked in Docker Model Runner, 2026-10-10, with a schema whose two fields both refer to one enum of three names and a prompt asking for names outside it: Qwen3 called the tool with two names from the enum, and Hermes 3 8B and 3B under response_format wrote only names from it. (Hermes calls no tool there with or without $ref — the template finding above.)

The same five graphs, sent by the starters and the agent with each type said once — each graph's relations rebuilt from its captured prompt, the request caught at the HTTP boundary and counted the same way:

GraphStarters, Hermes 3BAgent, Hermes 8BAgent, Qwen3
e2e corpus743 → 4481,033 → 6962,485 → 1,812
OpenFlights1,290 → 4381,580 → 6863,813 → 1,895
Nobel1,523 → 5441,813 → 7923,787 → 2,056
CORDIS3,569 → 8223,859 → 1,0707,397 → 2,535
LDBC SNB10,466 → 1,29010,756 → 1,53816,565 → 3,212

SNB's starters fit the 3B's 8K six times over. snb-ask — the page filtered to women, Which browsers do they use most? on Qwen3's 16,384 — takes 3,211 on its first question and, with three turns of history before it (each a question, the answer call, its result of up to 30 rows and a reply), 5,164: room for the answer and many more turns. Dropping the category values as well saves little once they are said once (SNB's starters 1,290 → 905), so values stay unless the budget asks.

What would reverse it: a model that answers measurably worse from a type's fields than from a relation's spelled out — one that cannot apply the numbering rule. Then the relation lines carry their fields' names, still without the types' values.

The linker is the budget

A question is asked over the types it names and their neighbours, not over the graph. Ask's linker keeps the types the question names — by the type's name, a field's or a value's — with the types one hop away, the type the previous answer counted, and the types the page's chips are on; the schema's enums and the prompt are theirs. This is LlamaIndex's ObjectIndex / SQLTableRetriever, which retrieves the tables a question needs, without the index: a graph's names are few enough to match outright. A question that names nothing is offered every type, and a type the linker missed is the reachability oracle's to say, as a diagnostic the model repairs from.

Nothing counts tokens. The agent once fitted each call to the context its alias declared — an estimate of 0.4 tokens a character, a reserve for the answer, the category values dropped first, a failure coded ai/context when nothing fitted. Each of those was there because the prompt held the whole graph; a prompt that holds a question's neighbourhood holds SNB's 8 types at most, about 3,500 characters with their values, inside any context a model of the size this is for is served with.

What would reverse it: a graph whose one-hop neighbourhood of a question's types is measured past a served model's context. Then the linker keeps the named types without their neighbours first, and the values after — and the token count comes back as the linker's stopping rule, not as a failure.

Held by packages/ai/src/data/ask.test.ts, "offers the types the question names, and their neighbours"; "finds a type by a value it holds, and keeps what the page's chips are on"; "offers every type to a question that names none"; "is one branch per type offered, each its own enum of paths from it".

On this page