Evals
engine.evals measures how well the engine answers your questions. You
write cases (a question, what each role should get, and the SQL a person
reviewed) in suite files. A run asks every case once per role and scores it
with registered scorers, and the result is an EvalReport.
The package ships one suite, Chinook's 47 gold cases, and
nl2sql init --demo copies it next to the demo's nl2sql.yaml. The format
is in Cases and suites, and what makes a case pass is in
Scorers. To build a suite for your own database, seed draft
cases from its schema and review them (Benchmark your own database).
Configuring it
evals.yaml is a config section, like curated.yaml. Its keys are strict and
it is loaded with the config:
# nl2sql.yaml
evals: { file: evals.yaml }
# evals.yaml
suites: [evals/chinook.yaml] # relative to nl2sql.yaml
scorers: [result_match, outcome, faithfulness, retrieval_recall, routing] # the default: all five
curated: true # mode full: curated answers on (the default)
max_cost_usd: 5.00 # mode full's dollar cap over a whole run; it needs one
gate: { max_regressions: 2, alpha: 0.05, max_cost_increase: 0.20 }
A suite file is data. It is read when a run asks for it, never when the
engine starts, so a large suite does not slow down engine start. gate
configures the regression gate, which compares a run with a baseline.
The facade
from nl2sql import NL2SQL, load_config
engine = NL2SQL(load_config("nl2sql.yaml"))
report = engine.evals.run(mode="plans") # no key, no cost
print(report.accuracy, report.accuracy_generated)
| Method | What it does |
|---|---|
engine.evals.load(path=None) |
The suites evals.yaml names, or the one file at path, as EvalSuites |
engine.evals.run(suites=None, *, mode="full", llm=None, cases=None, roles=None, scorers=None, curated=None, passes=1, max_cost_usd=None, on_case=None, clarify=False) |
Runs every approved case once per role in its outcome map, passes times, and returns an EvalReport |
engine.evals.gate(report, baseline) |
The regression gate: report against a baseline report, record or benchmarks/ folder, with evals.yaml's gate settings. Returns a GateResult (The gate) |
engine.evals.regenerate(path, *, allow_packaged=False) |
Runs each approved case's sql and alt_sql through its datasource's adapter and writes rows and alt_rows (Regenerating rows). A suite the package ships is refused unless allow_packaged |
engine.evals.seed(datasource_id, *, count=20, out, user_context, max_cost_usd=None) |
Drafts up to count cases from the datasource's schema, as user_context sees it, and appends them to out as status: draft. Returns a SeedReport (Benchmark your own database) |
engine.evals.scorers() |
The registered scorer names: the five built-ins and any plugin's |
The run arguments:
casesnarrows the run by case id or tag. A selector that matches nothing is an error.rolesnarrows it by role.- An argument left as
Nonetakes its value fromevals.yaml.
on_case(config, record, spent) is called after each case. It gets the
config's name, the case's record and the dollars spent so far.
The modes
| Mode | What runs | Model calls | Curated answers | Plan cache |
|---|---|---|---|---|
full |
The whole pipeline, on the configured LLM or on each named config | yes; needs max_cost_usd |
as curated says (on by default) |
off |
plans |
Each case's hand-written plan through the validator, generator and executor, on the gold-plan fake LLM |
none | off | as configured |
retrieval |
Datasource routing and schema retrieval only | none (the configured embedder) | off | not used |
Every mode runs on a runtime derived from the engine's (ctx.derive), so the
engine serving traffic never changes. In each mode:
- Refusals are generic. A refusal message never names a table
(
policies.refusal_names_tablesis off for the run). Theoutcomescorer checks for the generic message. - Mode
fullturns the plan cache off. A second pass asks the planner again instead of replaying the first pass's plans. - Modes
plansandretrievalturn curated answers off. The curated matcher makes a model call the gold-plan fake cannot answer, and a verified hit would skip the plan or the retrieval being measured. - Mode
retrievalalways runs the vector search. Its runtime setssettings.retrieval.full_snapshot_max_tablesto 0, because Chinook's 11 tables are under the limit that would otherwise skip the search.
Mode full: the configured model, priced and capped
llm takes one of three values:
None: the engine's ownllmsection, reported asdefault.- One
LLM. - A mapping of names to
LLMsections. Each one runs on its own derived runtime over the same cases, and the report compares them.
The named configs in nl2sql/evaluation/presets/ are llm sections too:
load_llm_file(path) and model_config("gpt-5.4") in
nl2sql.evaluation.presets build one.
Every call costs money, so mode full checks prices before it starts and checks the cap before every case:
- Every model must have a price. Before any call, every model that any
node of any config would use must have a price, in
llm.pricesor the engine's dated table (Prices). A missing price is anUnknownPriceErrornaming the config, the node and the model. - The run stops before it could pass the cap. Before each case, the run
adds the most expensive case so far to its running total. If that could
pass
max_cost_usd, the run stops and the report saysstopped: "max_cost". - The first case always runs. So a run can pass the cap by at most one case's cost.
- An unknown cost is not counted as zero. A call that reported no token
counts (a failed call) cannot be priced. The run goes on, leaves it out of
spent(sospentis a lower bound), counts it in the report'sunknown_cost_calls, and logs one warning.
Each call is priced by the model its node is configured with, and the dollars
are in report.spent and each record's cost.
Curated answers in mode full
curated: true is the default. Each case record says where its answer came
from in source (verified or generated).
A case that a verified query answered is run a second time with curated
answers off, and that second run is scored as the case's generated answer
(in the record's generated). Only the cases a verified query answered pay
for the second run.
The report gives two accuracies:
| Accuracy | What it measures |
|---|---|
accuracy |
What users see |
accuracy_generated |
Every case on the generated path: the model's own skill, comparable across configs |
Both are also split by source.
Clarification
A case may expect the engine to ask the caller to narrow the question
(outcome clarify). Wave B adds that result. Until then, evals run with
clarify=False, the default: those role runs are left out and counted in
report.skipped. With clarify=True they run, and the outcome scorer
passes them when the run's status is needs_clarification.
The report
EvalReport has:
modeandsuites(each suite's name, path and SHA-256 digest).roles,passes,curated,clarify,max_cost_usd,spent,stopped,skippedandsettings(mode retrieval's retrieval settings).database: the datasource, engine and schema fingerprint of the datasources the suites name.configs: one board per LLM config. Modeplansreports one board namedplans, and moderetrievalone namedretrieval.comparison, when there are several configs.
report.accuracy and report.accuracy_generated read the first config's
board.
Two accuracies, on purpose. accuracy.overall counts a case as passed
only when every scorer that applies passes. In mode full with the default
scorers, that includes faithfulness, retrieval_recall at 1.0 and
routing. So it is stricter than the accuracy 0.6's tier 2 published.
accuracy.execution is that older measure alone: result_match and
outcome passed, no other scorer counted. Compare it, not overall, with
the runs published before 0.7 (docs/benchmarks.md).
A board holds:
| Field | What it is |
|---|---|
accuracy |
overall (what users see), lenient, generated and execution, each with a 95% Wilson interval; pass_k over several passes; and the rates by_tag, by_difficulty, by_source and generated_by_source |
summary |
pass, fail, error and skip counts per role and in total |
scores |
Per scorer: how many cases it applied to, passed, failed and errored on, and its mean value |
retrieval |
The mean table and column recall over the cases retrieval_recall scored, how many were perfect, and how much was sent |
answerability, faithfulness, tokens_by_node, cost, latency, retries, errors_by_code, determinism |
As for a single run, summed or averaged over the cases |
models, planned_cases, completed_cases |
The config's model per node, and the cases the run would make and made |
results |
One record per case run: its status and reason, every scorer's score, the SQL, the answer, the plans, source and generated |
Accuracy over 47 cases has an interval about 14 points wide, so two runs are compared case by case, not headline to headline. The statistics are in the Chinook suite.
Events
Each case runs under its own run scope (QueryAPI.open_scope and
close_scope), so it publishes run.started and run.finished like any
question. This includes mode retrieval, where no graph runs: its scope
closes with an empty result. Every finished case also publishes eval.case on the engine's bus,
with the suite, case, role, whether it passed, the answer's source, each
scorer's verdict and its cost. The end of the run publishes eval.finished,
with the suites, the number of cases, both accuracies and the dollars spent.
The regression gate
engine.evals.gate(report, baseline) compares a run with a baseline and
returns a GateResult:
| Field | What it is |
|---|---|
ok |
True only when the runs were compared and nothing regressed |
regressions |
One line per problem: too many cases flipped to fail, a significant drop, or a cost rise |
series_break |
Why nothing was compared: another suite digest, another mode, or no config in common |
compared |
Per config and measure: the cases that flipped each way, McNemar's p-value, both accuracies and both costs per case |
The baseline is an EvalReport (or the JSON nl2sql eval run writes), a
record from benchmarks/, or a path to either. A path to a benchmarks/
folder means its newest record of the run's mode, database and config.
Only the same suite and mode compare. Two runs are compared only when the
suites' digests match and the mode is the same. Anything else is a series
break: ok is false and nothing is compared.
The gate reads execution accuracy. A case passes when result_match and
outcome pass; no other scorer counts. That is accuracy.execution, the
measure 0.6's tier 2 published, so a run compares with the history published
before 0.7 and a faithfulness verdict alone is never a regression. A
kind: routing case has no rows to check and is left out of it. With
curated answers on, it is checked twice: as users see it, and on the
generated path. Mode retrieval has no execution, so its cases are read by
their status.
For each config both runs have, the gate fails when:
- more cases passed in the baseline and fail now than are allowed. Mode
full allows
max_regressions(2 by default), because the model is noisy. Modes plans and retrieval run no model, so they allow none: any flip is a real break; - in mode full, more cases were lost than gained and McNemar's exact
two-sided p-value on the flips is under
alpha; or - cost per case rose by more than
max_cost_increase.
Comparable means what the history page means by it (records.series_key):
the same mode, suite digest and schema fingerprint and, in mode full, the
same roles and curated answers. Both runs must also have run the same roles,
so a role left out cannot quietly shrink the comparison.
The statistics are in the Chinook suite.
The CLI
| Command | What it does |
|---|---|
nl2sql eval run [--suite PATH ...] [--mode full\|plans\|retrieval] [--llm SPEC ...] [--cases ID/TAG ...] [--passes N] [--max-cost USD] [--baseline PATH] [--out FILE] |
engine.evals.run, then engine.evals.gate with --baseline |
nl2sql eval regenerate PATH |
engine.evals.regenerate; exits 1 when a case's SQL failed |
nl2sql eval seed --datasource ID --out PATH [--count N] [--role R] [--max-cost USD] |
engine.evals.seed, as --role (else policies.default_role); exits 2 on a refusal, 3 at the cost cap or the call limit, and refuses to run when CI is set (Benchmark your own database) |
nl2sql eval publish [REPORT ...] [--note TEXT] [--from DIR ...] |
Records each report under benchmarks/, then rebuilds the results page and the README block. Run from the repo root |
--llm names an LLM config to compare in mode full, and repeats:
gpt-5.4,mini-helpersorclaude-planner: a preset fromnl2sql/evaluation/presets/, named by itself;mine.yaml: a file holding anllmsection, named by its stem;NAME=PRESETorNAME=FILE: either, under a name you choose.
Without it, the config's own llm section runs as default.
nl2sql eval run writes the report to --out, by default
eval_<mode>.json next to nl2sql.yaml, with a run stamp: when it ran,
and the engine version and commit. nl2sql eval publish records that stamp,
not the time it publishes.
| Exit code | When |
|---|---|
| 0 | The run finished, and nothing regressed against --baseline |
| 1 | A regression against --baseline |
| 2 | A usage error: a bad --llm, a run the engine refuses (no cost cap, an unpriced model), mode full with CI set (whatever its value, CI=false included), a missing or unreadable baseline, or one that cannot be compared |
| 3 | The cost cap stopped mode full early: the report is partial |
# From a demo folder: the key-free gate CI runs, against the repo's records
nl2sql eval run --mode plans --baseline ../benchmarks
# A paid run comparing two configs, under a $10 cap
nl2sql eval run --mode full --llm gpt-5.4 --llm mini=mini-helpers --max-cost 10
# From the repo root: record it and rebuild the results page
nl2sql eval publish demo/eval_full.json --note "slim prompts"
In CI
The key-free integration job runs the eval gate on the demo's Chinook suite and on the routing suite, after indexing them:
nl2sql --config demo-ci/nl2sql.yaml eval run --mode plans --baseline benchmarks
nl2sql --config demo-ci/nl2sql.yaml eval run --mode retrieval --baseline benchmarks
nl2sql --config demo-ci/nl2sql.yaml eval run --mode retrieval --suite evals/routing.yaml \
--baseline benchmarks --out demo-ci/eval_routing.json
Each run is gated against the newest record of its mode and database in
benchmarks/ (plans/chinook/, retrieval/chinook/ and
retrieval/chinook-support-webanalytics/). The three reports are uploaded
as the eval-reports artifact whether the gate passed or not. On the routing
suite, a case that loses its routing top-1 or a shortlisted datasource is a
flip, and mode retrieval allows none. A changed suite or schema starts a new
series, so the gate exits 2 until the baselines are recorded again. The same job also runs tests/e2e/test_evals_plans.py, where
every case must pass for every role.
Re-recording CI's baselines
CI's plans and retrieval baselines are recorded on CI's own runner
(ubuntu-latest, Python 3.12), never on a laptop: retrieval recall passes a
case only at 100% column recall, which near-tie rankings of the local ONNX
embedder can move between platforms.
- Push the change. The integration job's "Eval gate" steps run the three
gates and upload
eval_plans.json,eval_retrieval.jsonandeval_routing.jsonas theeval-reportsartifact, pass or fail. - Download them:
gh run download <run id> -n eval-reports -D eval-reports. - From the repo root, record the ones the change moved, naming where they
came from:
nl2sql eval publish eval-reports/eval_retrieval.json eval-reports/eval_routing.json --note "CI baseline: ubuntu-latest, Python 3.12, https://github.com/nadeem4/nl2sql/actions/runs/<run id>". - Commit the records and the regenerated pages in the same PR, and push. The next run gates against them.
Never edit a record by hand, engine_version included. A change that moves
the gate on purpose (a suite change, routing or retrieval work) re-records
the baselines it moves this way, in the PR that makes the change.
Mode full spends a key: nl2sql eval run --mode full refuses to start when
CI is set. The owner runs it before a release
(Releasing).