feat(simulation): SeedChain seed-derivation primitive in server/src/seed.rs (#952, D-224)
New top-level seed module that owns every deterministic-RNG primitive: - splitmix64 — the one canonical mixer (was duplicated as a private fn in simulation/rng.rs; EntityRng now imports the shared one, no behavior change) - AtlasRng — moved here from atlas/rng.rs (it is a generation-RNG primitive, not atlas-specific); atlas now depends on seed, not the reverse - SeedDomain — append-only domain tags (Body/Layer1Topography/Layer3Settlement/ Layer4Quarter/Block/Npc) for collision-proof per-domain seed separation - SeedChain — root(world_seed) → derive(domain, id) → atlas_rng()/seed(), per the D-224 formula splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id) Structural move only — SeedChain is not yet threaded through the cascade callers (skeleton_gen still uses ad-hoc wrapping_add pre-mixing); that is the next step. Unit tests cover the splitmix64 known-vector, avalanche, determinism, domain/id separation, and chain composition. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the
|
||||
//! district count computation. Seed-driven noise uses seeded RNG.
|
||||
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::seed::AtlasRng;
|
||||
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,7 +12,6 @@ pub mod features;
|
||||
pub mod gen_queue;
|
||||
pub mod heightmap;
|
||||
pub mod layer1;
|
||||
pub mod rng;
|
||||
pub mod skeleton_gen;
|
||||
pub mod subbiome;
|
||||
pub mod tile_condition;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//! Seeded LCG for deterministic generation (D-010).
|
||||
//!
|
||||
//! Shared by all atlas generation modules that need seeded randomness.
|
||||
//! Uses Knuth's LCG parameters — integer-only arithmetic, no f32, D-010 compliant.
|
||||
//!
|
||||
//! Callers are responsible for any seed pre-mixing before calling `AtlasRng::new`.
|
||||
|
||||
/// Seeded linear congruential generator (D-010).
|
||||
pub struct AtlasRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl AtlasRng {
|
||||
/// Create a new RNG from a pre-mixed seed.
|
||||
///
|
||||
/// Callers must ensure the seed is non-degenerate (avoid passing 0 directly
|
||||
/// if the seed could realistically be 0 — add a constant before calling).
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn 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 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);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::seed::AtlasRng;
|
||||
use crate::simulation::generator::{
|
||||
BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId,
|
||||
DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod cause_chain;
|
||||
pub mod knowledge;
|
||||
pub mod npc;
|
||||
pub mod perception;
|
||||
pub mod seed;
|
||||
pub mod settings;
|
||||
pub mod simulation;
|
||||
pub mod storyteller;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Injectable ChaCha RNG resource for deterministic replay (D-030)
|
||||
// Ensures same seed produces same outcomes
|
||||
|
||||
use crate::seed::splitmix64;
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
@@ -61,16 +62,6 @@ impl EntityRng {
|
||||
}
|
||||
}
|
||||
|
||||
/// Splitmix64 mixing function — good avalanche properties for seed derivation.
|
||||
/// Ensures that similar inputs (e.g., consecutive StableIds) produce
|
||||
/// uncorrelated output seeds.
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user