# Notes on SamaritanPCS :::success [Kurt Pan](https://kurtpan.xyz) @ [ZKPunk](https://zkpunk.pro) ::: ## Overview [SamaritanPCS](https://eprint.iacr.org/2025/419.pdf) transforms a **homomorphic univariate PCS** into a **multilinear PCS** with linear-time proving. The key innovation is the **divide-and-conquer evaluation proof** that achieves $O(n)$ prover complexity instead of $O(n \log n)$. --- ## The Decomposition Idea The core insight is to split a large multilinear polynomial evaluation into evaluations of smaller polynomials. ### Setup Given: - A multilinear polynomial $\widetilde{f}$ over $\mu$ variables where $n = 2^\mu$ - We split $\mu = \nu + \kappa$ where $n = \ell \cdot m$ with $\ell = 2^\nu$ and $m = 2^\kappa$ - Variables partitioned as $\mathbf{z} = (\mathbf{z}_y, \mathbf{z}_x)$ where $\mathbf{z}_y \in \mathbb{F}^\nu$ and $\mathbf{z}_x \in \mathbb{F}^\kappa$ ### Key Identity The multilinear polynomial can be decomposed as: $$ \widetilde{f}(\mathbf{x}, \mathbf{y}) = \sum_{i=1}^{\ell} \widetilde{eq}(\langle i \rangle, \mathbf{y}) \cdot \widetilde{f}(\mathbf{x}, \langle i \rangle) $$ This means we can express the evaluation in terms of $\ell$ smaller multilinear polynomials $\widetilde{g}_i(\mathbf{x}) = \widetilde{f}(\mathbf{x}, \langle i \rangle)$, each over only $\kappa$ variables instead of $\mu$. :::info **Intuition**: Instead of proving one big evaluation directly, we break it into $\ell$ smaller evaluations and then aggregate them using the $\widetilde{eq}$ function as weights. ::: --- ## Concrete Example: $\mu = 2$, split as $\nu = 1, \kappa = 1$ Let's walk through a minimal example with $n = 4$ total variables. ### Step 1: Define the multilinear polynomial Suppose we have $\widetilde{f}(X_1, X_2)$ with coefficient vector $\mathbf{f} = (f_0, f_1, f_2, f_3)$ where: - $f_0 = 5, f_1 = 3, f_2 = 7, f_3 = 2$ The multilinear polynomial is: $$ \widetilde{f}(X_1, X_2) = 5 \cdot (1-X_1)(1-X_2) + 3 \cdot X_1(1-X_2) + 7 \cdot (1-X_1)X_2 + 2 \cdot X_1 X_2 $$ Via the isomorphism $\varphi$, the univariate version is: $$ \hat{f}(X) = 5 + 3X + 7X^2 + 2X^3 $$ ```python # SageMath code to define the polynomials F = GF(101) # Working over finite field F_101 R.<X1, X2> = PolynomialRing(F) # Define multilinear polynomial from coefficient vector coeffs = [5, 3, 7, 2] f_tilde = (coeffs[0] * (1-X1)*(1-X2) + coeffs[1] * X1*(1-X2) + coeffs[2] * (1-X1)*X2 + coeffs[3] * X1*X2) print("Multilinear polynomial f̃(X₁, X₂):") print(f_tilde) # Define the univariate version via isomorphism φ R_uni.<X> = PolynomialRing(F) f_hat = sum(coeffs[i] * X^i for i in range(4)) print("\nUnivariate polynomial f̂(X):") print(f_hat) ``` ### Step 2: Decompose using base-$m$ representation With $m = 2$ and $\ell = 2$, we write $\hat{f}(X)$ in base $X^2$: $$ \hat{f}(X) = \underbrace{(5 + 3X)}_{\hat{g}_1(X)} + X^2 \cdot \underbrace{(7 + 2X)}_{\hat{g}_2(X)} $$ These correspond to multilinear polynomials: - $\widetilde{g}_1(X_1) = \widetilde{f}(X_1, 0) = 5(1-X_1) + 3X_1$ - $\widetilde{g}_2(X_1) = \widetilde{f}(X_1, 1) = 7(1-X_1) + 2X_1$ With coefficient vectors: - $\mathbf{g}_1 = (5, 3)$ - $\mathbf{g}_2 = (7, 2)$ ```python # Decompose f̂(X) in base X^m where m = 2 m = 2 ell = 2 # Extract coefficients for g_1 and g_2 g1_coeffs = [coeffs[0], coeffs[1]] # Coefficients of X^0, X^1 g2_coeffs = [coeffs[2], coeffs[3]] # Coefficients of X^2, X^3 (shifted down) # Define univariate g_i polynomials g1_hat = g1_coeffs[0] + g1_coeffs[1] * X g2_hat = g2_coeffs[0] + g2_coeffs[1] * X print("Decomposition in base X^m:") print(f"ĝ₁(X) = {g1_hat}") print(f"ĝ₂(X) = {g2_hat}") # Verify the decomposition f_reconstructed = g1_hat + X^m * g2_hat print(f"\nVerification: ĝ₁(X) + X² · ĝ₂(X) = {f_reconstructed}") print(f"Original f̂(X) = {f_hat}") print(f"Match: {f_reconstructed == f_hat}") # Define multilinear versions R_single.<X1> = PolynomialRing(F) g1_tilde = g1_coeffs[0] * (1-X1) + g1_coeffs[1] * X1 g2_tilde = g2_coeffs[0] * (1-X1) + g2_coeffs[1] * X1 print(f"\nMultilinear versions:") print(f"g̃₁(X₁) = {g1_tilde}") print(f"g̃₂(X₁) = {g2_tilde}") ``` :::warning **Key Observation**: The decomposition $\hat{f}(X) = \hat{g}_1(X) + X^m \hat{g}_2(X) + \cdots$ is **unique** given the degree constraints on each $\hat{g}_i(X)$. ::: ### Step 3: Evaluation claim Suppose we want to prove $\widetilde{f}(z_2, z_1) = v$ for some evaluation point $\mathbf{z} = (z_2, z_1)$. Let's use $z_2 = 1$ and $z_1 = 0$ (typical Boolean hypercube points). First, let's compute the actual value: $$ \widetilde{f}(0, 1) = 5 \cdot (1-0)(1-1) + 3 \cdot 0 \cdot (1-1) + 7 \cdot (1-0) \cdot 1 + 2 \cdot 0 \cdot 1 = 7 $$ The claim decomposes as: $$ \widetilde{f}(0, 1) = \widetilde{eq}(\langle 1 \rangle, 1) \cdot \widetilde{g}_1(0) + \widetilde{eq}(\langle 2 \rangle, 1) \cdot \widetilde{g}_2(0) $$ Where: - $\widetilde{eq}(\langle 1 \rangle, 1) = \widetilde{eq}(0, 1) = (1-0)(1-1) + 0 \cdot 1 = 0$ - $\widetilde{eq}(\langle 2 \rangle, 1) = \widetilde{eq}(1, 1) = (1-1)(1-1) + 1 \cdot 1 = 1$ This means only $\widetilde{g}_2$ contributes to the final value (since its weight is 1). ```python # Define the eq̃ function for single variable def eq_tilde(a, b): """Multilinear extension of equality: eq̃(a,b) = (1-a)(1-b) + ab""" return (1-a)*(1-b) + a*b # Evaluation point z1 = F(0) # X₁ coordinate z2 = F(1) # X₂ coordinate # Compute actual evaluation v_actual = f_tilde(z1, z2) print(f"Actual evaluation: f̃({z1}, {z2}) = {v_actual}") # Compute evaluations of g_i at z₁ v1 = g1_tilde(z1) v2 = g2_tilde(z1) print(f"\nSub-evaluations:") print(f"v₁ = g̃₁({z1}) = {v1}") print(f"v₂ = g̃₂({z1}) = {v2}") # Compute eq̃ weights for aggregation # <1> represents bit string 0, <2> represents bit string 1 weight1 = eq_tilde(F(0), z2) # eq̃(<1>, z₂) = eq̃(0, 1) weight2 = eq_tilde(F(1), z2) # eq̃(<2>, z₂) = eq̃(1, 1) print(f"\neq̃ weights:") print(f"eq̃(⟨1⟩, {z2}) = eq̃(0, {z2}) = {weight1}") print(f"eq̃(⟨2⟩, {z2}) = eq̃(1, {z2}) = {weight2}") # Verify decomposition v_reconstructed = weight1 * v1 + weight2 * v2 print(f"\nAggregated value: {weight1} · {v1} + {weight2} · {v2} = {v_reconstructed}") print(f"Matches actual: {v_reconstructed == v_actual}") ``` --- ## The Evaluation Proof Protocol Now we describe exactly how the prover convinces the verifier of the evaluation claim. ### Prover's Actions **Step 1**: Compute and send commitments to the smaller polynomials - Compute $\mathrm{cm}_1 \leftarrow \mathrm{uPC.Com}(\hat{g}_1(X))$ - Compute $\mathrm{cm}_2 \leftarrow \mathrm{uPC.Com}(\hat{g}_2(X))$ - Send $(\mathrm{cm}_1, \mathrm{cm}_2)$ to the verifier **Step 2**: Compute evaluations at $\mathbf{z}_x$ - Compute $v_1 = \widetilde{g}_1(z_1) = \widetilde{g}_1(0) = 5(1-0) + 3 \cdot 0 = 5$ - Compute $v_2 = \widetilde{g}_2(z_1) = \widetilde{g}_2(0) = 7(1-0) + 2 \cdot 0 = 7$ - Send $(v_1, v_2) = (5, 7)$ to the verifier ```python # Prover computes the evaluations print("Prover's computations:") print(f"Step 1: Commit to ĝ₁(X) and ĝ₂(X)") print(f" (In practice, use KZG: cm₁ = [ĝ₁(τ)]₁, cm₂ = [ĝ₂(τ)]₁)") print(f"\nStep 2: Evaluate at z_x = {z1}") print(f" v₁ = g̃₁({z1}) = {v1}") print(f" v₂ = g̃₂({z1}) = {v2}") print(f" Send (v₁, v₂) = ({v1}, {v2}) to verifier") ``` ### Verifier's Checks The verifier must verify three things: #### Check 1: Correct Decomposition **Claim**: The polynomials $\hat{g}_1(X), \hat{g}_2(X)$ correctly decompose $\hat{f}(X)$. **How**: The verifier checks that: $$ \hat{f}(X) = \hat{g}_1(X) + X^m \hat{g}_2(X) $$ This is done using the homomorphic property of the commitment scheme. The verifier can check: $$ \mathrm{cm}_f \stackrel{?}{=} \mathrm{cm}_1 \cdot \mathrm{cm}_2^{X^m} $$ In KZG, if $\mathrm{cm}_f = [\hat{f}(\tau)]_1$ and $\mathrm{cm}_i = [\hat{g}_i(\tau)]_1$, then this check becomes: $$ [\hat{f}(\tau)]_1 \stackrel{?}{=} [\hat{g}_1(\tau)]_1 + \tau^m \cdot [\hat{g}_2(\tau)]_1 $$ ```python # Simulate the homomorphic check at a random point # In KZG, this would be at the trusted setup point τ tau = F.random_element() # Simulating the secret τ print(f"\nCheck 1: Correct Decomposition") print(f"Verify: f̂(τ) = ĝ₁(τ) + τ^m · ĝ₂(τ)") # Evaluate polynomials at τ f_at_tau = f_hat(tau) g1_at_tau = g1_hat(tau) g2_at_tau = g2_hat(tau) # Check the homomorphic relation lhs = f_at_tau rhs = g1_at_tau + tau^m * g2_at_tau print(f" LHS: f̂(τ) = {lhs}") print(f" RHS: ĝ₁(τ) + τ² · ĝ₂(τ) = {rhs}") print(f" Check passes: {lhs == rhs}") ``` :::info **Why this works**: The commitment scheme is homomorphic, so we can perform polynomial operations directly on commitments. The uniqueness of the decomposition ensures no prover can cheat here. ::: #### Check 2: Correct Evaluations **Claim**: Each $v_i = \widetilde{g}_i(\mathbf{z}_x)$ is correct. **How**: For each $i \in [\ell]$, the prover provides an evaluation proof for the claim $\hat{g}_i(z_1) = v_i$ using the underlying univariate PCS. In our example: - Prove $\hat{g}_1(0) = 5$ with opening proof for $\mathrm{cm}_1$ - Prove $\hat{g}_2(0) = 7$ with opening proof for $\mathrm{cm}_2$ Since we're evaluating at $X = 0$, these proofs are particularly simple (just verify the constant term). **Optimization**: These $\ell$ proofs can be batched into a single proof using random linear combination. ```python print(f"\nCheck 2: Correct Evaluations") print(f"Verify: ĝ₁({z1}) = {v1} and ĝ₂({z1}) = {v2}") # Direct evaluation check g1_eval = g1_hat(z1) g2_eval = g2_hat(z1) print(f" ĝ₁({z1}) = {g1_eval}, claimed v₁ = {v1}, match: {g1_eval == v1}") print(f" ĝ₂({z1}) = {g2_eval}, claimed v₂ = {v2}, match: {g2_eval == v2}") # Batching with random linear combination gamma = F.random_element() print(f"\nOptimization: Batch with random γ = {gamma}") # Batched polynomial P(X) = γ^0 · g₁(X) + γ^1 · g₂(X) P_batched = g1_hat + gamma * g2_hat v_batched = v1 + gamma * v2 print(f" P(X) = ĝ₁(X) + γ · ĝ₂(X)") print(f" P({z1}) = {P_batched(z1)}") print(f" v₁ + γ · v₂ = {v_batched}") print(f" Batched check passes: {P_batched(z1) == v_batched}") ``` #### Check 3: Correct Aggregation **Claim**: The final evaluation $v$ correctly aggregates the sub-evaluations. **How**: The verifier computes: $$ v^* = \sum_{i=1}^{\ell} \widetilde{eq}(\langle i \rangle, \mathbf{z}_y) \cdot v_i $$ and checks that $v^* = v$. In our example: $$ v^* = 0 \cdot 5 + 1 \cdot 7 = 7 $$ The verifier checks that the claimed value $v = 7$. ```python print(f"\nCheck 3: Correct Aggregation") print(f"Verify: v = Σᵢ eq̃(⟨i⟩, z_y) · vᵢ") # Verifier computes the aggregation v_star = weight1 * v1 + weight2 * v2 print(f" v* = {weight1} · {v1} + {weight2} · {v2}") print(f" v* = {v_star}") print(f" Claimed v = {v_actual}") print(f" Final check passes: {v_star == v_actual}") ``` :::success **Verification complete**: If all three checks pass, the verifier is convinced that $\widetilde{f}(\mathbf{z}) = 7$. ::: --- ## Why This Achieves $O(n)$ Complexity ### The Recursive Structure The key to linear complexity is that we can **recursively apply** this decomposition. When proving $\widetilde{g}_i(\mathbf{z}_x)$ for each of the $\ell$ smaller polynomials: - Each $\widetilde{g}_i$ is a $\kappa$-variate polynomial (instead of $\mu$-variate) - We can recursively decompose each again if $\kappa$ is still large - Continue until we reach base case (small enough to prove directly) ### Complexity Analysis Let $T(n)$ be the time to prove an evaluation for an $n$-dimensional hypercube. **Recurrence**: $$ T(n) = \ell \cdot T(m) + O(n) $$ Where: - We make $\ell$ recursive calls on problems of size $m$ (where $n = \ell \cdot m$) - The $O(n)$ term is for computing the decomposition and aggregation **Solution**: When $\ell = m = \sqrt{n}$, we get: $$ T(n) = \sqrt{n} \cdot T(\sqrt{n}) + O(n) $$ By the Master theorem, this solves to $T(n) = O(n)$. ```python # Demonstrate the recursive structure for larger example def compute_complexity(n, ell=None, m=None): """Compute the number of operations for recursive decomposition""" if n <= 2: # Base case return 1 if ell is None: ell = int(n**0.5) # √n split m = n // ell # T(n) = ℓ · T(m) + O(n) recursive_cost = ell * compute_complexity(m, ell, m) decomposition_cost = n return recursive_cost + decomposition_cost print("\nComplexity for different sizes:") for size in [4, 16, 64, 256]: ops = compute_complexity(size) print(f" n = {size:3d}: T(n) = {ops:5d} operations (vs n·log n = {size * int(size).bit_length():5d})") ``` :::info **Comparison with naive approach**: - Basic protocol: $O(n \log n)$ from computing $\hat{f}(X) \cdot \hat{\Psi}(X; \mathbf{z})$ via FFT-style multiplication - Divide-and-conquer: $O(n)$ by breaking into smaller independent subproblems ::: --- ## Summary Table | Component | Purpose | Complexity | |-----------|---------|------------| | Decompose $\hat{f}$ into $\hat{g}_i$ | Split problem into $\ell$ subproblems | $O(n)$ | | Commit to each $\hat{g}_i$ | Create verifiable commitments | $O(\ell \cdot m) = O(n)$ | | Prove each $\widetilde{g}_i(\mathbf{z}_x) = v_i$ | Recursive evaluation proofs | $\ell \cdot T(m)$ | | Aggregate via $\widetilde{eq}$ weights | Combine subresults | $O(\ell) = O(\sqrt{n})$ | | **Total** | | **$O(n)$** | --- ## Additional Optimizations [The paper](https://eprint.iacr.org/2025/419.pdf) further optimizes the constant factors by: 1. **Batching the $\ell$ evaluation proofs** using random linear combinations (reduces from $\ell$ proofs to 1 proof) 2. **Using bivariate KZG** to relate $\hat{f}$ and the batched $\hat{P}(X) = \sum_{i=1}^{\ell} \gamma^{i-1} \hat{g}_i(X)$ via a unique bivariate polynomial $Q$ 3. **Exploiting KZG-specific properties** to minimize concrete proof size These optimizations maintain the asymptotic $O(n)$ prover time while achieving $O(\log n)$ communication (proof size + commitments).