From c04f9aed23d77781907b1ab29cff7c4031c04566 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sun, 19 Jul 2026 14:47:07 -0700 Subject: [PATCH] R1: deterministic --sor-seed core for reproducible measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd_chat/sor/__init__.py | 8 ++ cmd_chat/sor/config.py | 135 ++++++++++++++++++ hh/src/main.rs | 24 ++++ hh/src/sor/mod.rs | 288 +++++++++++++++++++++++++++++++++++++++ tests/test_sor_config.py | 58 ++++++++ 5 files changed, 513 insertions(+) create mode 100644 cmd_chat/sor/__init__.py create mode 100644 cmd_chat/sor/config.py create mode 100644 hh/src/sor/mod.rs create mode 100644 tests/test_sor_config.py diff --git a/cmd_chat/sor/__init__.py b/cmd_chat/sor/__init__.py new file mode 100644 index 0000000..c833a63 --- /dev/null +++ b/cmd_chat/sor/__init__.py @@ -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``. +""" diff --git a/cmd_chat/sor/config.py b/cmd_chat/sor/config.py new file mode 100644 index 0000000..46896f8 --- /dev/null +++ b/cmd_chat/sor/config.py @@ -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 ``. 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) diff --git a/hh/src/main.rs b/hh/src/main.rs index 2110a77..87b55f3 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -11,6 +11,7 @@ mod layout; mod net; mod persona; mod sbx; +mod sor; mod theme; mod ui; @@ -54,6 +55,19 @@ enum Cmd { }, /// Run the offline SRP golden-vector self-test. 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). Srpm { a_hex: String, @@ -139,6 +153,16 @@ fn main() -> Result<()> { Ok(()) } 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 { a_hex, salt_hex, diff --git a/hh/src/sor/mod.rs b/hh/src/sor/mod.rs new file mode 100644 index 0000000..7c1adec --- /dev/null +++ b/hh/src/sor/mod.rs @@ -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 +//! `. 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 { + let mut idx: Vec = (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>, +} + +/// 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 = (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::(), 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::(), 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()); + } + } +} diff --git a/tests/test_sor_config.py b/tests/test_sor_config.py new file mode 100644 index 0000000..e2f549c --- /dev/null +++ b/tests/test_sor_config.py @@ -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]]