PrivacyCircle STARK Verifier

DEEP Quotient Computation

Out-of-domain quotient accumulation — line equations, conjugate pairs, and periodicity samples

Overview

The DEEP (Domain Extension for Eliminating Pretenders) technique is how the verifier connects the committed polynomial evaluations (at queried domain points) with the out-of-domain (OOD) evaluations (at a random challenge point). It is the most mathematically intricate component of the verifier and the source of both critical bugs found during the port.

All DEEP quotient logic is in OodQuotients.sol (221 lines).

1. Purpose

The prover claims that a committed polynomial ff satisfies f(Pood)=vf(P_{\text{ood}}) = v for some out-of-domain point PoodP_{\text{ood}}. The verifier cannot evaluate ff at PoodP_{\text{ood}} directly (it only has Merkle commitments). Instead, it constructs a quotient that is zero if and only if the claim is true:

Q(P)=f(P)L(P)Z(P)Q(P) = \frac{f(P) - L(P)}{Z(P)}

where LL is the unique line passing through the claimed evaluation and its conjugate, and ZZ vanishes at those points. If f(Pood)=vf(P_{\text{ood}}) = v, then QQ is a polynomial of degree deg(f)1\deg(f) - 1. If the claim is false, QQ has a pole and the FRI check will (with high probability) fail.

2. Points Involved

Three types of evaluation points appear:

OOD Point PoodP_{\text{ood}}

Generated via stereographic projection from a random QM31 challenge tt:

Pood=(1t21+t2,  2t1+t2)C(Fp4)P_{\text{ood}} = \left(\frac{1-t^2}{1+t^2}, \; \frac{2t}{1+t^2}\right) \in \mathcal{C}(\mathbb{F}_{p^4})

This point lies on the QM31 circle (not the M31 circle), ensuring it is "outside" the evaluation domain with overwhelming probability.

Shifted Point PshiftedP_{\text{shifted}}

For columns with mask offset 1 (reading the next row), the evaluation is at:

Pshifted=PoodGtraceP_{\text{shifted}} = P_{\text{ood}} \cdot G_{\text{trace}}

where GtraceG_{\text{trace}} is the subgroup generator of order 2LOG_TRACE_SIZE=262^{\text{LOG\_TRACE\_SIZE}} = 2^6. Circle point multiplication:

(x1,y1)(x2,y2)=(x1x2y1y2,  x1y2+y1x2)(x_1, y_1) \cdot (x_2, y_2) = (x_1 x_2 - y_1 y_2, \; x_1 y_2 + y_1 x_2)

with all arithmetic in QM31.

Periodicity Point PperiodP_{\text{period}}

For columns with 2+ mask offsets, Stwo adds a periodicity sample at:

Pperiod=Pshifted+GperiodP_{\text{period}} = P_{\text{shifted}} + G_{\text{period}}

For our parameters, Gperiod=identityG_{\text{period}} = \text{identity}, so Pperiod=PshiftedP_{\text{period}} = P_{\text{shifted}}. See Section 6 for the derivation.

Query Points q=(qx,qy)q = (q_x, q_y)

These are M31 circle domain points at the queried positions. They lie on the M31 circle (not QM31), accessed in bit-reversed order from the lifting domain.

3. Line Equation Through Conjugate Pairs

The key insight in DEEP quotients for circle STARKs is that we work with line equations through conjugate pairs. Given a sample (Ps,v)(P_s, v) where PsP_s is a QM31 circle point and vv is the claimed evaluation:

The conjugate pair is (Psˉ,vˉ)(\bar{P_s}, \bar{v}) where ˉ\bar{\cdot} denotes QM31 complex conjugation.

The unique line (x,y)=ay+b\ell(x, y) = a \cdot y + b passing through both (Ps,v)(P_s, v) and (Psˉ,vˉ)(\bar{P_s}, \bar{v}) has coefficients:

a=vˉva = \bar{v} - v c=ysˉysc = \bar{y_s} - y_s b=vcaysb = v \cdot c - a \cdot y_s

