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>
354 lines
15 KiB
Rust
354 lines
15 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,
|
||
/// 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.25–2 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 ≈8–64 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()
|
||
);
|
||
}
|
||
}
|