feat(simulation): integer-deterministic Layer-3 settlement placement (#955)
Wire the existing attractor-matching engine (#919/#925) into the generation cascade as Layer 3, and make the whole placement-scoring path integer-deterministic. Layer 3 (D-211): - CascadeLayer::Settlement + Layer3Output (placements) on the snapshot; BodyWorldState gains a `placements` field (the D-203 hot cache). - run_layer3 runs the five-phase match_cities against Layer-1 attractors via the authored D-195 compatibility matrix; pure function of (attractors, cities) — no RNG. cities are passed in by the caller so the cascade stays DB-free and testable. A `// cache seam` marks where a persistent cache wraps it later (#1021). - gen_queue passes &[] for now (Topography needs no cities); the runtime settlement read (gen_queue/layer_proxy) is the #955 follow-on. Integer determinism (D-010 / D-227 — D-195 amended): - Wiring match_cities into the deterministic cascade made its f32 scoring a live cross-platform divergence risk (a near-tie comparison or the Hungarian's f32 reductions can round differently per platform → a different world from the same seed). Converted the entire path to integers: CompatibilityMatrix is a 0-100 affinity table; attractor strength is 0-100 and terrain cost is a percent (100 = baseline), quantized once at the Layer-1 feature boundary; cell_score, the Hungarian, and CityPlacement.score are i64. No f32 in any placement or ranking decision. - Layer-1 golden fixture rebaked: confirmed selection/positions are unchanged (same 256 attractors, 93 river cells) — only the strength/cost representation changed. Tests: lib green (1292); new settlement_layer_places_cities_deterministically covers placement + determinism + propagation into BodyWorldState. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+130
-5
@@ -19,10 +19,12 @@ use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::attractor_matching::{match_cities, CityPlacement, CityRecord};
|
||||
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork};
|
||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||
use crate::atlas::layer1::{self, Layer1Output};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
|
||||
|
||||
/// 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;
|
||||
@@ -33,6 +35,9 @@ pub enum CascadeLayer {
|
||||
Heightmap,
|
||||
/// Layer 1 — empty-world topography: drainage, feature tags, sub-biome (#953).
|
||||
Topography,
|
||||
/// Layer 3 — settlement placement: attractor-matched city positions (#955, D-211).
|
||||
/// First RNG-using layer (uses the carried `SeedChain`).
|
||||
Settlement,
|
||||
}
|
||||
|
||||
/// Output of the cascade for one body, up to the requested layer (#952).
|
||||
@@ -49,6 +54,15 @@ pub struct CascadeSnapshot {
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -61,6 +75,7 @@ impl CascadeSnapshot {
|
||||
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();
|
||||
BodyWorldState {
|
||||
body_id: self.body_id,
|
||||
heightmap: self.heightmap.data,
|
||||
@@ -69,20 +84,42 @@ impl CascadeSnapshot {
|
||||
river_network,
|
||||
drainage_basins,
|
||||
attractors,
|
||||
placements,
|
||||
districts: std::collections::BTreeMap::new(),
|
||||
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.
|
||||
fn run_layer3(
|
||||
attractors: &[GeographicAttractor],
|
||||
cities: &[CityRecord],
|
||||
grid_w: u32,
|
||||
grid_h: u32,
|
||||
) -> Layer3Output {
|
||||
let matrix = CompatibilityMatrix::d195();
|
||||
let placements = match_cities(cities, attractors, &matrix, None, grid_w, grid_h);
|
||||
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)`.
|
||||
/// `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.
|
||||
pub fn run_cascade_from_heightmap(
|
||||
body_seed: SeedChain,
|
||||
heightmap: BodyHeightmap,
|
||||
cities: &[CityRecord],
|
||||
up_to: CascadeLayer,
|
||||
) -> CascadeSnapshot {
|
||||
let mut snapshot = CascadeSnapshot {
|
||||
@@ -90,6 +127,7 @@ pub fn run_cascade_from_heightmap(
|
||||
seed: body_seed,
|
||||
heightmap,
|
||||
layer1: None,
|
||||
layer3: None,
|
||||
};
|
||||
|
||||
// Layer 1 — topography (RNG-free; pure function of the heightmap).
|
||||
@@ -97,6 +135,25 @@ pub fn run_cascade_from_heightmap(
|
||||
snapshot.layer1 = Some(layer1::run_layer1(&snapshot.heightmap));
|
||||
}
|
||||
|
||||
// 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) — wrap a persistent cache here when we add one
|
||||
// (build-time bake or local cache; see #1021).
|
||||
let l3 = run_layer3(
|
||||
attractors,
|
||||
cities,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
);
|
||||
snapshot.layer3 = Some(l3);
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
@@ -109,11 +166,14 @@ pub fn run_cascade(
|
||||
body_id: &str,
|
||||
heightmap_path: &Path,
|
||||
default_sea_level: f32,
|
||||
cities: &[CityRecord],
|
||||
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, up_to))
|
||||
Ok(run_cascade_from_heightmap(
|
||||
body_seed, heightmap, cities, up_to,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -149,7 +209,7 @@ mod tests {
|
||||
#[test]
|
||||
fn heightmap_layer_skips_layer1() {
|
||||
let snap =
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), CascadeLayer::Heightmap);
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), &[], CascadeLayer::Heightmap);
|
||||
assert_eq!(snap.body_id, "test_body");
|
||||
assert!(
|
||||
snap.layer1.is_none(),
|
||||
@@ -159,8 +219,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn topography_layer_runs_layer1() {
|
||||
let snap =
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), CascadeLayer::Topography);
|
||||
let snap = run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
);
|
||||
let l1 = snap.layer1.expect("Layer 1 should have run");
|
||||
assert_eq!(l1.body_id, "test_body");
|
||||
}
|
||||
@@ -177,11 +241,13 @@ mod tests {
|
||||
let a = extract(run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
let b = extract(run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -203,8 +269,67 @@ mod tests {
|
||||
"missing",
|
||||
std::path::Path::new("/nonexistent/sr-test/heightmap.png"),
|
||||
0.3,
|
||||
&[],
|
||||
CascadeLayer::Heightmap,
|
||||
);
|
||||
assert!(res.is_err(), "missing heightmap must Err, not panic");
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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");
|
||||
// The placements propagate into the hot-cache BodyWorldState.
|
||||
assert_eq!(
|
||||
snap.into_body_world_state().placements.len(),
|
||||
placement_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user