The Temp ONE Problem: How a Single Input Variable Nearly Broke GPU Proving for Filecoin's WindowPoSt

Introduction

In the world of zero-knowledge proof systems, correctness hinges on absolute structural consistency. Every constraint, every variable, and every input must be accounted for with mathematical precision. When an optimization technique called Pre-Compiled Constraint Evaluation (PCE) was extended to support WindowPoSt proofs in the CuZK proving engine, a seemingly minor mismatch in how two constraint system implementations handled a single "temp ONE" variable caused a crash that required deep detective work to resolve. This article examines a pivotal moment in that debugging journey — message 139 of the conversation — where the assistant paused to verify the correctness of its fix for the variable index remapping logic, uncovering a subtle initialization mismatch that would have silently broken the extraction pipeline.

The Context: PCE Extraction Meets Parallel Synthesis

The CuZK proving engine accelerates zero-knowledge proofs by pre-compiling the fixed constraint structure of a circuit (the R1CS topology) into a GPU-friendly format. This extraction is performed once per circuit type using RecordingCS, a constraint system implementation that captures the structure without evaluating witness values. The extracted PreCompiledCircuit is then reused for all subsequent proofs, allowing the GPU to evaluate constraints without re-synthesizing the circuit each time.

The challenge arose when PCE was enabled for WindowPoSt, one of Filecoin's proof types. Unlike the simpler PoRep circuit, WindowPoSt's circuit — specifically the FallbackPoStCircuit — dispatches to different synthesis paths depending on whether the constraint system supports extensibility. The synthesize_extendable path splits the circuit's sectors into parallel chunks, each synthesized in its own child constraint system, then merged back via an extend() method. This parallelization reduces wall-clock time but introduces structural complexity: each child chunk allocates a "temp ONE" input variable that becomes a permanent public input after merging.

The crash manifested as a mismatch in input counts: the witness produced 26036 inputs while the PCE expected 25840. The difference was exactly 196 — matching the configured number of synthesis CPUs. The root cause was traced to the is_extensible() flag: RecordingCS returned false (the trait default), causing the circuit to take the synthesize_default path, while WitnessCS returned true, routing through synthesize_extendable. This meant the PCE recorded a circuit with fewer inputs than the actual proving path would produce.

The Fix in Progress: Making RecordingCS Extensible

By message 139, the assistant had already confirmed the root cause and begun implementing the fix. The approach was straightforward in principle: make RecordingCS implement is_extensible() -> true and provide an extend() method that mirrors WitnessCS::extend(). The challenge lay in the details of variable index remapping.

WitnessCS::extend() is simple because WitnessCS stores only assignment vectors (the actual field element values for each variable). It skips the first input of the child (the built-in ONE variable) and appends the rest. But RecordingCS stores the actual constraint structure as CSR (Compressed Sparse Row) matrices — the A, B, and C coefficient matrices that define the R1CS constraints. When merging a child's constraints into the parent, every column index referencing a child variable must be remapped to the corresponding parent variable index. Input indices need one offset, aux indices another, and the child's built-in ONE variable (input 0) must map to the parent's existing ONE rather than being duplicated.

The assistant had already implemented this remapping logic in a previous edit (msg 137), but with a subtle error: it assumed that child input 0 (the ONE variable) should always be skipped, mapping child input 1 to parent input input_offset. This logic was corrected in msg 138 to handle the case where constraints legitimately reference the child's ONE variable — those should map to the parent's ONE (input 0), not be skipped.

The Critical Insight: What About the Temp ONE?

Message 139 opens with the assistant realizing a gap in its reasoning:

Wait, I also need to handle the "temp ONE" that synthesize_extendable allocates at line 224: ``rust cs.alloc_input(|| "temp ONE", || Ok(Fr::ONE))?; ` This allocates a NEW input variable at index 1 in the child (after the built-in ONE at index 0). This "temp ONE" becomes a real public input in the parent after extend. So child input 1 = "temp ONE" → maps to parent input input_offset + 0 = input_offset. That's correct with the current logic: col=1 → (1 - 1 + input_offset) = input_offset`.

This is a moment of verification. The assistant traces through the variable indexing to confirm that its remapping formula — child_input_idx - 1 + input_offset — correctly handles the temp ONE. In the child WitnessCS, the built-in ONE is at index 0, the temp ONE is at index 1, and sector inputs start at index 2. After extend, the built-in ONE is skipped (it already exists in the parent), the temp ONE becomes the first new input of the parent's chunk, and sector inputs follow. The formula col=1 → (1 - 1 + input_offset) = input_offset confirms that child input 1 (temp ONE) correctly maps to the first available parent input slot.

