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:
2026-05-23 11:51:09 +02:00
co-authored by Claude Opus 4.7
parent f4f43e826c
commit de9142ce37
7 changed files with 222 additions and 70 deletions
+1 -1
View File
@@ -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};
// ---------------------------------------------------------------------------
-1
View File
@@ -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;
-57
View File
@@ -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);
}
}
+1 -1
View File
@@ -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,