test(ADR-0172/W0.1): add trace replay-equivalence pinning tests
This commit is contained in:
parent
981d764810
commit
767c837740
1 changed files with 219 additions and 0 deletions
219
tests/test_adr_0172_w0_1_trace_replay_equivalence.py
Normal file
219
tests/test_adr_0172_w0_1_trace_replay_equivalence.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""ADR-0172 W0.1 — Trace replay-equivalence tests.
|
||||
|
||||
This test module verifies the determinism, cross-process stability,
|
||||
and float-free property of canonical ReasoningTrace serialization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
from teaching.math_reasoning_trace import (
|
||||
ReasoningStep,
|
||||
ReasoningTrace,
|
||||
build_trace,
|
||||
canonical_bytes,
|
||||
compute_trace_id,
|
||||
)
|
||||
|
||||
|
||||
def _step(
|
||||
*,
|
||||
index: int = 0,
|
||||
kind: str = "observation",
|
||||
pointers: tuple[str, ...] = (),
|
||||
claim: str = "claim",
|
||||
justification: str = "because",
|
||||
payload: object | None = None,
|
||||
) -> ReasoningStep:
|
||||
return ReasoningStep(
|
||||
step_index=index,
|
||||
step_kind=kind, # type: ignore[arg-type]
|
||||
input_pointers=pointers,
|
||||
claim=claim,
|
||||
justification=justification,
|
||||
output_payload={} if payload is None else payload,
|
||||
)
|
||||
|
||||
|
||||
def test_build_trace_replay_equivalence_minimal() -> None:
|
||||
"""Verify that a 2-step trace built 100 times yields identical canonical bytes."""
|
||||
ref_bytes: bytes | None = None
|
||||
ref_id: str | None = None
|
||||
|
||||
for _ in range(100):
|
||||
step0 = _step(index=0, kind="observation", payload={"a": 1})
|
||||
step1 = _step(index=1, kind="grouping", payload={"b": [2, 3]})
|
||||
trace = build_trace([step0, step1])
|
||||
c_bytes = canonical_bytes(trace)
|
||||
|
||||
if ref_bytes is None:
|
||||
ref_bytes = c_bytes
|
||||
ref_id = trace.trace_id
|
||||
else:
|
||||
assert c_bytes == ref_bytes
|
||||
assert trace.trace_id == ref_id
|
||||
|
||||
|
||||
def test_build_trace_payload_dict_key_order_invariance() -> None:
|
||||
"""Verify that dict key insertion order variations do not affect trace_id or canonical bytes."""
|
||||
payload_a = {"a": 1, "b": 2}
|
||||
payload_b = {"b": 2, "a": 1}
|
||||
|
||||
step_a = _step(index=0, payload=payload_a)
|
||||
step_b = _step(index=0, payload=payload_b)
|
||||
|
||||
trace_a = build_trace([step_a])
|
||||
trace_b = build_trace([step_b])
|
||||
|
||||
assert trace_a.trace_id == trace_b.trace_id
|
||||
assert canonical_bytes(trace_a) == canonical_bytes(trace_b)
|
||||
|
||||
# Validate recursive key sorting in nested structures
|
||||
nested_a = {"x": {"a": 1, "b": 2}, "y": [3, {"c": 4, "d": 5}]}
|
||||
nested_b = {"y": [3, {"d": 5, "c": 4}], "x": {"b": 2, "a": 1}}
|
||||
|
||||
trace_nested_a = build_trace([_step(index=0, payload=nested_a)])
|
||||
trace_nested_b = build_trace([_step(index=0, payload=nested_b)])
|
||||
|
||||
assert trace_nested_a.trace_id == trace_nested_b.trace_id
|
||||
assert canonical_bytes(trace_nested_a) == canonical_bytes(trace_nested_b)
|
||||
|
||||
|
||||
def test_build_trace_under_process_restart() -> None:
|
||||
"""Verify determinism across separate Python processes using uv run python -c."""
|
||||
step0 = _step(
|
||||
index=0,
|
||||
kind="observation",
|
||||
claim="Initial claim",
|
||||
justification="Some explanation",
|
||||
payload={"x": 10},
|
||||
)
|
||||
step1 = _step(
|
||||
index=1,
|
||||
kind="hypothesis",
|
||||
claim="Next claim",
|
||||
justification="Another explanation",
|
||||
payload={"y": [20, 30]},
|
||||
)
|
||||
trace = build_trace([step0, step1])
|
||||
original_bytes = canonical_bytes(trace)
|
||||
original_id = trace.trace_id
|
||||
|
||||
# Write the canonical bytes to a temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(original_bytes)
|
||||
temp_path = tmp.name
|
||||
|
||||
try:
|
||||
# Re-derive trace in a separate process and compare
|
||||
script = f"""
|
||||
from teaching.math_reasoning_trace import ReasoningStep, build_trace, canonical_bytes
|
||||
import sys
|
||||
|
||||
step0 = ReasoningStep(
|
||||
step_index=0,
|
||||
step_kind="observation",
|
||||
input_pointers=(),
|
||||
claim="Initial claim",
|
||||
justification="Some explanation",
|
||||
output_payload={{"x": 10}}
|
||||
)
|
||||
step1 = ReasoningStep(
|
||||
step_index=1,
|
||||
step_kind="hypothesis",
|
||||
input_pointers=(),
|
||||
claim="Next claim",
|
||||
justification="Another explanation",
|
||||
output_payload={{"y": [20, 30]}}
|
||||
)
|
||||
trace = build_trace([step0, step1])
|
||||
new_bytes = canonical_bytes(trace)
|
||||
|
||||
with open(r"{temp_path}", "rb") as f:
|
||||
old_bytes = f.read()
|
||||
|
||||
if new_bytes != old_bytes:
|
||||
print("Canonical bytes mismatch across process!", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if trace.trace_id != "{original_id}":
|
||||
print("Trace ID mismatch across process!", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
sys.exit(0)
|
||||
"""
|
||||
cmd = ["uv", "run", "python", "-c", script]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=os.getcwd())
|
||||
|
||||
assert res.returncode == 0, (
|
||||
f"Process restart validation failed (code {res.returncode}):\n"
|
||||
f"stdout: {res.stdout}\n"
|
||||
f"stderr: {res.stderr}"
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
def test_trace_id_collision_resistance() -> None:
|
||||
"""Verify that 1000 random-but-deterministic traces don't suffer trace_id collisions."""
|
||||
import random
|
||||
rng = random.Random(1337)
|
||||
observed_ids: set[str] = set()
|
||||
kinds = ["observation", "grouping", "hypothesis", "conclusion"]
|
||||
|
||||
for i in range(1000):
|
||||
# Generate variations deterministically using seeded rng
|
||||
step0_claim = f"claim_0_{i}_{rng.randint(0, 100000)}"
|
||||
step1_claim = f"claim_1_{i}_{rng.randint(0, 100000)}"
|
||||
payload0 = {"val": rng.randint(0, 100000), "nested": {"str": f"val_{i}"}}
|
||||
payload1 = {"arr": [rng.randint(0, 1000) for _ in range(3)]}
|
||||
|
||||
step0 = _step(index=0, kind=rng.choice(kinds), claim=step0_claim, payload=payload0)
|
||||
step1 = _step(index=1, kind=rng.choice(kinds), claim=step1_claim, payload=payload1)
|
||||
|
||||
trace = build_trace([step0, step1])
|
||||
|
||||
assert len(trace.trace_id) == 64
|
||||
assert trace.trace_id not in observed_ids, f"Collision at index {i} on id {trace.trace_id}"
|
||||
observed_ids.add(trace.trace_id)
|
||||
|
||||
assert len(observed_ids) == 1000
|
||||
|
||||
|
||||
def test_canonical_bytes_no_floating_point() -> None:
|
||||
"""Verify that floats are rejected at validation and no float representations appear in bytes."""
|
||||
float_pattern = re.compile(rb"[0-9]+\.[0-9]+")
|
||||
|
||||
# 1. Ensure float values are recursively rejected at build time
|
||||
with pytest.raises(ValueError, match="floating-point"):
|
||||
build_trace([_step(payload={"w": 1.5})])
|
||||
|
||||
with pytest.raises(ValueError, match="floating-point"):
|
||||
build_trace([_step(payload={"nested": {"ratio": 0.0}})])
|
||||
|
||||
with pytest.raises(ValueError, match="floating-point"):
|
||||
build_trace([_step(payload={"list": [1, 2, 3.14]})])
|
||||
|
||||
# 2. Assert no float pattern appears in canonical bytes for a variety of valid types
|
||||
corpus = [
|
||||
build_trace([_step(index=0, payload={"int": 42, "bool": True, "str": "hello", "none": None})]),
|
||||
build_trace([
|
||||
_step(index=0, payload={"list": [1, 2, 3, {"nested": "value"}]}),
|
||||
_step(index=1, kind="conclusion", payload={"another": {"x": 99}})
|
||||
]),
|
||||
build_trace([_step(index=0, payload={"str_float": "not_a_float"})]),
|
||||
build_trace([_step(index=0, payload={"large": 12345678901234567890})]),
|
||||
]
|
||||
|
||||
for trace in corpus:
|
||||
cb = canonical_bytes(trace)
|
||||
match = float_pattern.search(cb)
|
||||
assert match is None, f"Floating point pattern found in canonical bytes: {cb!r}"
|
||||
Loading…
Reference in a new issue