Skip to content

Commit d2bd178

Browse files
tcoratgerclaude
andauthored
refactor(poseidon1): inline circulant MDS helper and tighten permutation docs (leanEthereum#780)
* refactor(poseidon1): inline circulant MDS helper and tighten permutation docs Inline the single-use _build_circulant_mds helper into Poseidon1.__init__ with the circulant invariant kept as a comment. Restructure the JIT permutation body around Phase 1/2/3 labels so each block carries its own rationale (boundary non-linearity, Hades partial-round optimization, algebraic-attack defense). Move the int64-overflow argument next to the S-box line where it lives, and add line-by-line documentation to the MDS matrix-vector loop explaining the per-product modular reduction. Drop "from __future__ import annotations" since the file defines a Pydantic model and project rules forbid lazy annotations there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(poseidon1): cover field validators, field-boundary inputs, and add rounds_f even-check Add a Pydantic field validator that rejects odd values for the full-round count, with rationale: the permutation splits full rounds into equal halves around the partial middle, so an odd count silently drops one full round and orphans a width-sized block of round constants. Extend the test suite from 8 to 18 cases: - Each Field constraint on Poseidon1Params now has its own pytest.raises check (width > 0, rounds_f > 0, rounds_p >= 0, mds_first_row non-empty, round_constants non-empty). - New test pins the rounds_f even-check. - Output-in-field invariant is asserted for both widths. - All-zero and field-boundary inputs (Fp(P - 1) across all lanes) verify the int64 path stays in-field on degenerate and maximum-value states. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(poseidon1): assert on full error messages, not substrings Each pytest.raises match pattern now spells out the complete authored sentence. The two engine ValueError checks use anchored regexes so the match cannot accidentally pass on a longer message that happens to contain the expected fragment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(poseidon1): document paper deviations and the unenforced RF lower bound Annotate the parameter and constant tables with the cryptanalytic context that justifies the chosen instances: - Poseidon1Params records the paper's 6-round (or 10 in some regimes) lower bound on the full-round count and disclaims enforcement; callers remain responsible for choosing secure parameters. - Both MDS first-row constants flag the circulant-vs-Cauchy deviation from the paper, link the upstream Plonky3 source, and note the GRS21 invariant-subspace requirement. - PARAMS_16 and PARAMS_24 pin the round-count derivation to Plonky3's koala-bear instance and the paper's Eq. 2-4 bounds, and call out the ABM23 attack's inapplicability (explicit for width 24). The module header and permutation docstring also note that this is the original Hades-based Poseidon1, distinguishing it from the Poseidon2 successor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 82f7c92 commit d2bd178

2 files changed

Lines changed: 186 additions & 43 deletions

File tree

src/lean_spec/subspecs/poseidon1/permutation.py

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44
Based on "Poseidon: A New Hash Function for Zero-Knowledge Proof Systems".
55
See https://eprint.iacr.org/2019/458.
66
7+
This is the original Hades-based design.
8+
79
Uses Numba JIT compilation for native-speed permutation.
810
"""
911

10-
from __future__ import annotations
11-
1212
from typing import Self
1313

1414
import numpy as np
1515
from numba import njit
1616
from numpy.typing import NDArray
17-
from pydantic import Field, model_validator
17+
from pydantic import Field, field_validator, model_validator
1818

1919
from ...types import StrictBaseModel
2020
from ..koalabear.field import Fp, P
@@ -24,18 +24,6 @@
2424
)
2525

2626

27-
def _build_circulant_mds(first_row: list[int], n: int, p: int) -> NDArray[np.int64]:
28-
"""
29-
Expand a circulant matrix from its first row into a dense NxN matrix.
30-
31-
A circulant matrix C defined by first row [r0, r1, ..., rn-1]:
32-
C[i][j] = r[(j - i) mod n]
33-
34-
Row i is the first row rolled right by i positions.
35-
"""
36-
return np.array([np.roll(first_row, i) for i in range(n)], dtype=np.int64) % p
37-
38-
3927
@njit(cache=True)
4028
def _mds_multiply_jit(
4129
state: NDArray[np.int64], mds: NDArray[np.int64], p: int
@@ -46,13 +34,26 @@ def _mds_multiply_jit(
4634
Computes y = MDS * x where MDS is the circulant MDS matrix.
4735
Each product is reduced mod p before accumulation to prevent overflow.
4836
"""
37+
# State length doubles as the matrix dimension since MDS is square.
4938
n = state.shape[0]
39+
40+
# Output buffer, written in place by the inner loop.
5041
result = np.empty(n, dtype=np.int64)
42+
43+
# One iteration computes a single row of the matrix-vector product.
5144
for i in range(n):
45+
# Accumulator collects n pre-reduced contributions before the final fold.
5246
s = np.int64(0)
47+
5348
for j in range(n):
49+
# Each factor sits below p, so the product fits in 62 bits.
50+
#
51+
# Without per-product reduction, summing n products would risk int64 overflow.
5452
s += (mds[i, j] * state[j]) % p
53+
54+
# Final fold back into the field after n already-reduced terms.
5555
result[i] = s % p
56+
5657
return result
5758

5859

@@ -72,44 +73,51 @@ def _permute_jit(
7273
Modifies state array in-place.
7374
S-box: x^3 computed as (x*x % p) * x % p to avoid int64 overflow.
7475
75-
Round structure: AddRoundConstants -> S-box -> MDS multiply.
76-
No initial linear layer is applied before the round structure begins.
76+
- Round structure: AddRoundConstants -> S-box -> MDS multiply.
77+
- No initial linear layer is applied before the round structure begins.
78+
- This matches the original Poseidon1 design.
7779
"""
7880
const_idx = 0
7981

80-
# 1. First half of full rounds.
82+
# Phase 1: opening full rounds.
8183
#
82-
# Full rounds apply the S-box to every state element.
83-
# Note: for S_BOX_DEGREE=3, state**3 would overflow int64 before modulo.
84-
# Expand S-box to `(state*state % P) * state % P` to stay in range.
84+
# Full S-boxes at the boundary maximize non-linearity where
85+
# attacker control over inputs is highest.
8586
for _ in range(half_rounds_f):
8687
# Add round constants to entire state.
8788
state[:] = (state + round_constants[const_idx : const_idx + width]) % p
8889
const_idx += width
8990

9091
# Apply S-box (x -> x^d) to full state.
92+
#
93+
# Cubing in one shot would overflow int64 inside Numba.
94+
# Splitting into two modular multiplies keeps each intermediate below p squared.
9195
state[:] = (state * state % p) * state % p
9296

9397
# Apply dense MDS multiply for diffusion.
9498
state[:] = _mds_multiply_jit(state, mds, p)
9599

96-
# 2. Partial rounds.
100+
# Phase 2: partial rounds.
97101
#
98-
# Partial rounds add constants to ALL state elements but apply
99-
# the S-box only to state[0]. The same dense MDS matrix is used.
102+
# AddRoundConstants and the MDS multiply still run on the entire state.
103+
# Only the S-box layer is partial.
104+
# Applying the S-box to only one element is the central Hades optimization.
105+
# It still saturates algebraic degree while cutting SNARK constraint cost.
100106
for _ in range(rounds_p):
101107
# Add round constants to entire state.
102108
state[:] = (state + round_constants[const_idx : const_idx + width]) % p
103109
const_idx += width
104110

105111
# Apply S-box to first element only.
106-
# This is the main optimization of the Hades design.
107112
state[0] = (state[0] * state[0] % p) * state[0] % p
108113

109114
# Apply dense MDS multiply.
110115
state[:] = _mds_multiply_jit(state, mds, p)
111116

112-
# 3. Second half of full rounds.
117+
# Phase 3: closing full rounds.
118+
#
119+
# A second wall of full S-boxes blocks algebraic attacks that could
120+
# unwind the partial-round middle.
113121
for _ in range(half_rounds_f):
114122
# Add round constants to entire state.
115123
state[:] = (state + round_constants[const_idx : const_idx + width]) % p
@@ -123,7 +131,12 @@ def _permute_jit(
123131

124132

125133
class Poseidon1Params(StrictBaseModel):
126-
"""Parameters for a specific Poseidon1 instance."""
134+
"""Parameters for a specific Poseidon1 instance.
135+
136+
- The paper requires at least 6 full rounds for statistical-attack security.
137+
- Some regimes raise this bound to 10 per Eq. 2 of the paper.
138+
- This minimum is not enforced here. Callers must choose secure parameters.
139+
"""
127140

128141
width: int = Field(gt=0, description="The size of the state (t).")
129142
rounds_f: int = Field(gt=0, description="Total number of 'full' rounds.")
@@ -137,6 +150,19 @@ class Poseidon1Params(StrictBaseModel):
137150
description="The list of pre-computed constants for all rounds.",
138151
)
139152

153+
@field_validator("rounds_f")
154+
@classmethod
155+
def _rounds_f_must_be_even(cls, value: int) -> int:
156+
"""Require an even full-round count.
157+
158+
- The permutation runs equal halves of full rounds before and after the partial middle.
159+
- An odd count silently drops one full round and orphans a width-sized block of constants.
160+
- The original Poseidon design assumes an even split.
161+
"""
162+
if value % 2 != 0:
163+
raise ValueError("Full-round count must be even.")
164+
return value
165+
140166
@model_validator(mode="after")
141167
def check_lengths(self) -> Self:
142168
"""Ensures vector lengths match the configuration."""
@@ -151,12 +177,7 @@ def check_lengths(self) -> Self:
151177

152178

153179
class Poseidon1:
154-
"""
155-
Optimized execution engine for Poseidon1.
156-
157-
Pre-processes parameters into numpy arrays during initialization.
158-
Minimizes overhead during permute calls.
159-
"""
180+
"""Execution engine for Poseidon1."""
160181

161182
__slots__ = ("_width", "_half_rounds_f", "_rounds_p", "_mds", "_round_constants")
162183

@@ -186,9 +207,14 @@ def __init__(self, params: Poseidon1Params) -> None:
186207
self._half_rounds_f = params.rounds_f // 2
187208
self._rounds_p = params.rounds_p
188209

189-
# Build the dense circulant MDS matrix from first row.
190-
first_row_ints = [int(fp) for fp in params.mds_first_row]
191-
self._mds = _build_circulant_mds(first_row_ints, params.width, P)
210+
# Expand the n-by-n circulant MDS matrix from its first row r.
211+
#
212+
# Row i is r rolled right by i positions.
213+
# Equivalently, C[i][j] = r[(j - i) mod n].
214+
first_row = [int(fp) for fp in params.mds_first_row]
215+
self._mds = (
216+
np.array([np.roll(first_row, i) for i in range(self._width)], dtype=np.int64) % P
217+
)
192218

193219
# Pre-convert round constants to numpy array.
194220
self._round_constants = np.array([int(fp) for fp in params.round_constants], dtype=np.int64)
@@ -228,7 +254,12 @@ def permute(self, current_state: list[Fp]) -> list[Fp]:
228254

229255

230256
_MDS_FIRST_ROW_16: list[int] = [1, 1, 51, 1, 11, 17, 2, 1, 101, 63, 15, 2, 67, 22, 13, 3]
231-
"""MDS first row for width-16 circulant matrix. From Plonky3: koala-bear/src/mds.rs."""
257+
"""MDS first row for width-16 circulant matrix.
258+
259+
- From Plonky3: https://github.com/Plonky3/Plonky3/blob/main/koala-bear/src/mds.rs
260+
- The paper recommends Cauchy matrices over the circulant family used here.
261+
- The matrix must avoid invariant subspace trails per GRS21.
262+
"""
232263

233264
_MDS_FIRST_ROW_24: list[int] = [
234265
0x2D0AAAAB,
@@ -256,7 +287,12 @@ def permute(self, current_state: list[Fp]) -> list[Fp]:
256287
0x17E118F6,
257288
0x0878A07F,
258289
]
259-
"""MDS first row for width-24 circulant matrix. From Plonky3: koala-bear/src/mds.rs."""
290+
"""MDS first row for width-24 circulant matrix.
291+
292+
- From Plonky3: https://github.com/Plonky3/Plonky3/blob/main/koala-bear/src/mds.rs
293+
- The paper recommends Cauchy matrices over the circulant family used here.
294+
- The matrix must avoid invariant subspace trails per GRS21.
295+
"""
260296

261297
PARAMS_16 = Poseidon1Params(
262298
width=16,

tests/lean_spec/subspecs/poseidon1/test_permutation.py

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
"""
66

77
import pytest
8+
from pydantic import ValidationError
89

9-
from lean_spec.subspecs.koalabear.field import Fp
10+
from lean_spec.subspecs.koalabear.field import Fp, P
1011
from lean_spec.subspecs.poseidon1.permutation import (
1112
PARAMS_16,
1213
PARAMS_24,
@@ -106,7 +107,7 @@ class TestPoseidon1ParamsValidation:
106107

107108
def test_invalid_mds_first_row_length(self) -> None:
108109
"""Raises error when mds_first_row length doesn't match width."""
109-
with pytest.raises(ValueError, match="Length of mds_first_row must equal width"):
110+
with pytest.raises(ValueError, match=r"Length of mds_first_row must equal width\."):
110111
Poseidon1Params(
111112
width=3,
112113
rounds_f=8,
@@ -117,7 +118,7 @@ def test_invalid_mds_first_row_length(self) -> None:
117118

118119
def test_invalid_round_constants_count(self) -> None:
119120
"""Raises error when round_constants count is incorrect."""
120-
with pytest.raises(ValueError, match="Incorrect number of round constants"):
121+
with pytest.raises(ValueError, match=r"Incorrect number of round constants provided\."):
121122
Poseidon1Params(
122123
width=3,
123124
rounds_f=8,
@@ -126,20 +127,92 @@ def test_invalid_round_constants_count(self) -> None:
126127
round_constants=[Fp(1)] * 20,
127128
)
128129

130+
def test_width_must_be_positive(self) -> None:
131+
"""Rejects a non-positive width."""
132+
with pytest.raises(ValidationError, match=r"Input should be greater than 0"):
133+
Poseidon1Params(
134+
width=0,
135+
rounds_f=8,
136+
rounds_p=20,
137+
mds_first_row=[Fp(1), Fp(2), Fp(3)],
138+
round_constants=[Fp(1)] * 84,
139+
)
140+
141+
def test_rounds_f_must_be_positive(self) -> None:
142+
"""Rejects a non-positive full-round count before the even-check runs."""
143+
with pytest.raises(ValidationError, match=r"Input should be greater than 0"):
144+
Poseidon1Params(
145+
width=3,
146+
rounds_f=0,
147+
rounds_p=20,
148+
mds_first_row=[Fp(1), Fp(2), Fp(3)],
149+
round_constants=[Fp(1)] * 60,
150+
)
151+
152+
def test_rounds_p_must_be_non_negative(self) -> None:
153+
"""Rejects a negative partial-round count."""
154+
with pytest.raises(ValidationError, match=r"Input should be greater than or equal to 0"):
155+
Poseidon1Params(
156+
width=3,
157+
rounds_f=8,
158+
rounds_p=-1,
159+
mds_first_row=[Fp(1), Fp(2), Fp(3)],
160+
round_constants=[Fp(1)] * 21,
161+
)
162+
163+
def test_mds_first_row_must_be_non_empty(self) -> None:
164+
"""Rejects an empty MDS first row."""
165+
with pytest.raises(
166+
ValidationError,
167+
match=r"List should have at least 1 item after validation, not 0",
168+
):
169+
Poseidon1Params(
170+
width=3,
171+
rounds_f=8,
172+
rounds_p=20,
173+
mds_first_row=[],
174+
round_constants=[Fp(1)] * 84,
175+
)
176+
177+
def test_round_constants_must_be_non_empty(self) -> None:
178+
"""Rejects an empty round-constants list."""
179+
with pytest.raises(
180+
ValidationError,
181+
match=r"List should have at least 1 item after validation, not 0",
182+
):
183+
Poseidon1Params(
184+
width=3,
185+
rounds_f=8,
186+
rounds_p=20,
187+
mds_first_row=[Fp(1), Fp(2), Fp(3)],
188+
round_constants=[],
189+
)
190+
191+
def test_rounds_f_must_be_even(self) -> None:
192+
"""Rejects odd full-round counts that would leave constants unused."""
193+
with pytest.raises(ValidationError, match=r"Full-round count must be even\."):
194+
Poseidon1Params(
195+
width=3,
196+
rounds_f=7,
197+
rounds_p=20,
198+
mds_first_row=[Fp(1)] * 3,
199+
round_constants=[Fp(1)] * 81,
200+
)
201+
129202

130203
class TestPoseidon1Engine:
131204
"""Tests for Poseidon1 engine."""
132205

133206
def test_permute_wrong_state_length_too_short(self) -> None:
134207
"""Raises error when input state is too short."""
135208
engine = Poseidon1(PARAMS_16)
136-
with pytest.raises(ValueError, match="Input state must have length 16"):
209+
with pytest.raises(ValueError, match=r"^Input state must have length 16$"):
137210
engine.permute([Fp(1)] * 10)
138211

139212
def test_permute_wrong_state_length_too_long(self) -> None:
140213
"""Raises error when input state is too long."""
141214
engine = Poseidon1(PARAMS_16)
142-
with pytest.raises(ValueError, match="Input state must have length 16"):
215+
with pytest.raises(ValueError, match=r"^Input state must have length 16$"):
143216
engine.permute([Fp(1)] * 20)
144217

145218
def test_permute_determinism(self) -> None:
@@ -160,3 +233,37 @@ def test_permute_output_differs_from_input(self) -> None:
160233
output = engine.permute(state)
161234

162235
assert output != state
236+
237+
@pytest.mark.parametrize(
238+
"params, input_state",
239+
[
240+
(PARAMS_16, INPUT_16),
241+
(PARAMS_24, INPUT_24),
242+
],
243+
ids=["width_16", "width_24"],
244+
)
245+
def test_permute_output_in_field(self, params: Poseidon1Params, input_state: list[Fp]) -> None:
246+
"""Every output element lies strictly below the field modulus."""
247+
engine = Poseidon1(params)
248+
249+
output = engine.permute(input_state)
250+
251+
assert all(int(x) < P for x in output)
252+
253+
def test_permute_all_zero_input(self) -> None:
254+
"""All-zero input produces an in-field output of the expected width."""
255+
engine = Poseidon1(PARAMS_16)
256+
257+
output = engine.permute([Fp(0)] * 16)
258+
259+
assert len(output) == 16
260+
assert all(int(x) < P for x in output)
261+
262+
def test_permute_field_boundary_input(self) -> None:
263+
"""Maximum-value input stays in-field and exposes int64 regressions."""
264+
engine = Poseidon1(PARAMS_16)
265+
266+
output = engine.permute([Fp(value=P - 1)] * 16)
267+
268+
assert len(output) == 16
269+
assert all(int(x) < P for x in output)

0 commit comments

Comments
 (0)