//! Generation cascade harness (#952, D-200). //! //! [`run_cascade`] runs the deterministic generation cascade for one body, from //! Layer 0 (load the baked `heightmap.png`, D-202) up to a requested layer, and //! returns a [`CascadeSnapshot`]. The harness is extensible: each new layer is //! added to [`CascadeLayer`] and populated on the snapshot as it lands (#954+). //! The golden-seed regression test (#952) diffs a snapshot against a stored //! fixture. //! //! [`run_cascade_from_heightmap`] is the pure, in-memory core (no file I/O); the //! path-loading [`run_cascade`] is a thin wrapper around it. //! //! **Determinism (D-010 #4):** for a fixed heightmap + [`SeedChain`], the //! snapshot is reproducible. Layers 0–3 are RNG-free — Layers 0–1 are pure //! functions of the heightmap, and Layer 3 is a pure function of //! (attractors, cities). The carried `SeedChain` is reserved for the future //! RNG-using layers (Layer 4+, D-224). use std::path::Path; use serde::{Deserialize, Serialize}; use crate::atlas::attractor_matching::{ match_cities, territorial_status_from_faction, CityPlacement, CityRecord, }; use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork}; use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError}; use crate::atlas::layer1::{self, Layer1Output}; use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile}; use crate::seed::SeedChain; use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus}; /// Cascade layers in execution order (D-200). [`run_cascade`] runs every layer /// up to and including the requested one. Append new layers as they are built; /// the `Ord` derive relies on declaration order, so only ever append. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum CascadeLayer { /// Layer 0 — load the pre-baked 16-bit `heightmap.png` (D-202). Heightmap, /// Layer 1 — empty-world topography: drainage, feature tags, sub-biome (#953). Topography, /// Layer 3 — settlement placement: attractor-matched city positions (#955, D-211). /// Deterministic and RNG-free: a pure function of (attractors, cities) via /// `match_cities`. The carried `SeedChain` is unused here; later stochastic /// layers (Layer 4+) will consume it. Settlement, /// Layer — RegionProfile (~1 km carriers, D-239 §1, T-1023). Pure function of /// `(seed, body_params, terrain_analysis)`. Appended after Settlement so /// declaration order (= Ord) is preserved — never reorder (D-010). RegionProfile, } /// Output of the cascade for one body, up to the requested layer (#952). /// /// Extensible: each layer's artifact is an `Option` that becomes `Some` once /// that layer has run. Layer 0 (`heightmap`) is always present. #[derive(Debug, Clone)] pub struct CascadeSnapshot { pub body_id: String, /// This body's root in the deterministic seed tree (D-224). Unused by the /// RNG-free Layers 0–1; carried for the RNG-using layers (Layer 3+). pub seed: SeedChain, /// Layer 0 — the loaded heightmap. pub heightmap: BodyHeightmap, /// Layer 1 — topography. `Some` once [`CascadeLayer::Topography`] has run. pub layer1: Option, /// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run. pub layer3: Option, /// RegionProfile layer — ~1 km carriers. `Some` once /// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1). pub layer_region: Option, } /// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain /// profiles covering the whole body. Stored in `BodyWorldState.regions`. #[derive(Debug, Clone, Default)] pub struct LayerRegionOutput { pub regions: std::collections::BTreeMap, } /// Layer 3 output (#955, D-211): attractor-matched settlement placements for the /// body. Re-derivable from (Layer-1 attractors + settlement records + seed). #[derive(Debug, Clone, Default)] pub struct Layer3Output { pub placements: Vec, } impl CascadeSnapshot { /// Convert into a [`BodyWorldState`] for the D-203 cache (#968). Moves the /// heightmap raster and the Layer-1 outputs in; `last_accessed` starts at 0 /// (the cache stamps it on read). A snapshot that stopped at Layer 0 yields /// empty river/basin/attractor data. pub fn into_body_world_state(self) -> BodyWorldState { let (river_network, drainage_basins, attractors) = match self.layer1 { Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors), None => (RiverNetwork::default(), Vec::new(), Vec::new()), }; let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default(); let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default(); BodyWorldState { body_id: self.body_id, heightmap: self.heightmap.data, heightmap_width: self.heightmap.width, heightmap_height: self.heightmap.height, river_network, drainage_basins, attractors, placements, quarters: std::collections::BTreeMap::new(), regions, last_accessed: 0, } } } /// Layer 3 — settlement placement (#955, D-211). Pure: matches the body's /// settlements to its Layer-1 attractors via the authored D-195 compatibility /// matrix (the five-phase `match_cities` pipeline). Deterministic — a pure /// function of (attractors, cities); no RNG. /// /// `terrain_costs` is `None` for now (uniform 1.0); wiring sub-biome /// `terrain_modification_cost` (D-234) is a follow-on refinement. /// /// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed` /// drive the per-settlement spatial-character enrichment (#956, D-213/214/215). fn run_layer3( attractors: &[GeographicAttractor], cities: &[CityRecord], territorial_status: &TerritorialStatus, seed: SeedChain, grid_w: u32, grid_h: u32, ) -> Layer3Output { let matrix = CompatibilityMatrix::d195(); let placements = match_cities( cities, attractors, &matrix, None, grid_w, grid_h, territorial_status, seed, ); Layer3Output { placements } } /// Run the cascade from a heightmap already in memory, up to `up_to`. /// /// Pure (no I/O); this is the testable core. `body_seed` is this body's /// [`SeedChain`] position — the caller derives it from the world seed via /// `SeedChain::root(world_seed).derive(SeedDomain::Body, id)`. `cities` are the /// body's settlements (from `atlas_city_names`, supplied by the caller — the /// cascade stays DB-free); empty until Layer 3 (`Settlement`) is requested. /// `dominant_faction` is the body's authored system faction (D-237); it drives /// the `TerritorialStatus` on each province and the per-settlement spatial /// character (#956). `None` → `FrontierUnclaimed`. /// `body_params` supplies the physical parameters needed for the RegionProfile /// layer (T-1023); `None` → region layer skips (empty `regions` map). pub fn run_cascade_from_heightmap( body_seed: SeedChain, heightmap: BodyHeightmap, cities: &[CityRecord], dominant_faction: Option<&str>, body_params: Option<&BodyParams>, up_to: CascadeLayer, ) -> CascadeSnapshot { let mut snapshot = CascadeSnapshot { body_id: heightmap.body_id.clone(), seed: body_seed, heightmap, layer1: None, layer3: None, layer_region: None, }; // TerritorialStatus is derived once per body from the system's dominant // faction (D-212, #956). Uniform across the body's provinces for now. let territorial_status = territorial_status_from_faction(dominant_faction); // Layer 1 — topography (RNG-free; pure function of the heightmap). if up_to >= CascadeLayer::Topography { let mut l1 = layer1::run_layer1(&snapshot.heightmap); // Stamp the province TerritorialStatus (D-212) onto each basin. for basin in &mut l1.drainage_basins { basin.territorial_status = territorial_status.clone(); } snapshot.layer1 = Some(l1); } // Layer 3 — settlement placement (D-211). Requires Layer 1 attractors, which // are present because Settlement > Topography in the layer order. if up_to >= CascadeLayer::Settlement { let attractors: &[GeographicAttractor] = match snapshot.layer1.as_ref() { Some(l1) => &l1.attractors, None => &[], }; // cache seam: run_layer3 is a pure, deterministic function of // (attractors, cities, territorial_status, seed) — wrap a persistent // cache here when we add one (build-time bake or local cache; see #1021). let l3 = run_layer3( attractors, cities, &territorial_status, body_seed, snapshot.heightmap.width, snapshot.heightmap.height, ); snapshot.layer3 = Some(l3); } // RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params + // terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass. // Layer 1 already ran drainage inside run_layer1, but neither the drainage // result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both // here. Pure → determinism preserved, but the drainage re-run is NOT free at // the ~6 000-regions/body working scale (D-203). // PERF/TODO(T-1028): cache TerrainAnalysis on Layer1Output to drop this // redundant drainage pass, and validate the combined cost against the D-239 §10 // ~45 ms/body budget in the T-1031 verification harness. This is now a LIVE // production cost: T-1032 wired the real body_params read, so every analyzed // body runs this path. If body_params is None, the region layer is skipped // (e.g. unit tests without DB). if up_to >= CascadeLayer::RegionProfile { if let Some(params) = body_params { use crate::atlas::drainage; let dr = drainage::analyze( &snapshot.heightmap.data, snapshot.heightmap.width, snapshot.heightmap.height, snapshot.heightmap.sea_level, ); let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr); // ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions; // at full working resolution the budget is ~6 000/body (D-203). const CELLS_PER_REGION: usize = 8; let regions = region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION); snapshot.layer_region = Some(LayerRegionOutput { regions }); } } snapshot } /// Run the cascade for one body, loading its baked `heightmap.png` from `path`. /// /// `default_sea_level` is the fallback used when the PNG lacks a `sea_level` /// tEXt chunk. Delegates to [`run_cascade_from_heightmap`] for the layer work. pub fn run_cascade( body_seed: SeedChain, body_id: &str, heightmap_path: &Path, default_sea_level: f32, cities: &[CityRecord], dominant_faction: Option<&str>, body_params: Option<&BodyParams>, up_to: CascadeLayer, ) -> Result { // Layer 0 — the cascade's input; always loaded. let heightmap = heightmap::load_heightmap_png(heightmap_path, body_id, default_sea_level)?; Ok(run_cascade_from_heightmap( body_seed, heightmap, cities, dominant_faction, body_params, up_to, )) } #[cfg(test)] mod tests { use super::*; use crate::seed::SeedDomain; /// A small synthetic heightmap with a diagonal slope so drainage and feature /// extraction have real structure to work on. fn test_heightmap() -> BodyHeightmap { let (width, height) = (64u32, 32u32); let n = (width * height) as usize; let data = (0..n) .map(|i| { let r = (i / width as usize) as f32 / height as f32; let c = (i % width as usize) as f32 / width as f32; (r * 0.6 + c * 0.4).min(1.0) }) .collect(); BodyHeightmap { body_id: "test_body".into(), width, height, data, sea_level: 0.3, } } fn body_seed() -> SeedChain { SeedChain::root(42).derive(SeedDomain::Body, 1) } #[test] fn heightmap_layer_skips_layer1() { let snap = run_cascade_from_heightmap( body_seed(), test_heightmap(), &[], None, None, // body_params CascadeLayer::Heightmap, ); assert_eq!(snap.body_id, "test_body"); assert!( snap.layer1.is_none(), "Layer 1 must not run when up_to = Heightmap" ); } #[test] fn topography_layer_runs_layer1() { let snap = run_cascade_from_heightmap( body_seed(), test_heightmap(), &[], None, None, // body_params CascadeLayer::Topography, ); let l1 = snap.layer1.expect("Layer 1 should have run"); assert_eq!(l1.body_id, "test_body"); } #[test] fn cascade_is_deterministic() { let extract = |s: CascadeSnapshot| { let l1 = s.layer1.expect("layer1"); l1.attractors .iter() .map(|a| (a.position, a.attractor_type, a.sub_biome)) .collect::>() }; let a = extract(run_cascade_from_heightmap( body_seed(), test_heightmap(), &[], None, None, // body_params CascadeLayer::Topography, )); let b = extract(run_cascade_from_heightmap( body_seed(), test_heightmap(), &[], None, None, // body_params CascadeLayer::Topography, )); assert_eq!( a, b, "same heightmap must yield identical Layer-1 attractors" ); } #[test] fn layers_are_ordered() { // The `up_to >= CascadeLayer::Settlement` guards in the cascade rely on // this declaration order — pin it explicitly so reordering the enum (or // inserting a layer out of sequence) fails here instead of silently // breaking which layers run. assert!(CascadeLayer::Heightmap < CascadeLayer::Topography); assert!(CascadeLayer::Topography < CascadeLayer::Settlement); assert!(CascadeLayer::Settlement < CascadeLayer::RegionProfile); } #[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, &[], None, None, // body_params CascadeLayer::Heightmap, ); assert!(res.is_err(), "missing heightmap must Err, not panic"); } /// RegionProfile layer runs, produces regions, and is deterministic (T-1023). #[test] fn region_profile_layer_runs_and_is_deterministic() { use crate::atlas::region_profile::BodyParams; let params = BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), ..Default::default() }; let run = || { run_cascade_from_heightmap( body_seed(), test_heightmap(), &[], None, Some(¶ms), CascadeLayer::RegionProfile, ) }; let snap1 = run(); let snap2 = run(); let lr1 = snap1.layer_region.expect("layer_region should be Some"); let lr2 = snap2.layer_region.expect("layer_region should be Some"); assert!(!lr1.regions.is_empty(), "regions map must not be empty"); assert_eq!( lr1.regions.len(), lr2.regions.len(), "region count deterministic" ); // BTreeMap iteration order is deterministic — compare all entries. for (pos, p1) in &lr1.regions { let p2 = lr2.regions.get(pos).expect("matching pos in second run"); assert_eq!(p1.river_threshold, p2.river_threshold); assert_eq!(p1.tectonic_class, p2.tectonic_class); assert_eq!(p1.glaciation_grade, p2.glaciation_grade); } } /// Layer 3 — settlement placement runs, places the body's settlements onto /// attractors, and is deterministic (#955, D-211). #[test] fn settlement_layer_places_cities_deterministically() { use crate::atlas::attractor_matching::CityRecord; use crate::simulation::generator::SettlementClass; let cities = vec![ CityRecord { city_id: 1, name: "Capital".into(), settlement_class: SettlementClass::NameLocked, population: 2_000_000, economic_role: "financial".into(), }, CityRecord { city_id: 2, name: "Farm Town".into(), settlement_class: SettlementClass::OrganicGrowth, population: 120_000, economic_role: "agricultural".into(), }, ]; let run = || { run_cascade_from_heightmap( body_seed(), test_heightmap(), &cities, Some("concord_assembly"), None, // body_params CascadeLayer::Settlement, ) }; let snap = run(); let placement_count = { let l3 = snap.layer3.as_ref().expect("Layer 3 should have run"); assert!( !l3.placements.is_empty(), "settlements must be placed when Layer 1 produced attractors" ); l3.placements.len() }; // Determinism: same inputs → identical placements (positions + city_ids). let key = |s: &CascadeSnapshot| { s.layer3 .as_ref() .unwrap() .placements .iter() .map(|p| (p.city_id, p.position, p.attractor_type, p.synthetic)) .collect::>() }; assert_eq!(key(&snap), key(&run()), "placement must be deterministic"); // #956 enrichment propagates: a concord_assembly body is // CommissionControlled, so every placement derives the Commission // archetype + RadialCore arrangement (D-212/214/215). { use crate::simulation::generator::{ ArrangementPattern, PoliticalArchetype, TerritorialStatus, }; let l3 = snap.layer3.as_ref().unwrap(); for p in &l3.placements { assert_eq!(p.political_archetype, PoliticalArchetype::Commission); assert_eq!(p.arrangement_pattern, ArrangementPattern::RadialCore); } // TerritorialStatus is stamped on every province (D-212). let l1 = snap.layer1.as_ref().unwrap(); assert!( l1.drainage_basins .iter() .all(|b| b.territorial_status == TerritorialStatus::CommissionControlled), "every basin inherits the body's TerritorialStatus" ); } // The placements propagate into the hot-cache BodyWorldState. assert_eq!( snap.into_body_world_state().placements.len(), placement_count ); } }