Files
settled-reach/server/src/seed.rs
T
jpmschweitzerandClaude Fable 5 204359927a feat(simulation): BuildingExteriorTag grammar + Layer-5 material fill + ops-surface (T-988/T-959/T-1097)
T-988 — BuildingExteriorTag {wall_material, roof_form, facade_rhythm,
setback_tier, color:HsvColor, street_surface}. New trait_exterior.rs:
3-step era-free derivation (visual_bundle filter -> zone-bias weighted
pick -> density->setback), color seed-sampled within the register band.
Resolved at GenerateSkeleton plan time (T-994 precedent), frozen on
BuildingPropertyTag; FillChunk only reads it. Four append-only
integer-discriminant enums (WallMaterial/RoofForm/FacadeRhythm/
StreetSurface), unknown token -> axis Generic + warn. trait_catalog_reader
now parses visual_bundle + the two new tables (OnceLock-cached).
New SeedDomain::TraitExterior (per-axis sub-chains — no cross-field
correlation).

T-959 — FillChunk reads the frozen exterior tag: wall_material/roof_form
onto Wall/Roof shell voxels (FilledChunk.surface_material). Additive,
ShellVoxel untouched, all shell tests pass. (Interstitial-fill-from-setback
split to T-1098 — needs a FillChunk block-metadata + geometry design pass;
nothing consumes FillChunk until Phase 5.)

T-1097 — BlockSkeleton.interstitial_character (OpenSpace|OperationsSurface),
D-233 bulk-driven: bulk-industry blocks' non-roofed remainder tags as built
economic infrastructure, not generic open space.

cargo check --all-targets clean; 1666 lib tests pass; no golden impact
(harnesses top out at CascadeLayer::RoadGraph, unreachable by Layer 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:42:20 +02:00

344 lines
14 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,
}
/// 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);
}
#[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()
);
}
}