QM31 Conjugation

Recall: (m0,m1,m2,m3)=(m0,m1,m2,m3)\overline{(m_0, m_1, m_2, m_3)} = (m_0, m_1, -m_2, -m_3) — negate entire second CM31, NOT element-wise CM31 conjugation. See Field Arithmetic for details.

4. Quotient Contribution

For each column sample, the quotient contribution is:

ΔQ=αkcf(q)(aqy+b)denom\Delta Q = \alpha^k \cdot \frac{c \cdot f(q) - (a \cdot q_y + b)}{\text{denom}}

where:

  • αk\alpha^k is the current power of the random coefficient (one power per contribution)
  • f(q)f(q) is the queried M31 trace value at the query point
  • The numerator checks whether f(q)f(q) lies on the line through the claimed evaluations
  • The denominator is the line denominator evaluated at the query point

Line Denominator

The denominator isolates the vanishing behavior at the sample point:

denom=(Ps,xreqx)Ps,yim(Ps,yreqy)Ps,xim\text{denom} = (P_{s,x}^{\text{re}} - q_x) \cdot P_{s,y}^{\text{im}} - (P_{s,y}^{\text{re}} - q_y) \cdot P_{s,x}^{\text{im}}

where superscripts denote CM31 real/imaginary parts of the QM31 coordinates. This is a CM31 value.

The denominator depends only on the sample point and the query point, not on the column values. For the OOD point and shifted point, we pre-compute their inverses once per query:

ctx.denomOodsInv = cm31Inv(_lineDenom(pts.oodsX, pts.oodsY, ctx.qx, ctx.qy));
ctx.denomShiftedInv = cm31Inv(_lineDenom(pts.shiftedX, pts.shiftedY, ctx.qx, ctx.qy));

These are reused across all columns that share the same evaluation point.

Numerator Computation

function _computeNumerator(uint128 c, uint128 a, uint128 b, uint32 fVal, uint32 qy)
    internal pure returns (uint128)
{
    uint128 fQ = qm31FromM31(fVal, 0, 0, 0);
    uint128 qyQ = qm31FromM31(qy, 0, 0, 0);
    return qm31Sub(
        qm31Mul(c, fQ),
        qm31Add(qm31Mul(a, qyQ), b)
    );
}

Note that fVal and qy are M31 values promoted to QM31 for the computation. The result is QM31.

Final Assembly

function _addQ(
    uint128 acc, uint128 alphaPow,
    uint32 queriedVal, uint128 sampleVal, uint128 samplePtY,
    uint32 qy, uint64 denomInv
) internal pure returns (uint128) {
    // Line coefficients
    uint128 a = qm31Sub(_conjQ(sampleVal), sampleVal);
    uint128 c = qm31Sub(_conjQ(samplePtY), samplePtY);
    uint128 b = qm31Sub(qm31Mul(sampleVal, c), qm31Mul(a, samplePtY));

    // Scale by alphaPow
    a = qm31Mul(alphaPow, a);
    b = qm31Mul(alphaPow, b);
    c = qm31Mul(alphaPow, c);

    // Numerator: c * f(q) - (a * qy + b)
    uint128 num = _computeNumerator(c, a, b, queriedVal, qy);

    // num * denomInv (CM31 inverse promoted to QM31)
    uint128 diQ = qm31Pack(denomInv, cm31Pack(0, 0));
    return qm31Add(acc, qm31Mul(num, diQ));
}

5. Accumulation Order

The verifier processes columns in strict order, accumulating quotient contributions with increasing powers of α\alpha:

Single-Offset Columns

For the withdrawal circuit, 44 of 46 trace columns are single-offset. For the transfer circuit, 55 of 57 trace columns are single-offset.

One contribution per column, consuming one α\alpha power:

acc += α^k * quotient(col_value, ood_value, ood_point)
k += 1

Shifted Columns (columns 25 and 29)

These column indices are now constructor parameters (MULTI_MASK_COL_0, MULTI_MASK_COL_1) rather than hardcoded values, allowing the same verifier code to support both the withdrawal circuit (46 columns) and the transfer circuit (57 columns). Both circuits use columns 25 and 29 as the shifted columns.

