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:
@@ -0,0 +1,8 @@
|
||||
"""SOR (self-observing-relay) measurement instrument — Python side.
|
||||
|
||||
R1 lands the deterministic seed core (`config`). Later items add provenance
|
||||
(R2), the immutable event log (R3), the isolated-engine forwarder (R4),
|
||||
federation (R6), and churn/selector/analysis (R7). Everything stochastic is
|
||||
driven by the single ``--sor-seed`` defined here, mirrored bit-for-bit in
|
||||
``hh/src/sor/mod.rs``.
|
||||
"""
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user