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
+44 -9
View File
@@ -439,27 +439,62 @@ pub struct GeographicAttractor {
/// Pixel position in heightmap space [row, col].
pub position: (u16, u16),
pub attractor_type: AttractorType,
/// Normalized strength 0.01.0. Derived from flow accumulation or habitability score.
pub strength: f32,
/// Strength on a 0100 integer scale (100 = strongest). Quantized from flow
/// accumulation / habitability at Layer-1 output. Integer for D-010
/// determinism — every ranking/scoring decision is exact, no f32 (#955).
pub strength: i32,
/// Fine-grained terrain classification at this position (D-210).
pub sub_biome: SubBiomeVariant,
/// Infrastructure-build cost multiplier (1.0 = baseline grassland; higher =
/// more expensive). Derived from `sub_biome` + local slope. Consumed by the
/// attractor-matching pipeline (D-211) to penalize marginal cities. (D-210)
pub terrain_modification_cost: f32,
/// Infrastructure-build cost as a percent of baseline (100 = baseline
/// grassland; >100 = more expensive, e.g. 200 = 2× cost). Derived from
/// `sub_biome` + local slope; penalizes marginal cities in the matching
/// pipeline (D-211). Integer for D-010 determinism (#955). (D-210)
pub terrain_modification_cost: i32,
}
/// Compatibility weights between economic roles and attractor types.
/// A 10×7 matrix (10 economic_role values × 7 AttractorType variants).
/// Each cell is a weight multiplier 0.03.0 applied during attractor-matching scoring.
/// Source: D-195
/// Each cell is an integer affinity 0100 (100 = ideal terrain for that role,
/// ~50 = neutral, low = poor) used during attractor-matching scoring.
/// Integer for D-010 determinism (#955). Source: D-195
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CompatibilityMatrix {
/// Row order: manufacturing, financial, agricultural, extraction, service_mixed,
/// institutional, transit_hub, research, military, residential.
/// Column order: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor,
/// PassEntrance, LakeShore, PlainCenter.
pub weights: [[f32; 7]; 10],
pub weights: [[i32; 7]; 10],
}
impl CompatibilityMatrix {
/// The canonical D-195 authored compatibility table (#955).
///
/// Integer affinity 0100: 100 = ideal terrain for the role, ~50 = neutral,
/// low (1030) = poor fit (graduated, never a hard 0 — D-195). Re-authored
/// on the 0100 scale from D-195's relative examples (the agricultural→
/// ValleyFloor 3.0 peak maps to the global max 100).
///
/// Rows: manufacturing, financial, agricultural, extraction, service_mixed,
/// institutional, transit_hub, research, military, residential.
/// Cols: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor,
/// PassEntrance, LakeShore, PlainCenter.
pub fn d195() -> Self {
CompatibilityMatrix {
weights: [
// RMth Coast RXing Vall Pass Lake Plain
[95, 80, 75, 60, 30, 35, 55], // manufacturing
[70, 90, 65, 25, 40, 45, 70], // financial
[85, 30, 55, 100, 10, 70, 90], // agricultural
[30, 35, 40, 65, 95, 30, 25], // extraction
[60, 65, 70, 50, 35, 50, 70], // service_mixed
[65, 65, 60, 45, 20, 55, 90], // institutional
[65, 75, 100, 35, 95, 25, 60], // transit_hub
[40, 70, 35, 75, 40, 95, 60], // research
[60, 75, 80, 55, 100, 25, 40], // military
[70, 85, 50, 65, 20, 85, 70], // residential
],
}
}
}
// ---------------------------------------------------------------------------