Skip to content
Oumuamua Labs

The estimate that chose its own geometry

Hekate 0.35.0 postmortem: the security level was overstated, and 0.35.0 restates it at 100 bits that follow from published theorems. No forgery is known.

On this page

Hekate accepted proofs at a 110-bit floor and logged figures just above it. Both terms feeding those figures were optimistic: the query term read 131 to 173 per table where the theorems it cited give 57 to 73, and the term beside it was missing two factors worth 4 to 7 bits.

No forgery is known that exploits this. Every value the verifier computed for this argument was computed correctly, and a proof that verified was a real proof of a real statement. What was wrong is the number attached to the answer, and in a proving system that number is the product.

0.35.0 restates the claim at 100 bits that follow from published theorems, and pays 287 column queries for them instead of 176.

§ Summary

#GapEffect
1Column queries priced at capacity, not unique decodingQuery term read 131 to 173 where the papers give 57 to 73
2The same formula selected the encoding geometryOverstating picked the cheaper encoding, then graded it safe
3The proximity term omitted its tensor and curve factors4 to 7 bits unaccounted, worst on wide tables
4The LogUp challenge term was never countedA term reaching 102 bits at 2^26 rows appeared in no estimate
5The reported figure covered one table and one argumentChiplet tables and the outer argument sat outside the number

Gap 1 is the arithmetic. Gap 2 is why it was not caught: the formula that reported security also chose the geometry it reported on, which cannot contradict itself. Gaps 3, 4 and 5 are terms missing from the sum.

§ Affected versions

RunningExposed toAction
0.34.0 and earlier1, 2, 3, 4, 5Upgrade to 0.35.0 and re-prove.
0.35.0100 bits proven; 128 needs a wider challenge fieldCurrent.

No proof crosses this boundary. num_queries, ldt_support_size and outer_queries are absorbed into the transcript before any challenge and all three moved, which means a 0.34.0 proof fails transcript replay at the first challenge. The proof wire format moves v5 to v6 and the bundle format v4 to v5, carried by the proof-size work shipping alongside. The pinned prover release is 0.13.0.

The second row states a ceiling. Checked: every term we can identify is now computed, charged, and logged per table on both sides. Not checked: whether a term exists that we have not identified. Four postmortems in this series are evidence that the second category is never empty.

§ Where this one sits

0.32.0 was about values reaching the verdict with nothing binding them to the committed witness. 0.33.0 was about committed columns no constraint policed. 0.34.0 was about the frame: who decides which rows are live, and which circuit is being verified. All three are questions about the protocol.

This one is about the measurement of the protocol. The earlier three each had a witness, a column or a validator you could point at. This one had a function returning a number nothing in the system could contradict, because the number and the thing it measured came out of the same expression.

A security parameter is a claim about the worst case. If the function that reports it also selects the configuration it reports on, the report cannot fail, and its agreement with itself is not evidence.

The tell is mechanical. If a threshold on a security estimate appears where a performance or layout decision is made, the estimate has stopped being a measurement.

§ 1. Column queries were priced at capacity

Config::ldt_bits computed t · log2(1 / rho) with rho = msg_len / code_width, pricing each query at rho, which is 1.0 bit per query at rate 1/2. That is list-decoding-capacity pricing, and the citation attached to it pointed at a section of Brakedown bounding a different quantity.

The commitment is checked by opening t random columns of an encoded matrix. Security is t times the negative log of the per-query pass probability of a cheating prover, and the question is which pass probability the papers license.

What the literature gives, unanimously:

SourceStatement
Brakedown (Golovnev et al. 2022), App. B Lemma 4Decoding radius capped at (1 - rho) / 2
Brakedown, App. B Theorem 7Per-query term (rho + delta), minimised at (1 + rho) / 2
Ligero journal version, Lemma 4.6Same radius
Ligerito (Novakovic and Angeris, 2025/1187)ceil(-(lambda + log l) / log((1 + rho) / 2)) queries
Diamond and Gruen 2024/1351, Corollary 3.7Tensor-fold proximity gaps at exactly the unique-decoding radius

At rate 1/2 the correct term is 0.4150 bits per query, not 1.0. Brakedown's own concrete parameters follow their theorem: 309 queries at rate 1/2 and 189 at rate 1/4 for 128 bits, each exactly ceil(128 / -log2((1 + rho) / 2)). Nobody in this family sizes queries from rho alone.

Shipped tables at the old t = 176:

TablerhoQuery term loggedBy the papersOverstated
Keccak 2^15 main0.5122170712.39x
ML-DSA chiplet0.5977131572.30x
ML-DSA main, full-half0.5000176732.41x

