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:
2026-06-03 16:30:57 +02:00
co-authored by Claude Opus 4.8
parent a4727bf73e
commit ab084269f3
13 changed files with 818 additions and 633 deletions
+59 -47
View File
@@ -12,8 +12,8 @@
//! 5. Name fulfillment check: warn if any atlas city was not placed.
//!
//! **Mismatch flagging (D-211):**
//! - score < 0.35 → WARNING
//! - score < 0.15 → ERROR (flagged for manual review; generation continues)
//! - score < 1500 → WARNING
//! - score < 500 → ERROR (flagged for manual review; generation continues)
use tracing::{error, warn};
@@ -47,7 +47,8 @@ pub struct CityPlacement {
pub city_id: u64,
pub position: (u16, u16),
pub attractor_type: AttractorType,
pub score: f32,
/// Integer match score (D-010). See [`cell_score`].
pub score: i64,
pub synthetic: bool,
}
@@ -88,23 +89,27 @@ fn attractor_col(at: &AttractorType) -> usize {
}
}
/// Compute the raw match score between a city and an attractor.
/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost).
/// Compute the raw match score between a city and an attractor (integer, D-010).
///
/// `score = weight(0–100) × strength(0–100) × 100 / cost_pct`, where `cost_pct`
/// is the terrain build cost as a percent of baseline (100 = 1.0×). Higher cost
/// → lower score. All integer — no f32 in any placement decision (#955).
fn cell_score(
city: &CityRecord,
attractor: &GeographicAttractor,
matrix: &CompatibilityMatrix,
terrain_cost: f32,
) -> f32 {
terrain_cost_pct: i32,
) -> i64 {
let row = role_row(&city.economic_role);
let col = attractor_col(&attractor.attractor_type);
let weight = matrix.weights[row][col];
let cost_factor = if terrain_cost > 0.0 {
1.0 / terrain_cost
let weight = matrix.weights[row][col] as i64;
let strength = attractor.strength as i64;
let cost = if terrain_cost_pct > 0 {
terrain_cost_pct as i64
} else {
1.0
100
};
weight * attractor.strength * cost_factor
weight * strength * 100 / cost
}
// ---------------------------------------------------------------------------
@@ -118,7 +123,7 @@ fn cell_score(
/// by using `max_score - score` as cost.
///
/// Returns `assignment[i] = j` for each row i.
fn hungarian(cost: &[Vec<f32>]) -> Vec<usize> {
fn hungarian(cost: &[Vec<i64>]) -> Vec<usize> {
let n = cost.len();
if n == 0 {
return Vec::new();
@@ -128,25 +133,29 @@ fn hungarian(cost: &[Vec<f32>]) -> Vec<usize> {
return vec![usize::MAX; n];
}
// Sentinel "infinity" — far above any real cost (scores ≤ ~10^4, padded
// costs accumulate well under this) yet far below i64::MAX so the potential
// updates can't overflow.
let inf: i64 = 1 << 60;
// Pad to square n×n if m < n (more cities than attractors handled by overflow).
let sz = n.max(m);
let mut c: Vec<Vec<f32>> = vec![vec![0.0; sz]; sz];
let mut c: Vec<Vec<i64>> = vec![vec![0i64; sz]; sz];
for i in 0..n {
for j in 0..m {
c[i][j] = cost[i][j];
}
// Pad extra columns with high cost so overflow cities pick them last.
for item in c[i].iter_mut().take(sz).skip(m) {
*item = f32::MAX / 2.0;
*item = inf;
}
}
// Pad extra rows with 0 cost (dummy workers).
// Already initialized to 0.
// Standard O(n³) Hungarian.
let inf = f32::MAX / 2.0;
let mut u = vec![0.0f32; sz + 1];
let mut v = vec![0.0f32; sz + 1];
let mut u = vec![0i64; sz + 1];
let mut v = vec![0i64; sz + 1];
let mut p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed)
let mut way = vec![0usize; sz + 1];
@@ -243,9 +252,9 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
GeographicAttractor {
position: (row, col),
attractor_type: AttractorType::PlainCenter,
strength: 0.5,
strength: 50,
sub_biome: SubBiomeVariant::TemperateGrassland,
terrain_modification_cost: 1.0,
terrain_modification_cost: 100,
}
}
@@ -256,16 +265,16 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
/// Run the five-phase attractor-matching pipeline (D-211).
///
/// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline).
/// If `None`, all costs default to 1.0.
/// If `None`, all costs default to 100 (1.0× baseline).
pub fn match_cities(
cities: &[CityRecord],
attractors: &[GeographicAttractor],
matrix: &CompatibilityMatrix,
terrain_costs: Option<&[f32]>,
terrain_costs: Option<&[i32]>,
grid_w: u32,
grid_h: u32,
) -> Vec<CityPlacement> {
let default_cost = vec![1.0f32; attractors.len()];
let default_cost = vec![100i32; attractors.len()];
let costs = terrain_costs.unwrap_or(&default_cost);
let mut placements: Vec<CityPlacement> = Vec::with_capacity(cities.len());
@@ -274,7 +283,7 @@ pub fn match_cities(
// -------------------------------------------------------------------------
// Phase 1: Score matrix
// -------------------------------------------------------------------------
let scores: Vec<Vec<f32>> = cities
let scores: Vec<Vec<i64>> = cities
.iter()
.map(|city| {
attractors
@@ -306,7 +315,7 @@ pub fn match_cities(
.iter()
.enumerate()
.filter(|(ai, _)| !used_attractors[*ai])
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
.max_by(|(_, a), (_, b)| a.cmp(b));
if let Some((ai, &score)) = best {
used_attractors[ai] = true;
@@ -340,12 +349,13 @@ pub fn match_cities(
if !tier_bc_indices.is_empty() && !free_attractors.is_empty() {
// Build cost sub-matrix (maximization → minimization via complement).
let scores_ref = &scores;
let max_score: f32 = tier_bc_indices
let max_score: i64 = tier_bc_indices
.iter()
.flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai]))
.fold(0.0f32, f32::max);
.max()
.unwrap_or(0);
let cost: Vec<Vec<f32>> = tier_bc_indices
let cost: Vec<Vec<i64>> = tier_bc_indices
.iter()
.map(|&ci| {
free_attractors
@@ -387,7 +397,7 @@ pub fn match_cities(
continue;
}
let synthetic = synthetic_attractor(&placements, grid_w, grid_h);
let score = cell_score(city, &synthetic, matrix, 1.0);
let score = cell_score(city, &synthetic, matrix, 100);
flag_mismatch(&city.name, score);
placements.push(CityPlacement {
city_id: city.city_id,
@@ -416,18 +426,20 @@ pub fn match_cities(
placements
}
fn flag_mismatch(city_name: &str, score: f32) {
if score < 0.15 {
fn flag_mismatch(city_name: &str, score: i64) {
// Integer score space (#955): a baseline-cost perfect match peaks at ~10000,
// neutral ~2500. < 500 is a severe mismatch; < 1500 below expected quality.
if score < 500 {
error!(
city = %city_name,
score,
"attractor mismatch score < 0.15 — flagged for manual review"
"attractor mismatch score < 500 — flagged for manual review"
);
} else if score < 0.35 {
} else if score < 1500 {
warn!(
city = %city_name,
score,
"attractor mismatch score < 0.35 — below expected quality"
"attractor mismatch score < 1500 — below expected quality"
);
}
}
@@ -481,17 +493,17 @@ mod tests {
fn uniform_matrix() -> CompatibilityMatrix {
CompatibilityMatrix {
weights: [[1.0; 7]; 10],
weights: [[50; 7]; 10],
}
}
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor {
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: i32) -> GeographicAttractor {
GeographicAttractor {
position: (row, col),
attractor_type: at,
strength,
sub_biome: SubBiomeVariant::TemperateGrassland,
terrain_modification_cost: 1.0,
terrain_modification_cost: 100,
}
}
@@ -508,7 +520,7 @@ mod tests {
#[test]
fn single_city_single_attractor() {
let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)];
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 0.8)];
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 80)];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
assert_eq!(placements.len(), 1);
@@ -525,8 +537,8 @@ mod tests {
make_city(2, SettlementClass::PopulationBudget, 200_000),
];
let attractors = vec![
make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best
make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second
make_attractor(5, 5, AttractorType::RiverMouth, 90), // best
make_attractor(10, 10, AttractorType::ValleyFloor, 40), // second
];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
@@ -541,7 +553,7 @@ mod tests {
make_city(1, SettlementClass::NameLocked, 2_000_000),
make_city(2, SettlementClass::PopulationBudget, 60_000),
];
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 1.0)];
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 100)];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
assert_eq!(placements.len(), 2);
@@ -555,8 +567,8 @@ mod tests {
.map(|i| make_city(i, SettlementClass::PopulationBudget, 100_000))
.collect();
let attractors = vec![
make_attractor(10, 10, AttractorType::RiverMouth, 0.9),
make_attractor(20, 20, AttractorType::CoastalAccess, 0.7),
make_attractor(10, 10, AttractorType::RiverMouth, 90),
make_attractor(20, 20, AttractorType::CoastalAccess, 70),
];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
@@ -568,9 +580,9 @@ mod tests {
// 2 cities, 2 attractors. City A scores best on attractor 0, city B best on attractor 1.
let mut matrix = uniform_matrix();
// agricultural (row 2) scores high on ValleyFloor (col 3) = 3.0
matrix.weights[2][3] = 3.0;
matrix.weights[2][3] = 100;
// transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0
matrix.weights[6][2] = 3.0;
matrix.weights[6][2] = 100;
let cities = vec![
CityRecord {
city_id: 1,
@@ -588,8 +600,8 @@ mod tests {
},
];
let attractors = vec![
make_attractor(5, 5, AttractorType::ValleyFloor, 1.0),
make_attractor(10, 10, AttractorType::RiverCrossing, 1.0),
make_attractor(5, 5, AttractorType::ValleyFloor, 100),
make_attractor(10, 10, AttractorType::RiverCrossing, 100),
];
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
assert_eq!(placements.len(), 2);