Skip to content

Commit 8ba27e4

Browse files
authored
Step ivp (#113)
* step ivp * fix context clobbering in viz
1 parent 8bb86d0 commit 8ba27e4

File tree

10 files changed

+326
-47
lines changed

10 files changed

+326
-47
lines changed

.claude/skills/kimchi-circuit-json-comparison/SKILL.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ describe "Kimchi gate matches" do
142142
npx spago test -p snarky-kimchi -- --example "CircuitJson"
143143
```
144144

145+
## Analysis Tools
146+
147+
Two scripts in `tools/` help analyze circuit comparison results:
148+
149+
### circuit-diff-summary.mjs
150+
Quick structural overview: gate counts, type breakdown, cached constants, first difference, section breakdown.
151+
152+
```bash
153+
node tools/circuit-diff-summary.mjs packages/pickles-circuit-diffs/circuits/results/ivp_step_circuit.json
154+
```
155+
156+
Use this FIRST to understand the shape of the diff (gate count mismatch? type mismatch? coefficient-only?).
157+
158+
### circuit-r1cs-diff.mjs
159+
Unpacks Generic gates into individual R1CS constraints in generation order (right half = queued/first, left half = new/second) and finds the first divergence. Essential for diagnosing R1CS double-packing alignment issues where gate types match but coefficients differ.
160+
161+
```bash
162+
node tools/circuit-r1cs-diff.mjs packages/pickles-circuit-diffs/circuits/results/ivp_step_circuit.json
163+
```
164+
165+
The output shows the R1CS sequence side-by-side with decoded constraints, highlighting the first divergence. The divergent R1CS's constant coefficient often identifies which operation (seal, constant materialization, boolean check) is misplaced.
166+
145167
## Step 5: Iterate on Differences
146168

147169
### Extra boolean check constraints
@@ -165,6 +187,18 @@ OCaml may seal (reduce to single variable) inputs before passing to a gate. Chec
165187

166188
**Seal at loop boundaries**: OCaml's `scale_fast_unpack` calls `seal base` once at the top, before the VarBaseMul loop. Without this, a complex CVar base point (e.g. from `groupMapCircuit`) gets re-reduced in every round, generating 2 extra GenericPlonk gates per round. PureScript's `varBaseMul` must `sealPoint base` before the loop.
167189

190+
**Seal in sponge add_assign**: OCaml's sponge `add_assign` calls `Utils.seal` after every `state[i] += x`. PureScript's circuit sponge (`Snarky.Circuit.RandomOracle.Sponge.absorb`) must do the same. Without seal, complex CVars like `Add(Const 0, Var v)` accumulate in the sponge state and are only reduced during the Poseidon gate's `reduceToVariable` call. This changes the TIMING of R1CS generation: seal produces R1CS during absorption (as standalone `KimchiBasic` constraints), while deferred reduction produces R1CS inside the Poseidon `reduce`. The different timing shifts the R1CS double-packing alignment, causing every subsequent Generic gate's coefficient layout to differ from OCaml even though the same total R1CS exist. This is invisible in standalone sub-circuit tests (which start with an empty packing queue) and only manifests when composing sub-circuits (like the IVP).
191+
192+
### R1CS double-packing alignment
193+
194+
The Kimchi constraint system pairs consecutive R1CS constraints into a single Generic gate (2 R1CS per gate). The pending queue persists across ALL constraint types — non-Generic gates (Poseidon, CompleteAdd, VarBaseMul, etc.) do NOT flush the queue.
195+
196+
This means: if any operation before a composed circuit boundary generates an ODD number of R1CS, all subsequent Generic gate coefficient layouts shift by one position. The circuits have the same gate kinds, same gate counts, same cached constants — but different coefficient pairings within Generic gates.
197+
198+
**Diagnosis**: Extract R1CS in generation order by unpacking Generic gates (right half = first/queued, left half = second/new). Compare the two R1CS sequences to find the first divergence index. The diverging R1CS's constant coefficient often identifies which operation (seal, constant materialization, boolean check) is at the wrong position.
199+
200+
**Prevention**: When translating OCaml code that modifies state with side effects (like sponge `add_assign`), check whether OCaml calls `seal` or `reduce_to_v` at that point. Missing a seal changes the R1CS generation timing.
201+
168202
### Different witness variable ordering
169203

170204
If the gate structure matches but wires differ, the issue may be in the order variables are introduced via `exists`.

packages/pickles-circuit-diffs/test/Test/Pickles/CircuitDiffs/Main.purs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import Snarky.Curves.Pasta (PallasG, VestaG)
5858
import Snarky.Curves.Vesta as Vesta
5959
import Snarky.Data.EllipticCurve (AffinePoint)
6060
import Snarky.Types.Shifted (Type1(..))
61-
import Test.Spec (SpecT, beforeAll_, describe, it, pending)
61+
import Test.Spec (SpecT, beforeAll_, describe, it)
6262
import Test.Spec.Assertions (shouldEqual)
6363
import Test.Spec.Reporter.Console (consoleReporter)
6464
import Test.Spec.Runner.Node (runSpecAndExitProcess')
@@ -410,7 +410,6 @@ spec =
410410
{ lagrangeComms: (coerce $ vestaSrsLagrangeCommitments stepSrs 16 30) :: Array (AffinePoint (F Fp))
411411
, blindingH: (coerce $ vestaSrsBlindingGenerator stepSrs) :: AffinePoint (F Fp)
412412
}
413-
-- pending "xhat_step_circuit"
414413
exactMatch "xhat_step_circuit" (fromCompiledCircuit $ compileXhatStep stepSrsData)
415414
describe "Pickles Wrap sub-circuits" do
416415
exactMatch "finalize_other_proof_wrap_circuit" (fromCompiledCircuit compileFopWrap)
@@ -435,13 +434,13 @@ spec =
435434
}
436435
exactMatch "ivp_wrap_circuit" (fromCompiledCircuit $ compileIvpWrap wrapSrsData)
437436
let
438-
stepSrs = pallasCrsCreate (2 `Int.pow` 16)
437+
-- OCaml uses SRS.Fq.create (1 lsl 15) and domain Pow_2_roots_of_unity 15
438+
stepSrs = pallasCrsCreate (2 `Int.pow` 15)
439439
stepSrsData =
440-
{ lagrangeComms: (coerce $ vestaSrsLagrangeCommitments stepSrs 16 175) :: Array (AffinePoint (F Fp))
440+
{ lagrangeComms: (coerce $ vestaSrsLagrangeCommitments stepSrs 15 175) :: Array (AffinePoint (F Fp))
441441
, blindingH: (coerce $ vestaSrsBlindingGenerator stepSrs) :: AffinePoint (F Fp)
442442
}
443-
pending "ivp_step_circuit"
444-
-- exactMatch "ivp_step_circuit" (fromCompiledCircuit $ compileIvpStep stepSrsData)
443+
exactMatch "ivp_step_circuit" (fromCompiledCircuit $ compileIvpStep stepSrsData)
445444
describe "Linearization" do
446445
exactMatch "linearization_step_circuit" (fromCompiledCircuit compileLinearizationStep)
447446
exactMatch "linearization_wrap_circuit" (fromCompiledCircuit compileLinearizationWrap)

packages/pickles-circuit-diffs/visualizer/src/CircuitDiffTable.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,13 @@ function GateTable({ title, gates, diffIndices, publicInputSize }) {
5858
count: gates.length,
5959
getScrollElement: () => parentRef.current,
6060
estimateSize: useCallback(
61-
(idx) =>
62-
expanded[idx]
63-
? 28 + 16 * Math.max(gates[idx]?.context?.length || 0, 1) + 8
64-
: 28,
61+
(idx) => {
62+
if (!expanded[idx]) return 28;
63+
const ctx = gates[idx]?.context || [];
64+
// Estimate height: each context line may wrap (long OCaml paths ~120 chars,
65+
// visible width ~60 chars at 11px monospace), so allow ~32px per line
66+
return 28 + 32 * Math.max(ctx.length, 1) + 8;
67+
},
6568
[expanded, gates]
6669
),
6770
overscan: 20,
@@ -132,6 +135,7 @@ function GateTable({ title, gates, diffIndices, publicInputSize }) {
132135
? styles.virtualRowDiff
133136
: "",
134137
hasContext ? styles.virtualRowClickable : "",
138+
isExpanded ? styles.virtualRowExpanded : "",
135139
]
136140
.filter(Boolean)
137141
.join(" ");

packages/pickles-circuit-diffs/visualizer/src/styles/gate-table.module.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,10 @@
115115
color: #555;
116116
font-size: 11px;
117117
line-height: 16px;
118+
word-break: break-all;
119+
}
120+
121+
/* Expanded rows need higher z-index so context isn't hidden behind the next row */
122+
.virtualRowExpanded {
123+
z-index: 1;
118124
}

packages/pickles/src/Pickles/Verify.purs

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type IncrementallyVerifyProofParams f r =
6161
, endo :: f -- ^ EndoScalar constant for challenge expansion
6262
, groupMapParams :: GroupMapParams f
6363
, correctionMode :: CorrectionMode
64+
, useOptSponge :: Boolean -- ^ true for Wrap (Opt_sponge), false for Step (regular sponge)
6465
| r
6566
}
6667

@@ -108,7 +109,7 @@ type IncrementallyVerifyProofOutput d f =
108109
-- | - `g`: commitment curve group
109110
-- | - `sf`: shifted scalar type (Type1 or Type2)
110111
incrementallyVerifyProof
111-
:: forall publicInput sgOldN d f f' @g sf t m _l3 r
112+
:: forall publicInput sgOldN totalBases d f f' @g sf t m _l3 _l4 r
112113
. PrimeField f
113114
=> FieldSizeInBits f 255
114115
=> FieldSizeInBits f' 255
@@ -121,6 +122,8 @@ incrementallyVerifyProof
121122
=> PublicInputCommit publicInput f
122123
=> Reflectable d Int
123124
=> Add 1 _l3 d
125+
=> Add sgOldN 45 totalBases
126+
=> Add 1 _l4 totalBases
124127
=> IpaScalarOps f t m sf
125128
-> IncrementallyVerifyProofParams f r
126129
-> IncrementallyVerifyProofInput publicInput sgOldN d (FVar f) sf
@@ -147,27 +150,50 @@ incrementallyVerifyProof scalarOps params input = labelM "incrementally-verify-p
147150
-- Squeeze digest
148151
Sponge.squeeze
149152

150-
-- 2. Compute x_hat (public input commitment)
151-
xHat <- liftSnarky $ label "ivp_xhat" $ publicInputCommit params input.publicInput
153+
-- 2. Sponge transcript + x_hat
154+
-- Step (regular sponge): absorb index_digest + sg_old BEFORE x_hat, matching
155+
-- OCaml step_verifier.ml ordering. Uses regular sponge (not Opt_sponge).
156+
-- Wrap (OptSponge): compute x_hat first, then run spongeTranscriptOptCircuit.
157+
{ xHat, beta, gamma, alphaChal, zetaChal, digest } <-
158+
if params.useOptSponge then do
159+
-- Wrap path: compute x_hat first, then OptSponge for all absorptions
160+
xHat <- liftSnarky $ label "ivp_xhat" $ publicInputCommit params input.publicInput
161+
let spongeInput = { indexDigest, sgOld: input.sgOld, publicComm: xHat, wComm: input.wComm, zComm: input.zComm, tComm: input.tComm }
162+
result <- labelM "ivp_opt_sponge" $ spongeTranscriptOptCircuit endoParams spongeInput
163+
pure { xHat, beta: result.beta, gamma: result.gamma, alphaChal: result.alphaChal, zetaChal: result.zetaChal, digest: result.digest }
164+
else do
165+
-- Step path: absorb index_digest + sg_old into main sponge BEFORE x_hat
166+
-- Matches step_verifier.ml:528-530
167+
Sponge.absorb indexDigest
168+
for_ input.sgOld Sponge.absorbPoint
169+
-- Compute x_hat
170+
xHat <- liftSnarky $ label "ivp_xhat" $ publicInputCommit params input.publicInput
171+
-- Continue sponge transcript: absorb x_hat, w_comm, squeeze beta/gamma, etc.
172+
-- step_verifier.ml:551-568
173+
Sponge.absorbPoint xHat
174+
for_ input.wComm Sponge.absorbPoint
175+
-- beta/gamma: squeeze_challenge (constrain_low_bits:true)
176+
beta <- Sponge.squeezeScalarChallenge endoParams
177+
gamma <- Sponge.squeezeScalarChallenge endoParams
178+
-- z_comm: receive
179+
Sponge.absorbPoint input.zComm
180+
-- alpha: squeeze_scalar (constrain_low_bits:false)
181+
alphaChal <- Sponge.squeezeScalar endoParams
182+
-- t_comm: receive
183+
for_ input.tComm Sponge.absorbPoint
184+
-- zeta: squeeze_scalar (constrain_low_bits:false)
185+
zetaChal <- Sponge.squeezeScalar endoParams
186+
-- Copy sponge before squeezing digest (step_verifier.ml:559)
187+
spongeBeforeEvals <- Sponge.getSponge
188+
digest <- Sponge.squeeze
189+
Sponge.putSponge spongeBeforeEvals
190+
pure { xHat, beta, gamma, alphaChal, zetaChal, digest }
152191

153-
-- 3. Run Fq-sponge transcript
154-
let
155-
spongeInput =
156-
{ indexDigest
157-
, sgOld: input.sgOld
158-
, publicComm: xHat
159-
, wComm: input.wComm
160-
, zComm: input.zComm
161-
, tComm: input.tComm
162-
}
163-
{ beta, gamma, alphaChal, zetaChal, digest } <- labelM "ivp_opt_sponge" $
164-
spongeTranscriptOptCircuit endoParams spongeInput
165-
166-
-- 4. Assert deferred values match sponge output (all 128-bit scalar challenges)
192+
-- 3. Assert deferred values match sponge output (all 128-bit scalar challenges)
167193
liftSnarky $ label "ivp_assert_plonk" $
168194
assertEq { beta, gamma, alpha: alphaChal, zeta: zetaChal } (toPlonkMinimal input.deferredValues.plonk)
169195

170-
-- 5. Compute ft_comm
196+
-- 4. Compute ft_comm
171197
ftCommResult <- liftSnarky $ label "ivp_ftcomm" $ ftComm
172198
scalarOps
173199
{ sigmaLast: constPt params.sigmaCommLast
@@ -177,15 +203,18 @@ incrementallyVerifyProof scalarOps params input = labelM "incrementally-verify-p
177203
, zetaToDomainSize: input.deferredValues.plonk.zetaToDomainSize
178204
}
179205

180-
-- 5. Assemble 45 commitment bases in to_batch order
206+
-- 5. Assemble commitment bases: sg_old + 45 fixed bases (to_batch order)
207+
-- Matches OCaml: sg_old[0..1], x_hat, ft_comm, z_comm, index(6), w_comm(15), coeff(15), sigma(6)
181208
let
182-
allBases :: Vector 45 (AffinePoint (FVar f))
209+
allBases :: Vector totalBases (AffinePoint (FVar f))
183210
allBases =
184-
(xHat :< ftCommResult :< input.zComm :< Vector.nil)
185-
`Vector.append` map constPt params.columnComms.index
186-
`Vector.append` input.wComm
187-
`Vector.append` map constPt params.columnComms.coeff
188-
`Vector.append` map constPt params.columnComms.sigma
211+
input.sgOld `Vector.append`
212+
( (xHat :< ftCommResult :< input.zComm :< Vector.nil)
213+
`Vector.append` map constPt params.columnComms.index
214+
`Vector.append` input.wComm
215+
`Vector.append` map constPt params.columnComms.coeff
216+
`Vector.append` map constPt params.columnComms.sigma
217+
)
189218

190219
-- 6. Build CheckBulletproofInput and run checkBulletproof
191220
let
@@ -225,7 +254,7 @@ incrementallyVerifyProof scalarOps params input = labelM "incrementally-verify-p
225254
-- |
226255
-- | Reference: mina/src/lib/pickles/step_verifier.ml:1164-1222
227256
verify
228-
:: forall publicInput sgOldN d f f' @g sf t m _l2 _l3 r
257+
:: forall publicInput sgOldN totalBases d f f' @g sf t m _l2 _l3 _l4 r
229258
. PrimeField f
230259
=> FieldSizeInBits f 255
231260
=> FieldSizeInBits f' 255
@@ -239,6 +268,8 @@ verify
239268
=> Reflectable d Int
240269
=> Add 1 _l2 7
241270
=> Add 1 _l3 d
271+
=> Add sgOldN 45 totalBases
272+
=> Add 1 _l4 totalBases
242273
=> IpaScalarOps f t m sf
243274
-> IncrementallyVerifyProofParams f r
244275
-> IncrementallyVerifyProofInput publicInput sgOldN d (FVar f) sf

packages/pickles/src/Pickles/Wrap/Circuit.purs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ type WrapParams f =
8181
-- Shared
8282
, endo :: f
8383
, correctionMode :: CorrectionMode
84+
, useOptSponge :: Boolean
8485
}
8586

8687
-- | The Wrap circuit: finalizes deferred values and verifies IPA opening.

packages/pickles/test/Test/Pickles/TestContext.purs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,7 @@ buildWrapCircuitParams ctx =
751751
, linearizationPoly: Linearization.vesta
752752
, groupMapParams: Kimchi.groupMapParams (Proxy @Vesta.G)
753753
, correctionMode: InCircuitCorrections
754+
, useOptSponge: true
754755
}
755756

756757
-------------------------------------------------------------------------------
@@ -1800,6 +1801,7 @@ buildStepIVPParams ctx =
18001801
, endo: stepEndo
18011802
, groupMapParams: Kimchi.groupMapParams (Proxy @Pallas.G)
18021803
, correctionMode: PureCorrections
1804+
, useOptSponge: false
18031805
}
18041806

18051807
-- | Build IVP circuit input for an Fp circuit verifying a Wrap (Pallas) proof.

packages/random-oracle/src/Snarky/Circuit/RandomOracle/Sponge.purs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import Poseidon (class PoseidonField)
2727
import RandomOracle.Sponge (Sponge, SpongeState(..), create, rate, state) as ReExports
2828
import RandomOracle.Sponge (Sponge, SpongeState(..), rate)
2929
import RandomOracle.Sponge as Sponge
30-
import Snarky.Circuit.DSL (class CircuitM, FVar, Snarky, add_, const_)
30+
import Snarky.Circuit.DSL (class CircuitM, FVar, Snarky, add_, const_, seal)
3131
import Snarky.Circuit.Kimchi (poseidon)
3232
import Snarky.Constraint.Kimchi (KimchiConstraint)
3333
import Snarky.Curves.Class (class PrimeField)
@@ -54,21 +54,24 @@ absorb x sponge = case sponge.spongeState of
5454
if n == rate then do
5555
-- Rate limit reached, permute first then absorb
5656
newState <- poseidon sponge.state
57-
let newState' = Vector.modifyAt p0 (add_ x) newState
57+
-- OCaml: add_assign ~state i x = state.(i) <- Utils.seal Field.(state.(i) + x)
58+
sealed <- seal (add_ x (Vector.index newState p0))
59+
let newState' = Vector.updateAt p0 sealed newState
5860
pure { state: newState', spongeState: Absorbed p1 }
59-
else
61+
else do
6062
-- Add to current position (no permutation needed)
63+
-- OCaml: add_assign ~state i x = state.(i) <- Utils.seal Field.(state.(i) + x)
64+
sealed <- seal (add_ x (Vector.index sponge.state n))
6165
let
62-
newState = Vector.modifyAt n (add_ x) sponge.state
66+
newState = Vector.updateAt n sealed sponge.state
6367
pNext = unsafePartial fromJust $ succ n
64-
in
65-
pure { state: newState, spongeState: Absorbed pNext }
66-
Squeezed _ ->
68+
pure { state: newState, spongeState: Absorbed pNext }
69+
Squeezed _ -> do
6770
-- Coming from squeezed state, add at position 0
68-
let
69-
newState = Vector.modifyAt p0 (add_ x) sponge.state
70-
in
71-
pure { state: newState, spongeState: Absorbed p1 }
71+
-- OCaml: add_assign ~state i x = state.(i) <- Utils.seal Field.(state.(i) + x)
72+
sealed <- seal (add_ x (Vector.index sponge.state p0))
73+
let newState = Vector.updateAt p0 sealed sponge.state
74+
pure { state: newState, spongeState: Absorbed p1 }
7275
where
7376
p0 :: Finite 3
7477
p0 = unsafeFinite 0

0 commit comments

Comments
 (0)