Reach. Every table in every release of this series. The inner commitment is the only place column queries are priced.

Fix. One line, the shape the outer argument already used:

rust
let log2_ratio = log2_ratio_fixed(2 * code_width as u128, (code_width + msg_len) as u128);

§ 2. The formula also chose the geometry

ldt_bits did two jobs: it reported a table's security, and it decided whether that table got the cheap encoding. Overstating by 2.4x meant every table cleared a 128-bit gate it could not really clear, took the cheaper geometry, and was then graded safe in it by the same overstating function.

Each table picks one of two encodings. A fractional geometry carries a fixed support block at rate 1/2. A full-half geometry doubles the codeword width, is safer, and costs twice the encoded matrix. The choice was made like this:

rust
if frac.support_size <= grid_cols
    && self.ldt_bits(frac_msg, frac.encoded_width) >= FRACTIONAL_MODE_BITS

FRACTIONAL_MODE_BITS was 128.

The smallest fractional table cleared that gate by two bits, 130 against 128. Under the correct formula it needs 396 queries to stay there.

This is also why the correction could not land alone. Fixing the formula with t at 176 drops every fractional table to full-half, doubling every encoded matrix, a measured 36 to 50 percent increase in peak memory on an edge prover. There is no intermediate state where the formula is right and the system is shippable, and no "measure the formula first" step that avoids confounding a query cost with a geometry change.

Fix. The gate now reads MIN_PRODUCTION_BITS, the floor it actually serves, and the formula and the query bump land together. A memory decision gated on a security formula, in that formula's units, is an accident waiting for the units to change, and the units changed.

§ 3. The proximity term was missing two factors

The proximity term was field_bits - log2(width), the single-line term from BCIKS20, and it omits two factors this protocol incurs. It sits beside the query term, bounding the probability that the random fold is unrepresentative. The two omissions:

  • the eq-tensor factor over the grid's rows, charged by Diamond and Gruen as theta for a tensor over 2^theta rows;
  • the curve factor over the eta walk, BCIKS20 Theorem 1.5, charging the number of units combined.
field_bits - log2(width) - log2(log2(rows) + units - 1)

Worth 4 to 7 bits. A wide table at 80 units and 2^7 rows drops from 110 to 103.6. Every rounding is a ceiling against us, and the arithmetic is integer only, because both sides must derive identical geometry and powf is not bit-reproducible across platforms.

This term is why large traces need an adaptive grid. It is 104.2 bits at a codeword of 2^21 and falls about a bit per doubling, reaching 100 near 2^25. RingSwitchPlan::split_vars now scans down from the proof-size optimum and takes the widest grid still clearing the floor. Narrowing buys proximity bits and costs proof bytes, and the scan makes that trade on every table.

§ 4. The LogUp challenge term was never counted

A term worth 108 bits at 2^20 bus rows and 102 at 2^26 appeared in no estimate and no check. Tables are joined by a bus argument whose soundness rests on a global challenge gamma avoiding every gamma + key[i] denominator across every bus row in the proof, which is field_bits - log2(bus rows).

Config::check_logup_security now computes it from the program's bus counts and the committed table heights, and both sides run it before gamma is drawn. The prover runs it before any commitment work, turning a rejection that cost a full proving run into one that costs milliseconds.

§ 5. The reported figure covered one table and one argument

The old System Security line reported one number for the main table's inner argument. A proof has one main table, up to seven chiplet tables, and an outer argument, each with its own geometry and its own numbers.

Per-table security is now logged on both sides. The difference is not cosmetic. On a current ML-KEM-768 proof:

main table security       bits=107  ldt_query=119  fold_gap=112  logup_gamma=107
table security chiplet=1  bits=100  ldt_query=100  fold_gap=110
table security chiplet=3  bits=110

The main table reports 107. The proof is worth 100, set by one chiplet, with no margin. Anyone reading the old single line would have reported the first number.

§ How we know the old number was wrong

Two pricings differing by 99 bits at t = 176 differ by only 10x to 25x in acceptance at t = 8, which is why no existing test separated them and why the question was settled by measurement.

hekate/tests/mixture_proximity.rs builds the shipped encode, reconstructs its evaluation domain, and measures the per-query pass rate of two cheating strategies over 200,000 query sets:

  • chosen: commit a real codeword, send a wrong fold. Passes at rho.
  • mixture: commit a word delta-far from the code and open it as the far codeword, corruption placed at the crossing point of 1 - delta and rho + delta. Passes at (1 + rho) / 2.
