refactor(simulation): thread SeedChain through atlas RNG callers (#952, D-224)
generate_skeleton / compute_district_mix / derive_layout_mode / organic_placements / derive_reservations now take a SeedChain instead of a bare seed: u64. The two ad-hoc wrapping_add pre-mixing hacks are replaced with real domain separation: - district-type allocation draws from chain.derive(SeedDomain::Layer4Quarter, 0) - organic block placement draws from chain.derive(SeedDomain::Block, 0) DistrictSkeleton.seed now records chain.seed(); test call sites pass SeedChain::root(N); stale seed-param docs updated. Re-tuned all_district_types_can_appear: the seed-stream change exposed it as latently fragile — every type has a clamped weight >= 1 (reachable), but 50 weighted draws can miss a low-weight type (Administrative) depending on the sequence; the old seed got lucky. Raised the draw count to 500 so it tests genuine reachability rather than a lucky sequence. Determinism-affecting by design — this is the RNG-using layer D-224 flagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the
|
||||
//! district count computation. Seed-driven noise uses seeded RNG.
|
||||
|
||||
use crate::seed::AtlasRng;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -172,13 +172,14 @@ pub struct DistrictMix {
|
||||
/// `total_districts` is the number of districts to allocate. A good default is
|
||||
/// `max(4, population_tier * 2)`.
|
||||
///
|
||||
/// `seed` is the city-level RNG seed (D-010 determinism).
|
||||
/// `chain` is this city's position in the deterministic seed tree; the district
|
||||
/// allocation draws from its `Layer4Quarter` sub-stream (D-224, D-010).
|
||||
pub fn compute_district_mix(
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
archetype: &PoliticalArchetype,
|
||||
total_districts: u32,
|
||||
seed: u64,
|
||||
chain: SeedChain,
|
||||
) -> DistrictMix {
|
||||
let tier = population_tier(population);
|
||||
let (transit_min, commercial_min, residential_min) = tier_guarantees(tier);
|
||||
@@ -197,7 +198,7 @@ pub fn compute_district_mix(
|
||||
// We avoid f32 by using integer weighted random selection.
|
||||
let weight_sum: u32 = weights.iter().sum();
|
||||
let mut counts: [u32; 9] = [0; 9];
|
||||
let mut lcg = AtlasRng::new(seed.wrapping_add(1));
|
||||
let mut lcg = chain.derive(SeedDomain::Layer4Quarter, 0).atlas_rng();
|
||||
|
||||
for _ in 0..total_districts {
|
||||
let mut pick = lcg.next_u32() % weight_sum;
|
||||
@@ -262,7 +263,7 @@ mod tests {
|
||||
"manufacturing",
|
||||
&PoliticalArchetype::Industrial,
|
||||
8,
|
||||
42,
|
||||
SeedChain::root(42),
|
||||
);
|
||||
// total may exceed requested due to guarantees
|
||||
assert!(mix.total >= 8, "district count should be >= requested");
|
||||
@@ -277,7 +278,7 @@ mod tests {
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
6,
|
||||
7,
|
||||
SeedChain::root(7),
|
||||
);
|
||||
let transit = mix
|
||||
.districts
|
||||
@@ -312,14 +313,14 @@ mod tests {
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
SeedChain::root(99),
|
||||
);
|
||||
let mix2 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
SeedChain::root(99),
|
||||
);
|
||||
assert_eq!(mix1, mix2, "same inputs must produce identical output");
|
||||
}
|
||||
@@ -331,10 +332,15 @@ mod tests {
|
||||
"financial",
|
||||
&PoliticalArchetype::Corporate,
|
||||
8,
|
||||
42,
|
||||
SeedChain::root(42),
|
||||
);
|
||||
let mix_pioneer = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
8,
|
||||
SeedChain::root(42),
|
||||
);
|
||||
let mix_pioneer =
|
||||
compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42);
|
||||
// Should differ in at least one district type count.
|
||||
assert_ne!(
|
||||
mix_corp.districts, mix_pioneer.districts,
|
||||
@@ -344,7 +350,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn military_archetype_has_administrative() {
|
||||
let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10);
|
||||
let mix = compute_district_mix(
|
||||
2_000_000,
|
||||
"military",
|
||||
&PoliticalArchetype::Military,
|
||||
8,
|
||||
SeedChain::root(10),
|
||||
);
|
||||
let admin = mix
|
||||
.districts
|
||||
.iter()
|
||||
@@ -358,13 +370,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_district_types_can_appear() {
|
||||
// With enough districts and a balanced role, every type should appear at least once.
|
||||
// Reachability check: every type has a clamped weight of at least 1
|
||||
// (compute_district_mix `.max(1)`), so all are reachable. A large draw
|
||||
// count makes every one appear regardless of the RNG sequence — a small
|
||||
// count can miss a low-weight type purely by luck, which is sequence- and
|
||||
// therefore seed-dependent (this is not a realistic city size).
|
||||
let requested: u32 = 500;
|
||||
let mix = compute_district_mix(
|
||||
50_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
50,
|
||||
0,
|
||||
requested,
|
||||
SeedChain::root(0),
|
||||
);
|
||||
for dt in &DIST_COLS {
|
||||
let present = mix
|
||||
@@ -373,8 +390,7 @@ mod tests {
|
||||
.any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||||
assert!(
|
||||
present,
|
||||
"DistrictType {:?} never appeared in 50-district mix",
|
||||
dt
|
||||
"DistrictType {dt:?} never appeared in {requested}-district mix"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::seed::AtlasRng;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{
|
||||
BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId,
|
||||
DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
@@ -39,14 +39,14 @@ use crate::simulation::generator::{
|
||||
/// - `economic_role`: Economic role string (one of the 10 canonical values).
|
||||
/// - `district_id`: Content-addressable identifier for this district.
|
||||
/// - `founding_age_years`: Years since founding — controls block irregularity.
|
||||
/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain).
|
||||
/// - `chain`: This district's position in the deterministic seed tree (D-224 SeedChain).
|
||||
pub fn generate_skeleton(
|
||||
context: &CityGenerationContext,
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
district_id: DistrictId,
|
||||
founding_age_years: u32,
|
||||
seed: u64,
|
||||
chain: SeedChain,
|
||||
) -> DistrictSkeleton {
|
||||
// ── 1. SettingType ────────────────────────────────────────────────────
|
||||
// Pass through the surrounding_biome from context — it already encodes
|
||||
@@ -59,7 +59,7 @@ pub fn generate_skeleton(
|
||||
|
||||
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
|
||||
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
|
||||
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed);
|
||||
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, chain);
|
||||
|
||||
// ── 4. District mix → block grid ─────────────────────────────────────
|
||||
// A single district occupies a 4×4 block grid = 16 blocks.
|
||||
@@ -69,11 +69,11 @@ pub fn generate_skeleton(
|
||||
economic_role,
|
||||
&context.political_archetype,
|
||||
total_blocks,
|
||||
seed,
|
||||
chain,
|
||||
);
|
||||
|
||||
// ── 5. Multi-block reservations ───────────────────────────────────────
|
||||
let reservations = derive_reservations(tier, economic_role, seed);
|
||||
let reservations = derive_reservations(tier, economic_role, chain);
|
||||
|
||||
// Build the reservation lookup: block position → reservation id.
|
||||
let mut block_reservation: [[Option<ReservationId>; 4]; 4] = [[None, None, None, None]; 4];
|
||||
@@ -105,7 +105,7 @@ pub fn generate_skeleton(
|
||||
|
||||
DistrictSkeleton {
|
||||
district_id,
|
||||
seed,
|
||||
seed: chain.seed(),
|
||||
district_type: district_type_from_mix(&primary_district_type),
|
||||
context: String::new(), // stub — DistrictContext = String
|
||||
world_tier: context.world_tier.clone(),
|
||||
@@ -223,7 +223,7 @@ fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> C
|
||||
fn derive_layout_mode(
|
||||
archetype: &PoliticalArchetype,
|
||||
irregularity: f32,
|
||||
seed: u64,
|
||||
chain: SeedChain,
|
||||
) -> DistrictLayoutMode {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission
|
||||
@@ -233,7 +233,7 @@ fn derive_layout_mode(
|
||||
|
||||
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
|
||||
// Organic: generate per-block offsets and rotations seeded from district seed.
|
||||
let placements = organic_placements(irregularity, seed);
|
||||
let placements = organic_placements(irregularity, chain);
|
||||
DistrictLayoutMode::Organic { placements }
|
||||
}
|
||||
}
|
||||
@@ -243,9 +243,9 @@ fn derive_layout_mode(
|
||||
///
|
||||
/// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0)
|
||||
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
||||
fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] {
|
||||
fn organic_placements(irregularity: f32, chain: SeedChain) -> [[BlockPlacement; 4]; 4] {
|
||||
let max_offset = (irregularity * 16.0) as i16;
|
||||
let mut lcg = AtlasRng::new(seed.wrapping_add(0x9e37_79b9_7f4a_7c15));
|
||||
let mut lcg = chain.derive(SeedDomain::Block, 0).atlas_rng();
|
||||
|
||||
// Build the 2D array using a flat closure to keep things readable.
|
||||
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
|
||||
@@ -362,7 +362,7 @@ fn density_for_zoning(zoning: &ZoningType) -> u8 {
|
||||
fn derive_reservations(
|
||||
pop_tier: u8,
|
||||
economic_role: &str,
|
||||
_seed: u64,
|
||||
_chain: SeedChain,
|
||||
) -> Vec<MultiBlockReservation> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -429,14 +429,14 @@ mod tests {
|
||||
fn setting_station_passthrough() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
ctx.surrounding_biome = SettingType::Station;
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, SeedChain::root(42));
|
||||
assert!(matches!(sk.setting, SettingType::Station));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_urban_for_city_on_planet() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
assert!(matches!(sk.setting, SettingType::Urban));
|
||||
}
|
||||
|
||||
@@ -444,14 +444,14 @@ mod tests {
|
||||
fn complexity_epicenter_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 10M pop → pop_tier = 1 → Full on Epicenter
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_waypoint_tiny_pop_is_empty() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint);
|
||||
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Empty);
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@ mod tests {
|
||||
fn complexity_backwater_low_pop_is_moderate() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Moderate);
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ mod tests {
|
||||
fn complexity_backwater_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
@@ -475,28 +475,28 @@ mod tests {
|
||||
fn complexity_passage_low_pop_is_minimal() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage);
|
||||
// 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, 42);
|
||||
let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, SeedChain::root(42));
|
||||
assert_eq!(sk.complexity, ComplexityTier::Minimal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_commission_is_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, SeedChain::root(99));
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_pioneer_is_organic() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, SeedChain::root(99));
|
||||
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_grid_is_fully_populated() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
// All 16 blocks must have valid positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
@@ -511,7 +511,7 @@ mod tests {
|
||||
fn no_reservations_for_small_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// pop_tier 0, not transit_hub → no reservations.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42);
|
||||
let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, SeedChain::root(42));
|
||||
assert!(sk.reservations.is_empty());
|
||||
}
|
||||
|
||||
@@ -519,7 +519,7 @@ mod tests {
|
||||
fn park_reservation_for_large_city() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → pop_tier 2 → park reservation.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
|
||||
let has_park = sk
|
||||
.reservations
|
||||
.iter()
|
||||
@@ -531,7 +531,7 @@ mod tests {
|
||||
fn transit_terminal_for_transit_hub_role() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
// pop_tier 0 but transit_hub → terminal reservation.
|
||||
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42);
|
||||
let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, SeedChain::root(42));
|
||||
let has_terminal = sk
|
||||
.reservations
|
||||
.iter()
|
||||
@@ -543,7 +543,7 @@ mod tests {
|
||||
fn reserved_blocks_linked_in_grid() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
// 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1.
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42);
|
||||
let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
|
||||
// All park blocks must reference the park reservation (id=1).
|
||||
for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
|
||||
let b = &sk.blocks[row as usize][col as usize];
|
||||
@@ -557,8 +557,22 @@ mod tests {
|
||||
#[test]
|
||||
fn determinism_same_seed_same_output() {
|
||||
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
|
||||
let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345);
|
||||
let sk1 = generate_skeleton(
|
||||
&ctx,
|
||||
2_000_000,
|
||||
"manufacturing",
|
||||
77,
|
||||
250,
|
||||
SeedChain::root(12345),
|
||||
);
|
||||
let sk2 = generate_skeleton(
|
||||
&ctx,
|
||||
2_000_000,
|
||||
"manufacturing",
|
||||
77,
|
||||
250,
|
||||
SeedChain::root(12345),
|
||||
);
|
||||
// Compare block grid zoning and positions.
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
|
||||
Reference in New Issue
Block a user