logUp optimization: no committed inverses - #69
Merged
Conversation
Replace the committed-inverse logUp layout with chained partial
accumulators. Stage 2 previously committed (1 + L) extension columns
per circuit — a running accumulator plus one message inverse per
lookup — tied together by L message-inverse constraints and three
accumulator constraints (first row, transition, last row). Now it
commits max(L, 1) columns: acc_j (slot j) is the running sum ENTERING
lookup j's step on that row, and each step asserts
acc_{j+1} - acc_j = mult_j/m_j multiplied through by the message:
interior (j < L-1): m_j * (acc_{j+1} - acc_j) - mult_j = 0
wrap (j = L-1): m_j * (acc_0' - acc_j + is_last_row*D) - mult_j = 0
where acc_0' is the next row's first slot and D = acc_final -
acc_initial (the existing publics, slots 3 and 2).
The wrap step needs no transition selector: on the trace subgroup
"next row" is rotation by the generator, so the chain is a CYCLE.
Telescoping the steps around the cycle cancels every accumulator term
and forces the circuit's multiset sum to zero; the is_last_row*D term
perturbs exactly one link, turning that into "the multiset sum equals
the public accumulator difference". No first/transition/last-row
constraints remain, and the committed accumulator is gauge-free (only
differences are constrained; the prover starts it at zero).
The wrap step consumes is_last_row ADDITIVELY, so it must be the
NORMALIZED last-row Lagrange selector: p3's is_last_row is the unnormalized numerator
(x^n - 1)/(x - g^{-1}) whose value at the last row is n*g (pinned by a
new test against the textbook Lagrange product). The prover and
verifier now normalize is_first_row by 1/n and is_last_row by 1/(n*g);
vanishing-form user constraints are unaffected by the rescale, and the
witness-side lookup evaluation already used indicator values 1.
Effects:
- stage-2 width (1+L)*D -> max(L,1)*D; logUp constraints L+3 -> L.
- Degrees are uniform: every step is max(deg(args)+1, deg(mult)) (the
injection is selector*publics, degree multiple 1, absorbed by
add = max) — never above the old profile, lower where the old
last-row constraint peaked.
- Witness build: same batch inversion, then a single pass with one
multiply-add per (row, lookup), pushing each partial accumulator;
the circuit's local sum feeds the global public chain.
- Zero-multiplicity (padding) rows force the accumulators to ride
through unchanged.
Benchmarked against the pre-change rev (2501b4f) with identical
instrumentation (lookup-heavy circuit, 16 lookups x 4 args, 2^14
rows): stage-2 build ~9.6 ms on both; quotient constraint eval
~19.0 ms vs ~18.9 ms — no regression, with one fewer committed column
and fewer alpha terms.
29/29 tests (pin test re-derived against the new spec; even/odd e2e
exercises a nonzero per-circuit accumulator difference, pinning the
selector normalization through a real proof); clippy/fmt clean.
The wrap constraint consumes the last-row selector additively, which previously meant rescaling the is_first_row/is_last_row selector vectors over the whole quotient domain, per circuit — two extra full vector passes inside the quotient phase. But the selector only needs its normalized VALUE where it multiplies the boundary injection, and the injected difference is constant across the domain: is_last_norm * delta == is_last_raw * (delta / (n*g)) so the constant is now absorbed into delta once per circuit (two field multiplies) and the selectors stay exactly as p3 provides them — standard unnormalized semantics for user constraints, zero per-point normalization work. logup_constraint_values takes the pre-scaled delta alongside the raw selector; the pin test passes delta raw with an arbitrary selector value, since the identity holds for any scale and the normalization is a caller contract. Constraint values, proofs, and the transcript are unchanged for the logUp argument itself (identical products); user constraints see p3's raw selector values again. 29/29 tests, clippy/fmt clean.
arthurpaulino
approved these changes
Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
logUp: chained partial accumulators, no committed inverses
Replaces the committed-inverse logUp layout with chained partial
accumulators. Stage 2 previously committed (1 + L) extension columns
per circuit — a running accumulator plus one message inverse per lookup —
tied together by L message-inverse constraints and three accumulator
constraints (first row, transition, last row). It now commits
max(L, 1) columns and folds exactly L constraints.
How it works
Stage-2 slot
jholdsacc_j, the running sum entering lookupj'sstep on that row. Each step asserts
acc_{j+1} − acc_j = mult_j / m_j,multiplied through by the message
m_j = β + fingerprint(γ, args_j)tostay polynomial:
where
acc_0′is the next row's first slot andΔ = acc_final − acc_initial(the existing publics, slots 3 and 2).The wrap step needs no transition selector: on the trace subgroup "next
row" is rotation by the generator, so the accumulator chain is a
cycle. Telescoping the steps around the cycle cancels every
accumulator term and forces the circuit's multiset sum to zero; the
is_last_row·Δterm perturbs exactly one link, converting that into"the multiset sum equals the public accumulator difference". No
first/transition/last-row constraints remain, and the committed
accumulator is gauge-free (only differences are constrained; the prover
starts it at zero by convention). A circuit with no lookups keeps a
single pass-through column with
acc′ − acc + is_last_row·Δ = 0,forcing
Δ = 0.Selector normalization
The wrap constraint consumes
is_last_rowadditively, so its scaleis load-bearing. Plonky3's selector is the unnormalized Lagrange
numerator
(xⁿ − 1)/(x − g⁻¹), whose value at the last row isn·g(pinned by a new test against the textbook Lagrange basis product,
across several domain sizes). The prover and verifier now normalize the
selectors protocol-wide —
is_first_rowby1/n,is_last_rowby1/(n·g)— so both evaluate to exactly 1 at their row. Vanishing-formuser constraints are unaffected by the rescale, and the witness-side
lookup evaluation already used indicator value 1.
Effects
(1+L)·D → max(L,1)·D; logUp constraintsL+3 → L.max(deg(args)+1, deg(mult))(theinjection is selector·publics, degree multiple 1, absorbed by
add = max) — never above the old profile, lower where the oldlast-row constraint peaked.
multiply-add per (row, lookup), pushing each partial accumulator; the
circuit's local sum feeds the global public accumulator chain.
through unchanged.
Soundness
Unchanged Schwartz–Zippel structure: a zero message (
m_j = 0) wouldleave a step unconstrained, but the challenges are sampled after
commitment, so that event carries the same negligible
N/|F_ext|termthat previously justified the committed inverses (where it made the
proof unsatisfiable instead). The cyclic telescoping is an exact
polynomial identity.
Benchmarks
Measured against the pre-change rev (
2501b4f) with identicalinstrumentation on a lookup-heavy circuit (16 lookups × 4 args,
width 40, 2^14 rows):
No regression, with one fewer committed column and fewer α terms.
Testing
29/29: the new selector-normalization pin test; the spec ↔ direct-
evaluator pin test re-derived for the chained constraints; and the full
e2e set (even/odd lookups — which proves with a nonzero per-circuit
Δ, the exact case that would expose a wrong normalization constant —
u32-add, blake3, byte operations, sparse activation, and the verifier
tamper-rejection suite). Clippy/fmt clean.
Note: downstream (ix) must follow: the vk codec's derived
stage2_width/constraint_count, the Lean verifier's direct logUpevaluation, and its
trace_selectorsnormalization.