TablerhoStrategyMeasuredBoundAccept at t=8
Keccak 2^15 main0.5122chosen0.51210.51220.48%
Keccak 2^15 main0.5122mixture0.75600.756110.60%
ML-DSA chiplet0.5977chosen0.59720.59771.63%
ML-DSA chiplet0.5977mixture0.79830.798816.40%
ML-DSA main, full-half0.5000chosen0.49950.50000.39%
ML-DSA main, full-half0.5000mixture0.74950.75009.87%

Both columns reproduce their bound to three decimals. The decision rule was fixed before the run: acceptance near 10 percent at t = 8 settles it.

Two properties of that probe matter more than its result, because they are how a measurement like this fails silently:

The worst case has to be constructed. The probe's first version used two independent random messages and measured agreement of 0.0000, because two random codewords coincide nowhere. Reaching either bound needs the deviation to be a minimum-weight codeword. A guard built on random deviations passes forever while measuring nothing.

The probe has to validate its own domain. It asserts each subspace vanishing polynomial has exactly the zero set the shipped encode implies. Without that it could faithfully measure a code that is not the one being shipped.

§ Why 100 bits and not 128

128 proven bits are not reachable over GF(2^128) at any query count. Every additive term has the form n / |F| and sits at 128 - log2(n): the fold proximity term, the LogUp challenge term and the outer field term all lose bits to the size of the object they range over, and queries shrink none of them. Reaching 128 needs challenges from a 256-bit field plus roughly 309 queries, and costs about 2.25x in proof size on a 2^24 trace.

Where the deployed field sits:

SystemLevelBasis
SP1 Hypercube100Proven, unique decoding (16 of the 100 are grinding bits)
SP1 Turbo, Plonky3 example FRI100Conjectured
Ligerito100Proven, unique decoding, 148 queries at rate 1/4
Ethereum Foundation zkEVM100Provable target for M2, 128 provable for M3

SP1's target and its 16 grinding bits sit in crates/primitives/src/fri_params.rs, Plonky3's count in conjectured_soundness_bits, and Ligerito's 148 is ceil(100 / -log2((1 + 1/4) / 2)).

Binius64's specification sets lambda = 128 over GF(2^128) and writes the total as O(l)/|K| + 2^-lambda. Its fold's proximity error is additive and queries cannot shrink it, landing near 2^-100 or worse at large codewords, which makes that 128 a parameter rather than an end-to-end bound. Its FRI code sets the proximity parameter "to the code's unique decoding radius" in crates/iop/src/fri/common.rs, and Plonky3's binary-tower path opens security/src/binary.rs with "Only unique decoding is supported."

A larger radius is not available to us:

RouteWhy it is unavailable
Johnson radiusBuys about 10 percent per query, loses more in the batching term: 2^-89.8 against unique decoding's 2^-104.2 on our geometry, and we take the minimum
CapacityRefuted by Ben-Sasson, Carmon, Habock, Kopparty and Saraf (ECCC TR25-169), counterexamples over characteristic-2 fields
STIR, WHIRFold over multiplicative cosets of 2-power order; |F*| = 2^k - 1 is odd in every binary field

For calibration, an unconditional floor from Elias list-decoding capacity puts a rate-1/2 scheme whose generator entries live in GF(2^128) at 103 queries minimum, whatever is eventually proved (Fenzi-Sanso 2025/2197, Corollaries 3.6 and 4.5). The floor follows that alphabet: over F_2 entries the same bound reads 595. At 287 we are within 2.8x of a bound nobody goes below.

§ What correctness cost

287 is the binding number, and the derivation is worth stating because the obvious answer is wrong twice.

The support block hiding opened columns must be at least as large as the query count. Raising t therefore widens every message, worsening rho, lowering the bits each query is worth. Sweeping t from 200 to 1200, the smallest fractional grid peaks at 98 bits and never reaches 100 at any query count. It changes mode, the binding grid moves up one doubling, and that grid needs exactly 287.

The unconstrained minimum for 100 bits is 241 queries, at which every grid falls to full-half and every encoded matrix doubles. Query count and memory move in opposite directions.

Proof size goes as sqrt(t), because the grid split re-optimises against it. A 63 percent query increase therefore costs about 25 percent.

Workload0.34.0 proof, ZK0.35.0 proof, ZKChange
ML-KEM-7682,732 KiB3,483 KiB+27.5%
ML-DSA-443,328 KiB4,184 KiB+25.7%
ML-DSA-653,338 KiB4,193 KiB+25.6%
ML-DSA-874,280 KiB5,484 KiB+28.1%
AES-1283,726 KiB4,692 KiB+25.9%
AES-2563,923 KiB4,982 KiB+27.0%
Keccak 2^15759 KiB864 KiB+13.8%
Keccak 2^202,735 KiB3,536 KiB+29.3%
Fibonacci 2^20962 KiB1,145 KiB+19.0%
Fibonacci 2^243,369 KiB4,100 KiB+21.7%

