schemagate on BIRD, UniQL, Spider and Spider 2.0

Every other number in this repo is measured on schemas I invented. That is fine for catching regressions and worth very little to anyone else: a schema whose questions happen to share vocabulary with its own table names will flatter any retriever, and you have no way to check that I did not do exactly that.

So here are the same measurements on the public text-to-SQL benchmarks. The data is downloaded from the original sources, the scripts are in benchmarks/, and the retrieval part reruns in about three minutes.

Execution accuracy, scored by each benchmark's own evaluator

These are end-to-end numbers: schemagate picks the tables, the model writes one query against only those tables, the query runs, and the benchmark's own evaluator compares the rows with its reference answer. Nothing reads the gold SQL. Every prediction file and evaluator output is in benchmarks/results/.

Benchmark Model Execution accuracy Best published, on 2026-10-19
BIRD dev, all 1,534 questions (SQLite) Claude Opus 5.5 77.12 (simple 80.97 / moderate 71.34 / challenging 71.03) main board, hidden test set: 82.95; our hidden test-set results from BIRD are 72.11 EX for this setup and 74.34 EX (simple 81.03 / moderate 72.25 / challenging 56.14) for the second configuration, which adds the four closest training questions to each prompt (dev 84.03)
BIRD Mini-Dev, 500 questions, SQLite Claude Opus 5.5 81.00 75.60 (Jitto Build, the same model)
BIRD Mini-Dev, PostgreSQL Claude Opus 5.5 72.60 65.80 (Ontology2SQL, deepseek-v4-flash as a 12-step agent); 69.00 since (SDIP + gemini-3.8-flash), still below
BIRD Mini-Dev, MySQL Claude Opus 5.5 72.40 49.20 (TA + GPT-4)
BIRD Mini-Dev, SQLite / PostgreSQL / MySQL Gemini 2.5 Flash, a light model, one query per question (also on BIRD's board) 63.60 / 55.60 / 54.80 above the MySQL leader; below the other two
UniQL, Oracle dialect, 1,534 questions, first try only Gemini 2.5 Flash 64.47 (67.60 with one execution repair) 49.61 (Claude Sonnet 4.5, UniQL's own baseline)
Spider 2.0-lite, the 135 SQLite questions (one third of the set; not a leaderboard row) Claude Sonnet 5.5 71.9 (97 of 135) 57.78 Pass@1 / 64.44 Majority@8 (FlexSQL, gpt-oss-120b, an agent)
Spider 2.0-lite SQLite, same 135 Gemini 2.5 Flash 39.3 (full schema, no schemagate: 40.7) the light model is not competitive here
Spider 2.0-lite SQLite, same 135 Claude Opus 5.5 67.4 (91 of 135) below Sonnet 5.5 with the same pipeline
Spider 2.0-lite, the 205 BigQuery questions Claude Sonnet 5.5 45.9 (94 of 205) no BigQuery-only row on the board; Snowflake third not yet run

What is the same in every row: one model call per question, about 2,100 prompt tokens, no candidate generation, no voting, no agent loop. The only extra call is a repair when the database raises an error (1 of 1,534 on BIRD dev). The BIRD numbers come from BIRD's evaluation_ex.py, the Oracle number from UniQL's evaluate.py --dialect oracle, each run on the files in the results folder.

What to read with care. Opus is not deterministic: a re-run of the Mini-Dev SQLite setting gave 80.20 against 81.00, so treat single-run numbers as about ±1. Mini-Dev and dev rows on BIRD's site are self-reported; ours were added in bird-bench PR #251, merged on 2026-10-25, and the hidden test set, which only the BIRD team runs, is the one number that is not self-reported. BIRD's evidence string is passed through with the question, as the benchmark intends and as published systems do. The Spider 2.0 row is one third of that benchmark: the BigQuery and Snowflake tasks need credentials these runs did not have, so it is a measurement, not a placing, and the FlexSQL comparison is not same-model. Details, difficulty splits, and what the Oracle rules changed: Mini-Dev, dev, UniQL Oracle, Spider 2.0-lite SQLite, Spider 2.0-lite BigQuery.

Access control is scored separately, by two benchmarks whose evaluators I did not write: AC-F1 90.1 on the RBAC Text-to-SQL benchmark and refusal F1 0.984 on Spider-ACL, with zero queries that read ungranted data in either. Those sections are further down.

What is being measured in the retrieval sections

Everything from here to the access-control sections scores the part that is schemagate's own job: picking the tables. What is scored is table recall: given a question, does the selected set contain every table the benchmark's own reference SQL reads.

Two numbers per row:

Spider (dev): 1,034 questions, 20 databases

PER-DATABASE                    all gold present     per-table recall
  top_k=3                            98.5%                99.3%
  top_k=5                           100.0%               100.0%

Every Spider and BIRD table in this section was re-measured on 2026-10-16 against 1.5.2 with the default (hashed) embedder, python benchmarks/spider.py and python benchmarks/bird.py. The figures they replace were measured on an earlier release and were mostly lower; which change moved them has not been isolated, so none is claimed.

This row is a floor, not a result. Spider databases have a median of three tables and a maximum of eleven. Picking five out of three is not retrieval, and any method that returns the whole schema scores 100% here. It is reported so you can see nothing is broken, and for no other reason.

The interesting setting is the one Spider does not ship: put every database in one catalog and stop telling it which one to look in.

POOLED — all 166 Spider databases, 876 tables, no database hint

                      all gold present     per-table recall
  top_k=5                  76.7%                81.2%
  top_k=10                 86.1%                89.5%
  top_k=20                 94.2%                95.6%

  was, before the re-run:  71.4% / 82.6% / 92.9%

That is 876 tables from 166 unrelated domains, with heavy name collision — dozens of name, id, student, country columns that mean different things — and no hint about where to look. It is closer to a real warehouse than anything in Spider itself.

BIRD (dev): 1,534 questions, 11 databases, 75 tables

BIRD is the harder benchmark: the questions are phrased the way people ask rather than the way the schema is named, and the databases carry real naming instead of tidy benchmark naming.

PER-DATABASE, question only     all gold present     per-table recall
  top_k=3                            90.2%                95.6%
  top_k=5                            96.7%                98.6%

POOLED (75 tables, no hint)
  top_k=5                            83.8%                91.2%
  top_k=10                           91.3%                95.2%

BIRD ships an evidence string per question — a human hint like "eligible free rate = Free Meal Count / Enrollment". Retrieval is measured without it above, because the honest question is what the user's words alone can find. With it, which is what a real caller would pass through:

PER-DATABASE, question + evidence
  top_k=3                            92.4%                96.9%
  top_k=5                            98.0%                99.2%

POOLED, question + evidence
  top_k=5                            86.6%                93.4%
  top_k=10                           93.7%                97.0%

BIRD moved less than Spider: up about a point in most cells, and pooled with evidence at k=10 is 0.2 points lower than before (93.9% then).

Spider 2.0-lite: the one built for real schemas

Spider 1.0 databases have a median of three tables, which is why the pooled setting had to be invented to say anything at all. Spider 2.0 needs no such invention: 162 databases and 8,255 tables taken from real BigQuery, Snowflake and SQLite warehouses (5,497 / 2,326 / 432), a median of 16 tables per database and a maximum of 785. (The 103 databases the usable questions actually touch run slightly larger, median 17.) Retrieval is the acknowledged bottleneck there rather than a formality.

Counted on a complete checkout: resource/databases rooted at 43 characters, core.longpaths set, all 8,255 files present. An earlier count in this file said 7,892 -- 363 fewer -- and was taken on a different checkout; the figures below were all re-measured against the complete one.

Only the questions whose gold SQL is public are usable -- the rest is held out -- which leaves 247 across 103 databases.

Of those 247, the gold SQL names at least one table this loader can resolve for 233. The other 14 name nothing resolvable, and they are where the ablation below is measured from rather than on: a paired comparison needs both arms scored over the same questions, so it uses the 212, while any figure meant to be read as recall uses 247 and counts an unresolved question as a miss. Both denominators are printed against every number here, because the first is a comparison set and the second is the result.

It was 203 before two fixes. Spider 2.0's description key is a per-column list aligned to nested_column_names, not a table description, and columns were being built from the top-level list alone -- so a table with nested fields arrived without the columns its gold SQL reads. That took it to 212. Then the harness started calling collapse_partitions(), as bootstrap() does for every user, and the wildcard form the gold SQL itself uses began to exist as an object: 233.

The 14 that remain break down as a BigQuery function read as a table -- FROM UNNEST(...) matches the FROM/JOIN regex -- a handful genuinely absent from the schema directory, and two whose every reference is a CTE defined inside the query. They are counted as misses in the 247 figure, not excluded.

247 questions, none excluded        MiniLM      hashed
  top_k=5                           69.6%       66.8%
  top_k=10                          79.8%       80.6%
  top_k=20                          84.2%       85.0%

for comparison, scoring only the 233 whose gold tables resolve:
  top_k=5     73.8% / 70.8%    top_k=10  84.5% / 85.4%    top_k=20  89.3% / 90.1%
                                         (MiniLM / hashed)

per-table recall at k=10: 90.9% MiniLM, 91.3% hashed

Report the first block. The second is the same run with 44 questions removed, and those 44 are not a random 44: they are the ones whose gold SQL reads a wildcard partition -- events_*, ga_sessions_* -- which schemagate cannot select because it holds each day as a separate object. Dropping the questions a method fails is how a benchmark number gets inflated, and this file did it for two releases before the difference was measured.

The history is worth keeping, because both errors moved the number the same way:

  v0.1.48  86.1% @20   schema files silently unreadable (long paths)
  later    83.3% @20   files fixed, but 44 unresolved questions still dropped
  then     67.2% @20   166/247, every usable question counted
  then     71.3% @20   176/247, loader reading per-column descriptions correctly
  then     76.9% @20   190/247, column comments no longer indexed three times
  now      84.2% @20   208/247, harness calling collapse_partitions() as
                       bootstrap() does -- a measurement fix, not a retrieval one

The last line is the only one that moved the number up, and it is worth being precise about why, because "our number went up" is the claim most worth distrusting. The loader fix did not make retrieval better at anything it was already doing: it made nine more questions resolvable and answered six of them. On the 212-question comparison set the k=10 figure actually fell, 77.8% to 77.4% with MiniLM, because the nine questions that joined the set are harder than the average of the 203. The recall figure over 247 rises because the numerator grew and the denominator did not.

The "39%" in the first line has been removed rather than corrected, because it is not a property of the benchmark. Windows refuses a path over 260 characters, and the limit applies to the whole absolute path, so how many files are lost depends on where the repository sits on disk. This re-run was done with resource/databases at a 182-character root, where 2,868 of 7,892 paths exceed 260. One character deeper is 3,056. The durable numbers are relative: the longest path is 154 characters below databases/, so any checkout rooted deeper than 105 characters loses at least one file. python benchmarks/longpath.py prints all of this for whatever checkout you have.

Nineteen points of the original figure were measurement error, all of it flattering. The fix for the first is benchmarks/longpath.py; the fix for the second is counting.

Prompt tokens on public schemas: the saving grows with the schema

benchmarks/token_savings/measure.py counts, for every question, the tokens of the schema a text-to-SQL prompt carries: every column of every table in the database (no selection), against what Catalog.select returns. No model is called. cl100k_base tokens; the hashed embedder pip install schemagate gives you; top_k 6 on BIRD, 12 on Spider 2.0. Results are in benchmarks/token_savings/results.json.

Schemas Questions Databases Median tables Full schema, mean tokens Selected, mean Mean saving Median saving per database
BIRD dev 1,534 11 7 579 462 20.1% 8.8%
Spider 2.0-lite, local SQLite 135 30 13 924 642 30.6% 6.3%
Spider 2.0-lite, BigQuery and Snowflake 412 128 11 44,571 1,450 96.7% 44.3%
the same, databases with 50+ tables 24 91.1%

Small schemas leave little to cut: on BIRD's seven-table databases the whole schema is already a few hundred tokens. The saving is where schemas are big. The mean on the warehouses is pulled up by a few very large databases, so the per-database median is the fairer single number.

The cost of cutting: on BIRD, every table the gold SQL reads was in the selection for 98.4% of questions. On the warehouses (questions with public gold SQL) it was 86.7%. A question whose table was cut can't be answered from the prompt. That is what the retrieval sections above measure in detail.

This table was revised after 1.14.0. It first said 79.6% for the warehouses, with a median of 16 tables, 46,560 full-schema tokens and a median saving of 92.9% over 33 databases with 50+ tables. Two things changed, and both are in the numbers above:

Folding also shrinks the full schema the saving is measured against: eleven storms_ tables now count as one. That is why the median table count fell from 16 to 11, nine databases dropped below 50 tables, and the 50+ median moved from 92.9% to 91.1%. The tokens actually sent went down, from 1,469 to 1,450 per question.

Head-to-head: schemagate against the retrievers other tools use

benchmarks/token_savings/compare.py picks tables four ways, on the same questions and the same catalog, with no model calls: full schema (no selection); BM25 keyword search over each table's definition; embeddings, all-MiniLM-L6-v2 similarity, the default way LangChain, LlamaIndex and Vanna pick tables from a vector store; and schemagate, with sentence-transformers installed so it uses the same MiniLM model -- the difference is ranking and join expansion, not the embedder. Every method gets the same number of tables (12 on Spider 2.0, 6 on BIRD), and the baselines get schemagate's partition folding for free, which flatters them. Results are in benchmarks/token_savings/compare_results.json.

Spider 2.0-lite warehouses, 225 questions BIRD dev, 1,534 questions
every gold table selected mean tokens every gold table selected mean tokens
full schema 93.8% 56,237 100% 579
BM25 78.7% 1,513 81.0% 407
embeddings (MiniLM) 78.2% 1,555 89.4% 438
schemagate 85.3% 1,677 98.7% 468

Paired, on the same question: schemagate had every gold table where embeddings did not on 17 warehouse questions, and the reverse happened on 1; against BM25, 18 to 3. On BIRD, 151 to 9 against embeddings and 277 to 6 against BM25.

What it costs: schemagate sends 8-11% more tokens than plain retrieval, mostly the tables needed to join the ones it picked. What it does not show: selectors that ask a model to pick tables (LangChain's table-selection chain, research systems such as CHESS) were not run, since they need a model call per question. The full schema stays below 100% on the warehouses because some gold SQL reads tables that are not in the benchmark's catalog files. With the default hashed embedder (pip install schemagate, no extras) schemagate's warehouse figure is 86.7%, as in the table above this one.

What the dropped questions say about the tool

They are not noise. Spider 2.0's ga4 database is 92 tables named events_20201101 through events_20210131, and ga360 is 366 of the same shape. They differ by a date, which no embedder can reason about, so a question about January selected twelve tables from November -- measured -- and the model correctly refused a question it had been handed the wrong month for.

The library has folded these since 9c68ec6, and this benchmark was not using it. Catalog.collapse_partitions() turns a family of date-suffixed siblings into one entry named with a wildcard -- events_* -- which is also what the SQL should say, because BigQuery reads a wildcard table directly and the gold SQL for these questions does exactly that. Catalog.bootstrap() calls it, so every user reflecting a real database already gets it.

benchmarks/spider2.py did not call it. It builds its catalog with add() and index() rather than bootstrap(), so the one thing that makes a partitioned warehouse tractable was switched off for the whole benchmark. Every Spider 2.0 figure this file has ever published was measured against a schemagate nobody runs.

Turning it on -- one line, plus passing the catalog's own names through the same bare() normalisation the gold references already go through, so a collapsed events_* can match a gold events_* at all:

                          k=5      k=10     k=20
  without collapsing     57.9%    72.1%    76.9%     (what this file reported)
  with collapsing        66.8%    80.6%    85.0%     (what the library does)

and the gold SQL resolves for 233 of 247 questions instead of 212, because the wildcard form the benchmark's own SQL uses now exists as an object.

That is a measurement fix, not a retrieval improvement, and it is worth being blunt about the difference: nothing about the product got better here. The harness was told to measure a configuration the product does not ship. The normalisation half is measured separately and is worth exactly one question on its own, so the movement is the collapsing.

An earlier version of this paragraph said the collapsing was "implemented in benchmarks/spider2_e2e.py and not yet in the library". Both halves were wrong: that file has no such code, and the library has had it since 9c68ec6. Recorded rather than deleted, because a file that keeps its own corrections should keep this one.

The embedder, on data I did not write

pip install schemagate uses a hashed n-gram vectoriser; installing schemagate[huggingface] switches it to a sentence model automatically. On my own schemas that was worth +3 questions out of 98, which is thin evidence. On Spider pooled:

876 tables, no hint        hashed      MiniLM      (re-measured 2026-10-16, 1.5.2)
  top_k=5                   76.7%   →   80.3%
  top_k=10                  86.1%   →   90.7%
  top_k=20                  94.2%   →   97.0%

(The MiniLM top_k=5 figure, 80.3%, is the same 830/1034 that the reranker section below starts from as its "maths order".)

Consistent, and larger than my own benchmarks suggested. Then Spider 2.0 said something different:

233-question comparison set     hashed      MiniLM
  top_k=5                     70.8%   →   73.8%
  top_k=10                    85.4%   →   84.5%
  top_k=20                    90.1%   →   89.3%

over all 247 usable questions   hashed      MiniLM
  top_k=5                     66.8%   →   69.6%
  top_k=10                    80.6%   →   79.8%
  top_k=20                    85.0%   →   84.2%

Nothing, and fractionally worse at the wider cuts. So "install the extra and retrieval improves" is not a claim this evidence supports.

I guessed at why: Spider 2.0 tables carry descriptions from the warehouse's own data dictionary and Spider 1.0 tables carry none, so where there is prose, BM25 over it already does what the sentence vectors were compensating for. That was a hypothesis fitted to two benchmarks that differ in everything, so here it is tested inside one of them -- same questions, same databases, same columns and types, with only the description field suppressed.

Spider 2.0-lite, n=212, BEFORE the fix this table led to (see below)
(percentages over all 247 usable questions in brackets)
McNemar exact on the discordant pairs: b = removing prose fixed the question,
c = removing prose broke it

  k    embedder   with prose            prose removed          b   c     p
  5    hashed    136/212 64.2% (55.1%)  143/212 67.5% (57.9%)  16   9  0.2295
  5    MiniLM    140/212 66.0% (56.7%)  154/212 72.6% (62.3%)  19   5  0.0066
  10   hashed    165/212 77.8% (66.8%)  177/212 83.5% (71.7%)  15   3  0.0075
  10   MiniLM    164/212 77.4% (66.4%)  176/212 83.0% (71.3%)  13   1  0.0018
  20   hashed    178/212 84.0% (72.1%)  187/212 88.2% (75.7%)  12   3  0.0352
  20   MiniLM    176/212 83.0% (71.3%)  189/212 89.2% (76.5%)  15   2  0.0023

Both arms are built from one read of each schema, one database at a time, so the only difference between them is whether the per-column descriptions are present. One embedder instance is shared across every catalog: constructing one per database loads the sentence model 206 times and the process dies with SIGSEGV, which is how the first attempt at this table failed.

The hypothesis is wrong. MiniLM's advantage over the hashed embedder, counted in questions, goes from +1/-1/-1 with prose to +2/+2/-1 without it. The largest shift is three questions, and at one cut it is zero. Spider 1.0's gap was about nine questions. Removing prose does not bring it back, so prose is not what separates the two benchmarks. What does, I do not know: it could be the dialect, the phrasing of the questions, the size of the tables, or pooled-versus-per-database. I am not going to guess a second time.

And the column does not hold up either. Deleting every description looked like it made retrieval better -- four to seven more questions answered, for both embedders, at every cut. That is a net margin on a paired design, and a net margin cannot tell you whether seven questions flipped one way or twenty-one flipped both. McNemar's exact test on the discordant pairs, both embedders, n=203 (b = fixed by the cap, c = broken by it, d = discordant):

hashed                                      MiniLM
  cap=0     k=5   b=12 c=11  d=23  p=1.000    b=14 c=8   d=22  p=0.286
  cap=0     k=10  b=10 c=4   d=14  p=0.180    b=7  c=2   d=9   p=0.180
  cap=0     k=20  b=11 c=6   d=17  p=0.332    b=15 c=1   d=16  p=0.001
  cap=40    k=5   b=8  c=6   d=14  p=0.791    b=8  c=6   d=14  p=0.791
  cap=40    k=10  b=2  c=2   d=4   p=1.000    b=5  c=2   d=7   p=0.453
  cap=40    k=20  b=2  c=2   d=4   p=1.000    b=6  c=1   d=7   p=0.125
  cap=200   k=5   b=3  c=5   d=8   p=0.727    b=3  c=6   d=9   p=0.508
  cap=200   k=10  b=1  c=1   d=2   p=1.000    b=3  c=2   d=5   p=1.000
  cap=200   k=20  b=1  c=1   d=2   p=1.000    b=3  c=0   d=3   p=0.250
  cap=1000  k=5   b=2  c=1   d=3   p=1.000    b=3  c=1   d=4   p=0.625
  cap=1000  k=10  b=1  c=1   d=2   p=1.000    b=3  c=1   d=4   p=0.625
  cap=1000  k=20  b=0  c=1   d=1   p=1.000    b=4  c=0   d=4   p=0.125

One cell of twenty-four clears significance, and it is the interesting one: MiniLM, prose deleted, k=20 -- fifteen questions fixed against one broken, p=0.001. At n=158 the nearest equivalent was p=0.065, so the larger question set did move it.

It still does not carry the claim, for a reason worth being explicit about. Swap the embedder and it evaporates: the same cap, the same cut, the same 203 questions, scored with the hashed vectoriser instead, is b=11 c=6, p=0.332. A result that survives one embedder and not the other has measured the embedder. It is also one cell of twenty-four, where a Bonferroni threshold is 0.002 -- p=0.001 squeaks under, which is not the kind of margin that should change anybody's mind on its own.

That table was a bug report, and the bug is fixed. Read on its own it says "delete your descriptions": removing them won at every cut on both embedders, five of six cells significant, thirteen questions fixed against one broken at k=10 with MiniLM. A design whose whole premise is that prose helps should not lose to deleting the prose, so the question was not whether to believe the table but what it was pointing at.

It was pointing at the same text being counted three times. A column comment went into _prose_text for the prose channel, and into embed_text() -- which feeds the body channel and the vectors. A Spider 2.0 table carrying a description per column therefore looked like a match on three of four channels for any question sharing a word with any one of its columns, and the widest tables became magnets. Decomposed on the same 212 questions: removing the comments from the prose channel alone recovered 4 questions at k=10 and from the body channel alone recovered 11, which is where the damage was.

Naming the channel matters. embed_text() has two consumers: the vectors, and _body_text (catalog.py), which builds the body channel's BM25 document from it. _body_text is what changed -- it now drops the per-column comments -- and embed_text() itself is unchanged and still carries them (models.py). An earlier version of this paragraph attributed the 11 to "embed_text() alone", which contradicted the next sentence: a fix that leaves the vectors byte-identical could not have produced a recovery that came from the vectors.

The two arms are not additive: 4 and 11 sum to 15 against a whole of 13, because each arm is measured against the same unmodified baseline, and some questions are recovered by either change on its own.

The fix takes column comments out of the body channel and the prose channel and leaves embed_text() alone, so the vectors -- a published, pinned guarantee -- are byte-identical and no stored index is invalidated. Re-run paired on the same 212 questions with the fix in place, hashed embedder:

  k    with prose        without prose      b   c     p
  5    143/212 67.5%     143/212 67.5%      6   6   1.0000
  10   177/212 83.5%     177/212 83.5%      2   2   1.0000
  20   188/212 88.7%     187/212 88.2%      2   3   1.0000

One check that the table is the artifact it claims to be, and the check is across the fix, not across embedders: the prose-removed arm is 143, 177, 187 both before and after, hashed throughout, because deleting every description cannot care which channel would have carried them. Only the with-prose arm was ever going to move, and it is the arm that moved.

Read the b and c columns, not the p column. With d = b + c discordant pairs of 12, 4 and 5, the smallest two-sided exact p attainable is 2^(1-d): 0.125 at k=10 and 0.0625 at k=20, so those two cells could not have reached p < 0.05 at any split of b and c. What the rows support is that b and c converged -- the penalty is gone -- not anything about significance.

The penalty is gone. Same embedder on both sides, hashed, before and after the fix:

  k    b/c before   b/c after    d
  5    16/9         6/6          25 -> 12
  10   15/3         2/2          18 ->  4
  20   12/3         2/3          15 ->  5

The stronger statement is in the levels, not the discordance: hashed with prose goes 136 -> 143 at k=5, 165 -> 177 at k=10 and 178 -> 188 at k=20. At k=5 and k=10 the with-prose arm lands exactly on the prose-removed arm; at k=20 it passes it. Descriptions no longer cost anything on this benchmark -- they neither help nor hurt it, which is the floor the fielded design was supposed to guarantee and did not. The shipped numbers above are the with-prose column, because that is what a described catalogue now costs: nothing.

An earlier version of this paragraph said "thirteen-to-one became two-to-two". That paired the MiniLM cell of the before-table at k=10 (13/1) with the hashed after-table (2/2), crossing the embedder axis -- the exact error the paragraph above warns against. The hashed before-cell at k=10 is 15/3. Caught by howcani on dev.to.

Two things this does not say. It does not say descriptions are useless -- see the live measurement below, where a generated catalogue takes end-to-end SQL from 6/8 to 8/8. And it does not vindicate the withdrawn claim: "long descriptions hurt retrieval" was true of this implementation, not of prose, and the right response to a true measurement was to fix the thing it measured rather than publish it.

The cap sweep below was measured with the old loader and has not been re-run. Truncating descriptions at 200 or 1,000 words was indistinguishable from leaving them alone -- one to four discordant pairs, p=1.000 -- and only deleting them entirely moved anything. That conclusion is consistent with the re-run, but the cap cells themselves are stale and are kept only as the record of what was done.

For comparison, prose in the other two benchmarks:

Spider 1.0      0 of 876 tables have a description
BIRD            0 of  75

Both are bare names and columns, which is why the ablation could only be run on Spider 2.0.

What the ablation was removing, and the re-run that fixed it. Until recently every number in this section was measured with a loader that read description as a table description -- joined into one paragraph and indexed as prose. Sampling the raw files for the long-path report showed the key is nothing of the kind: it is a per-column list, aligned by index to nested_column_names when the table has nested fields (31 of 31 sampled) and to column_names otherwise (9 of 9), entries sometimes null, never a string (150 of 150). The loader also built columns from column_names alone, so a nested table -- gnomAD's v3_genomes__chr7, 61 top-level and 181 flattened -- was missing the very columns its gold SQL reads. benchmarks/spider2.py now attaches each entry to its column and uses the flattened list; tests/test_spider2_loader.py holds it there.

That re-run is what the numbers above are. It was done against a complete checkout of the benchmark -- resource/databases at a 43-character root with core.longpaths set, all 8,255 files readable -- with the shipped 0.1.56 library, and the paired arms were scored by a script written twice independently, in-memory and streaming, agreeing cell for cell before either was believed. The numbers this replaced are in the history block above rather than deleted.

Indexing 876 tables takes 0.8s hashed and 9.3s with the sentence model.

The reranker, measured for the first time

select(..., reranker=provider, rerank_candidates=20) has existed since the reranker landed and had never been measured. tests/test_rerank.py proves it is safe -- a model that fails, or replies with nonsense, leaves the maths order untouched, and a restricted object cannot be promoted into an answer -- but safety is not usefulness, and no recall number for it existed.

benchmarks/rerank_eval.py scores every business-language question twice against the same catalog, the same embedder and the same top_k: once with reranker=None, once with a model reordering the shortlist. Paired, so the report is discordant pairs and McNemar's exact test rather than a net margin.

anthropic:claude-sonnet-4-5, all-MiniLM-L6-v2, top_k=6, candidates=20:

schema      set          base   rerank    b    c        p
commerce    TUNE        75.0%    91.7%    2    0    0.500
health      TUNE        90.0%   100.0%    1    0    1.000
warehouse   HELDOUT     80.0%   100.0%    2    0    0.500
finance     HELDOUT     80.0%   100.0%    2    0    0.500
telemetry   HELDOUT     80.0%   100.0%    2    0    0.500
complex     HELDOUT    100.0%   100.0%    0    0    1.000
---------------------------------------------------------
TUNE                    81.8%    95.5%    3    0    0.250
HELD OUT                83.3%   100.0%    6    0    0.031
OVERALL                 82.8%    98.3%    9    0    0.004

Nine questions fixed, none broken. That the count of broken questions is zero is the more interesting half: the model only reorders a shortlist of 20 and the maths still decides eligibility, so it can lift a gold table from position 7-20 into the top 6 but cannot invent one, and every question the ranking already answered it left alone.

What this is not. Fifty-eight questions on the six schemas in this repository, which are invented, not public benchmarks -- the Spider and BIRD numbers above are untouched by this and no reranked figure has been measured on either. One model. And the held-out p=0.031 rests on six discordant pairs: it clears 0.05, and a Bonferroni threshold across the six schemas is 0.008, which it does not clear. The overall p=0.004 does.

The second-model control, which this one passes. The prose ablation below evaporated when the embedder was swapped, so the same question had to be put here. Re-run end to end with claude-haiku-4-5 -- a smaller and cheaper model -- the result is not merely similar, it is identical:

                        base   rerank    b    c        p
TUNE                    81.8%    95.5%    3    0    0.250
HELD OUT                83.3%   100.0%    6    0    0.031
OVERALL                 82.8%    98.3%    9    0    0.004

Same nine questions fixed, same none broken, the same b and c in every schema. Two models of different sizes agreeing to the question is evidence that what was measured is the shortlist -- gold sitting at rank 7-20 that any competent reader can lift into the top 6 -- and not one model's taste.

Both are still Anthropic models. A model from another vendor is the remaining control and has not been run, because no other provider key was available on the machine that ran this.

Cost, since it is not free: one API call and roughly two seconds per question (58 selections in 115s).

Access control, scored by someone else: the RBAC Text-to-SQL benchmark

Everything above scores retrieval. This scores the thing schemagate exists for, on a benchmark it did not write: Benchmarking Text-to-SQL under Role-Based Access Control (Fei, Jiang, Yang, Xiao; SIGMOD'27, arXiv 2607.22115, github.com/2020dfff/RBAC-Text2SQL-Benchmark). Each row is a question, a role and a column-level policy. The system must answer, or refuse when the role cannot read what the answer needs. Scoring is the authors' own evaluator, unchanged (evaluate_protocol_v2.py --fair_comparison --num_trials 5 --execute_sql): five passes, each pairing every question with one random role.

benchmarks/rbac_text2sql.py turns each policy into schemagate visibility and runs the shipped pipeline: select (top_k 6, hashed embedder, no reranker), prompt_fragment, generate_sql, then run_query's scope check. INSUFFICIENT, or SQL the scope check refuses, becomes the benchmark's refusal. claude-sonnet-4-5, the model behind the paper's best results; mean ± std over 5 trials:

BIRD dev (10,175 rows)            AC-F1          SafeEX        violation      over-refusal   SQL read forbidden data
  schemagate                      90.14 ± 0.32   68.38 ± 1.07   5.44 ± 0.19    1.98          0.00
  schemagate, no scope check      84.69 ± 0.69   69.14 ± 0.85  11.25 ± 0.44    1.23          3.78
  paper: policy in the prompt     88.12 ± 0.90   65.02 ± 0.89   7.37 ± 0.49    1.81           --
  paper: role-schema, best model  72.46 (GPT-5-mini)             26.72          0.33           --

Spider dev (6,926 rows)
  schemagate                      93.02 ± 0.37   78.87 ± 1.05   5.05 ± 0.49    2.59          0.00
  schemagate, no scope check      90.68 ± 0.31   79.56 ± 1.05   8.67 ± 0.57    1.91          2.01
  paper: policy in the prompt     93.03 ± 0.48   80.99 ± 0.86   4.55 ± 0.22    3.01           --
  paper's best Spider model       95.94 ± 0.37   75.73 ± 1.19   1.97 ± 0.25    2.36           --
    (DeepSeek V3.2-Reasoning)

The "paper" rows are the paper's Claude Sonnet 4.5, the model used here. On BIRD that is also the best of its thirteen models; on Spider the best is DeepSeek V3.2-Reasoning, shown separately.

"Violation" is the benchmark's: any answer to a question the role may not ask, even one that reads only permitted columns. "SQL read forbidden data" is the stricter thing a database cares about, the SQL touching a table or column outside the policy, and it is zero by construction: that is what the scope check refuses.

What it shows. On BIRD schemagate beats the paper's best configuration on AC-F1, SafeEX and violations, without showing the model the restricted part of the schema at all; the paper's prompt shows the full schema and the policy beside it. On Spider it ties the same model on AC-F1 and is slightly behind it on violations and SafeEX; the paper's best Spider model, DeepSeek V3.2-Reasoning, is ahead of it on AC-F1 (95.9) and violations (2.0%), behind on SafeEX. The paper's own schema-restriction baseline -- the model sees only what the role may read -- fails because models "rarely refuse" and approximate from the columns that are left. Two things fix that here. The scope check turns a reach for anything outside the policy into a refusal; the table isolates it (with vs without, both with the notice). And Catalog.restriction_notice (on by default from 1.6.0) adds one line saying the view is partial, never naming what is withheld. The notice was isolated only on the 100-question BIRD pilot, with the scope check on: violations 16% without it, 5% with it, AC-F1 0.82 to 0.92. The full runs were not repeated without it.

What it does not show, and the caveats. - The notice's wording was chosen on a 100-question pilot drawn from the start of the BIRD evaluation set, so it was tuned on 100 of BIRD's 1,534 questions. Spider was not used for any choice and is the cleaner of the two. - Same model name as the paper, not necessarily the same API snapshot. The prompts differ by design: the paper's is theirs, this is schemagate's. - BIRD databases are the 2024-06-27 dev release; the paper used 2025-11-06. Gold SQL comes from the benchmark's own file, unchanged. - Loading BIRD's column descriptions as comments made it worse on the pilot (violations 16% to 26% without the notice): richer notes on the visible columns helped the model find workarounds for questions it should have declined. The runs above use the schema as reflected. - One model; the paper reports thirteen. About $195 of API calls for both datasets.

Access control, a second benchmark: Spider-ACL

Role-Conditioned Refusals: Evaluating Access Control Reasoning in Large Language Models (Klisura, Khoury, Kundu, Krishnan, Rios; Findings of EACL 2026; github.com/klisura-code/LLM-Access-Control-Datasets) gives every Spider question four roles, from full read access to almost none, as PostgreSQL GRANT statements. The label is what PostgreSQL itself said when the reference SQL ran as that role: PERMIT, or DENY ("permission denied"). 19,624 records: 8,095 PERMIT, 11,529 DENY. Scored as the paper does: refusal precision, recall and F1, with refusal as the positive class.

benchmarks/acl_text2sql.py turns each record's GRANTs into schemagate visibility and runs the shipped pipeline -- select (top 6, default embedder), prompt_fragment with the restriction notice, generate_sql, then run_query's scope check. INSUFFICIENT, or SQL the scope check refuses, is a refusal. One model, claude-sonnet-5-5, all 19,624 records, none failed:

                                         refusal P   refusal R   refusal F1   false permits
  schemagate, all 19,624                 0.985       0.982       0.984        1.8%  (206 / 11,529)
  schemagate, 600 never used for tuning  0.989       0.983       0.986        1.7%
  paper's best, Spider                   --          --          0.933        --     (LoRA-tuned Mistral-7B)
  paper's best without fine-tuning       0.898       0.868       0.883        --     (DeepSeek-R1 + verifier)

No answered query read anything the role was not granted. Checked independently of the scope check that enforces it: every one of the 8,128 answered queries was parsed with sqlglot and its tables and columns compared with the record's GRANTs -- none outside them. (Two queries the parser could not read, because the model wrote a sentence after the SQL, were checked by hand; every column they name is granted.)

False permits are not leaks. A false permit is a DENY question the system answered anyway, from columns the role may read. Some are the label's doing -- "list the customer ids" is DENY because the reference SQL also selects a name the question never asked for -- and some are a model answering a near-miss of the question from the columns that remain. None of them read withheld data.

What changed to get here. A 200-question pilot first scored F1 0.83, with 32 of its 33 wrong refusals from one bug: the scope check read FROM students AS T1 -- upper-case AS -- as two tables and refused valid queries. Fixed in 1.6.1 (tests/test_scope_check_uppercase_as.py). The pilot's 200 records were then set aside, and the 600-record figure above comes from records it never touched.

Limits. The paper's systems are 2024-25 models (GPT-4o-mini, DeepSeek-R1, Llama 3.1, Mistral-7B); this is a 2026 model, and the gap is partly the model. Execution accuracy is not measured here (it needs the PostgreSQL databases), only the answer-or-refuse decision. BIRD-ACL, the paper's second dataset, was not run. Every per-record decision and query is in benchmarks/results/acl/spider_acl_claude-sonnet-5-5_decisions.jsonl.gz.

The reranker on Spider and BIRD

The numbers above are the reranker on this repository's own invented schemas. This measures it on the public benchmarks, in the setting where the order inside the shortlist decides the answer: POOLED, every database in one catalogue and no hint which one a question belongs to (benchmarks/rerank_public.py). Each question is scored twice on the same catalogue, embedder and top_k: the maths order (RRF of the lexical, vector, name and prose rankings), and a model reordering the top 20. Metric: every table the gold SQL reads is in the top 5. Paired, McNemar's exact test.

claude-haiku-5-5, default embedder, top_k=5, candidates=20, all of Spider dev and BIRD dev:

set     tables   n      maths order       reranked          b     c    p
spider  876      1034   830  (80.3%)      998  (96.5%)      169   1    2.3e-49
bird    75       1534   1359 (88.6%)      1483 (96.7%)      125   1    3.0e-36

The model answered every one of the 2,568 calls -- this is counted, not assumed, because until the fix below a reasoning model answered none of them and the result would have read as "no effect".

What this is and is not. It is table retrieval, not execution accuracy: the right tables in the prompt, not the right answer. One model; Spider and BIRD questions are public and may be in its training data, which would flatter a reranker that reads them -- though it only reorders candidates the maths already shortlisted and sees table names and summaries, not the gold SQL. About one call and a few hundred tokens per question.

The fix that made this measurable. rerank() asked for max_tokens=120. Claude 5 models spend that on reasoning before writing, so every call raised, the reranker fell back to the maths order as designed, and --rerank silently did nothing. It is now 1024, and tests/test_rerank.py holds a model that fails below 512 tokens. A fallback that is safe by design also hides total failure: the benchmark logs whether the model answered for exactly that reason.

The reranker stays opt-in -- picking tables needs no API key, and that is the default -- but with a key available it is the largest single gain measured in this file. The MCP server takes it with SCHEMAGATE_MCP_RERANK=1.

Column ranking, and why this file reports no gain for it

render_ddl kept visible[:max_columns] -- the first 40 columns in declaration order, whatever was asked. That is truncation, not selection: on a 200-column fact table the column the question needs can be number 147 and is cut, while the prompt pays for 40 nobody asked about. Columns over the budget are now chosen by overlap with the question, with keys kept unconditionally because dropping a foreign key costs a join rather than a column.

On the six schemas in this repository it changes nothing at all. Measured rather than assumed -- all 58 business-language questions, prompt rendered with and without the question passed down:

prompts identical with and without column ranking : 58
prompts changed by column ranking                 :  0

The reason is in the schemas, not the code. Exactly one object across all six is wider than the budget:

schema       objects  maxcols  over40  p95cols
commerce          42       11       0        8
health            27       10       0        7
warehouse         51       22       0       12
finance           39       14       0       10
telemetry         40        8       0        7
complex          260      321       1       15

That one is wide_measurement_matrix, whose 321 columns are named attribute_000 through attribute_319. It exists to test that a wide table does not break rendering, and no question can prefer one of its columns to another, so even there ranking has nothing to work with.

So the honest statement is narrow. The mechanism is covered by tests/test_column_ranking.py, including the property that ranking runs after visible_columns and so cannot surface a column the caller may not see -- both of those tests were checked by reverting the change and confirming they fail. What is not demonstrated is a retrieval or token gain, because these fixtures cannot demonstrate one. A realistic wide table -- a claims or telemetry fact table with meaningful column names -- is what would measure it, and this repository does not have one yet.

The change is therefore recorded here as safe and unmeasured. It cannot regress the numbers above for the concrete reason that it does not alter a single prompt that produces them.

A stemming bug, and what it had been propping up

_stem exists to let a plural meet its singular, and for the commonest plural in English it did not. The "-es" rule stripped both letters unconditionally -- right for boxes -> box, wrong for every noun whose singular already ends in "e":

employees -> employe     employee -> employee     never met
invoices  -> invoic      invoice  -> invoice      never met
notes     -> not         note     -> note         never met
prices, packages, services                        never met

Six of eleven common plurals did not reach their singular. Both sides of the lexical index go through this function, so a question about "invoices" did not match the invoice table lexically at all; it was rescued, when it was rescued, by the vector channel. A second bug compounded it: the coverage pass looked up idf.get(t) with the raw token against an index that stores stems, so coverage silently never fired for contacts, payments or invoices -- the ordinary way anyone phrases a question.

Both fixed. "-es" is stripped only after a sibilant, where English actually adds one; otherwise the "s" alone. Twelve of fourteen plurals now match; the two that do not (addresses, statuses) are the pre-existing "-ses" case, which no suffix rule separates from houses -> house without a dictionary, and were left alone rather than guessed at.

What it changed on the shipped schemas: recall unchanged on all six; average prompt tokens 742 -> 764 (+3%), because coverage now fires for plurals and adds the table it should have added all along. The README cell was updated to match.

What it changed on the 1,200-object fixture is the part worth reading. Correct stemming lifted the flat bag from 9/16 to 12/16 at k=6 and left fielded scoring at 12/16 -- so the fielded-versus-flat comparison recorded in PR #41 no longer reproduces at the operating point at all. A meaningful share of the advantage attributed to fielded scoring was an artifact of the flat bag being unable to match plurals. Foreign-key expansion on the same fixture went from worth 3 of 8 complex questions to 0 of 8, for the same reason: with plurals matching, ranking reaches those tables on its own.

That is reported rather than absorbed. The claim that fielded scoring beats a flat bag on a saturated corpus is now narrower than this file has stated. On that fixture after the fix it wins at k=1, 3 and 5, ties at 6, loses at 10 and wins again at 20 -- a sign that flips with the budget is measuring the budget, and it is not stated as a result here.

The AI catalogue, measured where it is actually used

use_desc in tests/run_paraphrase_eval.py was a parameter that existed and did nothing: it was accepted and never applied, so anyone measuring the catalogue through that harness got a catalog with no descriptions and would have concluded descriptions were worth nothing. Wired. With it wired, the same fixtures test_business_language.py uses give:

schema       identifiers  +AI catalogue
commerce           50.0%       100.0%
health             50.0%        90.0%
warehouse          50.0%        80.0%
finance            70.0%       100.0%
telemetry          60.0%       100.0%
OVERALL            55.8%        94.2%   n=52

And on the metric that matters -- SQL that runs -- against the live Oracle 1,200-object schema. Eighteen objects described by a model, then the eight complex questions asked end to end (selection, model writes SQL from the fragment alone, Oracle executes it):

no catalogue                          6/8 executed
shipped prompt, AI catalogue          7/8 executed
a "better" prompt, AI catalogue       5/8 executed   (two runs, same failures)

The third row is a prompt this session wrote to remove framing words from descriptions, on the strength of a measurement that "answering" appeared in about half of every checked-in catalogue. It described the same eighteen objects and produced fewer runnable queries, twice, with identical failures both times. It was reverted. The shipped prompt is the better one, and the only reason that is known is that it was measured against the thing users actually get -- a query that runs -- rather than against the intermediate statistic the change was designed to improve.

Column evidence gets one slot

Rank fusion rewards breadth over depth. An object that is first on two channels can lose to twenty objects that are tenth on three, because RRF turns every rank into 1/(60+rank) and adds -- so three mediocre ranks beat two excellent ones and an absence.

Measured on the 1,200-object fixture, with the AI catalogue applied: "Top 5 contacts by email opens in the last 30 days, with their company" ranked crm_engagement_fact first of 1,199 on the body channel and first on prose -- its columns are email_open_7d, email_open_30d and so on -- and fused it to 30th, behind twenty-nine crm_contact_* and crm_company_* siblings that merely share a word with the question in their name. The coverage pass could not help: it guarantees a slot for informative words that appear in NAMES, and "email" and "open" appear only in columns.

The body channel is the only one that sees columns, so its single best hit is the one piece of evidence nothing else guarantees. It now gets one slot, under exactly the rules coverage uses: budget-neutral, displacing the weakest ranked pick, never a pinned or covering one, abandoned rather than break the budget. The rule names no schema and no word.

Where it is a no-op, which is everywhere it should be. On the six shipped schemas the benchmark gate is byte-identical -- every recall cell and the token cell unchanged -- because on a small schema the best lexical match already made the cut. The 52-question business-language measurement is unchanged at 55.8% / 94.2%. Parity between the Python and JS ports holds on all 1,789 cases.

Where it bites. On the 1,200-object fixture, fielded recall at k=6 went from 12/16 to 14/16 (fixes 3, breaks 1), and column ranking now changes 2 of 16 prompts as retrieved, up from 0 -- which is to say the wide fact table is finally being retrieved rather than pinned. The apparatus sweep on (b) is still apparatus-dependent (fielded wins at 4 of 7 values of K) and is reported as such.

On the metric that matters. Eight complex questions, end to end against the live Oracle schema -- selection, a model writes SQL from the fragment alone, Oracle executes it -- run twice with identical results both times:

                          before      after
no catalogue               6/8         7/8
shipped-prompt catalogue   7/8         8/8

The one question still failing without a catalogue ("contacts with a tag but no note") is an anti-join across two children of the same parent, and with the catalogue applied it runs.

The same fixture on PostgreSQL

A finding that holds on one database is a finding about that database. So benchmarks/fixture_1200.py --target postgres builds the identical spec -- same 1,201 objects, same 1,144 foreign keys, same 3,579 rows, same one-voice descriptions -- on PostgreSQL 16 (a local container; 1,200 tables is not something to put on a shared database unasked), and every number is re-measured there.

The generator is what is checked in. The two databases differ only in dialect: NUMBER -> NUMERIC, VARCHAR2 -> VARCHAR, DATE -> TIMESTAMP, the date literal, and identifier case. Reflection takes 8 s on PostgreSQL against 160 s on the Autonomous Database.

Every acceptance check reproduces number for number:

                                   Oracle 26ai    PostgreSQL 16
(a) idf('contact') name / flat     4.288 / 0.147  4.288 / 0.148
(b) flat vs fielded @k=6           12 / 14 of 16  12 / 14 of 16
(c) column ranking, as retrieved   2 of 16        2 of 16
(d) probes reaching the guard      2 of 3         2 of 3
(e) complex, all gold present      4 of 8         4 of 8
    (b) sweep: fielded wins at     4 of 7 K       4 of 7 K

And on the metric that matters -- SQL that executes on the live database. Eight complex questions, a model writing SQL from the fragment alone, each run twice with identical results:

                          Oracle    PostgreSQL
no catalogue               7/8        6/8
real AI catalogue          8/8        8/8

The one extra miss on PostgreSQL without a catalogue is the refund question, which fails at generation -- the model declines the tables it was shown. With a catalogue it runs on both. The AI catalogue is generated separately for each database, because the qualified names differ (SGBENCH.crm_contact against sgbench.crm_contact); it is 18 objects and takes 18 s.

One thing the harness had wrong and now says correctly: its summary line read "executed on Oracle" whatever the target was.

The real catalogue, always

The AI catalogue was the largest accuracy lever in the library and the step everyone skipped, because using it meant knowing schemagate describe exists, running it, and carrying a --config file around. Every path that answers a question -- select, --answer, the MCP server, the Studio's connect -- now describes the catalogue first, whenever a key is present, and caches the result per connection. SCHEMAGATE_AUTO_DESCRIBE=0 turns it off; with no key nothing is called.

The first version did nothing on the fixture, and that was correct. It described only objects with no description and no hint. Every object on the 1,200-object schema carries a database comment, so it wrote 0 descriptions and PostgreSQL stayed at 6 of 8 complex questions -- the no-catalogue number. A comment is a comment.

But those comments are one voice repeated 1,036 times, and the 8 of 8 measured earlier had come only from overwriting them by hand. So a second rule: a comment whose best word, in the prose index, is less informative than BOILERPLATE_MAX_IDF = 0.5 is boilerplate -- it says something every object also says -- and is replaced. A hint is never boilerplate; a missing comment is missing, not boilerplate; a call that fails puts the original comment back.

The rule needed one refinement, found by running it: the object's own name and column words do not count. A generated comment nearly always restates them ("holds crm contact note records"), and with the name counted, 2 of 1,036 one-voice comments read as boilerplate -- each carried exactly one rare word, its own. The digits in crm_thing_005 were the last of it: not "own" under an alphabetic tokenizer, and a token that appears once in the corpus scores 3.7. Own words are now taken through the same tokenizer as the description, and a run of digits is never prose.

Through the product path, no flags, no hand-fed file, PostgreSQL 16:

                                    before       after
boilerplate comments replaced        0 / 1,201    1,035 / 1,201
comments kept (the other voice)      --           165
complex SQL executed                 6 / 8        8 / 8   (twice)
second run, warm cache               --           30 s, zero describe calls

1,035 is the number the fixture specified for the saturated voice, to the object; the 165 written in a different voice carry words the rest of the schema does not, and were kept. That is the rule discriminating, not clearing everything it sees.

Oracle, same path, same rule: 7 of 8. Three runs through the product path scored 6, 6 and 7 -- the middle one lost a query to a dialect slip by the model that did not recur -- and the consistent miss is the email-opens question, which PostgreSQL answers. The cause is deterministic and worth recording exactly, because it is not the dialect and not the rule. The fact table's indexed text is byte-identical on both databases. What differs is a neighbour: crm_channel_type was described independently on each database, and the Oracle description happens to read "ways customers can reach or interact with the company, such as phone, email, web" -- two of the question's words -- scoring 17.23 on the body channel against the fact table's 17.14, and taking the single body slot by 0.09. The PostgreSQL description of the same table, "defines the different kinds of communication or sales channels", carries neither word, and there the fact table is first.

So: the catalogue is nondeterministic across generations, and a one-slot rule is sensitive to that at the margin. Widening the slot would fix this question and be tuning to it, so the slot stays at one and the 7 stays in this table.

One object on each database kept its boilerplate comment: the fact table's own, whose synthetic text reads "holds engagement measures per contact", and "measures" appears in no other description. By the rule's definition that is a word the schema does not otherwise carry, and the rule is not bent for one fixture artifact. 1,035 of 1,036 is the honest count.

The suite is guarded: tests/conftest.py sets the kill switch by default, so a key in a developer's shell cannot turn a test run into billed calls, and tests/test_auto_describe.py asserts "no call was made" with a provider that counts its own calls rather than assuming it.

FK closure: what a selection carries beyond the budget

A question from the dev.to thread: when you ask for top_k=K, how much do you actually get? Reported as requested-versus-delivered that is a pair of numbers with no error bars. Reported as a quantity it is

closure(K) = returned(K) - min(K, supply)

where supply is the number of objects in the schema. It counts what arrived that ranking alone could not have put there. In this library that is foreign-key expansion: a ranked pick drags in the table it references.

FK closure, not closure. The coverage pass also appends, but over the 2,074 selections below it returns a covers pick three times -- 0 commerce, 0 health, 0 warehouse, 1 finance, 2 telemetry -- and it is budget-neutral in both branches: inside the budget it displaces a ranked pick, and when nothing is droppable it abandons the pick rather than widening the answer. It cannot enter closure. That split is identical under both embedders, which is why it is stated as a fact rather than a reading.

One curve, in full, for health's which claims were turned down and why, supply 27, K = 1..27:

2 2 3 3 4 5 5 6 7 7 8 7 6 5 5 5 5 6 5 4 4 3 2 2 1 1 0
                    ^ peak 8 at K=11

python benchmarks/closure.py prints one of these per question, with the supply and the K grid beside it, for 52 questions across five schemas.

Why there is no min/max pair here

Both endpoints are artifacts of the apparatus, and publishing them as bounds would be publishing the grid.

The floor is arithmetic. Once K reaches supply, the ranked set is everything, min(K, supply) is supply, and closure is 0 because there is nothing left to add. That is a clamp firing, not a measurement, and on a coarse grid most of the zeros are clamp-forced that way.

The ceiling moves with the grid. On a six-value grid health's reference question peaks at 7; swept over every K it peaks at 8. How many of the 52 questions peak higher on the dense sweep than on a coarse grid depends entirely on which coarse grid:

  compared against          questions peaking higher on the dense sweep
  [1, 3, 6, 10, 20, 50]                 24
  [1, 5, 10, 20, 40]                    23
  [1, 2, 4, 8, 16, 32]                  27
  [3, 6, 12]                            28
  [6]                                   43

Twenty-three to forty-three, for the same 52 questions and the same system. The number measures the grid. The curve does not, so the curve is what is printed.

And the ceiling is embedder-dependent too: health's reference question peaks at 8 with the hashed embedder and 5 with MiniLM. closure.py names the embedder in its output for the same reason the paraphrase harness does.

Bridge tables that share no word with the question

Suggested on r/LangChain: finding the two business tables is not enough if the selected DDL omits the table that joins them, and that table often shares no word with the question. So benchmarks/bridge_tables.py isolates them. A gold table is zero-overlap when no word of its name, split on _ and stemmed, appears in the question. No model is called.

questions with one zero-overlap tables all selected every gold table selected
BIRD dev, top_k=8 965 / 1,534 964 / 965 (99.9%) 1,533 / 1,534 (99.9%)
BIRD dev, + expand_inward=16 965 / 965 (100%) 1,534 / 1,534 (100%)
BEAVER dw (first 500), top_k=25 104 / 500 74 / 104 (71.2%) 466 / 500 (93.2%)
BEAVER dw, + expand_inward=50 94 / 104 (90.4%) 486 / 500 (97.2%)

BIRD's largest database is 13 tables, so a top_k of 8 already holds most of it; the case only bites on a warehouse. BEAVER's 97-table dw (MIT's enterprise benchmark) is one, and it declares no primary keys, so until 1.15.4 join inference found no edges there at all.

FK expansion follows a chosen table's own keys outward. A bridge or child table points into the selection -- TIP_DETAIL.TIP_SUBJECT_OFFERED_KEY into TIP_SUBJECT_OFFERED -- and was never pulled in by its parent. select(..., expand_inward=N) adds, from the N best-ranked objects, any not yet chosen whose foreign key points at a chosen table. It is off by default: with it off, tests/bench.py prints the same 57 lines as before, and BIRD dev selection is identical on every question.

The gate these numbers had to pass

Before any figure above was written down it was re-measured with one part of the apparatus changed -- the K grid, the embedder, the hit predicate, the denominator. If it moved, it was a fact about the apparatus and is reported as one. If it held, it is reported as a result.

What moved, and is therefore labelled rather than claimed:

What held:

End to end: execution accuracy on BIRD

Everything above scores retrieval -- did the right tables get selected. This scores what the benchmark scores: run the SQL, run BIRD's reference SQL, compare the rows. That is execution accuracy, the number published systems report, and it is the only figure here directly comparable to work outside this project.

The pipeline is the shipped one. select() picks the tables, generate_sql() writes the query against only those tables, run_sql() executes it. The model never sees a table selection did not return.

BIRD dev, all 1,534 questions, claude-opus-5-5, scored by BIRD's evaluation_ex.py (2026-10-22)

  EXECUTION ACCURACY        77.12%
    simple       (925)      80.97%
    moderate     (464)      71.34%
    challenging  (145)      71.03%
  repair calls (SQLite raised an error)       1 of 1,534
  asked again after the model declined       12 of 1,534

An earlier version of this section reported 68.0% on a seeded 150-question sample with claude-opus-5. The full set with the current model is above, and the sample runner (benchmarks/bird_e2e.py) is still in the repo.

Rows are compared as sets, which is how BIRD's own evaluator scores them: order is not graded.

Three things this is not. It is not yet a leaderboard placing -- that needs the held-out test set, which only the BIRD team can run; the submission package in benchmarks/results/bird_dev/ was sent to them on 2026-10-22 and this page will carry whatever number they report. It is one model call per question, so it is not comparable in cost with the multi-candidate systems at the top of the main board. And BIRD's own evidence string is passed through with the question, which is what the benchmark intends and what published systems do, but it is a hint a real user would have to write.

Run it yourself with python benchmarks/bird_minidev.py --questions dev.json --db-root <dev_databases> --provider anthropic --model claude-opus-5-5; the Mini-Dev and UniQL Oracle runs are in the same folder, with their exact commands in their READMEs.

Reproducing this

pip install schemagate pandas pyarrow

# Spider dev questions and the schema dump (two public files)
curl -L -o spider_dev.parquet \
  https://huggingface.co/datasets/xlangai/spider/resolve/main/spider/validation-00000-of-00001.parquet
curl -L -o spider_schema.json \
  https://huggingface.co/datasets/richardr1126/spider-schema/resolve/main/spider_schema_rows_v2.json
python benchmarks/spider.py

# BIRD dev (346 MB from the official mirror)
curl -L -o bird_dev.zip https://bird-bench.oss-cn-beijing.aliyuncs.com/dev.zip
python -c "import zipfile; z=zipfile.ZipFile('bird_dev.zip'); z.extract('dev_20240627/dev.json'); z.extract('dev_20240627/dev_tables.json')"
python benchmarks/bird.py

# Spider 2.0-lite: questions, plus a sparse clone for schemas and gold SQL
curl -L -o spider2_lite.jsonl   https://raw.githubusercontent.com/xlang-ai/Spider2/main/spider2-lite/spider2-lite.jsonl
git clone --depth 1 --filter=blob:none --sparse https://github.com/xlang-ai/Spider2.git
git -C Spider2 config core.longpaths true          # Windows: paths exceed 260 chars
git -C Spider2 sparse-checkout set   spider2-lite/resource/databases spider2-lite/evaluation_suite/gold
python benchmarks/spider2.py

SCHEMAGATE_AUTO_EMBEDDER=0 forces the hashed embedder if you have the sentence model installed and want the base-install numbers. SPIDER2_NO_DESC=1 reruns the Spider 2.0 script with descriptions suppressed, which is the ablation above.

What these numbers are not

The retrieval numbers are not a leaderboard placing: the boards score generated SQL, and table recall is one step before it. They are not end-to-end accuracy either — a perfect table selection still leaves the model to write a correct query, which is why the execution-accuracy section at the top exists. The Mini-Dev rows are on BIRD's own leaderboard (PR #251, merged 2026-10-25),

1 in all three dialects as of 2026-10-31; the main-board test-set result is

BIRD's to report.

What they are is a claim you can check without trusting me, on data neither of us controls.

The scriptsTry the demo