//! Attractor-matching five-phase pipeline for settlement placement (D-211). //! //! Given a body's `Vec` and a list of cities, assigns //! each city to the terrain feature that best fits its economic role and //! population tier. //! //! **Phases (D-211):** //! 1. Score matrix build: `CompatibilityMatrix[economic_role][attractor_type] × strength × (1/cost)` //! 2. Tier A greedy: `NameLocked` or pop ≥ 1,000,000 — assigned first, highest-score greedy. //! 3. Hungarian (Tier B+C): pop 50,000–999,999 cities — optimal global assignment. //! 4. Synthetic overflow: any remaining city gets a synthetic `PlainCenter` attractor. //! 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) use tracing::{error, warn}; use crate::simulation::generator::{ AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, }; // --------------------------------------------------------------------------- // Input types // --------------------------------------------------------------------------- /// One city record from atlas_city_names, projected for matching. #[derive(Debug, Clone)] pub struct CityRecord { pub city_id: u64, pub name: String, pub settlement_class: SettlementClass, pub population: i64, /// One of: manufacturing, financial, agricultural, extraction, /// service_mixed, institutional, transit_hub, research, military, residential. pub economic_role: String, } // --------------------------------------------------------------------------- // Output // --------------------------------------------------------------------------- /// Result of matching one city to one attractor (real or synthetic). #[derive(Debug, Clone)] pub struct CityPlacement { pub city_id: u64, pub position: (u16, u16), pub attractor_type: AttractorType, pub score: f32, pub synthetic: bool, } // --------------------------------------------------------------------------- // Score matrix helpers // --------------------------------------------------------------------------- /// Row index in CompatibilityMatrix for an economic_role string. /// Order from D-195: manufacturing(0), financial(1), agricultural(2), extraction(3), /// service_mixed(4), institutional(5), transit_hub(6), research(7), military(8), residential(9). fn role_row(economic_role: &str) -> usize { match economic_role { "manufacturing" => 0, "financial" => 1, "agricultural" => 2, "extraction" => 3, "service_mixed" => 4, "institutional" => 5, "transit_hub" => 6, "research" => 7, "military" => 8, "residential" | _ => 9, } } /// Column index in CompatibilityMatrix for an AttractorType. /// Order from D-195: RiverMouth(0), CoastalAccess(1), RiverCrossing(2), ValleyFloor(3), /// PassEntrance(4), LakeShore(5), PlainCenter(6). fn attractor_col(at: &AttractorType) -> usize { match at { AttractorType::RiverMouth => 0, AttractorType::CoastalAccess => 1, AttractorType::RiverCrossing => 2, AttractorType::ValleyFloor => 3, AttractorType::PassEntrance => 4, AttractorType::LakeShore => 5, AttractorType::PlainCenter => 6, } } /// Compute the raw match score between a city and an attractor. /// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost). fn cell_score( city: &CityRecord, attractor: &GeographicAttractor, matrix: &CompatibilityMatrix, terrain_cost: f32, ) -> f32 { 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 } else { 1.0 }; weight * attractor.strength * cost_factor } // --------------------------------------------------------------------------- // Phase 3: Hungarian algorithm (minimization) // --------------------------------------------------------------------------- /// O(n³) Hungarian algorithm for assignment problem. /// /// Input: `cost[i][j]` — cost of assigning task j to worker i. /// Lower cost = better fit. Converts the maximization problem to minimization /// by using `max_score - score` as cost. /// /// Returns `assignment[i] = j` for each row i. fn hungarian(cost: &[Vec]) -> Vec { let n = cost.len(); if n == 0 { return Vec::new(); } let m = cost[0].len(); if m == 0 { return vec![usize::MAX; n]; } // 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![vec![0.0; 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 j in m..sz { c[i][j] = f32::MAX / 2.0; } } // 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 p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed) let mut way = vec![0usize; sz + 1]; for i in 1..=sz { p[0] = i; let mut j0 = 0usize; let mut minv = vec![inf; sz + 1]; let mut used = vec![false; sz + 1]; loop { used[j0] = true; let i0 = p[j0]; let mut delta = inf; let mut j1 = 0usize; for j in 1..=sz { if used[j] { continue; } let cur = c[i0 - 1][j - 1] - u[i0] - v[j]; if cur < minv[j] { minv[j] = cur; way[j] = j0; } if minv[j] < delta { delta = minv[j]; j1 = j; } } for j in 0..=sz { if used[j] { u[p[j]] += delta; v[j] -= delta; } else { minv[j] -= delta; } } j0 = j1; if p[j0] == 0 { break; } } loop { let j1 = way[j0]; p[j0] = p[j1]; j0 = j1; if j0 == 0 { break; } } } // Extract assignment: for each row i (1-indexed), find column j where p[j] == i. let mut result = vec![usize::MAX; n]; for j in 1..=sz { if p[j] > 0 && p[j] <= n { let col = j - 1; if col < m { result[p[j] - 1] = col; } } } result } // --------------------------------------------------------------------------- // Synthetic PlainCenter placement // --------------------------------------------------------------------------- /// Minimum pixel separation between synthetic attractor positions. const MIN_SPACING: u16 = 15; fn synthetic_attractor( placed: &[CityPlacement], grid_w: u32, grid_h: u32, ) -> GeographicAttractor { // Place at grid center as default, then walk until spacing is satisfied. let mut row = (grid_h / 2) as u16; let mut col = (grid_w / 4) as u16; // Simple search: try positions in a grid until spacing is met. 'outer: for dr in 0..(grid_h as u16 / MIN_SPACING) { for dc in 0..(grid_w as u16 / MIN_SPACING) { let r = (dr * MIN_SPACING).min(grid_h as u16 - 1); let c = (dc * MIN_SPACING).min(grid_w as u16 - 1); let ok = placed.iter().all(|p| { let dr2 = (p.position.0 as i32 - r as i32).abs() as u16; let dc2 = (p.position.1 as i32 - c as i32).abs() as u16; dr2.max(dc2) >= MIN_SPACING }); if ok { row = r; col = c; break 'outer; } } } GeographicAttractor { position: (row, col), attractor_type: AttractorType::PlainCenter, strength: 0.5, } } // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- /// 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. pub fn match_cities( cities: &[CityRecord], attractors: &[GeographicAttractor], matrix: &CompatibilityMatrix, terrain_costs: Option<&[f32]>, grid_w: u32, grid_h: u32, ) -> Vec { let default_cost = vec![1.0f32; attractors.len()]; let costs = terrain_costs.unwrap_or(&default_cost); let mut placements: Vec = Vec::with_capacity(cities.len()); let mut used_attractors: Vec = vec![false; attractors.len()]; // ------------------------------------------------------------------------- // Phase 1: Score matrix // ------------------------------------------------------------------------- let scores: Vec> = cities .iter() .map(|city| { attractors .iter() .zip(costs.iter()) .map(|(att, &cost)| cell_score(city, att, matrix, cost)) .collect() }) .collect(); // ------------------------------------------------------------------------- // Phase 2: Tier A greedy — NameLocked or pop ≥ 1_000_000 // ------------------------------------------------------------------------- let tier_a_indices: Vec = cities .iter() .enumerate() .filter(|(_, c)| { c.settlement_class == SettlementClass::NameLocked || c.population >= 1_000_000 }) .map(|(i, _)| i) .collect(); for &ci in &tier_a_indices { if attractors.is_empty() { break; } // Highest-scoring unused attractor. let best = scores[ci] .iter() .enumerate() .filter(|(ai, _)| !used_attractors[*ai]) .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); if let Some((ai, &score)) = best { used_attractors[ai] = true; flag_mismatch(&cities[ci].name, score); placements.push(CityPlacement { city_id: cities[ci].city_id, position: attractors[ai].position, attractor_type: attractors[ai].attractor_type.clone(), score, synthetic: false, }); } } // ------------------------------------------------------------------------- // Phase 3: Hungarian — Tier B+C (50,000–999,999) // ------------------------------------------------------------------------- let tier_bc_indices: Vec = cities .iter() .enumerate() .filter(|(i, c)| { !tier_a_indices.contains(i) && c.population >= 50_000 && c.population < 1_000_000 }) .map(|(i, _)| i) .collect(); let free_attractors: Vec = (0..attractors.len()) .filter(|&ai| !used_attractors[ai]) .collect(); 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 .iter() .flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai])) .fold(0.0f32, f32::max); let cost: Vec> = tier_bc_indices .iter() .map(|&ci| { free_attractors .iter() .map(|&ai| max_score - scores_ref[ci][ai]) .collect() }) .collect(); let assignment = hungarian(&cost); for (local_i, &ci) in tier_bc_indices.iter().enumerate() { let local_j = assignment[local_i]; if local_j == usize::MAX || local_j >= free_attractors.len() { continue; // overflow — handled in phase 4 } let ai = free_attractors[local_j]; let score = scores[ci][ai]; used_attractors[ai] = true; flag_mismatch(&cities[ci].name, score); placements.push(CityPlacement { city_id: cities[ci].city_id, position: attractors[ai].position, attractor_type: attractors[ai].attractor_type.clone(), score, synthetic: false, }); } } // ------------------------------------------------------------------------- // Phase 4: Synthetic overflow — all remaining cities // ------------------------------------------------------------------------- let placed_ids: std::collections::HashSet = placements.iter().map(|p| p.city_id).collect(); for city in cities { if placed_ids.contains(&city.city_id) { continue; } let synthetic = synthetic_attractor(&placements, grid_w, grid_h); let score = cell_score(city, &synthetic, matrix, 1.0); flag_mismatch(&city.name, score); placements.push(CityPlacement { city_id: city.city_id, position: synthetic.position, attractor_type: AttractorType::PlainCenter, score, synthetic: true, }); } // ------------------------------------------------------------------------- // Phase 5: Name fulfillment check // ------------------------------------------------------------------------- let placed_ids: std::collections::HashSet = placements.iter().map(|p| p.city_id).collect(); for city in cities { if !placed_ids.contains(&city.city_id) { warn!( city = %city.name, city_id = city.city_id, "atlas city was not placed — missing from pipeline output" ); } } placements } fn flag_mismatch(city_name: &str, score: f32) { if score < 0.15 { error!( city = %city_name, score, "attractor mismatch score < 0.15 — flagged for manual review" ); } else if score < 0.35 { warn!( city = %city_name, score, "attractor mismatch score < 0.35 — below expected quality" ); } } // --------------------------------------------------------------------------- // FoundingOrientation derivation from matched attractor (D-211, D-213) // --------------------------------------------------------------------------- use crate::simulation::generator::FoundingOrientation; use crate::simulation::generator::TerritorialStatus; /// Derive `FoundingOrientation` from the attractor type that anchored the city (D-211, D-213). /// /// `river_bearing` and `coastal_facing` are compass degrees 0–359. /// Pass 0 as default when the terrain doesn't dictate a specific bearing. pub fn founding_orientation( attractor_type: &AttractorType, territorial_status: &TerritorialStatus, river_bearing: u16, coastal_facing: u16, ) -> FoundingOrientation { match attractor_type { AttractorType::RiverMouth | AttractorType::CoastalAccess => { FoundingOrientation::Coastal { facing_degrees: coastal_facing } } AttractorType::RiverCrossing => { FoundingOrientation::RiverAligned { bearing_degrees: river_bearing } } AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing, AttractorType::PlainCenter => { if matches!(territorial_status, TerritorialStatus::CommissionControlled) { FoundingOrientation::Cardinal } else { FoundingOrientation::Free { bearing_degrees: 0 } } } AttractorType::PassEntrance | AttractorType::LakeShore => { FoundingOrientation::TerrainFollowing } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor}; fn uniform_matrix() -> CompatibilityMatrix { CompatibilityMatrix { weights: [[1.0; 7]; 10] } } fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor { GeographicAttractor { position: (row, col), attractor_type: at, strength } } fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord { CityRecord { city_id: id, name: format!("City{id}"), settlement_class: class, population: pop, economic_role: "manufacturing".to_string(), } } #[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 matrix = uniform_matrix(); let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); assert_eq!(placements.len(), 1); assert_eq!(placements[0].city_id, 1); assert_eq!(placements[0].position, (10, 20)); assert!(!placements[0].synthetic); } #[test] fn tier_a_gets_priority() { // NameLocked city should get the best attractor (high strength). let cities = vec![ make_city(1, SettlementClass::NameLocked, 100_000), 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 ]; let matrix = uniform_matrix(); let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); let p1 = placements.iter().find(|p| p.city_id == 1).unwrap(); assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor"); } #[test] fn overflow_produces_synthetic() { // 2 cities, 1 attractor → second city gets synthetic. let cities = vec![ 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 matrix = uniform_matrix(); let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); assert_eq!(placements.len(), 2); let p2 = placements.iter().find(|p| p.city_id == 2).unwrap(); assert!(p2.synthetic); } #[test] fn all_cities_placed() { let cities: Vec = (1..=5) .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), ]; let matrix = uniform_matrix(); let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); assert_eq!(placements.len(), 5, "all cities must be placed"); } #[test] fn hungarian_assigns_optimally() { // 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; // transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0 matrix.weights[6][2] = 3.0; let cities = vec![ CityRecord { city_id: 1, name: "Farm".to_string(), settlement_class: SettlementClass::PopulationBudget, population: 60_000, economic_role: "agricultural".to_string(), }, CityRecord { city_id: 2, name: "Hub".to_string(), settlement_class: SettlementClass::PopulationBudget, population: 80_000, economic_role: "transit_hub".to_string(), }, ]; let attractors = vec![ make_attractor(5, 5, AttractorType::ValleyFloor, 1.0), make_attractor(10, 10, AttractorType::RiverCrossing, 1.0), ]; let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); assert_eq!(placements.len(), 2); let farm = placements.iter().find(|p| p.city_id == 1).unwrap(); let hub = placements.iter().find(|p| p.city_id == 2).unwrap(); // Farm should be on ValleyFloor (5,5), Hub on RiverCrossing (10,10). assert_eq!(farm.position, (5, 5)); assert_eq!(hub.position, (10, 10)); } #[test] fn founding_orientation_from_attractor() { use crate::simulation::generator::TerritorialStatus; let status = TerritorialStatus::FrontierUnclaimed; let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270); assert!(matches!(o, FoundingOrientation::Coastal { facing_degrees: 270 })); let o2 = founding_orientation( &AttractorType::PlainCenter, &TerritorialStatus::CommissionControlled, 0, 0, ); assert!(matches!(o2, FoundingOrientation::Cardinal)); let o3 = founding_orientation( &AttractorType::ValleyFloor, &status, 0, 0, ); assert!(matches!(o3, FoundingOrientation::TerrainFollowing)); } }