Proof size is deterministic given the prover binary, 0.12.0 for the 0.34.0 column and 0.13.0 for the 0.35.0 column, and moves with neither the machine nor the build.

Prove time and peak memory land inside the benchmark noise floors, 5 and 10 percent, on every workload. That is what choosing 287 over the unconstrained 241 bought: the geometry did not move.

A 2^26 Fibonacci trace is absent from that table. Earlier in this release cycle the proof-size work widened long tables by one doubling, which pushed that trace below the then-current floor: it rejected with SecurityTooLow { estimated_bits: 109, min_bits: 110 }. The honest proximity term and the adaptive grid restored it, and it verifies at 100 bits today. Its figure in the 0.34.0 tables was measured under a different geometry and is not a like-for-like baseline.

§ What changed for circuit authors

Minimum table height is 512 rows. A table needs at least as many rows as there are queries, and 287 rounds up to 2^9. A shorter table aborts with ldt_support_size (256) must be >= num_queries (287). One shipped example needed its tables raised.

The outer statement gained capacity. Lowering outer queries from 155 to 121 raises the message length, pushing the cap on multiplication wires from 173,098 to 11,183,509, a 64x gain.

Everything re-proves. The configuration is transcript-bound and all of it moved.

§ What this release does not promise

128 bits. It needs a wider challenge field, which is a protocol change rather than a parameter change.

Grinding. We do not use proof-of-work to buy query bits, and the reference point we cite for 100 proven bits does. The 1:1 subtraction is a theorem about probability: an adversary guesses one nonce, passes a g-bit grind with probability 2^-g at constant cost, and nothing in the grind touches the query term. The average-case reading behind the folklore is real but is a weaker claim than a query bound, and Grover halves it, which matters for a post-quantum-positioned system. If we adopt it, those bits will be reported separately.

Terms we have not identified. Five terms were wrong or missing and we found them. How many terms a soundness argument has is not something the argument tells you.

Circuit semantics and determination of published cells. Unchanged from 0.34.0.

Independent audit. This workspace has not had one.

§ For other implementers

Name the radius your query count is priced at. Capacity, Johnson and unique decoding differ by more than 2x in bits per query, and the difference is invisible in any test small enough to run: at eight queries two pricings differ by a factor, at 176 by 99 bits. Write the per-query term next to its citation, and check the citation bounds the quantity you are using it for.

A number that gates a decision cannot grade that decision. If a security estimate appears in a threshold selecting geometry, rate or layout, it is part of the machine and its agreement with itself is worth nothing. Keep the reported figure and any configuration gate on separate expressions.

Sum every term. Ours spread across a query bound, an additive term missing two factors, a bus challenge term counted nowhere, and a log line covering one table out of eight. Each was individually plausible. A soundness figure is a minimum over terms, and a term you never wrote down is absent from it.

Construct the worst case adversary. The measurement that settled this was nearly useless: random deviations gave a pass rate of zero and would have passed as a guard forever. The bound is attained only by a constructed minimum-weight deviation. A probe validating a soundness claim must also validate that it measures the shipped object.

§ Scope

This covers hekate-core, hekate-program and hekate-verifier, all open, and the matching parameter derivation in the prover. The prover reaches this workspace as a signed shared library built from a closed repository. The security accounting lives entirely in the open crates, both sides derive identical geometry from the same functions, and the measurement above ships in the public test suite.

Previous postmortems: 0.34, 0.33, 0.32. The first asked what binds each value entering the verdict, the second whether the witnesses satisfying a circuit are the ones it meant to admit, the third who decides. This one asks what the answer is worth, and it stands at 100 bits until someone finds the sixth term.

Andrei Kochergin

Protocol Architect

Designs and builds the zero-knowledge proof engines Hekate and Charon, from the binary-field arithmetic up. Writes the lab's research articles and postmortems.

LinkedInGitHub

One support cell shortHekate 0.37.0 postmortem: in 0.35.0 and 0.36.0, a zero-knowledge proof could confirm a guess of its own witness. The fix costs one field element per proof. Columns only the bus readHekate 0.36.0 postmortem: five soundness gaps, three with forgeries that 0.35.0 accepted, and the ordered service buses that close them. Liveness was a witnessHekate 0.34.0 postmortem: the prover decided whether a block existed, and the same decision switched off the constraints that policed it.