The One-Input Mismatch: How a Single Pre-Allocation Crashed WindowPoSt Proving
In a coding session spanning dozens of messages, one particular message stands out as the turning point where a subtle, deeply buried bug was finally identified and fixed. Message 179 is deceptively simple — a single line confirming that an edit was applied to a file:
[assistant] [edit] /tmp/czk/extern/cuzk/cuzk-pce/src/recording_cs.rsEdit applied successfully.
This terse confirmation belies the extraordinary debugging effort that preceded it. The edit was the culmination of a multi-step investigation into a crash that occurred when Pre-Compiled Constraint Evaluator (PCE) extraction was enabled for WindowPoSt proofs in the CuZK proving engine. The crash manifested as a C++ GPU assertion failure: points_a.size() == p.inp_assignment_size + p.a_aux_popcount. Behind that assertion lay a mismatch of exactly 196 inputs between the PCE's circuit description and the witness produced during proving — a discrepancy that traced back to a single, seemingly innocuous line in a constructor.
The Context: Enabling PCE for All Proof Types
The CuZK proving engine uses PCE extraction to accelerate proof generation by pre-compiling the circuit structure (constraint matrices and density bitmaps) so that subsequent proofs only need to fill in witness values. The assistant had successfully implemented PCE extraction for WinningPoSt and SnapDeals proof types, but enabling it for WindowPoSt caused a crash. The initial hypothesis was that RecordingCS (the constraint system used during PCE extraction) needed to be made "extensible" — implementing is_extensible() and extend() methods — to match the behavior of WitnessCS, which the bellperson prover used during standard synthesis.
That first fix compiled cleanly but did not resolve the crash. The logs revealed a persistent discrepancy: the standard prover path produced num_inputs = 25840, while the PCE extraction path produced num_inputs = 26036. The difference was exactly 196 — the number of parallel chunks used in synthesize_extendable, a parallel synthesis strategy employed by the FallbackPoStCircuit to speed up proving by partitioning the work across multiple child constraint systems.
Tracing the Discrepancy: A Detective Story
The assistant embarked on a meticulous code-reading exercise, tracing the exact flow of synthesize_extendable through three different constraint system types: WitnessCS (used for witness generation in some contexts), ProvingAssignment (the actual constraint system used by the standard prover), and RecordingCS (the custom constraint system used for PCE extraction).
The critical insight came when the assistant examined the standard prover path in supraseal.rs. The standard prover does not use WitnessCS at all — it uses ProvingAssignment. And ProvingAssignment::new() starts with an empty input_assignment vector (zero inputs). The prover then explicitly allocates the ONE input before calling circuit.synthesize():
prover.alloc_input(|| "", || Ok(Scalar::ONE))?;
circuit.synthesize(&mut prover)?;
Meanwhile, RecordingCS::new() (and WitnessCS::new()) pre-allocated the ONE input at index 0, setting num_inputs = 1 from the start. This seemed like a sensible convention — after all, every R1CS constraint system needs a constant ONE input. But this assumption turned out to be the root cause of the crash.
The Subtle Arithmetic of Extend
When synthesize_extendable runs, it creates child constraint system instances via CS::new(), then calls alloc_input("temp ONE") on each child to reserve a temporary slot for the constant ONE value during parallel synthesis. After each child finishes synthesizing its sector of the circuit, the parent calls extend(child) to merge the child's inputs into the parent.
The extend() method in all three CS types skips the child's input at index 0, because index 0 is conventionally the ONE input that should map to the parent's own ONE input at index 0. The formula is: parent.num_inputs += child.num_inputs - 1.
Here is where the arithmetic diverges:
ProvingAssignmentchild:new()gives 0 inputs.alloc_input("temp ONE")gives 1 input at index 0. Sector synthesis adds N inputs. Total = 1 + N.extend()skips index 0, contributing N inputs to the parent.RecordingCSchild:new()gives 1 input (pre-allocated ONE at index 0).alloc_input("temp ONE")gives 2 inputs (index 0 = pre-allocated ONE, index 1 = temp ONE). Sector synthesis adds N inputs. Total = 2 + N.extend()skips index 0, contributing 1 + N inputs to the parent. The difference is exactly one extra input per child — the "temp ONE" that was allocated at index 1 inRecordingCS(because index 0 was already occupied by the pre-allocated ONE) but at index 0 inProvingAssignment(where it was subsequently skipped byextend()). With 196 parallel chunks, this produced exactly the 196 extra inputs observed in the crash logs.
The Fix: Harmonizing Initialization
The fix required two coordinated changes. First, RecordingCS::new() had to stop pre-allocating the ONE input, matching ProvingAssignment::new() which starts with zero inputs. Second, extract_precompiled_circuit() — the function that creates the RecordingCS for PCE extraction — had to explicitly allocate the ONE input before calling circuit.synthesize(), exactly as the standard prover does.
The assistant also verified that the remap_col logic in RecordingCS::extend() remained correct. The remap_col function maps child input index 0 to parent input index 0 (the ONE input). With the fix, the child's "temp ONE" allocation now lands at index 0 (since new() starts empty), and remap_col correctly maps it to the parent's explicitly-allocated ONE. The values match — both are Fr::ONE — so the constraint structure remains valid.
Why This Message Matters
Message 179 is the moment of resolution. The edit it confirms was not a speculative change or a workaround — it was a precisely targeted fix derived from a deep understanding of how three different constraint system types interact with the parallel synthesis protocol. The assistant had to:
- Recognize that the standard prover uses
ProvingAssignment, notWitnessCS - Trace the constructor of each CS type to understand their initial state
- Simulate the arithmetic of
extend()across all three types to identify the divergence - Verify that the
remap_collogic would remain consistent after the change - Ensure the caller (
extract_precompiled_circuit) was updated to explicitly allocate ONE The root cause was an incorrect assumption: that pre-allocating the ONE input inRecordingCS::new()was harmless because "all constraint systems need a ONE input." This assumption was false because it didn't account for howsynthesize_extendablecreates child CS instances and howextend()merges them. The child CS instances inherit the pre-allocation, and the "temp ONE" allocation shifts to a different index, creating an extra input that survives the extend operation. This fix restored structural parity between the PCE extraction path and the standard proving path, resolving the WindowPoSt crash and enabling PCE acceleration for all proof types. The message itself — a simple edit confirmation — marks the boundary between diagnosis and resolution, the point where understanding crystallized into action.