Files
settled-reach/server/src/seed.rs
T
jpmschweitzerandClaude Fable 5 4320df9b60 feat(simulation): T-1170 A2/A3 + T-1168 A5 — course inventor, coast termination, riparian signal
A2 (river_course.rs): Stage A rung-independent valley-seeking control
path (chord/8 stations, k=5 bilinear-scored candidates + continuity
penalty); Stage B rung-indexed perpendicular warp on GLOBAL arc-length
(window-independence, Ruling 1e), band chord/2 down to min_wavelength_m
hard-truncate, sine taper to zero at anchors, amplitude min(8% chord,
half-cell) slope/class-scaled. SeedDomain::RiverCourse=17, distinct
salt. Wire: RiverCourse{edge_id,class,points,terminus} on
DistrictWindowLayer.courses (serde-default); bbox-culled, window-
cropped +1 station. TerrainAnalysisCache retains Layer1Output (the
gen_queue:626 discard, Ruling 4b). A3: mouth termination walks
stations sampling the window's OWN rung-consistent morphology verdict,
6-iteration bisect; land-at-anchor probes one segment then None;
EdgeDrain never probes. A5: near_perennial_water point-to-segment
predicate (D-239 §8 governed bands 1-3m class-scaled) threaded through
both batch and window paths; Region always false; never touches
moisture_q. Discipline closed: dormant zoom-ladder bench run + numbers
recorded in the design doc (District 3.009/1.785, Quarter 1.823
us/cell, Region window 0.617ms); course-cost bench CAUGHT a real
+12-36% per-cell riparian scan regression -> precomputed bbox O(1)
reject (60ns->2.2ns/call), final delta +3.4-6.9% at budget; three
determinism tests (overlapping-window byte-identity, cross-rung
amplitude bound, warp-stream cross-correlation r<0.3); goldens: window
sweep gained a verified course-bearing position (pure append), new
river_course golden at both rungs, believability verified unchanged.
Revert-verification discovered the pole-row branch is structurally
unreachable (flow_direction bounds-check) — the real edge-drain path
is k<0 flat-plateau; test fixture rewritten to exercise reality.
scale.rs stale comment fixed. Full cargo test green.

Tickets: T-1170, T-1168

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:36:16 +02:00

354 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
/// Anti-squaring domain warp (D-239 §4, T-1026).
/// Keyed by per-tile position id (see `atlas::domain_warp::pos_to_id`).
DomainWarp = 7,
/// ChunkContext derivation (64 m carrier, D-239 §1, T-1028).
/// Keyed by region-scale position id (see `atlas::chunk_context::pos_to_id`).
ChunkContext = 8,
/// Per-voxel sub-chunk derivation (1 m, D-239 §10, T-1028). Keyed by the
/// post-warp integer voxel-position id. Distinct from `ChunkContext` so the
/// voxel stream can never collide with the region-scale meander seed (D-224
/// domain separation).
Voxel = 9,
/// Seasonal cover cluster scatter (D-239 §3, T-1030). Keyed by the coarse
/// cluster-cell id (zigzag-encoded and Cantor-paired). Distinct from `Voxel`
/// so the per-cluster cover hash can never collide with the per-voxel terrain
/// stream — domain separation guarantees no freeze-to-terrain correlation.
Cover = 10,
/// Voxel-tier mid-scale relief noise field (T-1081, D-243 §2). A body-global,
/// position-keyed elevation perturbation at sub-district wavelengths (≈0.252 km),
/// keyed by a single constant id (one field per body). Distinct domain so the
/// relief lattice can never correlate with the per-voxel terrain or cover streams.
VoxelRelief = 11,
/// Sub-chunk micro-habitat mosaic selection field (T-1084, D-246). A body-global,
/// position-keyed value-noise field at ≈864 m wavelengths (one field per body,
/// keyed by a single constant id). Distinct domain so the mosaic-selection lattice
/// can never correlate with the relief, cover, or per-voxel terrain streams.
VoxelMosaic = 12,
/// Architecture-flavor body-vocabulary K-draw (D-232 phase 1, T-994). Keyed by
/// a constant id (one draw per body — `SeedChain::for_body` already isolates
/// bodies). Distinct domain so the vocabulary draw can never correlate with
/// any other body-scoped stream.
TraitVocabulary = 13,
/// Architecture-flavor district-dominant template pick (D-232 phase 2, T-994).
/// Keyed by a **two-level derive chain** under this one domain — first by the
/// `DistrictPos` id (`pos_to_id`), then by the `DistrictType` ordinal — so
/// every settlement whose quarter falls in the same D-243 2 048 m district
/// independently derives the *identical* dominant template for a given
/// district type: same body seed + same two-level key, no cross-quarter
/// coordination required. (Two chained derives, not a packed single id —
/// see `trait_draw::pick_district_dominant_by_type`.)
TraitDistrict = 14,
/// Per-building deviation/swerve roll (D-232 deviation system, T-1003).
/// Derived off the footprint's own chain, keyed by footprint index. Distinct
/// domain so the rare-wildcard roll can never correlate with the zone/era/
/// extent draws sharing that chain.
TraitSwerve = 15,
/// Per-building D-235 exterior-grammar draw (`BuildingExteriorTag`, T-988):
/// wall/roof/facade/street token picks + color HSV sample. Derived off the
/// footprint's own chain (like `TraitSwerve`), keyed per axis (0=wall,
/// 1=roof, 2=facade, 3=street, 4=hue, 5=sat, 6=val) so each axis draws
/// independent entropy — the `assign_block_tags` lesson (distinct
/// sub-chains per field, not one shared roll) applies here too.
TraitExterior = 16,
/// River course invention (T-1170, Ruling 3a): the linear sibling of the
/// coastline warp, keyed by `edge_id` (the packed upstream-cell u32 of a
/// D8 river edge, see `atlas::river_course`). Distinct domain so the
/// course's Stage-B perpendicular warp octaves can never correlate with
/// the coast warp, terrain scatter, or vegetation massif fields sampled
/// at the same world position (D-224 domain separation, the module's own
/// `RIVER_COURSE_WARP_SALT` provides a second, position-keyed layer of
/// isolation on top of this domain tag).
RiverCourse = 17,
}
/// 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);
assert_eq!(SeedDomain::DomainWarp as u64, 7);
assert_eq!(SeedDomain::ChunkContext as u64, 8);
assert_eq!(SeedDomain::Voxel as u64, 9);
assert_eq!(SeedDomain::Cover as u64, 10);
assert_eq!(SeedDomain::VoxelRelief as u64, 11);
assert_eq!(SeedDomain::VoxelMosaic as u64, 12);
assert_eq!(SeedDomain::TraitVocabulary as u64, 13);
assert_eq!(SeedDomain::TraitDistrict as u64, 14);
assert_eq!(SeedDomain::TraitSwerve as u64, 15);
assert_eq!(SeedDomain::TraitExterior as u64, 16);
assert_eq!(SeedDomain::RiverCourse as u64, 17);
}
#[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()
);
}
}