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:
@@ -25,6 +25,20 @@ pub(crate) fn splitmix64(mut x: u64) -> u64 {
|
||||
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.
|
||||
@@ -97,6 +111,14 @@ impl SeedChain {
|
||||
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).
|
||||
@@ -215,4 +237,45 @@ mod tests {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user