clay-borg/crates/cb-kernel/src/rng.rs

160 lines
5.3 KiB
Rust
Raw Normal View History

//! Seeded RNG service (GameKernel K5) with the null/reference impl pair
//! required by AM-11. The only randomness source available to game code.
use crate::ids::Seed;
use rand_core::{RngCore, SeedableRng};
/// Kernel randomness: deterministic draws from the game seed.
pub trait KernelRng {
/// Uniform draw in `0..bound` (bound ≥ 1).
fn draw(&mut self, bound: u32) -> u32;
/// Deterministic FisherYates shuffle of `items`.
fn shuffle<T>(&mut self, items: &mut [T]) {
for i in (1..items.len()).rev() {
#[allow(clippy::cast_possible_truncation)]
let j = self.draw(i as u32 + 1) as usize;
items.swap(i, j);
}
}
}
/// Reference implementation: ChaCha12, seeded from the game `Seed`.
pub struct ChaChaRng(rand_chacha::ChaCha12Rng);
impl ChaChaRng {
pub fn from_seed(seed: Seed) -> Self {
Self(rand_chacha::ChaCha12Rng::seed_from_u64(seed.0))
}
}
impl KernelRng for ChaChaRng {
fn draw(&mut self, bound: u32) -> u32 {
assert!(bound >= 1, "draw bound must be >= 1");
// Rejection sampling for uniformity.
let zone = u32::MAX - (u32::MAX % bound);
loop {
let v = self.0.next_u32();
if v < zone {
return v % bound;
}
}
}
}
/// Null implementation for tests: always returns 0 (first choice, no
/// shuffle movement). Makes scenario fixtures fully predictable.
#[derive(Default)]
pub struct NullRng;
impl KernelRng for NullRng {
fn draw(&mut self, _bound: u32) -> u32 {
0
}
}
CB-WP-0006 T05: K9's assertion, K11's format, and the AM-11 suites K11 is implemented: crates/cb-events/src/store.rs, magic + version header, 4-byte little-endian length prefix, append-only. Reimplemented not assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are unchanged at 246,250 / 317,021 because nothing entered the graph. The operative clause is "detected", so corruption is tested rather than assumed: a tail short by one byte, a half-written length prefix, a length prefix corrupted to claim more than the file holds, foreign magic, and a future format version are each rejected with a distinct error. A reader that accepts a truncated tail is worse than no format, because it silently returns a short history that looks complete. AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore — driven through ONE conformance(). The trait carries raw/set_raw precisely so the corruption controls live in the shared suite: a format contract that only one impl enforces is not a contract. The same shape is retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism across fresh instances, and shuffle preserving the multiset. They were previously exercised by two separate tests, which is why "met, narrow" was never earned and ADR-0005 §4 downgraded it. K9 gets the assertion it did not have: snapshot at seq N + events N+1..M must equal the from-genesis fold, hash-compared, on GroundState, single-seed on purpose — AM-7's probe folds a multi-seed log, which is not a replay of anything, and that defect is not repeated. Two positive controls: the log must exceed 50 events, and the mid-log snapshot must differ from the end state or "apply the remainder" is vacuous. Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making Snapshot::take discard its EventSeq — now fails on the K9 assertion. AM-11's mutation breaks NullRng::draw to return its bound and the shared suite fails. That is what M-D4-SWAP claims — either impl substitutable — and exactly what two separate per-impl tests could never demonstrate. M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5 and AM-9 added, AM-6 moved to enforced, and the headline total corrected from 4 to 8 — it had gone stale inside the same workplan that produced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:50:52 +02:00
/// The AM-11 conformance suite for [`KernelRng`] — **one** suite, driven by
/// every impl.
///
/// Before CB-WP-0006 T05 the pair was exercised by two *separate*,
/// non-shared tests (`chacha_is_deterministic_per_seed` and
/// `null_rng_never_moves_a_shuffle`). M-D4-SWAP is a bool over impls
/// "passing the same conformance suite", so a pair with no shared suite
/// never earned it; ADR-0005 §4 downgraded AM-11 to unmet on exactly that
/// reading.
///
/// The assertions are the properties true of **both** a real CSPRNG and a
/// null draw — determinism, bounds, and shuffle integrity. An impl that
/// violates any of them cannot be substituted for the other, which is what
/// the port claims.
pub fn conformance<R: KernelRng>(label: &str, mut make: impl FnMut() -> R) {
// Bounds. `draw(n)` must land in `0..n` for every impl, or callers
// indexing with it are unsound.
let mut r = make();
for bound in [1u32, 2, 3, 7, 52, 1000] {
for _ in 0..64 {
let v = r.draw(bound);
assert!(
v < bound,
"{label}: draw({bound}) returned {v}, outside 0..{bound}"
);
}
}
// draw(1) has exactly one legal answer.
let mut r = make();
assert_eq!(r.draw(1), 0, "{label}: draw(1) must be 0");
// Determinism: two fresh instances must produce identical sequences.
// This is K5 for ChaCha and trivially true for the null impl — which
// is the point of a shared suite.
let (mut a, mut b) = (make(), make());
let seq_a: Vec<u32> = (0..32).map(|_| a.draw(97)).collect();
let seq_b: Vec<u32> = (0..32).map(|_| b.draw(97)).collect();
assert_eq!(
seq_a, seq_b,
"{label}: two fresh instances must draw identically"
);
// Shuffle preserves the multiset — it may reorder, never invent or
// drop. A shuffle that loses an element would corrupt a deal.
let mut r = make();
let original: Vec<u8> = (0..52).collect();
let mut items = original.clone();
r.shuffle(&mut items);
let mut sorted = items.clone();
sorted.sort_unstable();
assert_eq!(
sorted, original,
"{label}: shuffle must preserve the multiset"
);
// Shuffling is deterministic too, or replay diverges (K8).
let (mut c, mut d) = (make(), make());
let (mut x, mut y) = (original.clone(), original.clone());
c.shuffle(&mut x);
d.shuffle(&mut y);
assert_eq!(x, y, "{label}: shuffle must be deterministic per impl");
}
#[cfg(test)]
mod tests {
use super::*;
CB-WP-0006 T05: K9's assertion, K11's format, and the AM-11 suites K11 is implemented: crates/cb-events/src/store.rs, magic + version header, 4-byte little-endian length prefix, append-only. Reimplemented not assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are unchanged at 246,250 / 317,021 because nothing entered the graph. The operative clause is "detected", so corruption is tested rather than assumed: a tail short by one byte, a half-written length prefix, a length prefix corrupted to claim more than the file holds, foreign magic, and a future format version are each rejected with a distinct error. A reader that accepts a truncated tail is worse than no format, because it silently returns a short history that looks complete. AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore — driven through ONE conformance(). The trait carries raw/set_raw precisely so the corruption controls live in the shared suite: a format contract that only one impl enforces is not a contract. The same shape is retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism across fresh instances, and shuffle preserving the multiset. They were previously exercised by two separate tests, which is why "met, narrow" was never earned and ADR-0005 §4 downgraded it. K9 gets the assertion it did not have: snapshot at seq N + events N+1..M must equal the from-genesis fold, hash-compared, on GroundState, single-seed on purpose — AM-7's probe folds a multi-seed log, which is not a replay of anything, and that defect is not repeated. Two positive controls: the log must exceed 50 events, and the mid-log snapshot must differ from the end state or "apply the remainder" is vacuous. Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making Snapshot::take discard its EventSeq — now fails on the K9 assertion. AM-11's mutation breaks NullRng::draw to return its bound and the shared suite fails. That is what M-D4-SWAP claims — either impl substitutable — and exactly what two separate per-impl tests could never demonstrate. M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5 and AM-9 added, AM-6 moved to enforced, and the headline total corrected from 4 to 8 — it had gone stale inside the same workplan that produced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:50:52 +02:00
/// AM-11: the SAME suite, both impls. Two separate tests are what the
/// pair had before, and why the metric was never earned.
#[test]
fn chacha_passes_the_conformance_suite() {
conformance("ChaChaRng", || ChaChaRng::from_seed(Seed(42)));
}
#[test]
fn null_rng_passes_the_conformance_suite() {
conformance("NullRng", NullRng::default);
}
/// AM-8 seed determinism at the unit level: same seed, same draws.
#[test]
fn chacha_is_deterministic_per_seed() {
let mut a = ChaChaRng::from_seed(Seed(42));
let mut b = ChaChaRng::from_seed(Seed(42));
let mut c = ChaChaRng::from_seed(Seed(43));
let draws_a: Vec<u32> = (0..64).map(|_| a.draw(1000)).collect();
let draws_b: Vec<u32> = (0..64).map(|_| b.draw(1000)).collect();
let draws_c: Vec<u32> = (0..64).map(|_| c.draw(1000)).collect();
assert_eq!(draws_a, draws_b);
assert_ne!(draws_a, draws_c);
}
#[test]
fn null_rng_never_moves_a_shuffle() {
let mut rng = NullRng;
let mut items = vec![1, 2, 3, 4];
// FisherYates with j=0 each step rotates deterministically.
rng.shuffle(&mut items);
let mut again = vec![1, 2, 3, 4];
NullRng.shuffle(&mut again);
assert_eq!(items, again);
}
}