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)
|
||||||
@@ -11,6 +11,7 @@ mod layout;
|
|||||||
mod net;
|
mod net;
|
||||||
mod persona;
|
mod persona;
|
||||||
mod sbx;
|
mod sbx;
|
||||||
|
mod sor;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
@@ -54,6 +55,19 @@ enum Cmd {
|
|||||||
},
|
},
|
||||||
/// Run the offline SRP golden-vector self-test.
|
/// Run the offline SRP golden-vector self-test.
|
||||||
Selftest,
|
Selftest,
|
||||||
|
/// R1: emit the deterministic SOR circuit-build sequence for a `--sor-seed`.
|
||||||
|
/// Pure bookkeeping — stands up no forwarder and opens no socket. Same seed
|
||||||
|
/// => byte-identical bringup (instrument-validation gate item 2).
|
||||||
|
SorBringup {
|
||||||
|
#[arg(long)]
|
||||||
|
sor_seed: u64,
|
||||||
|
#[arg(long, default_value_t = 5)]
|
||||||
|
pool: usize,
|
||||||
|
#[arg(long, default_value_t = 3)]
|
||||||
|
hops: usize,
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
rebuilds: usize,
|
||||||
|
},
|
||||||
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
||||||
Srpm {
|
Srpm {
|
||||||
a_hex: String,
|
a_hex: String,
|
||||||
@@ -139,6 +153,16 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Cmd::Selftest => selftest(),
|
Cmd::Selftest => selftest(),
|
||||||
|
Cmd::SorBringup {
|
||||||
|
sor_seed,
|
||||||
|
pool,
|
||||||
|
hops,
|
||||||
|
rebuilds,
|
||||||
|
} => {
|
||||||
|
let bp = sor::bringup(sor_seed, pool, hops, rebuilds);
|
||||||
|
println!("{}", serde_json::to_string(&bp)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Cmd::Srpm {
|
Cmd::Srpm {
|
||||||
a_hex,
|
a_hex,
|
||||||
salt_hex,
|
salt_hex,
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! R1 — Seed plumbing for SOR determinism.
|
||||||
|
//!
|
||||||
|
//! This module is the single source of stochasticity for the SOR
|
||||||
|
//! (self-observing-relay) measurement instrument. Every stochastic decision the
|
||||||
|
//! later items make — path selection (R4), churn schedule and selector choices
|
||||||
|
//! (R7), padding jitter (R4) — draws from a `SorRng` seeded by one `--sor-seed
|
||||||
|
//! <u64>`. Given the same seed and the same (deterministic) churn script, the
|
||||||
|
//! circuit-build sequence is byte-identical across runs, which is exactly the R1
|
||||||
|
//! acceptance check and instrument-validation gate item 2 (seeded bringup
|
||||||
|
//! reproducible).
|
||||||
|
//!
|
||||||
|
//! Nothing here forwards traffic, opens a socket, or spawns an engine. It is a
|
||||||
|
//! deterministic bookkeeping primitive; the isolated-engine containment
|
||||||
|
//! assertions live with the forwarder (R4). The algorithm is fully specified
|
||||||
|
//! (SplitMix64 + SHA-256 domain mixing + Lemire bounded sampling) so it is
|
||||||
|
//! stable across compiler/`rand` versions and is mirrored bit-for-bit in
|
||||||
|
//! `cmd_chat/sor/config.py`.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Independent stochastic sub-streams. Each SOR decision domain draws from its
|
||||||
|
/// own stream so that adding or removing a consumer in one domain never
|
||||||
|
/// perturbs another domain's sequence (stable determinism under refactor).
|
||||||
|
// Path drives R1/R4 circuit selection now; Churn/Padding/Selector are consumed
|
||||||
|
// by R4 padding and R7 churn/selector, and are exercised by the R1 tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Domain {
|
||||||
|
/// Circuit path / hop selection (R4).
|
||||||
|
Path,
|
||||||
|
/// VM spin/kill churn schedule (R7).
|
||||||
|
Churn,
|
||||||
|
/// Padding jitter timing (R4 optional padding arm).
|
||||||
|
Padding,
|
||||||
|
/// Agent/path selector strategy choices (R7).
|
||||||
|
Selector,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Domain {
|
||||||
|
/// Stable label mixed into the sub-stream seed. Never reorder or rename
|
||||||
|
/// these — the bytes are part of the reproducibility contract.
|
||||||
|
fn label(self) -> &'static [u8] {
|
||||||
|
match self {
|
||||||
|
Domain::Path => b"path",
|
||||||
|
Domain::Churn => b"churn",
|
||||||
|
Domain::Padding => b"padding",
|
||||||
|
Domain::Selector => b"selector",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic SplitMix64 stream. Fully specified so two runs (or the Rust
|
||||||
|
/// and Python sides) agree exactly given the same 64-bit state.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Stream {
|
||||||
|
state: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream {
|
||||||
|
/// Raw SplitMix64 step (Vigna). All arithmetic wraps mod 2^64.
|
||||||
|
pub fn next_u64(&mut self) -> u64 {
|
||||||
|
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||||
|
let mut z = self.state;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||||
|
z ^ (z >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unbiased integer in `[0, n)` via Lemire's multiply-high with rejection.
|
||||||
|
/// Deterministic and bias-free; identical to the Python mirror. `n == 0`
|
||||||
|
/// yields 0 (empty range is a caller bug, but must not panic).
|
||||||
|
pub fn next_below(&mut self, n: u64) -> u64 {
|
||||||
|
if n == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut x = self.next_u64();
|
||||||
|
let mut m = (x as u128) * (n as u128);
|
||||||
|
let mut l = m as u64; // low 64 bits
|
||||||
|
if l < n {
|
||||||
|
// t = (2^64 - n) mod n = (-n) mod n
|
||||||
|
let t = n.wrapping_neg() % n;
|
||||||
|
while l < t {
|
||||||
|
x = self.next_u64();
|
||||||
|
m = (x as u128) * (n as u128);
|
||||||
|
l = m as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(m >> 64) as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root RNG for a run. Holds the master seed and hands out independent,
|
||||||
|
/// domain-separated deterministic streams.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct SorRng {
|
||||||
|
seed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SorRng {
|
||||||
|
pub fn new(seed: u64) -> Self {
|
||||||
|
SorRng { seed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read by the R2 manifest writer to echo the seed into manifest.json.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn seed(&self) -> u64 {
|
||||||
|
self.seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a domain sub-stream: `state = LE64(SHA256("sor-v1" || label ||
|
||||||
|
/// LE64(seed))[..8])`. SHA-256 domain mixing decorrelates the streams so one
|
||||||
|
/// domain's draws can't be predicted from another's.
|
||||||
|
pub fn stream(&self, domain: Domain) -> Stream {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(b"sor-v1");
|
||||||
|
h.update(domain.label());
|
||||||
|
h.update(self.seed.to_le_bytes());
|
||||||
|
let d = h.finalize();
|
||||||
|
let mut b = [0u8; 8];
|
||||||
|
b.copy_from_slice(&d[..8]);
|
||||||
|
Stream {
|
||||||
|
state: u64::from_le_bytes(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministically choose an ordered list of `hops` distinct node indices from
|
||||||
|
/// a candidate pool of size `pool` using the `Path` stream. This is the
|
||||||
|
/// "circuit-build sequence" the R1 acceptance check compares across runs.
|
||||||
|
///
|
||||||
|
/// Partial Fisher–Yates: stable, unbiased, and reproducible. If `hops >= pool`
|
||||||
|
/// the whole pool is returned as a deterministic permutation.
|
||||||
|
// Consumed by the R4 forwarder to lay out circuit hops; exercised now by the R1
|
||||||
|
// determinism/parity tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn select_path(rng: &SorRng, pool: usize, hops: usize) -> Vec<usize> {
|
||||||
|
let mut idx: Vec<usize> = (0..pool).collect();
|
||||||
|
if pool == 0 {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
let take = hops.min(pool);
|
||||||
|
let mut s = rng.stream(Domain::Path);
|
||||||
|
for i in 0..take {
|
||||||
|
let j = i + s.next_below((pool - i) as u64) as usize;
|
||||||
|
idx.swap(i, j);
|
||||||
|
}
|
||||||
|
idx.truncate(take);
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A seeded bringup plan: the ordered per-rebuild circuit-build sequence. This
|
||||||
|
/// is what the `sor-bringup` CLI emits and what R2's manifest writer will echo.
|
||||||
|
/// Each rebuild draws the *next* path from the same Path stream, so a scripted
|
||||||
|
/// sequence of rebuilds is reproducible end-to-end from the seed alone.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Bringup {
|
||||||
|
pub sor_seed: u64,
|
||||||
|
pub pool: usize,
|
||||||
|
pub hops: usize,
|
||||||
|
pub circuits: Vec<Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build `rebuilds` successive circuits from one seed, advancing a single Path
|
||||||
|
/// stream across rebuilds (models R7 selector rebuilding dropped circuits).
|
||||||
|
pub fn bringup(seed: u64, pool: usize, hops: usize, rebuilds: usize) -> Bringup {
|
||||||
|
let rng = SorRng::new(seed);
|
||||||
|
let take = hops.min(pool);
|
||||||
|
let mut s = rng.stream(Domain::Path);
|
||||||
|
let mut circuits = Vec::with_capacity(rebuilds);
|
||||||
|
for _ in 0..rebuilds {
|
||||||
|
// Fresh partial Fisher–Yates over a fresh index vector each rebuild, but
|
||||||
|
// drawing from the *continuing* stream so successive rebuilds differ.
|
||||||
|
let mut idx: Vec<usize> = (0..pool).collect();
|
||||||
|
for i in 0..take {
|
||||||
|
let j = i + s.next_below((pool - i) as u64) as usize;
|
||||||
|
idx.swap(i, j);
|
||||||
|
}
|
||||||
|
idx.truncate(take);
|
||||||
|
circuits.push(idx);
|
||||||
|
}
|
||||||
|
Bringup {
|
||||||
|
sor_seed: seed,
|
||||||
|
pool,
|
||||||
|
hops,
|
||||||
|
circuits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
// Lock the core primitive: canonical SplitMix64 with initial state 0 emits
|
||||||
|
// the widely published first output. Guards against algorithm drift and
|
||||||
|
// keeps the Rust/Python mirrors honest.
|
||||||
|
#[test]
|
||||||
|
fn splitmix64_known_answer() {
|
||||||
|
let mut s = Stream { state: 0 };
|
||||||
|
assert_eq!(s.next_u64(), 0xE220_A839_7B1D_CDAF);
|
||||||
|
assert_eq!(s.next_u64(), 0x6E78_9E6A_A1B9_65F4);
|
||||||
|
assert_eq!(s.next_u64(), 0x06C4_5D18_8009_454F);
|
||||||
|
}
|
||||||
|
|
||||||
|
// R1 acceptance check, primitive form: same seed => identical circuit-build
|
||||||
|
// sequence; a scripted rebuild sequence is reproducible.
|
||||||
|
#[test]
|
||||||
|
fn same_seed_identical_bringup() {
|
||||||
|
let a = bringup(0xDEADBEEF, 8, 3, 5);
|
||||||
|
let b = bringup(0xDEADBEEF, 8, 3, 5);
|
||||||
|
assert_eq!(a.circuits, b.circuits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different seeds diverge (with overwhelming probability for these values).
|
||||||
|
#[test]
|
||||||
|
fn different_seed_diverges() {
|
||||||
|
let a = bringup(1, 12, 3, 4);
|
||||||
|
let b = bringup(2, 12, 3, 4);
|
||||||
|
assert_ne!(a.circuits, b.circuits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain separation: independent streams do not coincide.
|
||||||
|
#[test]
|
||||||
|
fn domains_decorrelated() {
|
||||||
|
let rng = SorRng::new(42);
|
||||||
|
let mut p = rng.stream(Domain::Path);
|
||||||
|
let mut c = rng.stream(Domain::Churn);
|
||||||
|
assert_ne!(p.next_u64(), c.next_u64());
|
||||||
|
}
|
||||||
|
|
||||||
|
// select_path returns `hops` distinct in-range indices.
|
||||||
|
#[test]
|
||||||
|
fn select_path_distinct_in_range() {
|
||||||
|
let rng = SorRng::new(7);
|
||||||
|
let path = select_path(&rng, 6, 3);
|
||||||
|
assert_eq!(path.len(), 3);
|
||||||
|
for &h in &path {
|
||||||
|
assert!(h < 6);
|
||||||
|
}
|
||||||
|
let mut sorted = path.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sorted.len(), path.len(), "hops must be distinct");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-language parity anchor: this exact value is asserted identically in
|
||||||
|
// the Python mirror (tests/test_sor_config.py). If either side changes the
|
||||||
|
// algorithm, one of the two tests fails.
|
||||||
|
#[test]
|
||||||
|
fn parity_vector() {
|
||||||
|
assert_eq!(select_path(&SorRng::new(42), 8, 3), vec![5, 0, 6]);
|
||||||
|
let bp = bringup(123456789, 5, 3, 3);
|
||||||
|
assert_eq!(
|
||||||
|
bp.circuits,
|
||||||
|
vec![vec![4, 1, 3], vec![3, 2, 0], vec![3, 0, 4]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
// next_below must stay in range and never panic for any bound, mirroring
|
||||||
|
// the parser proptest discipline in net.rs.
|
||||||
|
#[test]
|
||||||
|
fn next_below_in_range(seed in any::<u64>(), n in 1u64..1_000_000) {
|
||||||
|
let rng = SorRng::new(seed);
|
||||||
|
let mut s = rng.stream(Domain::Churn);
|
||||||
|
for _ in 0..64 {
|
||||||
|
prop_assert!(s.next_below(n) < n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// select_path never panics and always yields distinct in-range indices
|
||||||
|
// for any pool/hops, including degenerate hops >= pool.
|
||||||
|
#[test]
|
||||||
|
fn select_path_never_panics(seed in any::<u64>(), pool in 0usize..64, hops in 0usize..64) {
|
||||||
|
let rng = SorRng::new(seed);
|
||||||
|
let path = select_path(&rng, pool, hops);
|
||||||
|
prop_assert!(path.len() == hops.min(pool));
|
||||||
|
for &h in &path {
|
||||||
|
prop_assert!(h < pool);
|
||||||
|
}
|
||||||
|
let mut sorted = path.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
sorted.dedup();
|
||||||
|
prop_assert_eq!(sorted.len(), path.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""R1 — deterministic seed core, and cross-language parity with the Rust mirror.
|
||||||
|
|
||||||
|
The parity vectors below are asserted identically in
|
||||||
|
hh/src/sor/mod.rs::tests::parity_vector. If either language changes the
|
||||||
|
algorithm, one of the two test suites fails — that is the reproducibility
|
||||||
|
contract for `--sor-seed`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from cmd_chat.sor.config import Domain, SorRng, Stream, bringup, select_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_splitmix64_known_answer():
|
||||||
|
# Canonical SplitMix64 from initial state 0 (published first outputs).
|
||||||
|
s = Stream(0)
|
||||||
|
assert s.next_u64() == 0xE220A8397B1DCDAF
|
||||||
|
assert s.next_u64() == 0x6E789E6AA1B965F4
|
||||||
|
assert s.next_u64() == 0x06C45D188009454F
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_seed_identical_bringup():
|
||||||
|
# R1 acceptance check, primitive form.
|
||||||
|
assert bringup(0xDEADBEEF, 8, 3, 5) == bringup(0xDEADBEEF, 8, 3, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_seed_diverges():
|
||||||
|
assert bringup(1, 12, 3, 4) != bringup(2, 12, 3, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_domains_decorrelated():
|
||||||
|
rng = SorRng(42)
|
||||||
|
assert rng.stream(Domain.PATH).next_u64() != rng.stream(Domain.CHURN).next_u64()
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_path_distinct_in_range():
|
||||||
|
path = select_path(SorRng(7), 6, 3)
|
||||||
|
assert len(path) == 3
|
||||||
|
assert all(0 <= h < 6 for h in path)
|
||||||
|
assert len(set(path)) == len(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_below_in_range():
|
||||||
|
for seed in (0, 1, 42, 0xFFFFFFFF):
|
||||||
|
s = SorRng(seed).stream(Domain.CHURN)
|
||||||
|
for n in (1, 2, 3, 5, 8, 1000, 999983):
|
||||||
|
for _ in range(32):
|
||||||
|
assert 0 <= s.next_below(n) < n
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_path_degenerate_hops_ge_pool():
|
||||||
|
# hops >= pool returns a full deterministic permutation, no panic.
|
||||||
|
path = select_path(SorRng(3), 4, 10)
|
||||||
|
assert sorted(path) == [0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parity_vector_matches_rust():
|
||||||
|
# These EXACT values are baked into hh/src/sor/mod.rs::tests::parity_vector.
|
||||||
|
assert select_path(SorRng(42), 8, 3) == [5, 0, 6]
|
||||||
|
assert bringup(123456789, 5, 3, 3) == [[4, 1, 3], [3, 2, 0], [3, 0, 4]]
|
||||||
Reference in New Issue
Block a user