Three contributions per column, consuming three α\alpha powers:

acc += α^k   * quotient(col_value, shifted_ood_value, shifted_point)   // periodicity
acc += α^(k+1) * quotient(col_value, ood_value_offset0, ood_point)     // offset 0
acc += α^(k+2) * quotient(col_value, ood_value_offset1, shifted_point) // offset 1
k += 3

Composition Columns (8 columns)

One contribution per column at the OOD point:

acc += α^k * quotient(comp_value, comp_ood_value, ood_point)
k += 1

Total α\alpha Count

For the withdrawal circuit (46 trace columns):

44×1+2×3+8×1=58 contributions44 \times 1 + 2 \times 3 + 8 \times 1 = 58 \text{ contributions}

For the transfer circuit (57 trace columns):

55×1+2×3+8×1=69 contributions55 \times 1 + 2 \times 3 + 8 \times 1 = 69 \text{ contributions}

The final accumulator is the FRI input for that query.

6. Periodicity Samples: The Deep Cut

This Was Bug #2

Missing periodicity samples shifted all subsequent alpha powers by 2, corrupting every quotient contribution after column 25. The error was only discovered through exhaustive intermediate-value comparison with the Rust prover.

What Are Periodicity Samples?

In Stwo's build_samples_with_randomness_and_periodicity, for any column with 2 or more mask offsets, an additional "periodicity" quotient contribution is prepended before the regular offset samples.

The periodicity sample represents the constraint that the polynomial must be periodic with respect to the column's evaluation domain. For columns with only one mask offset, no periodicity sample is needed.

Periodicity Point Derivation

The periodicity point is:

Pperiod=Pshifted+GperiodP_{\text{period}} = P_{\text{shifted}} + G_{\text{period}}

where G_period = CanonicCoset(lifting_log_size).step().repeated_double(column_extended_log_size).

For our parameters:

  • lifting_log_size = 7
  • column_extended_log_size = 7 (= trace_log_size + log_blowup_factor = 6 + 1)
  • CanonicCoset(7).step() = subgroupGen(7) → index =2317=224= 2^{31-7} = 2^{24}
  • After 7 doublings: index =224×27=231= 2^{24} \times 2^7 = 2^{31}
  • G231=G^{2^{31}} = identity (since G=231|G| = 2^{31})

Therefore: Gperiod=identity=(1,0)G_{\text{period}} = \text{identity} = (1, 0)

And: Pperiod=Pshifted(1,0)=PshiftedP_{\text{period}} = P_{\text{shifted}} \cdot (1, 0) = P_{\text{shifted}}

Consequence

The periodicity point coincides with the shifted point. This means:

  • Same y-coordinate for the line equation
  • Same line denominator inverse

The periodicity sample uses the offset-1 OOD value (peeked ahead from the trace OOD array) at the shifted point.

Initial Wrong Assumption

Intermediate Error

We initially assumed column_extended_log_size = trace_log_size = 6 (not 7), which gave:

  • step().repeated_double(6) → index =224×26=230= 2^{24} \times 2^6 = 2^{30}
  • G230=(1,0)G^{2^{30}} = (-1, 0) (the antipodal point)
  • Pperiod=Pshifted(1,0)=(shiftedx,shiftedy)P_{\text{period}} = P_{\text{shifted}} \cdot (-1, 0) = (-\text{shifted}_x, -\text{shifted}_y)

This produced a distinct periodicity point with its own denominator, which didn't match the Rust prover's behavior. The fix was recognizing that column_extended_log_size is the extended (blown-up) size, not the raw trace size.

7. The OOD Value Array

The trace OOD values are stored in a flat array, indexed sequentially:

Array IndexColumnOffsetDescription
000owner_secret
110input_amount
......0(single-offset columns)
17170nullifier_3
18180merkle_current, offset 0
19181merkle_current, offset 1
20190merkle_sibling
21200merkle_index
22210merkle_result
23220asp_current, offset 0
24221asp_current, offset 1
25230asp_sibling
............
40380recipient

