Back to Blog

We Shipped a “Provable” Bound. It Wasn’t Provable.

Prateek SinghJuly 25, 202613 min read
We Shipped a “Provable” Bound. It Wasn’t Provable.

samkhya's whole reason to exist is a join-cardinality ceiling you can prove — so a model clamped under it can't wreck the query plan. In July I materialized the true output of 926 join trials and checked. The ceiling came in below the true answer in 2,179 of 3,704 evaluations. This is the bug, the repair that took it to zero, and every number I had to retract on the way.

samkhya exists to make one sentence true: a query optimizer should be able to take a number from a machine-learning model without the model being able to wreck the plan.

The mechanism is a ceiling — a join-cardinality upper bound you can prove, which the corrected estimate is clamped under. A model is allowed to be wrong below the ceiling. It is not allowed to be wrong above it. That asymmetry is the entire safety argument, and it is the reason the library is worth using at all.

In July I ran an audit that materialized the actual output of 926 join queries and compared each one against the ceiling the library had been reporting for them.

The ceiling came in below the true answer in 2,179 of 3,704 bound-evaluations. Fifty-eight point eight percent.

Not loose. Not conservative. Below. The number the join provably could not exceed was, most of the time, smaller than the number of rows the join actually returned. Which means it was never a bound. It was one more estimate wearing the word “provable.”

This post is what broke, how a green test suite hid it, the repair that took violations to zero, and everything I had to retract on the way out.

The oldest bug in the optimizer

Before any of the machine learning, there is a plainer problem. To pick a join order, an optimizer has to guess how many rows each join will return. Every engine does this with summary statistics and independence assumptions that are, politely, optimistic.

The errors would be survivable if they stayed put. They do not. A join tree multiplies: an estimate that is 3× off at the bottom feeds the next join, which is 3× off on top of that, and six levels up you are three orders of magnitude wrong about the size of an intermediate result. The optimizer has by then confidently chosen a hash join and a memory budget for a table it thinks has ten thousand rows and which actually has ten million.

One wrong guess, six joins deep Estimation error compounds multiplicatively down a join tree (illustrative) join 1 10× join 2 32× join 3 100× join 4 320× join 5 1,000× join 6 A plan chosen on the bottom estimate is still being executed at the top.
Illustrative. A single misestimate at the bottom of a join tree is multiplied by every level above it.

This is why cardinality estimation attracts machine learning. A learned model really can do better than independence assumptions on the workloads it has seen. The problem is what happens on the workloads it hasn’t.

A regression here is not a slightly slower query. It is a plan that never finishes.

Why a ceiling, and not a better guess

So samkhya’s bet was never “the model will be accurate.” It was “the model can be audited by arithmetic.”

You compute, from statistics you already have, a number the join cannot exceed no matter what the data looks like. Then you let whatever corrector you like — gradient-boosted trees, TabPFN, a language model — produce an estimate, and you clamp it under that number. The corrector can be miscalibrated, drifting, or hallucinating outright. It still cannot push the optimizer past a limit that arithmetic controls.

The ceiling is not an estimate. It is a wall. A number the join provably cannot exceed — so a wrong model can only be wrong below it PROVABLY IMPOSSIBLE no join on these relations can return this many rows c ceiling true cardinality model's raw estimate clamped The guarantee is only worth anything if the wall is actually where you claim it is.
The ceiling is not a better guess. It is a wall the corrected estimate is clamped under.

That is a good design. It is only a good design if the wall is actually where you say it is.

The audit: materialize the truth, then check

The v1.1 test suite was green. What it was checking, though, was internal consistency — bounds against each other, bounds against sketch outputs, invariants that all lived on the same side of the question. Nothing in it executed a join and counted the rows that came out.

So that is what the audit did. 1,080 trials — four join topologies × three sizes × three ℓp regimes × thirty repetitions. 154 were thrown out because the materialized truth overflowed u64, which left 926 audited trials. Four bounds evaluated on each: 3,704 bound-evaluations, each one compared against a row count that had actually been produced.

Three of the four bounds failed.

Three of the four bounds were not bounds v1.1 violation rate per bound — 926 audited trials each ProductBound 0.0% 0 / 926 ChainBound 96.0% 889 / 926 LpJoinBound 76.7% 710 / 926 AgmBound 62.6% 580 / 926 ProductBound held. It is also the loosest and least useful of the four.
v1.1 violation rates per bound, 926 audited trials each. Only ProductBound — the loosest of the four — held.

