Files
settled-reach/server/src/atlas/attractor_matching.rs
T
jpmschweitzerandClaude Sonnet 4.6 b9fd7a3fc8 feat(simulation): add server/src/atlas/ — full Phase 1 generation pipeline
Ten-module atlas package implementing the D-194–D-218 district generation
stack: heightmap loader, BodyWorldState LRU cache, D8 drainage routing,
background generation queue, five-phase attractor-matching, three-component
district mix, block irregularity, tile condition thresholds, and the Phase 1
skeleton generator that wires them into DistrictSkeleton.

Closes #916 #917 #918 #919 #920 #922 #923 #924 #899.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:11:13 +02:00

619 lines
22 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.
//! Attractor-matching five-phase pipeline for settlement placement (D-211).
//!
//! Given a body's `Vec<GeographicAttractor>` 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<f32>]) -> Vec<usize> {
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<f32>> = 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<CityPlacement> {
let default_cost = vec![1.0f32; attractors.len()];
let costs = terrain_costs.unwrap_or(&default_cost);
let mut placements: Vec<CityPlacement> = Vec::with_capacity(cities.len());
let mut used_attractors: Vec<bool> = vec![false; attractors.len()];
// -------------------------------------------------------------------------
// Phase 1: Score matrix
// -------------------------------------------------------------------------
let scores: Vec<Vec<f32>> = 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<usize> = 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<usize> = 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<usize> = (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<Vec<f32>> = 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<u64> =
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<u64> =
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<CityRecord> = (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));
}
}