Total: 41 trace OOD values (37 single + 2×2 shifted).

For shifted columns, the code "peeks ahead" to read the offset-1 value for the periodicity sample before advancing the index:

uint128 shiftedVal = p.traceOodValues[s.tOodIdx + 1]; // peek: offset-1
// ... use shiftedVal for periodicity sample ...
s.acc = _addQ(..., p.traceOodValues[s.tOodIdx++], ...); // offset-0, advance
s.acc = _addQ(..., p.traceOodValues[s.tOodIdx++], ...); // offset-1, advance

8. Complete Accumulation Flow

For each query position qiq_i:

1. Compute query context:
   - Bit-reverse position → domain index
   - Look up circle domain point (qx, qy)
   - Compute denomOodsInv = cm31Inv(lineDenom(ood, q))
   - Compute denomShiftedInv = cm31Inv(lineDenom(shifted, q))

2. Initialize: acc = 0, alphaPow = 1, tOodIdx = 0

3. For each trace column (0..TRACE_WIDTH-1):
   qVal = queriedTrace[col][qi]

   If col == MULTI_MASK_COL_0 or col == MULTI_MASK_COL_1:
     // Periodicity sample
     shiftedVal = traceOodValues[tOodIdx + 1]
     acc += addQ(alphaPow, qVal, shiftedVal, shiftedY, qy, denomShiftedInv)
     alphaPow *= randomCoeff

     // Offset-0 sample
     acc += addQ(alphaPow, qVal, traceOodValues[tOodIdx++], oodsY, qy, denomOodsInv)
     alphaPow *= randomCoeff

     // Offset-1 sample
     acc += addQ(alphaPow, qVal, traceOodValues[tOodIdx++], shiftedY, qy, denomShiftedInv)
     alphaPow *= randomCoeff
   Else:
     acc += addQ(alphaPow, qVal, traceOodValues[tOodIdx++], oodsY, qy, denomOodsInv)
     alphaPow *= randomCoeff

4. For each composition column (0..7):
   acc += addQ(alphaPow, queriedComp[col][qi], compOodValues[col], oodsY, qy, denomOodsInv)
   alphaPow *= randomCoeff

5. Return acc as FRI answer for query qi

9. Debugging Strategy

The quotient computation is the hardest component to debug because errors are non-local: a wrong value at one column affects all subsequent columns through the alpha accumulation.

Our debugging strategy:

  1. Rust transcript: Added logging to the Rust verifier's quotient computation at every column, outputting intermediate values (a,b,c,num,denom)(a, b, c, \text{num}, \text{denom})
  2. Column-by-column comparison: Hardcoded Rust values in Solidity tests, comparing at each column boundary
  3. Bisection: When the accumulator diverged, bisected to find the first differing column
  4. Root cause analysis: Traced the divergence to specific arithmetic operations

For Bug #1 (conjugation), the first divergence appeared at column 0's cc coefficient — the line coefficient computed from yoodˉyood\bar{y_{\text{ood}}} - y_{\text{ood}}, which directly uses the conjugate.

For Bug #2 (periodicity), all columns before 25 matched perfectly. Column 25 introduced an unexpected third contribution, shifting the alpha counter for all subsequent columns.

10. Gas Profile

DEEP quotient computation accounts for ~29% of total verification gas (~5M out of ~20M):

OperationPer ColumnCountTotal
QM31 conjugation~10058~6K
Line coefficients (3 QM31 muls)~3,60058~209K
Alpha scaling (3 QM31 muls)~3,60058~209K
Numerator (2 QM31 muls + promote)~2,50058~145K
Denom multiply + accumulate~1,30058~75K
Alpha power advance~1,20058~70K
Line denom + CM31 inv (per query)~10,0002×3~60K

The per-query overhead (2 CM31 inversions) is modest; the bulk of the cost is the 58 column-wise QM31 multiplications across 3 queries = 174 iterations.

On this page