ChainBound was unsound in 96% of trials. LpJoinBound in 77%. AgmBound in 63%. ProductBound held at zero — and ProductBound is the trivial one, the straight product of relation sizes, the loosest and least useful bound in the set. The only one that survived was the one doing the least work.

Five joins that walked straight through the ceiling

Aggregates make this sound abstract. The individual cases do not.

Five joins that walked straight through the ceiling Materialized truth vs the v1.1 ceiling that was supposed to cap it foreign-key join orders(10) ⋈ lineitem(100) 100 10 two relations, one key single shared key 20 4 three-relation chain R ⋈ S ⋈ T 27 3 skewed 20 × 20 heavy-hitter key 260 20 four-relation star one hub, three points 128 2 true rows v1.1 "provable" ceiling
Five counterexamples from the audit. In each, the “provable” ceiling was smaller than the answer it was capping.

Take the first one, because it is the most embarrassing and the most instructive. A textbook foreign-key join: orders with 10 rows, lineitem with 100, every lineitem pointing at an order. The true output is 100 rows. Anyone who has written SQL knows it is 100 rows.

v1.1 reported a provable ceiling of 10.

A corrector clamped under that ceiling would have been forced to tell the optimizer that a join returning 100 rows returns at most 10 — and the clamp would have looked like the safety feature doing its job. The four-relation star is worse in ratio: truth 128, ceiling 2.

How a green suite hid three broken bounds

Each failure had a specific, boring cause, and none of them were subtle once you were looking:

  • LpJoinBound — the fractional-edge-cover LP was missing per-attribute constraints. It checked coverage per predicate and quietly ignored private columns, so it solved a smaller problem than the one it claimed to solve.
  • AgmBound — computed min(product, |R_min| · |R_max|). On any join of more than two relations, that expression throws away every relation except two. It is not a bound on the query; it is a bound on a sub-query nobody asked about.
  • ChainBound — applied a uniform-distribution estimator where a ceiling was required. Uniformity is an assumption about the data, not a fact about it, and skew broke it instantly.

That last one is the whole lesson in miniature. An estimator and a bound are different objects with different obligations. An estimator is allowed to be wrong in both directions; that is what makes it an estimator. A bound has one job, in one direction, and a bound that is wrong in the forbidden direction is not a loose bound — it is a false statement. Somewhere in v1.1, an estimator got called a bound, and the type system had no opinion about it.

I would like to say I caught this by being careful. I caught it by finally comparing against something outside the system.

The repair: multiply degrees along a spanning tree

The fix in v1.2.0 replaces all of it with a single degree-based construction, and the thing I like about it is that it needs no new statistics.

Take the join graph, pick any spanning tree, root it anywhere. Start with the cardinality of the root relation. Walk each tree edge and multiply by the maximum degree across that edge — the largest number of rows on the far side that any single row on the near side can match. That product is a ceiling, and it is a ceiling for a reason you can hold in your head: every output row is some root row, extended along the tree, and each extension is capped by that edge’s maximum degree.

The repair: multiply degrees along a spanning tree Root cardinality × max degree per tree edge — every input already in the sidecar maxdeg maxdeg maxdeg maxdeg Rr root R1 R2 R3 R4 tree edge unused |Q| ≤ |Rr| · Π maxdeg(Rv, a_uv) Degrees come from HLL distinct counts and Count-Min sketches already in the Puffin sidecar — no new statistics.
v1.2.0: root cardinality multiplied by the maximum degree along each spanning-tree edge. Non-tree edges only ever help.

Non-tree edges are ignored, which is safe: extra join predicates can only filter, never add. And the degrees come from statistics already sitting in the Puffin sidecar — HyperLogLog distinct counts and Count-Min sketches that samkhya was writing anyway.

On the foreign-key join that produced a ceiling of 10, the new construction returns 100. Not a safe over-estimate of 100. Exactly 100 — the true output, which is also the tightest sound ceiling that exists for that query.

Re-run the audit, same harness, same trials:

