Skip to content

Commit 7776c43

Browse files
tests: Add property-based tests for hypercube using hypothesis (leanEthereum#35)
* chore: add hypothesis to test group and create a no_deadline profile * xmss: add property-based tests for hypercube
1 parent 10ba9aa commit 7776c43

3 files changed

Lines changed: 154 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ dependencies = ["pydantic>=2.9.2,<3", "typing-extensions>=4.4"]
3131
file = "LICENSE"
3232

3333
[project.optional-dependencies]
34-
test = ["pytest>=8.3.3,<9", "pytest-cov>=6.0.0,<7", "pytest-xdist>=3.6.1,<4"]
34+
test = ["pytest>=8.3.3,<9", "pytest-cov>=6.0.0,<7", "pytest-xdist>=3.6.1,<4", "hypothesis>=6.138.14"]
3535
lint = ["ruff>=0.11.8,<1"]
3636
typecheck = ["mypy>=1.15.0,<1.16"]
3737
docs = [

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
"""Pytest configuration and shared fixtures."""
2+
3+
from hypothesis import settings
4+
5+
# Create a profile named "no_deadline" with deadline disabled.
6+
settings.register_profile("no_deadline", deadline=None)
7+
settings.load_profile("no_deadline")

tests/lean_spec/subspecs/xmss/test_hypercube.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Tests for the hypercube mathematical operations."""
22

3+
import itertools
34
import math
45
from functools import lru_cache
5-
from typing import List
6+
from typing import Any, Callable, List, Tuple
67

78
import pytest
9+
from hypothesis import assume, given, settings
10+
from hypothesis import strategies as st
811

912
from lean_spec.subspecs.xmss.hypercube import (
1013
MAX_DIMENSION,
@@ -212,3 +215,146 @@ def test_big_map() -> None:
212215
# Assert that both steps of the roundtrip were successful.
213216
assert x == x_reconstructed
214217
assert vertex_a == vertex_b
218+
219+
220+
# ---------------------------
221+
# Hypothesis property tests
222+
# ---------------------------
223+
224+
DrawFn = Callable[[Any], Any]
225+
226+
227+
# Strategy for small hypercubes where we can feasibly enumerate
228+
@st.composite
229+
def small_hypercube(draw: DrawFn) -> Tuple[int, int]:
230+
w = draw(st.integers(min_value=2, max_value=6))
231+
v = draw(st.integers(min_value=1, max_value=6))
232+
assume(w**v <= 6000) # keep enumeration feasible
233+
return w, v
234+
235+
236+
@st.composite
237+
def small_hypercube_with_layer_and_index(draw: DrawFn) -> Tuple[int, int, int, int]:
238+
w = draw(st.integers(min_value=2, max_value=6))
239+
v = draw(st.integers(min_value=1, max_value=6))
240+
assume(w**v <= 6000)
241+
info = prepare_layer_info(w)[v]
242+
d = draw(st.integers(min_value=0, max_value=len(info.sizes) - 1))
243+
size = info.sizes[d]
244+
assume(size > 0)
245+
x = draw(st.integers(min_value=0, max_value=size - 1))
246+
return w, v, d, x
247+
248+
249+
# Strategies for two distinct vertices in a layer
250+
@st.composite
251+
def two_distinct_index(draw: DrawFn) -> Tuple[int, int, int, int, int]:
252+
w = draw(st.integers(2, 6))
253+
v = draw(st.integers(1, 6))
254+
assume(w**v <= 6000)
255+
info = prepare_layer_info(w)[v]
256+
# pick layer with size >= 2
257+
candidates = [d for d, s in enumerate(info.sizes) if s >= 2]
258+
assume(candidates)
259+
d = draw(st.sampled_from(candidates))
260+
s = info.sizes[d]
261+
x1 = draw(st.integers(0, s - 1))
262+
x2 = draw(st.integers(0, s - 1))
263+
assume(x2 != x1)
264+
return w, v, d, x1, x2
265+
266+
267+
@given(small_hypercube())
268+
@settings(max_examples=120)
269+
def test_property_layer_sizes_conservation_monotonicity(wv: Tuple[int, int]) -> None:
270+
"""All vertices should be accounted for and prefix sums monotone."""
271+
w, v = wv
272+
info = prepare_layer_info(w)[v]
273+
# conservation
274+
assert info.prefix_sums[-1] == w**v
275+
assert sum(info.sizes) == w**v
276+
# non-negative sizes
277+
assert all(s >= 0 for s in info.sizes)
278+
# monotone prefix sums
279+
assert all(
280+
info.prefix_sums[i] <= info.prefix_sums[i + 1] for i in range(len(info.prefix_sums) - 1)
281+
)
282+
283+
284+
@given(small_hypercube_with_layer_and_index())
285+
@settings(max_examples=200)
286+
def test_property_map_vertex_roundtrip(params: Tuple[int, int, int, int]) -> None:
287+
"""Tests that map_to_vertex returns valid coords in layer d and
288+
map_to_integer inverts it correctly."""
289+
w, v, d, x = params
290+
vertex = map_to_vertex(w, v, d, x)
291+
assert isinstance(vertex, list) and len(vertex) == v
292+
assert all(0 <= ai < w for ai in vertex)
293+
# membership in layer
294+
coord_sum = sum(vertex)
295+
assert (w - 1) * v - coord_sum == d
296+
# roundtrip via existing map_to_integer
297+
x_reconstructed = map_to_integer(w, v, d, vertex)
298+
assert x_reconstructed == x
299+
300+
301+
@given(small_hypercube())
302+
@settings(max_examples=40, deadline=None)
303+
def test_property_bijectivity_enum(wv: Tuple[int, int]) -> None:
304+
"""For tiny hypercubes, map_to_vertex enumerates exactly
305+
w**v distinct vertices."""
306+
w, v = wv
307+
assume(w**v <= 2000) # extra safety
308+
info = prepare_layer_info(w)[v]
309+
seen = set()
310+
for d, size in enumerate(info.sizes):
311+
for x in range(size):
312+
a = tuple(map_to_vertex(w, v, d, x))
313+
assert len(a) == v
314+
seen.add(a)
315+
assert len(seen) == w**v
316+
317+
318+
@given(small_hypercube())
319+
@settings(max_examples=80)
320+
def test_property_map_oob(wv: Tuple[int, int]) -> None:
321+
"""map_to_vertex should raise ValueError when x is equal
322+
to the layer size (out-of-range)."""
323+
w, v = wv
324+
info = prepare_layer_info(w)[v]
325+
for d, size in enumerate(info.sizes):
326+
if size > 0:
327+
with pytest.raises(ValueError):
328+
map_to_vertex(w, v, d, size)
329+
break
330+
331+
332+
@given(small_hypercube())
333+
@settings(max_examples=120, deadline=None)
334+
def test_property_find_layer_prefix(wv: Tuple[int, int]) -> None:
335+
"""hypercube_find_layer returns (d, r) consistent with
336+
prefix sums and global index."""
337+
w, v = wv
338+
info = prepare_layer_info(w)[v]
339+
total = w**v
340+
candidates = {0, 1, total - 1, total // 2}
341+
for x in candidates:
342+
if not (0 <= x < total):
343+
continue
344+
d, r = hypercube_find_layer(w, v, x)
345+
assert 0 <= d < len(info.sizes)
346+
assert 0 <= r < info.sizes[d]
347+
prev = info.prefix_sums[d - 1] if d > 0 else 0
348+
assert prev + r == x
349+
350+
351+
@given(two_distinct_index())
352+
@settings(max_examples=200)
353+
def test_property_layer_injectivity(params: Tuple[int, int, int, int, int]) -> None:
354+
"""Ensure injectivity inside a layer for map_to_vertex.
355+
This is a randomized test: we pick two distinct indices
356+
and assert their mapped vertices differ."""
357+
w, v, d, x1, x2 = params
358+
a1 = map_to_vertex(w, v, d, x1)
359+
a2 = map_to_vertex(w, v, d, x2)
360+
assert a1 != a2

0 commit comments

Comments
 (0)