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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user