//! 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::district_profile::{self, BodyParams, DistrictPos, DistrictProfile}; use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError}; use crate::atlas::layer1::{self, Layer1Output}; use crate::atlas::road_graph::{self, RoadGraph}; use crate::atlas::scale; 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 — DistrictProfile (~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). DistrictProfile, /// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure function /// of `(Layer-3 placements, Layer-1 terrain)`; RNG-free. Semantically "Layer /// 2", but it depends only on Settlement + Topography, so it is **appended /// last** to honour the append-only `Ord` rule (it neither needs nor blocks /// the DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly). RoadGraph, } /// 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, /// DistrictProfile layer — ~1 km carriers. `Some` once /// [`CascadeLayer::DistrictProfile`] has run (T-1023, D-239 §1). pub layer_district: Option, /// Layer 2 — inter-settlement road/rail graph. `Some` once /// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038). pub road_graph: Option, /// **Transient** — the `TerrainAnalysis` produced by the Layer-1 drainage /// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once /// both `DistrictProfile` and `RoadGraph` have consumed it. /// /// NOT persisted on `BodyWorldState` or the LRU cache (D-203 / T-1048 size /// concern — `TerrainAnalysis` is ~2 MB of full-grid Vecs). Callers that /// need it after the cascade must re-derive from `run_layer1`. pub terrain_analysis: Option, } /// DistrictProfile layer output (T-1023, D-239 §1): per-district (~1 km) terrain /// profiles covering the whole body. Stored in `BodyWorldState.districts`. #[derive(Debug, Clone, Default)] pub struct LayerDistrictOutput { pub districts: 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. /// /// `terrain_analysis` (transient, ~2 MB) is **dropped here** — it is not /// persisted on `BodyWorldState` per the D-203/T-1048 size budget. 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 districts = self .layer_district .map(|lr| lr.districts) .unwrap_or_default(); let road_graph = self.road_graph.unwrap_or_default(); // terrain_analysis (transient) is intentionally dropped here. let _ = self.terrain_analysis; 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, road_graph, quarters: std::collections::BTreeMap::new(), districts, 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 DistrictProfile /// layer (T-1023); `None` → district layer skips (empty `districts` 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_district: None, road_graph: None, terrain_analysis: 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). // run_layer1 now returns (Layer1Output, TerrainAnalysis); the TerrainAnalysis // is carried transiently on the snapshot so DistrictProfile + RoadGraph can // reuse it without the former ~45 ms redundant drainage re-run (T-1044). if up_to >= CascadeLayer::Topography { let (mut l1, ta) = 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); snapshot.terrain_analysis = Some(ta); } // 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); } // DistrictProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need // a TerrainAnalysis. The Layer-1 pass already produced one and stored it // transiently on `snapshot.terrain_analysis` — reuse it here instead of // re-running the full ~45 ms drainage pass (T-1044 eliminates the former // PERF/TODO re-run). The analysis is valid as long as the heightmap has not // changed, which is guaranteed by cascade invariant (pure, deterministic). // // The terrain_analysis is consumed after DistrictProfile + RoadGraph are // built; it is dropped (not stored on BodyWorldState) per D-203/T-1048. if up_to >= CascadeLayer::DistrictProfile && (body_params.is_some() || up_to >= CascadeLayer::RoadGraph) { // Borrow the transient TerrainAnalysis produced by Layer 1. If Layer 1 // did not run (e.g. up_to < Topography — impossible given the guard // above, since DistrictProfile > Topography in CascadeLayer Ord) this // is None and both consumers below will short-circuit gracefully. if let Some(ta) = snapshot.terrain_analysis.as_ref() { // DistrictProfile layer — pure derivation from body params + terrain. if let Some(params) = body_params { // Canonical cells-per-district for the working grid (T-1039): // shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs // converts CityPlacement pixel coords with the same constant. // body_id is required for the D-243 §4 climate edge-fuzz warp // domain separation — derive_all_districts builds the region // cache internally. // // district_basin_dirs from Layer1Output threads the true D8 // thalweg direction into each DistrictProfile.basin_direction // (T-1047). Pass the map through derive_all_districts. let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs); let districts = district_profile::derive_all_districts( body_seed, params, ta, scale::HEIGHTMAP_CELLS_PER_DISTRICT, &snapshot.body_id, basin_dirs, ); snapshot.layer_district = Some(LayerDistrictOutput { districts }); } // Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure // function of (Layer-3 placements, Layer-1 terrain). The named-route // pool is empty for now (atlas_roads/atlas_railroads carry no rows // post-D-223), so the named-route identity join is a designed-for // no-op. if up_to >= CascadeLayer::RoadGraph { let placements = snapshot .layer3 .as_ref() .map(|l3| l3.placements.as_slice()) .unwrap_or(&[]); let river_cells = snapshot .layer1 .as_ref() .map(|l1| l1.river_network.river_cells.as_slice()) .unwrap_or(&[]); let graph = road_graph::build_road_graph( placements, ta, river_cells, snapshot.heightmap.width, snapshot.heightmap.height, &territorial_status, &[], ); snapshot.road_graph = Some(graph); } } // Drop the transient TerrainAnalysis — both consumers are done. // Not stored on BodyWorldState (D-203/T-1048 size budget: ~2 MB per body). snapshot.terrain_analysis = None; } 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::DistrictProfile); assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph); } #[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"); } /// DistrictProfile layer runs, produces districts, and is deterministic (T-1023). #[test] fn district_profile_layer_runs_and_is_deterministic() { use crate::atlas::district_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::DistrictProfile, ) }; let snap1 = run(); let snap2 = run(); let lr1 = snap1.layer_district.expect("layer_district should be Some"); let lr2 = snap2.layer_district.expect("layer_district should be Some"); assert!(!lr1.districts.is_empty(), "districts map must not be empty"); assert_eq!( lr1.districts.len(), lr2.districts.len(), "district count deterministic" ); // BTreeMap iteration order is deterministic — compare all entries. for (pos, p1) in &lr1.districts { let p2 = lr2.districts.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); // basin_direction threads run_layer1 -> district_basin_dirs -> here; // guard the full chain's determinism (T-1047). assert_eq!(p1.basin_direction, p2.basin_direction); } } /// 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 ); } /// Layer 2 — road graph runs through the full cascade, connects the placed /// cities, is deterministic, and propagates into BodyWorldState (T-1038). #[test] fn road_graph_layer_connects_cities_deterministically() { use crate::atlas::attractor_matching::CityRecord; use crate::atlas::road_graph::RoadNodeKind; use crate::simulation::generator::SettlementClass; // Several inland cities (the slope heightmap is land away from the low // corner) so the MST has real edges to route. let cities: Vec = [ (1u64, "A", 800_000i64), (2, "B", 400_000), (3, "C", 200_000), (4, "D", 150_000), ] .iter() .map(|(id, name, pop)| CityRecord { city_id: *id, name: (*name).into(), settlement_class: SettlementClass::PopulationBudget, population: *pop, economic_role: "manufacturing".into(), }) .collect(); let run = || { run_cascade_from_heightmap( body_seed(), test_heightmap(), &cities, Some("independent"), None, // body_params — road graph needs none CascadeLayer::RoadGraph, ) }; let snap = run(); let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran"); let settlements = graph .nodes .iter() .filter(|n| n.kind == RoadNodeKind::Settlement) .count(); assert_eq!(settlements, cities.len(), "one road node per placed city"); assert!( !graph.edges.is_empty(), "placed cities on shared land must be connected" ); // Every edge endpoint is a settlement node and the path snaps to it. for e in &graph.edges { assert!(e.from < e.to); assert_eq!(graph.nodes[e.from].position, *e.path.first().unwrap()); assert_eq!(graph.nodes[e.to].position, *e.path.last().unwrap()); } // Determinism: identical inputs → identical graph. let key = |s: &CascadeSnapshot| { let g = s.road_graph.as_ref().unwrap(); ( g.nodes .iter() .map(|n| (n.city_id, n.position, n.degree)) .collect::>(), g.edges .iter() .map(|e| (e.from, e.to, e.length_cells, e.maintenance)) .collect::>(), ) }; assert_eq!(key(&snap), key(&run()), "road graph must be deterministic"); // Propagates into the hot-cache BodyWorldState. let edge_count = graph.edges.len(); assert_eq!( snap.into_body_world_state().road_graph.edges.len(), edge_count ); } }