refactor(simulation): harden #952 cascade per QA + architecture review
Addresses the Hoshe (QA) + Tyre (architecture) review of the SeedChain/cascade work: - Golden was pinning an empty river network (128x64 produced 0 river cells). Bumped to 256x128, where GJ1c yields a real network (93 river cells, 19 mouths) — Layer 1's rivers are now actually guarded, not just attractors. - SeedChain::for_body(world_seed, body_id) + fnv1a_64: the single canonical body_id(String) -> u64 path (FNV-1a, the repo convention), so callers can't derive divergent worlds from the same seed via different ad-hoc hashes. The golden now uses it. - Stability guards: seed_domain_discriminants_are_pinned test (CI fails if a SeedDomain tag is renumbered); AttractorType gains #[repr(u8)] + explicit discriminants (it's cast as a sort key in features.rs). - Tests: SeedChain::root(0) non-degenerate; run_cascade error path (missing file -> Err, not panic). - Comments: clarified the id=0 derivations (sibling separation is caller-side via the per-district/quarter chain; #957 threads the index), tightened the all_district_types reachability comment (it pins the seed-0 sequence, not a probabilistic claim), and noted the golden's WORLD_SEED is cosmetic at Layers 0-1 + the x86_64 f32 capture caveat. Deferred with reason: the run_cascade -> CascadeInputs struct refactor (Tyre) is left for #954 — designing Layer-2's context shape now would be later-phase detail, and there's a single caller to migrate then. EntityRng keeps its domainless combine (migrating is stream-changing) — noted in D-224. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -167,4 +167,17 @@ mod tests {
|
|||||||
fn layers_are_ordered() {
|
fn layers_are_ordered() {
|
||||||
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
|
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_cascade_missing_file_is_err() {
|
||||||
|
// The file-loading path returns an error (not a panic) for a bad path.
|
||||||
|
let res = run_cascade(
|
||||||
|
body_seed(),
|
||||||
|
"missing",
|
||||||
|
std::path::Path::new("/nonexistent/sr-test/heightmap.png"),
|
||||||
|
0.3,
|
||||||
|
CascadeLayer::Heightmap,
|
||||||
|
);
|
||||||
|
assert!(res.is_err(), "missing heightmap must Err, not panic");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ pub fn compute_district_mix(
|
|||||||
// We avoid f32 by using integer weighted random selection.
|
// We avoid f32 by using integer weighted random selection.
|
||||||
let weight_sum: u32 = weights.iter().sum();
|
let weight_sum: u32 = weights.iter().sum();
|
||||||
let mut counts: [u32; 9] = [0; 9];
|
let mut counts: [u32; 9] = [0; 9];
|
||||||
|
// id = 0: one mix per district. Sibling districts/quarters are separated by
|
||||||
|
// the distinct `chain` each receives (caller-derived; #957 threads a
|
||||||
|
// per-quarter chain), so a fixed id here does not collide across siblings.
|
||||||
let mut lcg = chain.derive(SeedDomain::Layer4Quarter, 0).atlas_rng();
|
let mut lcg = chain.derive(SeedDomain::Layer4Quarter, 0).atlas_rng();
|
||||||
|
|
||||||
for _ in 0..total_districts {
|
for _ in 0..total_districts {
|
||||||
@@ -371,10 +374,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn all_district_types_can_appear() {
|
fn all_district_types_can_appear() {
|
||||||
// Reachability check: every type has a clamped weight of at least 1
|
// Reachability check: every type has a clamped weight of at least 1
|
||||||
// (compute_district_mix `.max(1)`), so all are reachable. A large draw
|
// (compute_district_mix `.max(1)`), so all are reachable. This asserts the
|
||||||
// count makes every one appear regardless of the RNG sequence — a small
|
// specific deterministic LCG sequence for SeedChain::root(0); 500 draws is
|
||||||
// count can miss a low-weight type purely by luck, which is sequence- and
|
// far beyond what's needed (the rarest type here, Administrative, has
|
||||||
// therefore seed-dependent (this is not a realistic city size).
|
// weight ≈ 2.7%, so P(missed over 500 draws) ≈ 1e-6), so it stays green
|
||||||
|
// across seeds — but it is the seed-0 sequence that's pinned, not a
|
||||||
|
// probabilistic guarantee. Not a realistic city size; a unit test of the
|
||||||
|
// allocator's reachability, not of a real settlement.
|
||||||
let requested: u32 = 500;
|
let requested: u32 = 500;
|
||||||
let mix = compute_district_mix(
|
let mix = compute_district_mix(
|
||||||
50_000_000,
|
50_000_000,
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ fn derive_layout_mode(
|
|||||||
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
||||||
fn organic_placements(irregularity: f32, chain: SeedChain) -> [[BlockPlacement; 4]; 4] {
|
fn organic_placements(irregularity: f32, chain: SeedChain) -> [[BlockPlacement; 4]; 4] {
|
||||||
let max_offset = (irregularity * 16.0) as i16;
|
let max_offset = (irregularity * 16.0) as i16;
|
||||||
|
// id = 0: one placement pass per district. Sibling separation comes from the
|
||||||
|
// distinct `chain` per district/quarter (caller-derived; #957), not the id.
|
||||||
let mut lcg = chain.derive(SeedDomain::Block, 0).atlas_rng();
|
let mut lcg = chain.derive(SeedDomain::Block, 0).atlas_rng();
|
||||||
|
|
||||||
// Build the 2D array using a flat closure to keep things readable.
|
// Build the 2D array using a flat closure to keep things readable.
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ pub(crate) fn splitmix64(mut x: u64) -> u64 {
|
|||||||
x ^ (x >> 31)
|
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).
|
/// Seeded linear congruential generator (D-010).
|
||||||
///
|
///
|
||||||
/// Knuth's LCG parameters — integer-only arithmetic, no `f32`, D-010 compliant.
|
/// Knuth's LCG parameters — integer-only arithmetic, no `f32`, D-010 compliant.
|
||||||
@@ -97,6 +111,14 @@ impl SeedChain {
|
|||||||
Self(world_seed)
|
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)`.
|
/// Derive a child seed for `(domain, id)`.
|
||||||
///
|
///
|
||||||
/// `splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)` (D-224, load-bearing).
|
/// `splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)` (D-224, load-bearing).
|
||||||
@@ -215,4 +237,45 @@ mod tests {
|
|||||||
SeedChain::root(42).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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,22 +371,27 @@ pub enum TerritorialStatus {
|
|||||||
|
|
||||||
/// The type of terrain feature that attracts settlement placement.
|
/// The type of terrain feature that attracts settlement placement.
|
||||||
/// Source: D-195, D-209
|
/// Source: D-195, D-209
|
||||||
|
// `#[repr(u8)]` with explicit discriminants: `AttractorType` is cast `as u8` as
|
||||||
|
// the primary sort key for the attractor list (`features.rs`), so the layout is
|
||||||
|
// load-bearing — reordering would change attractor ordering and the cascade
|
||||||
|
// golden. Append new variants; never renumber or reorder existing ones.
|
||||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[repr(u8)]
|
||||||
pub enum AttractorType {
|
pub enum AttractorType {
|
||||||
/// Where a river meets sea level or coastline. Historically high-value.
|
/// Where a river meets sea level or coastline. Historically high-value.
|
||||||
RiverMouth,
|
RiverMouth = 0,
|
||||||
/// Proximity to coast without a river mouth. Port access.
|
/// Proximity to coast without a river mouth. Port access.
|
||||||
CoastalAccess,
|
CoastalAccess = 1,
|
||||||
/// Where a river crosses a topographic saddle or confluence point.
|
/// Where a river crosses a topographic saddle or confluence point.
|
||||||
RiverCrossing,
|
RiverCrossing = 2,
|
||||||
/// Local elevation minimum; flat, arable, sheltered.
|
/// Local elevation minimum; flat, arable, sheltered.
|
||||||
ValleyFloor,
|
ValleyFloor = 3,
|
||||||
/// Saddle point between adjacent drainage basins; controls a mountain pass.
|
/// Saddle point between adjacent drainage basins; controls a mountain pass.
|
||||||
PassEntrance,
|
PassEntrance = 4,
|
||||||
/// Adjacent to a lake polygon.
|
/// Adjacent to a lake polygon.
|
||||||
LakeShore,
|
LakeShore = 5,
|
||||||
/// Flat terrain away from all other attractors; fallback for plains settlements.
|
/// Flat terrain away from all other attractors; fallback for plains settlements.
|
||||||
PlainCenter,
|
PlainCenter = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fine-grained terrain classification carried by each `GeographicAttractor`.
|
/// Fine-grained terrain classification carried by each `GeographicAttractor`.
|
||||||
|
|||||||
@@ -14,22 +14,27 @@
|
|||||||
//!
|
//!
|
||||||
//! Regenerate after an intended change:
|
//! Regenerate after an intended change:
|
||||||
//! UPDATE_GOLDEN=1 cargo test --test cascade_golden
|
//! UPDATE_GOLDEN=1 cargo test --test cascade_golden
|
||||||
|
//!
|
||||||
|
//! Golden captured on x86_64. The downsample and sub-biome cost use f32, so a
|
||||||
|
//! different architecture could in principle round differently — regenerate
|
||||||
|
//! per-arch if CI ever moves off x86_64.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use settled_reach_server::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
|
use settled_reach_server::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
|
||||||
use settled_reach_server::atlas::heightmap::load_heightmap_png;
|
use settled_reach_server::atlas::heightmap::load_heightmap_png;
|
||||||
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
use settled_reach_server::seed::SeedChain;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
/// Source heightmap — a real committed body, relative to the server manifest dir.
|
/// Source heightmap — a real committed body, relative to the server manifest dir.
|
||||||
const SOURCE_HEIGHTMAP: &str = "../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png";
|
const SOURCE_HEIGHTMAP: &str = "../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png";
|
||||||
/// Downsample target: small enough for a compact golden, large enough for real
|
/// Downsample target: large enough that GJ1c's drainage produces a real river
|
||||||
/// drainage/feature structure.
|
/// network (not just attractors), small enough for a compact golden.
|
||||||
const DOWNSAMPLE: (u32, u32) = (128, 64);
|
const DOWNSAMPLE: (u32, u32) = (256, 128);
|
||||||
/// World seed for the run. Layers 0–1 are RNG-free; this is carried for the
|
/// World seed for the run. Cosmetic here — Layers 0–1 are RNG-free, so changing
|
||||||
/// SeedChain contract (D-224).
|
/// it does not change the golden; it is carried only to exercise the SeedChain
|
||||||
|
/// contract end-to-end (D-224). Seed-sensitivity gets pinned once Layer 3+ lands.
|
||||||
const WORLD_SEED: u64 = 42;
|
const WORLD_SEED: u64 = 42;
|
||||||
const GOLDEN: &str = "tests/golden/cascade_layer1.json";
|
const GOLDEN: &str = "tests/golden/cascade_layer1.json";
|
||||||
|
|
||||||
@@ -49,7 +54,7 @@ fn cascade_layer0_to_1_matches_golden() {
|
|||||||
|
|
||||||
// ── Layer 1 — downsample, then run the cascade to topography. ───────────
|
// ── Layer 1 — downsample, then run the cascade to topography. ───────────
|
||||||
let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1);
|
let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1);
|
||||||
let body_seed = SeedChain::root(WORLD_SEED).derive(SeedDomain::Body, 1);
|
let body_seed = SeedChain::for_body(WORLD_SEED, "GJ1c");
|
||||||
let snapshot = run_cascade_from_heightmap(body_seed, small, CascadeLayer::Topography);
|
let snapshot = run_cascade_from_heightmap(body_seed, small, CascadeLayer::Topography);
|
||||||
let layer1 = snapshot.layer1.expect("Layer 1 ran");
|
let layer1 = snapshot.layer1.expect("Layer 1 ran");
|
||||||
|
|
||||||
|
|||||||
+5826
-4004
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user