R1: deterministic --sor-seed core for reproducible measurement

Adds the single stochasticity source for the SOR measurement instrument so
every later stochastic decision (path selection R4, churn/selector R7, padding
jitter R4) is driven by one --sor-seed and is byte-reproducible run-to-run —
the R1 acceptance check and instrument-validation gate item 2.

- hh/src/sor/mod.rs: SplitMix64 + SHA-256 domain-separated sub-streams +
  unbiased Lemire bounded sampling -> deterministic select_path/bringup.
  Pure bookkeeping: no forwarder, no socket, no engine. Isolated-engine
  containment assertions land with the R4 forwarder.
- hh/src/main.rs: `sor-bringup --sor-seed` emits the deterministic
  circuit-build sequence as JSON (observable reproducibility).
- cmd_chat/sor/config.py: bit-for-bit Python mirror so the seed means the
  same thing on both sides.
- Tests (Rust proptest+unit, Python pytest): SplitMix64 known-answer vector,
  same-seed determinism, seed divergence, bounded/no-panic, and a shared
  cross-language parity vector asserted identically in both suites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-19 14:47:07 -07:00
parent c9aa4a68ad
commit c04f9aed23
5 changed files with 513 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
"""R1 — Seed plumbing for SOR determinism (Python mirror of hh/src/sor/mod.rs).
The SOR instrument's stochastic decisions — path selection (R4), churn schedule
and selector choices (R7), padding jitter (R4) — draw from a single
``--sor-seed <u64>``. Given the same seed and the same (deterministic) churn
script the circuit-build sequence is byte-identical across runs, which is the
R1 acceptance check and instrument-validation gate item 2.
Nothing here forwards traffic or spawns an engine; it is a deterministic
bookkeeping primitive. The isolated-engine containment assertions live with the
forwarder (R4). The algorithm — SplitMix64 + SHA-256 domain mixing + Lemire
bounded sampling — is fully specified and matches the Rust core exactly, so a
seed means the same thing on both sides (see tests/test_sor_config.py, which
asserts the same parity vectors as hh/src/sor/mod.rs::tests::parity_vector).
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from enum import Enum
from typing import List
_MASK64 = (1 << 64) - 1
class Domain(Enum):
"""Independent stochastic sub-streams. Each SOR decision domain draws from
its own stream so adding/removing a consumer in one domain never perturbs
another. The label bytes are part of the reproducibility contract — never
reorder or rename."""
PATH = b"path"
CHURN = b"churn"
PADDING = b"padding"
SELECTOR = b"selector"
class Stream:
"""Deterministic SplitMix64 stream. Identical to the Rust `Stream`."""
__slots__ = ("_state",)
def __init__(self, state: int) -> None:
self._state = state & _MASK64
def next_u64(self) -> int:
"""Raw SplitMix64 step (Vigna). All arithmetic wraps mod 2^64."""
self._state = (self._state + 0x9E3779B97F4A7C15) & _MASK64
z = self._state
z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & _MASK64
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & _MASK64
return z ^ (z >> 31)
def next_below(self, n: int) -> int:
"""Unbiased integer in [0, n) via Lemire's multiply-high with rejection.
Matches the Rust mirror. n == 0 yields 0 (must not raise)."""
if n <= 0:
return 0
x = self.next_u64()
m = x * n
low = m & _MASK64
if low < n:
t = ((_MASK64 + 1) - n) % n # (2^64 - n) mod n
while low < t:
x = self.next_u64()
m = x * n
low = m & _MASK64
return m >> 64
class SorRng:
"""Root RNG: master seed handing out domain-separated deterministic streams."""
__slots__ = ("_seed",)
def __init__(self, seed: int) -> None:
self._seed = seed & _MASK64
@property
def seed(self) -> int:
return self._seed
def stream(self, domain: Domain) -> Stream:
"""state = LE64(SHA256("sor-v1" || label || LE64(seed))[:8])."""
h = hashlib.sha256()
h.update(b"sor-v1")
h.update(domain.value)
h.update(self._seed.to_bytes(8, "little"))
state = int.from_bytes(h.digest()[:8], "little")
return Stream(state)
def select_path(rng: SorRng, pool: int, hops: int) -> List[int]:
"""Deterministically choose an ordered list of `hops` distinct node indices
from a candidate pool of size `pool` (partial Fisher-Yates on the Path
stream). This is the circuit-build sequence the R1 check compares."""
idx = list(range(pool))
if pool == 0:
return idx
take = min(hops, pool)
s = rng.stream(Domain.PATH)
for i in range(take):
j = i + s.next_below(pool - i)
idx[i], idx[j] = idx[j], idx[i]
return idx[:take]
def bringup(seed: int, pool: int, hops: int, rebuilds: int) -> List[List[int]]:
"""Build `rebuilds` successive circuits from one seed, advancing a single
Path stream across rebuilds (models R7 selector rebuilding dropped
circuits). Byte-identical to hh/src/sor/mod.rs::bringup."""
rng = SorRng(seed)
take = min(hops, pool)
s = rng.stream(Domain.PATH)
circuits: List[List[int]] = []
for _ in range(rebuilds):
idx = list(range(pool))
for i in range(take):
j = i + s.next_below(pool - i)
idx[i], idx[j] = idx[j], idx[i]
circuits.append(idx[:take])
return circuits
@dataclass
class SorConfig:
"""Run configuration carrying the master seed. Later items extend this with
topology, selector, and churn-schedule ids; R2's manifest writer reads it.
Kept minimal at R1 to avoid pre-building the later surface."""
seed: int
def rng(self) -> SorRng:
return SorRng(self.seed)