Files
settled-reach/server/src/atlas/cascade.rs
T
jpmschweitzerandClaude Opus 4.8 0a8933fbcc test+docs(simulation): address PR #159 review (T-1032)
- Hoshe: body_without_system_row test inserted a NULL system_id, impossible
  under production schema (system_id NOT NULL REFERENCES star_systems). Rewrite
  as body_with_orphan_system_id_* — non-NULL system_id with no matching
  star_systems row (the real case the LEFT JOIN guards) + NOT NULL in the test
  schema + corrected comment.
- Tyre: cascade.rs PERF/TODO comment said the region path 'defers to T-1032';
  T-1032 IS this PR, so production dispatch is now live. Update to track T-1028
  only and note the cost is live in production.

No production logic change. cargo test body_params_reader 7 pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:50:35 +02:00

515 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 03 are RNG-free — Layers 01 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 01; 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<Layer1Output>,
/// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run.
pub layer3: Option<Layer3Output>,
/// RegionProfile layer — ~1 km carriers. `Some` once
/// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1).
pub layer_region: Option<LayerRegionOutput>,
}
/// 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<RegionPos, RegionProfile>,
}
/// 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<CityPlacement>,
}
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<CascadeSnapshot, HeightmapLoadError> {
// 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::<Vec<_>>()
};
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(&params),
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::<Vec<_>>()
};
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
);
}
}