Files
settled-reach/server/src/seed.rs
T
jpmschweitzerandClaude Opus 4.7 a61d030b47 refactor(simulation): harden #952 cascade per QA + architecture review
Addresses the Hoshe (QA) + Tyre (architecture) review of the SeedChain/cascade
work:

- Golden was pinning an empty river network (128x64 produced 0 river cells).
  Bumped to 256x128, where GJ1c yields a real network (93 river cells, 19
  mouths) — Layer 1's rivers are now actually guarded, not just attractors.
- SeedChain::for_body(world_seed, body_id) + fnv1a_64: the single canonical
  body_id(String) -> u64 path (FNV-1a, the repo convention), so callers can't
  derive divergent worlds from the same seed via different ad-hoc hashes. The
  golden now uses it.
- Stability guards: seed_domain_discriminants_are_pinned test (CI fails if a
  SeedDomain tag is renumbered); AttractorType gains #[repr(u8)] + explicit
  discriminants (it's cast as a sort key in features.rs).
- Tests: SeedChain::root(0) non-degenerate; run_cascade error path (missing
  file -> Err, not panic).
- Comments: clarified the id=0 derivations (sibling separation is caller-side
  via the per-district/quarter chain; #957 threads the index), tightened the
  all_district_types reachability comment (it pins the seed-0 sequence, not a
  probabilistic claim), and noted the golden's WORLD_SEED is cosmetic at
  Layers 0-1 + the x86_64 f32 capture caveat.

Deferred with reason: the run_cascade -> CascadeInputs struct refactor (Tyre)
is left for #954 — designing Layer-2's context shape now would be later-phase
detail, and there's a single caller to migrate then. EntityRng keeps its
domainless combine (migrating is stream-changing) — noted in D-224.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:24:03 +02:00

282 lines
10 KiB
Rust

//! Deterministic seed derivation and the generation RNG primitive (D-224).
//!
//! [`SeedChain`] is the single sanctioned path from the master world seed to any
//! per-body / per-layer / per-district sub-stream. Derivation is domain-separated
//! `splitmix64` mixing: deterministic, full-avalanche, integer-only (D-010 #4),
//! and chainable (`root → Body → Layer3Settlement → Quarter → Block`).
//!
//! This module is the home of every deterministic-seeding primitive:
//! - [`splitmix64`] — the one canonical mixer, shared with
//! [`crate::simulation::rng`], where `EntityRng` first introduced it
//! specifically to avoid the `wrapping_add` collision class
//! (`(seed=0,id=N) == (seed=1,id=N-1)`).
//! - [`AtlasRng`] — the integer-only LCG stream used by the generation cascade,
//! normally produced via [`SeedChain::atlas_rng`].
//! - [`SeedChain`] / [`SeedDomain`] — the derivation contract itself.
/// SplitMix64 finalizer — strong avalanche, applied to every seed input.
///
/// Steele, Lea & Flood (2014). Integer-only (D-010 #4). `splitmix64(0)` returns
/// the documented reference value `0xe220a8397b1dcdaf`.
pub(crate) fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9e3779b97f4a7c15);
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
x ^ (x >> 31)
}
/// FNV-1a (64-bit) hash of a string — the repo's deterministic `&str → u64`
/// convention (matches `TemplateId`/`TriangleId` in `simulation::triangle` and
/// the `observer` snapshot hash). The single sanctioned way to turn a string
/// `body_id` into a `SeedDomain::Body` id (D-224), so two callers can never
/// derive different worlds from the same seed via different ad-hoc hashes.
pub(crate) fn fnv1a_64(s: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
for byte in s.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100_0000_01b3); // FNV-1a prime
}
hash
}
/// Seeded linear congruential generator (D-010).
///
/// Knuth's LCG parameters — integer-only arithmetic, no `f32`, D-010 compliant.
/// The generation cascade's pseudorandom stream. Normally seeded through
/// [`SeedChain::atlas_rng`]; `SeedChain` output is well-distributed, so no
/// caller-side pre-mixing is needed.
pub struct AtlasRng {
state: u64,
}
impl AtlasRng {
/// Create a new RNG from a seed.
///
/// A `SeedChain`-derived seed is already well-distributed. If seeding from a
/// raw value that could realistically be 0, mix it first (or derive it
/// through [`SeedChain`]).
pub fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self
.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.state
}
/// Next pseudorandom `u32` (top 31 bits of the LCG state).
pub fn next_u32(&mut self) -> u32 {
(self.next_u64() >> 33) as u32
}
}
/// Seed-derivation domain (D-224).
///
/// Each variant is a distinct domain tag, so a per-body stream and a per-district
/// stream derived with the same `id` never share a sequence. The discriminants
/// are explicit and **stable**: changing one re-rolls every world that derives a
/// seed through it, so they are append-only — never renumber an existing variant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u64)]
pub enum SeedDomain {
/// Per-body generation (keyed by body StableId).
Body = 1,
/// Layer 1 topography (reserved — currently RNG-free; see D-224 scope note).
Layer1Topography = 2,
/// Layer 3 settlement placement (keyed by candidate/site index).
Layer3Settlement = 3,
/// Layer 4 quarter skeleton (keyed by quarter index).
Layer4Quarter = 4,
/// Block-level detail (keyed by block index within a quarter).
Block = 5,
/// NPC generation (keyed by NPC StableId).
Npc = 6,
}
/// A position in the deterministic seed tree (D-224).
///
/// `Copy` and cheap to pass by value. Construct with [`SeedChain::root`] from the
/// master world seed, then descend with [`SeedChain::derive`]. Materialize an
/// [`AtlasRng`] with [`SeedChain::atlas_rng`], or read the raw seed with
/// [`SeedChain::seed`] to feed `SimRng::new` or to derive further.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SeedChain(u64);
impl SeedChain {
/// Root of the chain — the master world seed.
pub fn root(world_seed: u64) -> Self {
Self(world_seed)
}
/// This body's position in the seed tree, from the master `world_seed` and
/// its string `body_id` (D-224). The one canonical `body_id → u64` path:
/// `root(world_seed).derive(Body, fnv1a_64(body_id))`. Use this everywhere a
/// body needs a seed, so all callers agree on the mapping.
pub fn for_body(world_seed: u64, body_id: &str) -> Self {
Self::root(world_seed).derive(SeedDomain::Body, fnv1a_64(body_id))
}
/// Derive a child seed for `(domain, id)`.
///
/// `splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)` (D-224, load-bearing).
/// Deterministic, domain-separated, full-avalanche, integer-only.
pub fn derive(self, domain: SeedDomain, id: u64) -> Self {
Self(splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id))
}
/// Materialize the integer-only generation RNG at this point in the chain.
pub fn atlas_rng(self) -> AtlasRng {
AtlasRng::new(self.0)
}
/// The raw derived seed — feed to `SimRng::new` or a further
/// [`SeedChain::derive`].
pub fn seed(self) -> u64 {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splitmix64_known_vector() {
// Documented SplitMix64 output for seed 0.
assert_eq!(splitmix64(0), 0xe220a8397b1dcdaf);
}
#[test]
fn splitmix64_avalanche() {
// Adjacent inputs produce uncorrelated outputs (the property that makes
// it preferable to wrapping_add).
let a = splitmix64(1);
let b = splitmix64(2);
assert_ne!(a, b);
assert!((a ^ b).count_ones() > 16, "weak avalanche between 1 and 2");
}
#[test]
fn atlas_rng_deterministic_sequence() {
let mut a = AtlasRng::new(42);
let mut b = AtlasRng::new(42);
for _ in 0..100 {
assert_eq!(a.next_u32(), b.next_u32());
}
}
#[test]
fn atlas_rng_different_seeds_differ() {
let mut a = AtlasRng::new(1);
let mut b = AtlasRng::new(2);
let vals_a: Vec<u32> = (0..10).map(|_| a.next_u32()).collect();
let vals_b: Vec<u32> = (0..10).map(|_| b.next_u32()).collect();
assert_ne!(vals_a, vals_b);
}
#[test]
fn atlas_rng_from_seedchain_is_deterministic() {
let chain = SeedChain::root(42).derive(SeedDomain::Body, 7);
let mut a = chain.atlas_rng();
let mut b = chain.atlas_rng();
assert_eq!(a.next_u32(), b.next_u32());
}
#[test]
fn derive_is_deterministic() {
let a = SeedChain::root(42).derive(SeedDomain::Body, 7);
let b = SeedChain::root(42).derive(SeedDomain::Body, 7);
assert_eq!(a.seed(), b.seed());
}
#[test]
fn domains_are_separated() {
// Same root + same id, different domain → different stream.
let root = SeedChain::root(42);
let body = root.derive(SeedDomain::Body, 5).seed();
let quarter = root.derive(SeedDomain::Layer4Quarter, 5).seed();
let block = root.derive(SeedDomain::Block, 5).seed();
assert_ne!(body, quarter);
assert_ne!(body, block);
assert_ne!(quarter, block);
}
#[test]
fn ids_are_separated() {
let root = SeedChain::root(42);
assert_ne!(
root.derive(SeedDomain::Body, 1).seed(),
root.derive(SeedDomain::Body, 2).seed()
);
}
#[test]
fn different_world_seeds_diverge() {
let a = SeedChain::root(1).derive(SeedDomain::Body, 7).seed();
let b = SeedChain::root(2).derive(SeedDomain::Body, 7).seed();
assert_ne!(a, b);
}
#[test]
fn chains_compose_deterministically_and_order_matters() {
let base = SeedChain::root(42).derive(SeedDomain::Body, 7);
// Deterministic at each depth.
assert_eq!(
base.derive(SeedDomain::Block, 3).seed(),
SeedChain::root(42)
.derive(SeedDomain::Body, 7)
.derive(SeedDomain::Block, 3)
.seed()
);
// A deeper chain differs from deriving the same leaf off the root.
assert_ne!(
base.derive(SeedDomain::Block, 3).seed(),
SeedChain::root(42).derive(SeedDomain::Block, 3).seed()
);
}
#[test]
fn seed_domain_discriminants_are_pinned() {
// Load-bearing (D-224): renumbering a variant re-rolls every world that
// derives through it. This guard fails CI the moment a tag changes —
// append new variants, never renumber existing ones.
assert_eq!(SeedDomain::Body as u64, 1);
assert_eq!(SeedDomain::Layer1Topography as u64, 2);
assert_eq!(SeedDomain::Layer3Settlement as u64, 3);
assert_eq!(SeedDomain::Layer4Quarter as u64, 4);
assert_eq!(SeedDomain::Block as u64, 5);
assert_eq!(SeedDomain::Npc as u64, 6);
}
#[test]
fn root_zero_is_non_degenerate() {
// World seed 0 must still produce a well-distributed stream.
let derived = SeedChain::root(0).derive(SeedDomain::Body, 0);
assert_ne!(derived.seed(), 0, "derived seed must not be zero");
let mut rng = derived.atlas_rng();
assert_ne!(rng.next_u32(), 0, "RNG from world seed 0 must not stall");
}
#[test]
fn for_body_deterministic_and_distinct() {
assert_eq!(
SeedChain::for_body(42, "GJ1c").seed(),
SeedChain::for_body(42, "GJ1c").seed()
);
assert_ne!(
SeedChain::for_body(42, "GJ1c").seed(),
SeedChain::for_body(42, "GJ1d").seed()
);
// for_body is exactly root().derive(Body, fnv1a_64(id)).
assert_eq!(
SeedChain::for_body(42, "GJ1c").seed(),
SeedChain::root(42)
.derive(SeedDomain::Body, fnv1a_64("GJ1c"))
.seed()
);
}
}