Skip to content

Commit fe89d8e

Browse files
tcoratgerclaude
andauthored
feat(testing): bake order-sensitive determinism check into fill (leanEthereum#906)
The order_sensitive marker docstring claimed the determinism check "generates this vector twice and diffs the output", but no such logic existed in the plugin. The only real gate lived in a justfile recipe invoked by CI, so a plain `uv run fill` never verified determinism and contributors relied on CI. Bake the two-seed check into the fill command itself. After a successful fill, regenerate the order_sensitive subset under PYTHONHASHSEED=1 and =2 in throwaway directories and byte-diff them. The mocked prover is forced so proof bytes stay deterministic, and a single process pins each seed cleanly. A difference fails the command and lists the offending fixtures. Add --no-check-determinism to opt out for fast local iteration. Drop the now-redundant CI step: `just fill-ci` runs fill, which performs the identical subset check by default. Repurpose the fill-determinism recipe as the standalone, wide-scope audit the baked-in check cannot give: it runs without a full fill and can cover the whole tree to catch a filler that should be marked but is not. Pass --no-check-determinism in the recipe so its own fill calls do not nest the per-fill gate. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 029bd77 commit fe89d8e

4 files changed

Lines changed: 106 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,6 @@ jobs:
133133
- name: Fill test fixtures
134134
run: just fill-ci
135135

136-
- name: Check fixture determinism (order-sensitive vectors)
137-
run: just fill-determinism
138-
139136
interop-tests:
140137
name: Interop tests - Multi-node consensus
141138
runs-on: macos-latest

Justfile

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,13 @@ test-consensus *args:
8585
fill-ci *args:
8686
uv run --group test fill --fork=Lstar --clean -n auto --dist=worksteal "$@"
8787

88-
# Generate the order-sensitive vectors twice under different hash seeds and diff.
89-
# Only the vectors marked order_sensitive run, so this stays cheap.
88+
# Standalone determinism audit: regenerate vectors twice under different hash seeds and diff.
89+
# The fill command already gates the order_sensitive subset on every run.
90+
# This recipe runs that audit on its own, or widens it past the marked subset.
91+
# Pass a path to cover the whole tree (for example tests/consensus) and catch a
92+
# filler that should be marked order_sensitive but is not.
9093
# A difference means an emitted vector depends on set or dict iteration order,
9194
# which is hash-seeded and would break cross-client reproducibility.
92-
# Pass a path to widen the scope (for example the whole tests/consensus tree).
9395
[group('tests')]
9496
fill-determinism *args:
9597
#!/usr/bin/env bash
@@ -101,8 +103,9 @@ fill-determinism *args:
101103
trap 'rm -rf "$first" "$second"' EXIT
102104
# Single process: the marked subset is small, so xdist worker startup would
103105
# cost more than it saves, and one process pins the hash seed cleanly.
104-
PYTHONHASHSEED=1 uv run --group test fill --fork=Lstar --clean -n 0 -o "$first" $target -q
105-
PYTHONHASHSEED=2 uv run --group test fill --fork=Lstar --clean -n 0 -o "$second" $target -q
106+
# This recipe is itself the determinism check, so the per-fill gate is skipped.
107+
PYTHONHASHSEED=1 uv run --group test fill --fork=Lstar --clean --no-check-determinism -n 0 -o "$first" $target -q
108+
PYTHONHASHSEED=2 uv run --group test fill --fork=Lstar --clean --no-check-determinism -n 0 -o "$second" $target -q
106109
if diff -rq "$first" "$second"; then
107110
echo "Determinism check passed: fixtures are byte-identical across hash seeds."
108111
else

packages/testing/src/consensus_testing/cli/fill.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import subprocess
55
import sys
6+
import tempfile
67
from collections.abc import Sequence
78
from pathlib import Path
89

@@ -47,6 +48,12 @@
4748
default="mocked",
4849
help="Aggregation prover mode (default: mocked; pass real for the authoritative set)",
4950
)
51+
@click.option(
52+
"--check-determinism/--no-check-determinism",
53+
default=True,
54+
help="After filling, regenerate the order-sensitive vectors under two hash "
55+
"seeds and fail if the emitted bytes differ (default: on)",
56+
)
5057
@click.pass_context
5158
def fill(
5259
ctx: click.Context,
@@ -56,6 +63,7 @@ def fill(
5663
clean: bool,
5764
scheme: str,
5865
crypto: str,
66+
check_determinism: bool,
5967
) -> None:
6068
"""
6169
Generate consensus test fixtures from test specifications.
@@ -121,7 +129,95 @@ def fill(
121129
# Why a subprocess: a fresh interpreter imports the spec config anew.
122130
# Only then does the scheme exported above take effect.
123131
exit_code = subprocess.run([sys.executable, "-m", "pytest", *args]).returncode
124-
sys.exit(exit_code)
132+
if exit_code != 0:
133+
sys.exit(exit_code)
134+
135+
if check_determinism:
136+
verify_order_sensitive_determinism(config_path, project_root, fork)
137+
138+
sys.exit(0)
139+
140+
141+
def verify_order_sensitive_determinism(config_path: Path, project_root: Path, fork: str) -> None:
142+
"""
143+
Regenerate the order-sensitive vectors under two hash seeds and diff them.
144+
145+
Set and dict iteration order is randomized per process by PYTHONHASHSEED.
146+
A vector whose bytes depend on that order is not reproducible across clients.
147+
Two seeds producing byte-identical output proves the marked subset is order-free.
148+
149+
The mocked prover is forced so proof bytes stay deterministic across both runs.
150+
A single process pins each seed cleanly, so distribution is disabled.
151+
"""
152+
consensus_tests = project_root / "tests" / "consensus"
153+
emitted_under_seed: list[Path] = []
154+
155+
with tempfile.TemporaryDirectory() as scratch_root:
156+
for hash_seed in ("1", "2"):
157+
output_directory = Path(scratch_root) / f"seed-{hash_seed}"
158+
child_args = [
159+
"-c",
160+
str(config_path),
161+
f"--rootdir={project_root}",
162+
f"--output={output_directory}",
163+
f"--fork={fork}",
164+
"--crypto=mocked",
165+
"--clean",
166+
str(consensus_tests),
167+
"-m",
168+
"order_sensitive",
169+
"-n",
170+
"0",
171+
"-q",
172+
]
173+
child_environment = {**os.environ, "PYTHONHASHSEED": hash_seed}
174+
child_exit_code = subprocess.run(
175+
[sys.executable, "-m", "pytest", *child_args],
176+
env=child_environment,
177+
).returncode
178+
179+
# Exit code 5 means no test matched the marker, so there is nothing to check.
180+
if child_exit_code == 5:
181+
click.echo("Determinism check skipped: no order-sensitive vectors selected.")
182+
return
183+
if child_exit_code != 0:
184+
click.echo(
185+
"Determinism check could not generate the order-sensitive subset.",
186+
err=True,
187+
)
188+
sys.exit(child_exit_code)
189+
emitted_under_seed.append(output_directory)
190+
191+
differing_fixtures = diff_fixture_trees(emitted_under_seed[0], emitted_under_seed[1])
192+
if differing_fixtures:
193+
click.echo(
194+
"Determinism check FAILED: order-sensitive vectors differ across hash seeds.",
195+
err=True,
196+
)
197+
for relative_path in differing_fixtures:
198+
click.echo(f" differs: {relative_path}", err=True)
199+
sys.exit(1)
200+
201+
click.echo(
202+
"Determinism check passed: order-sensitive vectors are byte-identical across hash seeds."
203+
)
204+
205+
206+
def diff_fixture_trees(first_tree: Path, second_tree: Path) -> list[str]:
207+
"""Return the relative paths of fixtures whose bytes differ between two trees."""
208+
first_files = {
209+
path.relative_to(first_tree): path for path in first_tree.rglob("*") if path.is_file()
210+
}
211+
second_files = {
212+
path.relative_to(second_tree): path for path in second_tree.rglob("*") if path.is_file()
213+
}
214+
return sorted(
215+
str(relative_path)
216+
for relative_path in first_files.keys() | second_files.keys()
217+
if relative_path not in first_files
218+
or relative_path not in second_files
219+
or first_files[relative_path].read_bytes() != second_files[relative_path].read_bytes()
220+
)
125221

126222

127223
if __name__ == "__main__":

packages/testing/src/consensus_testing/pytest_plugins/filler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def pytest_configure(config: pytest.Config) -> None:
197197
config.addinivalue_line(
198198
"markers",
199199
"order_sensitive: emission could depend on set or dict iteration order; "
200-
"the determinism check generates this vector twice and diffs the output",
200+
"the fill command regenerates these vectors under two hash seeds and diffs the output",
201201
)
202202

203203
# Crypto mode is chosen explicitly and applies to either scheme.

0 commit comments

Comments
 (0)