But then the assistant asks a deeper question: does RecordingCS::new() initialize properly for the extensible path?

The Initialization Mismatch

The assistant reads RecordingCS::new() and finds:

fn new() -> Self {
    Self::new_empty()
}

new_empty() sets num_inputs = 0. In contrast, WitnessCS::new() pre-allocates input_assignment = vec![Scalar::ONE], making the built-in ONE variable occupy index 0 from the start.

The assistant traces through the synthesize_extendable flow for each child constraint system:

Child WitnessCS:

This is a fundamental mismatch. RecordingCS::new() doesn't pre-allocate a ONE variable, but WitnessCS::new() does. I need to make RecordingCS::new() also pre-allocate a ONE input to match.

The Ripple Effect: Fixing the Main Extraction Path

But changing new() has consequences. The main extraction function extract_precompiled_circuit() also calls RecordingCS::new() and then manually allocates ONE:

let mut cs = RecordingCS::<Scalar>::new();
cs.alloc_input(|| "one", || Ok(Scalar::ONE))?;

If new() now pre-allocates ONE (setting num_inputs = 1), then the manual alloc_input would push it to 2, double-counting the ONE variable. The assistant recognizes this and plans to update extract_precompiled_circuit() to remove the manual allocation.

The assistant also considers an alternative — only changing new() for the extensible path — but dismisses it because synthesize_extendable calls CS::new() generically through the trait interface. There's no way to provide a different constructor for the parallel chunk path versus the main extraction path. The only clean solution is to make new() consistent and adjust the extraction code.

The Verification Chain

What makes message 139 particularly interesting is the chain of reasoning the assistant performs. It doesn't just implement the fix and move on — it systematically verifies each assumption:

  1. Temp ONE mapping: Confirms that child input 1 (temp ONE) maps to input_offset under the formula col=1 → (1 - 1 + input_offset) = input_offset. This is correct.
  2. Constructor parity: Discovers that RecordingCS::new() and WitnessCS::new() produce different initial states. This would cause the child RecordingCS to have the temp ONE at index 0 instead of index 1, breaking the remapping logic.
  3. Ripple effect on extraction: Recognizes that changing new() would break the existing extract_precompiled_circuit() function, which manually allocates ONE after calling new().
  4. Structural equivalence: Verifies that after both changes (pre-allocating ONE in new() and removing the manual allocation from extraction), the final num_inputs count remains the same. The assistant traces through both the old and new flows to confirm equivalence. This verification chain demonstrates a rigorous approach to debugging in complex systems. Each step checks not just that the immediate fix is correct, but that it doesn't introduce new bugs elsewhere.

The Broader Lesson: Structural Consistency in ZK Systems

The underlying issue here is a cautionary tale about the challenges of optimizing zero-knowledge proof systems. The is_extensible() flag was designed to allow constraint systems to opt into parallel synthesis, but it created a hidden coupling between the constraint system implementation and the circuit's synthesis path. When RecordingCS was added as a new constraint system implementation for PCE extraction, it silently inherited the default is_extensible() = false, causing it to take a completely different synthesis path than WitnessCS. The circuit produced different constraint structures depending on which constraint system was used — a violation of the fundamental assumption that circuit synthesis is deterministic with respect to the circuit's logical structure.

This kind of bug is particularly dangerous because it doesn't manifest as a compilation error or a type mismatch. The code compiles cleanly. The circuit runs without errors. It only fails when the PCE's pre-compiled structure is compared against the actual witness produced by the fast proving path — and even then, the failure is a cryptic input count mismatch rather than a clear "extensible path mismatch" error.

Conclusion

Message 139 captures a pivotal moment in the debugging of a complex zero-knowledge proving system. The assistant, having identified the root cause of a crash in WindowPoSt proving, pauses to verify its fix and discovers a subtle initialization mismatch between RecordingCS::new() and WitnessCS::new(). This mismatch would have caused the temp ONE variable — allocated by the parallel synthesis path — to be silently dropped during the extend() operation, producing an incorrect constraint structure.

The fix required three coordinated changes: implementing is_extensible() and extend() on RecordingCS, pre-allocating the built-in ONE variable in RecordingCS::new() to match WitnessCS, and updating extract_precompiled_circuit() to remove the now-redundant manual ONE allocation. Each change was necessary; omitting any one would have left the system in an inconsistent state.

The episode underscores a critical lesson for developers working with zero-knowledge proof systems: structural consistency between different constraint system implementations is not optional. When the proving path and the extraction path diverge — even by a single input variable — the entire optimization collapses. The assistant's methodical verification, tracing through variable indices and constructor behavior, demonstrates the level of rigor required to safely extend high-performance proving systems.