The Chinook Suite
The Chinook suite holds 47 cases about the vendored Chinook database
(nl2sql/datasets/chinook.sqlite). Each case has hand-written SQL, the rows
that SQL returns, and a hand-written plan. Evaluations compare the engine's
answers against it. It is a suite in the eval case format
and runs through engine.evals.
| File | What it holds |
|---|---|
packages/nl2sql/src/nl2sql/evaluation/suites/chinook.yaml |
The suite: the cases, their rows and their plans |
packages/nl2sql/src/nl2sql/evaluation/suite.py |
EvalCase, EvalSuite, the loader and the writer |
packages/nl2sql/src/nl2sql/evaluation/regenerate.py |
regenerate: the rows, through the datasource's adapter |
packages/nl2sql/src/nl2sql/evaluation/run.py |
The three modes, prices and the cost cap |
packages/nl2sql/src/nl2sql/evaluation/gold_llm.py |
The gold-plan fake LLM that mode plans runs on |
packages/nl2sql/src/nl2sql/evaluation/scorers/ |
The scorer interface and the five built-ins |
packages/nl2sql/src/nl2sql/evaluation/evaluator.py |
Row comparison (compare_results, compare_results_lenient), which result_match reads |
packages/nl2sql/src/nl2sql/evaluation/faithfulness.py |
Answer faithfulness: the written answer's numbers and names against the rows |
packages/nl2sql/src/nl2sql/evaluation/report.py |
The report's boards: accuracy, intervals, pass^k, cost, latency |
packages/nl2sql/src/nl2sql/evaluation/gate.py |
The regression gate's statistics: flips, McNemar's test, the checks |
packages/nl2sql/src/nl2sql/llm/prices.py |
The dated price table every call, mode full's included, is billed from |
packages/nl2sql/src/nl2sql/evaluation/records.py |
Result records, comparable runs and the published pages |
packages/nl2sql/src/nl2sql/evaluation/presets/ |
Named LLM configs to compare (*.yaml, shipped in the wheel) |
benchmarks/<kind>/<database>/ |
Committed result records (layout) |
packages/nl2sql/tests/unit/test_chinook_suite.py |
Key-free checks, part of the normal unit suite |
The cases
The fields are described in Cases and suites. For this suite:
- The tags.
tagscome from a fixed list:single-table,filter,join,multi-join,aggregation,having,order-limit,offset,ties,date,paraphrase,keyword-in-question,keyword-in-value,data-rangeandvalue-matching.data-rangemarks a case that needs to know where the data ends ("the most recent month in the data"), andvalue-matchingone that names a stored value in other words ("heavy metal" for'Heavy Metal'). rbac-denialandunanswerable.rbac-denialmarks exactly the cases some role is refused, andunanswerablethe four the database cannot answer.- Every case is approved and hand written, about
chinook. So the run is scored, and identified, by Chinook, whatever else the demo registers. - The outcomes follow the demo policy.
outcomefollowsCHINOOK_POLICIESinnl2sql/datasets/chinook.py: a role getsrefusedwhen its scope does not allow one oftables, andallowedotherwise. An unanswerable case isunanswerablefor every role. - Ties are broken on a key. Where
ORDER BYhas ties,sqlbreaks them on a key (CustomerId,Countryand so on) so the result is deterministic, and the case carries thetiestag. When the tie is only about order,order_mattersisfalse.
The data never changes. The suite was moved from 0.6's
chinook_gold.yaml and chinook_gold_plans.yaml with only its keys renamed.
test_chinook_suite_holds_the_gold_data_unchanged pins a SHA-256 of every
case's question, SQL, rows, alternatives, outcomes, tables, columns and plan.
A new accepted answer is a reviewed alternative,
followed by regenerate; never an edit
to the gold data.
What the tests check
pytest packages/nl2sql/tests/unit/test_chinook_suite.py runs without an API
key and checks that:
- the data hashes to the pinned digest;
- every case is
approvedandhand, onchinook; - ids are unique, and paraphrase groups share one result;
- every table and column exists, and
tablesequals the tables insql; - every role in
outcomeexists in the demo policy, and each outcome follows from it; - every answerable case has a plan, and no other case does. Each plan is a
PlanModelthat reads exactlytablesand selects as many columns asrowshas; - every
alt_sqlreads no table outsidetables, returns between one and twenty-five rows, and is not the gold answer written again; - Chinook's twelve guided questions (
CHINOOK_QUESTIONS) are included verbatim. The demo offers twenty in all; the other eight ask aboutsupportandwebanalytics, which this suite does not cover.
tests/unit/test_eval_regenerate.py runs regenerate through the sqlite
adapter and checks that the committed file is byte for byte what it writes.
How a run is scored
Each case runs once per role in its outcome map, and each run is scored by
the scorers. For result_match and outcome:
| Outcome | Passes when |
|---|---|
allowed |
Status success, one result set, and its rows equal rows or any alternative |
refused |
Status error, a SECURITY_VIOLATION whose message is the generic refusal (it names no table), and no rows |
unanswerable |
Status error, a QUESTION_NOT_ANSWERABLE from the datasource resolver's answerability check, and no rows |
Rows are compared by value, in selected column order:
- Names don't count, order does. Column names and aliases are ignored, so
SELECT Country AS cmatches a goldCountrycolumn, but a swapped column order does not. - Numbers match within 0.005, because gold values are rounded to two decimals.
- A number matches a string that spells it, within the same tolerance, so
'2009'is2009: databases differ in which of the two they return for the same value. - Other strings must be equal. Two strings compare as strings, so
'2009'is not'2009.0'. - Row order counts only when
order_mattersis true.
Strict and lenient accuracy
Every run is scored twice, and the report gives both, strict first.
| Score | What it is |
|---|---|
| Strict | The execution match above, unchanged: the same columns, in the same order, and the same rows |
| Lenient | Every gold column is answered by some column of the result, wherever it sits |
Strict is the honest headline. Lenient exists because most of what it forgives is shape a person would accept:
- the same two names as
FirstName, LastNameor the other way round; - one extra key column;
- a year labelled
2009-01-01where the gold set writes2009.
Academic and vendor practice has moved the same way. Spider 2.0 counts a
prediction correct when every gold column vector appears in the result
(paper). Defog's sql-eval falls back
to subset_df, matching each gold column's values against some generated
column with names, types and order ignored
(post).
A run passes leniently when:
- every gold column is matched, as a multiset of values, by a distinct column of the result, in any position, with column names ignored and the same numeric tolerance;
- the rows of the matched columns then line up as the strict score requires, so values swapped between rows still fail;
- the row count is equal, and row order still counts only when
order_matters.
Two guardrails keep it from passing wrong answers:
- A column cap. The result may carry at most
max(2 x gold columns, gold columns + 2)columns: three for a one-column gold answer, four for two, six for three. Without it aSELECT *would pass by carrying the gold columns among many others. Databricks Genie counts any extra column as bad (docs), so a cap is the middle ground. - No constant columns. Once a result has more than one row, a column whose values are all the same -- all-null included -- may not answer for a gold column: a hard-coded literal has the right height and tells the gold column nothing. On a single row every column is constant, so the rule is off there.
One value normalisation is added, and only one: date and period labels.
When a gold value is a YYYY, YYYY-MM or YYYY-MM-DD string and the
predicted value is a date starting with it at a component boundary, the two
are equal: '2009' matches '2009-01-01', and '2013-02' matches
'2013-02-01'.
- Only the gold side may be the shorter label.
- Only strings are read this way: a number is a number, never a year.
- Nothing else is normalised. Anything broader belongs in an alternative answer, which is reviewed per case and cannot silently widen.
Lenient is a superset of strict by construction: everything strict accepts is
accepted. It is not partial credit -- a case passes a score or it does
not -- and there is no model in either score, so both stay reproducible.
Every record carries status and lenient_status, so a case that passes one
score and not the other is visible per case.
Alternative answers
A question can have more than one right answer:
- "Who are the top 5 customers by total spend?" is answered just as well
with the name in one column as with
FirstNameandLastNameside by side. - "What is the total revenue per year?" is answered with the year written
2009or2009-01-01.
alt_sql holds those other answers, one SQL each, and regenerate runs each
one exactly as it runs sql. A run passes, strictly or leniently, when its
rows match rows or any alternative.
This is how the field handles multiple right answers:
- Snowflake has humans pick every correct answer out of several models' SQL (post).
- LinkedIn reports that about 60% of its benchmark questions now have several answers, and that without them it "underreported recall by 10-15%" (post).
- Spider 2.0 supports multiple gold files per task (paper).
When to add one, and when not to
Prefer an alternative to loosening the comparator. An alternative is written down, reviewed once, and only ever affects the case it sits on. A looser rule applies to all forty-seven at once and can quietly start passing wrong answers. The lenient score is deliberately narrow for the same reason.
Add one when all of these hold:
- A real run produced it. Alternatives come from SQL a model actually wrote, not from shapes someone imagined it might write.
- A person has read the rows and would accept them as an answer to the question as written.
- It is the same question, from the same tables: an alternative may not read
a table outside
tables, and the test enforces that. - It is deterministic -- ties broken on a key, as
sqldoes.
Do not add one when the answer is a different question, however reasonable it looks. Two from the run this was built on, both refused:
- "Where do our customers from Germany live?" answered with three distinct
cities. It drops the customers, so it cannot say who lives where. The
alternative kept for
chinook_021is the gold answer with the name in one column, not this. - "Who are the top customers by total spend?" answered with one row. "Top
customers" is a list with no N; one row answers "who is the top customer",
a different question.
chinook_007is not the same case: "which employees support the most customers" carries a superlative, and the one employee who holds the maximum is a fair reading of it, so that one is kept.
To add one:
- Write the SQL into
alt_sql. - From a checkout of the repo, run
engine.evals.regenerate(CHINOOK_SUITE_PATH, allow_packaged=True)to write itsalt_rows(CHINOOK_SUITE_PATHfromnl2sql.evaluation.suite). The flag is needed because the suite lives in the package. - Read the rows it wrote.
- Commit both.
Never type an alternative's rows by hand.
Mode plans: gold plans through the code nodes
engine.evals.run(mode="plans") checks the nodes that are plain code -- the
logical validator (including RBAC), the SQL generator and the executor --
with no model involved. Every answerable case has a hand-written plan (the
typed AST the planner emits) that answers it.
A local FakeLLMServer (nl2sql/evaluation/gold_llm.py) answers every LLM
call:
| Node | The fake answers |
|---|---|
| Datasource resolver | The case's datasources for a case with sql, and an empty list for an unanswerable one, so the resolver's refusal is what is scored |
| Decomposer | One sub-query whose expected columns are the plan's select aliases |
| Planner | The gold plan |
| Refiner | "Keep the same plan." |
| Answer synthesizer | A fixed sentence |
Every answer comes from one table keyed by the structured output's schema name, so a new structured output is one more entry.
The rest is the real pipeline on the indexed demo, so a failing case is a bug in a code node or in the plan, never in a model. The run turns curated answers off, because a verified answer has no plan. It also scores refusals against the generic message: the demo's config names the refused table so the playground can show it, and the run turns that off.
Each plan reads exactly the case's tables (the RBAC outcome is derived from
them) and selects the rows' columns in order. PlanModel has no subqueries or
window functions, so a few plans express the answer differently from sql:
- anti-joins for "longest track per genre" (
chinook_008) and "never purchased" (chinook_039); - conditional sums in HAVING for "jazz but never rock" (
chinook_009); COUNT(DISTINCT x)through the aggregate'sdistinct: trueflag (chinook_011andchinook_034).
The suite file's header lists these.
Mode plans runs on every PR as packages/nl2sql/tests/e2e/test_evals_plans.py,
in the key-free integration job. It builds an engine on the generated demo
with no API key in the environment, runs engine.evals.run(mode="plans"), and
requires every case to pass -- allowed, refused and unanswerable, for every
role -- with none skipped. The same job runs
nl2sql eval run --mode plans --baseline benchmarks, which gates the run
against the newest mode plans record in benchmarks/
(In CI).
When a gold plan fails, the fault is in the engine or in the plan. Fix an engine bug with a regression test. Never change the gold data to make a plan pass.
Mode full: the real model, end to end
engine.evals.run(mode="full") runs the whole product -- every LLM node on a
real model -- on the cases, and scores it with the same scorers. It costs
money, so it never runs in CI and never runs without a dollar cap
(max_cost_usd). The key comes from the LLM config (${env:...}).
from nl2sql.evaluation.presets import PRESETS_DIR, load_llm_file, model_config
report = engine.evals.run(max_cost_usd=5) # the engine's own llm section
report = engine.evals.run(llm={"gpt-5.4": model_config("gpt-5.4"), # a comparison
"mini": load_llm_file(PRESETS_DIR / "gpt-5.4-mini-helpers.yaml")},
max_cost_usd=10)
model_config(MODEL) puts MODEL on all six LLM nodes:
- A model in
VERIFIED_MODELS(nl2sql/llm/providers.py) names its own provider and temperature:gpt-5.4,gpt-5.4-mini,gpt-4.1,gpt-4o,gpt-5.5,gpt-5-mini,claude-opus-5,claude-sonnet-5,claude-haiku-4-5, and others. None is sent where the model rejects one (gpt-5.5,gpt-5-mini,claude-opus-5,claude-sonnet-5). - Any other model is written
provider/model(openrouter/meta-llama/llama-3.3-70b-instruct,ollama/llama3) and runs at temperature 0 (none foranthropic).
From the command line, --llm takes a preset, a file or NAME= either:
The presets in nl2sql/evaluation/presets/ are llm sections, shipped in the
package:
| Preset | Models |
|---|---|
gpt-5.4 |
gpt-5.4 on every node |
gpt-5.4-mini-helpers |
gpt-5.4 on the planner and refiner; gpt-5.4-mini elsewhere |
claude-planner |
claude-opus-5 on the planner and refiner; gpt-5.4 elsewhere |
Cost and the cap
Every LLM call is billed from nl2sql/llm/prices.py, a committed table
of USD per 1M tokens, dated with the day it was checked (an llm.prices row
replaces a model's):
| Model | Input | Cached input | Cache write | Output | Source |
|---|---|---|---|---|---|
| gpt-5.4 | 2.50 | 0.25 | 2.50 | 15.00 | OpenAI pricing page, 2026-09-21 |
| gpt-5.4-mini | 0.75 | 0.075 | 0.75 | 4.50 | OpenAI pricing page, 2026-09-21 |
| claude-opus-5 | 5.00 | 0.50 | 6.25 | 25.00 | Anthropic model table (claude-api skill, cached 2026-06-24) |
| claude-sonnet-5 | 2.00 | 0.20 | 2.50 | 10.00 | same |
| claude-haiku-4-5 | 1.00 | 0.10 | 1.25 | 5.00 | same |
A call costs (input - cached - cache_write) x input + cached x cached_input +
cache_write x cache_write_rate + output x output, from the token counts each
wire adapter records in QueryResult.usage:
- Reasoning tokens are part of output and billed as output.
- Prompt caching: OpenAI charges nothing extra to write its automatic cache. Anthropic bills a cache write at 1.25x input and a read at 0.1x.
- Pricing by node: each call is priced by the model its node is configured with, so a dated snapshot name in the response still prices correctly.
How the cap holds:
- Before any call: every model that any node of any config would use
must have a price. A missing one is an
UnknownPriceErrornaming the config, node and model. - Before each case: the run adds the most expensive case so far to the
running total, and stops if that could pass
max_cost_usd. The cap covers the whole run, every config and pass. - The first case always runs. So the total can pass the cap only by what one case costs more than the most expensive case before it.
- A stopped run is still reported. It returns its report with
stopped: "max_cost", and each board'scompleted_casesis short ofplanned_cases.
The run turns the plan cache off, so a second pass asks the planner again
instead of replaying the first. Curated answers are on unless curated says
otherwise: a case a verified query answered also runs with them off, and the
report's accuracy_generated scores every case on the generated path
(Curated answers in mode full).
Answer faithfulness
Accuracy scores the rows. The answer synthesizer then writes a sentence about
them, and that sentence can state a number the rows do not hold while the
rows are right. The faithfulness scorer checks every written answer (its
summary and content) against the rows the run returned, deterministically,
with no model (nl2sql/evaluation/faithfulness.py).
| What the answer writes | Supported when |
|---|---|
A number: 1,297, 1234.5, $523.06, -5 |
It equals a value in the rows at the precision written (523.1 and 523 match 523.06; 523.60 does not), the row count, or the sum of a numeric column |
A percentage: 37.2% |
As above, or it is a share in the rows times 100 (0.3718) |
| A number inside a text cell or the question | Always: the year of 2009-01-01, Symphony No. 5, top 5, in 2013 |
A quoted ("Jazz") or bold (**Rock**) name |
A text cell, a column name or the question contains it, or it contains a text cell |
Some numbers and names are never checked:
- Not numbers: ordinals (
2nd), list markers (1.at the start of a line), numbers glued to a word or identifier (chinook_001,MPEG-4,#7), and the month and day of a date. - Names only when quoted or bold. Bold spans with a digit, ending in
:(**Note:**), or longer than six words are skipped, so the name check flags little it should not.
1,300 for 1297, a 2013 that neither the rows nor the question hold, and
a bold genre the rows do not list are all unsupported.
Each record carries faithfulness: faithful, unsupported_numbers and
unsupported_entities (as written), and checked (how many numbers and
names were checked). A refusal or error writes no answer, and the scorer does
not apply to it. The board's faithfulness gives the rate over the answers
written and lists each unfaithful one. Mode plans does not run it: its fake
synthesizer writes the same fixed sentence for every case.
Reading a run: intervals, flips and pass^k
Forty-seven cases cannot settle much on their own. 27 of 47 is 57.4%, and its 95% Wilson interval is 43.3%-70.5% -- about fourteen points either way. Every board therefore reports that interval next to each accuracy, and the gate below is built on the cases that moved rather than on the headline (Anthropic, A statistical approach to model evals). The interval is taken over the runs a config scored. With more than one pass, the runs of a single case are not independent, so it reads narrower than it really is; pass^k below counts cases and does not.
Compare two runs case by case, not headline to headline. The same cases
run both times, so the runs are paired and most of the variance cancels.
nl2sql/evaluation/gate.py lists, per config:
- how many cases went pass -> fail and how many fail -> pass, and which ones;
- McNemar's exact two-sided p-value on those flips.
Cases both runs got right, or both got wrong, say nothing about a change. Only the discordant pairs do, and under "the two runs are equally good" each flip is a fair coin. Ten lost and none gained is p = 0.002; five each way is p = 1.0.
A case counts as passed only when every pass passed, so over three passes a case that has become flaky reads as a regression.
The regression gate (engine.evals.gate, nl2sql eval run --baseline)
compares only comparable runs (the same mode, suite digest, schema and, in
mode full, roles and curated answers), and a case passes on
execution accuracy (result_match and outcome), the measure 0.6's tier
2 published: a faithfulness miss alone is not a regression. With curated
answers on it checks that accuracy as users see it and on the generated path.
It fails a run when, for any config both runs name:
| Check | Default (evals.yaml gate) |
Why |
|---|---|---|
More than max_regressions cases flipped from pass to fail |
2 in mode full; always 0 in modes plans and retrieval | A handful of named cases is something a person can go and read; with no model, any flip is a bug |
Mode full: more cases lost than gained, and McNemar's exact two-sided p < alpha |
0.05 | A small but real drop should not pass because it is under the count |
Cost per case rose by more than max_cost_increase |
0.2 | Unchanged |
With passes above one the board also reports pass^k: the share of
cases that passed in every pass, for both scores, beside mean accuracy.
Mean accuracy counts runs, so a case that passes two times of three still
lifts it. pass^k counts cases, and a flaky one never counts. It is the number
to read when asking whether the engine can be relied on, rather than how
often it happens to be right (tau-bench).
determinism is stricter than pass^k: it says whether the SQL and rows were
identical across passes.
Results over time
A recorded run is a JSON file under the repo's benchmarks/ folder:
| Part | What it is |
|---|---|
mode |
full, plans or retrieval; 0.6 wrote mode full runs under tier2, read as full |
database |
The datasource id (chinook) |
shortsha |
The first seven characters of the engine commit |
config |
The config name (retrieval for a retrieval run) |
-2, -3... are added when the name is taken.
Every record holds:
| Field | What it is |
|---|---|
schema, kind |
The record format (2) and the run's mode |
recorded_at |
UTC date and time |
engine_version |
The installed nl2sql-engine version (the same for every run until a release) |
git_commit, git_dirty |
The engine's commit and whether its tracked files had uncommitted changes. Found by walking up from the installed nl2sql package to its .git, so a run from the demo folder of an editable install still names the repo commit. "unknown" for a wheel install or without git, never null |
note |
What changed, e.g. "slim prompts" (null without one) |
dataset |
The suite's name and digest (a SHA-256 of its LF bytes, so a Windows checkout hashes the same). A record 0.6 wrote names chinook_gold.yaml |
database |
datasource_id, engine (sqlite), schema_fingerprint and the tables and columns counts. The fingerprint hashes the latest indexed schema snapshot's tables, columns, types and keys, so re-indexing an unchanged database keeps it. Built from the datasources the suite's cases name, not every datasource the project registered: the demo registers three and this suite asks about Chinook, so a run there is still chinook with Chinook's fingerprint |
config |
The config name, and provider:model per node |
metrics |
The headline numbers: execution accuracy (strict and lenient) with their intervals, then accuracy_all_scorers and accuracy_generated, pass_k, answerability, dollars, tokens, latency, determinism and faithfulness; or, for retrieval, the recall summary |
scoreboard |
The config's whole board, every case record included |
The headline accuracy is execution accuracy (accuracy.execution), the
measure 0.6's tier 2 published, so the series reads the same before and
after 0.7. The 0.6 records are on the 0.6 gold file, so the first 0.7 run
starts a new series beside them.
Records are never edited after writing: a run writes a new file. Two runs are
comparable when they share the mode, the suite digest and the schema
fingerprint, and for mode full the roles and whether curated answers were on.
nl2sql eval publish REPORT ..., run from the repo root, writes the records
of the reports nl2sql eval run saved, then reads every record and rewrites
docs/benchmarks.md and the README's results block. It
reads no clock, so the same records always give byte-identical pages.
packages/nl2sql/tests/unit/test_eval_records.py checks in CI, with no key,
that the committed pages are exactly what publish makes from the committed
records.
How mode full is tested
packages/nl2sql/tests/e2e/test_evals_full_fake_llm.py runs mode full with no
key and no spending. It points two llm sections at two FakeLLMServers
that serve the gold plans with canned usage (cached and reasoning tokens
included). One server has a plan deliberately wrong. The fakes learn which
case is being asked from each run's run.started event, and the fake
synthesizer writes each answer from the case's rows, so the good server's
answers are faithful and the bad server's answer on its wrong rows is not.
The test checks:
- the scoring, the comparison and its differences;
- the dollars against the price table;
- the cap stopping mid-run, with a partial report;
- an unpriced model failing before any call;
- two passes with the plan cache off;
- the database identity the report records.
The unit tests cover the rest:
| Test | What it covers |
|---|---|
tests/unit/test_eval_run.py |
The runner: approved cases only, roles and selectors, the derived runtime of each mode, configs compared, curated hits run twice, clarify, the events |
tests/unit/test_eval_report.py |
Prices, the boards, the Wilson interval on known counts, McNemar's exact test on a constructed table, pass^k and the gate |
tests/unit/test_eval_scorers.py |
Each scorer |
tests/unit/test_answer_faithfulness.py |
The faithfulness rules |
tests/unit/test_eval_records.py |
The records |
tests/unit/test_eval_gate.py |
The gate: same suite and mode, execution accuracy, both accuracies, baselines from records |
tests/cli/test_eval_command.py |
nl2sql eval run, regenerate and publish, with their exit codes |
Retrieval recall
Before the planner sees a schema, the schema retriever picks the tables and
columns it is given. A column it never sends is one the planner cannot use.
engine.evals.run(mode="retrieval") measures that, per answerable case and
role, against tables and columns, with no key, no LLM and no cost.
How it runs (nl2sql/evaluation/scorers/retrieval_recall.py):
- The search always runs. Chinook's 11 tables are under
settings.retrieval.full_snapshot_max_tables(15), which would send the whole schema without a search, so the run sets the limit to 0. - Each case is scored against the datasource it names, which for this
suite is always
chinook, whatever else the project has registered. Routing's ranking is scored separately by theroutingscorer; the resolver's answerability check is an LLM call and is skipped. - The retriever runs as the pipeline runs it (MMR, k 8 table entries, then k 12 column and relationship entries of those tables), on the index and embedder the project has. The demo's embedder is the local ONNX all-MiniLM-L6-v2. Its query is the question alone, since the decomposer that would add filters, groupings and expected columns is an LLM.
What was sent is read from the retriever's retrieval record, the one a run trace keeps.
The retrieval_recall score's detail has:
table_recall(needed tables sent / needed tables) andcolumn_recall(neededTable.Columns sent / needed);- the
missed_tablesandmissed_columns; tables_sentandcolumns_sent.
The board's retrieval has the means, and how many cases had every needed
table (perfect_tables) or column (perfect_columns). The report's
settings has the k values and the MMR settings, so two reports are only
compared like for like.
Read recall with the amount sent: sending all 11 tables would score 100%, so a change that raises recall by sending more is not an improvement by itself. A missed column is one the planner cannot select, filter or join on.
The committed baseline (2026-09-22, engine 0.1.2, 39 questions):
| Table recall | Column recall | Every table | Every column | Sent on average |
|---|---|---|---|---|
| 98.5% | 72.6% | 37 of 39 | 15 of 39 | 8.3 tables, 24.2 columns |
The first 0.7 record (2026-10-15, CI's baseline) scores each answerable case once per role allowed to read it, 96 case runs over 43 cases: 97.9% table recall, 76.5% column recall, 7.98 tables and 22.2 columns sent on average. It is a new series: the suite is not the 0.6 gold file.
Most misses are columns of a table that was sent. Once one column entry of
a table is picked, or a relationship entry adds its key columns, only those
columns are sent rather than all of them. So Invoice often reaches the
planner as BillingCountry or a key without Total or InvoiceDate
(chinook_014, "How much did we earn in each year?", gets Invoice with
BillingCountry only). The two table misses are chinook_009 (Customer,
Invoice) and chinook_034 (Artist).
packages/nl2sql/tests/e2e/test_evals_retrieval.py runs mode retrieval on the
generated demo in the key-free integration job. It checks that the run covers
every answerable case and reports recall and routing, not what the recall
is. The same job gates the recall itself:
nl2sql eval run --mode retrieval --baseline benchmarks fails when more than
two cases stopped passing (perfect recall, and routing where it applies)
against the newest mode retrieval record, or the loss is significant.