Soundness violations, out of 3,704 bound-evaluations Same harness, same trials, one release apart v1.1 2,179 58.8% v1.2.0 0 0.0% The bar on the right is not small. It is zero — the green mark is the axis.
Same harness, same trials, one release apart. The green mark on the lower bar is the axis, not a value.

What else came down with it

Once you find one measurement that could not detect its own failure, you have to assume you have others. I did.

The 40.95× bound-tightness figure is withdrawn. It was computed against the bounds that turned out to be unsound, so it was measuring how tight a wrong number was.

The 1.038× JOB-Slow speedup is withdrawn, and so is the entire campaign behind it. The audit found that the arm labelled “corrected” contained no corrector — the benchmark had no flag to attach a trained corrector in the first place. All four trials were OOM-killed at query 55 of 113. Run-order confound accounts for roughly two-thirds of the 3.8% effect that was there. And the root cardinality was always 1, which makes q-error meaningless as a validation metric on that harness. What actually got published was a portable-statistics result under a cardinality-correction headline.

The TabPFN-2.5 q-error claim was pre-registered at a 15% improvement. It measured 7.84%. The direction held; the magnitude threshold I had committed to in advance did not. Pre-registration is only worth something if you report the miss, so: it missed.

And the one I find genuinely useful — on held-out queries, the corrector made estimates worse, geomean q-error going from 13.46 to 26.41. That is a model actively degrading the thing it was added to improve. It is also the exact scenario the ceiling exists for, observed in the wild, in my own benchmark, against my own model.

What survived the audit, and what did not Published in the repo alongside the raw data, not in a footnote KEPT 0 violations / 3,704 bound-evaluations foreign-key join ceiling = 100, exactly the true output HLL p=14, n=10⁶ — RSE 0.676%, BCa CI [0.535, 0.848] TabPFN-2.5 P95 inference 31.15 ms, CI [29.39, 35.32] mixed-workload penalty 0.949× — samkhya loses ~5% 84 KB WASM bundle, TypeScript types included WITHDRAWN 40.95× bound-tightness — withdrawn 1.038× JOB-Slow speedup — withdrawn JOB-Slow campaign — no corrector in the "corrected" arm TabPFN q-error: pre-registered 15%, measured 7.84% STILL OPEN §4 ceiling never measured inside a real query plan §7 documented repro commands do not run as written §3/5/6/8 credible but provenance not traced to raw data
The full accounting, published in the repo next to the raw data.

What I actually believe now

Three things, and only the first is about databases.

A bound needs an adversary, not a test. Every assertion in the v1.1 suite was written by the same person who wrote the bound, in the same frame of mind, checking the same assumptions. Consistency tests cannot catch a shared misconception — they are made of it. The only test that found anything was the one that left the system entirely and materialized ground truth.

Report the direction you lose in. samkhya’s mixed-workload penalty is 0.949× — about 5% slower — and that number is in the README because a benchmark suite where you win everything is a benchmark suite you designed after seeing the results. The corrector-makes-it-worse number is in there for the same reason.

Retract loudly, in the same place you claimed. The withdrawn figures are not quietly gone from the docs; they are marked withdrawn, with the raw data committed and the open items enumerated. A project that only publishes its wins is telling you exactly one thing about its numbers: that you cannot check them.

And plenty is still open. The ceiling has never been measured inside a real query plan — there is no experiment in the repo that runs it in DataFusion and reports the effect on plan quality, which is, awkwardly, the actual claim. The documented reproduction commands do not run as written. Four sections remain credible but untraced to raw data. Those are listed as open, because they are.

The part that survived

The ceiling is sound now: 0 violations in 3,704 bound-evaluations, exact on foreign-key joins, computed from sketches you already have, in a build that also happens to be 84 KB of WebAssembly with TypeScript types if you want it in a browser.

But the claim I am comfortable making is narrower than the one I started with, and I think it is the better claim. It is not that a model will improve your query plans. On my own held-out set, mine made them worse.

It is that you can now find out — on your workload, with your data — without the experiment being able to hurt you.

That is what a ceiling is for.

Try it

cargo add samkhya-core · pip install samkhya · npm install samkhya

Apache-2.0, 15-crate workspace, no GPU in the default build. The soundness audit and the open items are committed in bench-results/ — start there rather than with the README if you want to know what to trust.

Sources

Share this article