Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,6 @@ jobs:
- name: Fill test fixtures
run: just fill-ci

- name: Check fixture determinism (order-sensitive vectors)
run: just fill-determinism

interop-tests:
name: Interop tests - Multi-node consensus
runs-on: macos-latest
Expand Down
13 changes: 8 additions & 5 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,13 @@ test-consensus *args:
fill-ci *args:
uv run --group test fill --fork=Lstar --clean -n auto --dist=worksteal "$@"

# Generate the order-sensitive vectors twice under different hash seeds and diff.
# Only the vectors marked order_sensitive run, so this stays cheap.
# Standalone determinism audit: regenerate vectors twice under different hash seeds and diff.
# The fill command already gates the order_sensitive subset on every run.
# This recipe runs that audit on its own, or widens it past the marked subset.
# Pass a path to cover the whole tree (for example tests/consensus) and catch a
# filler that should be marked order_sensitive but is not.
# A difference means an emitted vector depends on set or dict iteration order,
# which is hash-seeded and would break cross-client reproducibility.
# Pass a path to widen the scope (for example the whole tests/consensus tree).
[group('tests')]
fill-determinism *args:
#!/usr/bin/env bash
Expand All @@ -101,8 +103,9 @@ fill-determinism *args:
trap 'rm -rf "$first" "$second"' EXIT
# Single process: the marked subset is small, so xdist worker startup would
# cost more than it saves, and one process pins the hash seed cleanly.
PYTHONHASHSEED=1 uv run --group test fill --fork=Lstar --clean -n 0 -o "$first" $target -q
PYTHONHASHSEED=2 uv run --group test fill --fork=Lstar --clean -n 0 -o "$second" $target -q
# This recipe is itself the determinism check, so the per-fill gate is skipped.
PYTHONHASHSEED=1 uv run --group test fill --fork=Lstar --clean --no-check-determinism -n 0 -o "$first" $target -q
PYTHONHASHSEED=2 uv run --group test fill --fork=Lstar --clean --no-check-determinism -n 0 -o "$second" $target -q
if diff -rq "$first" "$second"; then
echo "Determinism check passed: fixtures are byte-identical across hash seeds."
else
Expand Down
98 changes: 97 additions & 1 deletion packages/testing/src/consensus_testing/cli/fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import subprocess
import sys
import tempfile
from collections.abc import Sequence
from pathlib import Path

Expand Down Expand Up @@ -47,6 +48,12 @@
default="mocked",
help="Aggregation prover mode (default: mocked; pass real for the authoritative set)",
)
@click.option(
"--check-determinism/--no-check-determinism",
default=True,
help="After filling, regenerate the order-sensitive vectors under two hash "
"seeds and fail if the emitted bytes differ (default: on)",
)
@click.pass_context
def fill(
ctx: click.Context,
Expand All @@ -56,6 +63,7 @@ def fill(
clean: bool,
scheme: str,
crypto: str,
check_determinism: bool,
) -> None:
"""
Generate consensus test fixtures from test specifications.
Expand Down Expand Up @@ -121,7 +129,95 @@ def fill(
# Why a subprocess: a fresh interpreter imports the spec config anew.
# Only then does the scheme exported above take effect.
exit_code = subprocess.run([sys.executable, "-m", "pytest", *args]).returncode
sys.exit(exit_code)
if exit_code != 0:
sys.exit(exit_code)

if check_determinism:
verify_order_sensitive_determinism(config_path, project_root, fork)

sys.exit(0)


def verify_order_sensitive_determinism(config_path: Path, project_root: Path, fork: str) -> None:
"""
Regenerate the order-sensitive vectors under two hash seeds and diff them.

Set and dict iteration order is randomized per process by PYTHONHASHSEED.
A vector whose bytes depend on that order is not reproducible across clients.
Two seeds producing byte-identical output proves the marked subset is order-free.

The mocked prover is forced so proof bytes stay deterministic across both runs.
A single process pins each seed cleanly, so distribution is disabled.
"""
consensus_tests = project_root / "tests" / "consensus"
emitted_under_seed: list[Path] = []

with tempfile.TemporaryDirectory() as scratch_root:
for hash_seed in ("1", "2"):
output_directory = Path(scratch_root) / f"seed-{hash_seed}"
child_args = [
"-c",
str(config_path),
f"--rootdir={project_root}",
f"--output={output_directory}",
f"--fork={fork}",
"--crypto=mocked",
"--clean",
str(consensus_tests),
"-m",
"order_sensitive",
"-n",
"0",
"-q",
]
child_environment = {**os.environ, "PYTHONHASHSEED": hash_seed}
child_exit_code = subprocess.run(
[sys.executable, "-m", "pytest", *child_args],
env=child_environment,
).returncode

# Exit code 5 means no test matched the marker, so there is nothing to check.
if child_exit_code == 5:
click.echo("Determinism check skipped: no order-sensitive vectors selected.")
return
if child_exit_code != 0:
click.echo(
"Determinism check could not generate the order-sensitive subset.",
err=True,
)
sys.exit(child_exit_code)
emitted_under_seed.append(output_directory)

differing_fixtures = diff_fixture_trees(emitted_under_seed[0], emitted_under_seed[1])
if differing_fixtures:
click.echo(
"Determinism check FAILED: order-sensitive vectors differ across hash seeds.",
err=True,
)
for relative_path in differing_fixtures:
click.echo(f" differs: {relative_path}", err=True)
sys.exit(1)

click.echo(
"Determinism check passed: order-sensitive vectors are byte-identical across hash seeds."
)


def diff_fixture_trees(first_tree: Path, second_tree: Path) -> list[str]:
"""Return the relative paths of fixtures whose bytes differ between two trees."""
first_files = {
path.relative_to(first_tree): path for path in first_tree.rglob("*") if path.is_file()
}
second_files = {
path.relative_to(second_tree): path for path in second_tree.rglob("*") if path.is_file()
}
return sorted(
str(relative_path)
for relative_path in first_files.keys() | second_files.keys()
if relative_path not in first_files
or relative_path not in second_files
or first_files[relative_path].read_bytes() != second_files[relative_path].read_bytes()
)


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers",
"order_sensitive: emission could depend on set or dict iteration order; "
"the determinism check generates this vector twice and diffs the output",
"the fill command regenerates these vectors under two hash seeds and diffs the output",
)

# Crypto mode is chosen explicitly and applies to either scheme.
Expand Down
Loading