Merge remote-tracking branch 'origin/ocean-guard-synthetic'

This commit is contained in:
2026-08-08 10:55:38 +02:00
5 changed files with 923 additions and 29 deletions
+854 -5
View File
@@ -17,7 +17,7 @@
use tracing::{error, warn};
use crate::atlas::features::NO_WATER_BEARING;
use crate::atlas::features::{TerrainAnalysis, NO_WATER_BEARING};
use crate::seed::{splitmix64, SeedChain};
use crate::simulation::generator::{
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, SubBiomeVariant,
@@ -267,7 +267,164 @@ fn hungarian(cost: &[Vec<i64>]) -> Vec<usize> {
/// Minimum pixel separation between synthetic attractor positions.
const MIN_SPACING: u16 = 15;
fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> GeographicAttractor {
/// T-1206 — ocean-mask guard search bound. Bounded ring-expansion search
/// radius (cells) [`nearest_cell_matching`] will walk from a water-arithmetic
/// synthetic position before giving up. That is the production consumer —
/// reached from `synthetic_attractor` with the land+spacing predicate, and
/// from the test-only `nearest_land_cell` wrapper with a trivial one.
///
/// **Sized empirically, not by analogy.** An early draft mirrored
/// `road_graph::COASTAL_ANCHOR_MAX_RING` (3) scaled up an order of magnitude
/// (20) — but a live scan of every body with a heightmap (T-1206 verification
/// scan, world seed 42) showed the dominant failure mode is `(0,0)` (the
/// arithmetic search loop's own first candidate, see `synthetic_attractor`)
/// landing in a genuine HIGH-LATITUDE OCEAN BAND near the grid's pole row —
/// not a small coastal-cell artifact. Real, substantially-land bodies (35–95%
/// land overall) had their nearest land cell to `(0,0)` as far as 125 cells
/// away. Half the grid's height is the natural ceiling: a point beyond that
/// is more than half the grid from the pole, and a wider search stops paying
/// for itself. It is also a bounded, cheap search: each ring contributes about
/// `8 · ring` candidates, so exhausting the bound costs `Σ 8r` for r in
/// `1..=h/2` ≈ **66k candidate cells** on the current grid — negligible beside
/// the cascade's other per-body costs, and reached only by a body with no land
/// at all within half a grid of the arithmetic position. (An earlier version of
/// this paragraph said `~(h/2)² ≈ 16k`, which is the area of one quadrant
/// rather than the cumulative ring cost — off by ~4×, though the "negligible"
/// conclusion is unchanged. PR #218 review, Hoshe.) Genuinely water-locked
/// bodies still correctly degrade to the skip path in the caller.
///
/// **Derived, not hardcoded** (PR #218 review, Tyre). The bound is `h / 2`
/// and it is computed from the grid actually in scope, because the whole
/// justification above is expressed in grid heights: a literal `128` silently
/// carried the 512×256 working-grid assumption, so a change in grid
/// resolution would have left the constant behind while its own doc still
/// claimed to be half the height. On the current 512×256 working grid this
/// evaluates to exactly the 128 it replaces — no behaviour change, and the
/// PR's byte-identical-placement guarantee is untouched.
///
/// **Recalibration owner:** whoever changes the body corpus or the working-
/// grid resolution. Re-run the T-1206 verification scan (every heightmap
/// body at world seed 42, recording each synthetic placement's distance to
/// nearest land) and confirm the observed maximum still sits comfortably
/// under `h / 2`. If it does not, the ceiling — not just the literal — is
/// what needs revisiting.
fn max_land_search_ring(ta: &TerrainAnalysis) -> u16 {
(ta.h / 2) as u16
}
/// Ring-expansion search from `(row, col)` to the nearest cell with
/// `!ocean_mask` (T-1206, the T-1116 `nearest_passable_cell` pattern at
/// native/working-grid resolution rather than the road graph's downsampled
/// routing grid). Deterministic: rings expand outward in fixed distance
/// order, and within a ring, candidates are visited in a fixed row-major
/// scan (top edge left→right, bottom edge left→right, then left/right edges
/// top→bottom) — the same tie-break order as `nearest_passable_cell` — so
/// two equidistant land cells always resolve to the same winner for a given
/// terrain (pure function of `(row, col, terrain)`, no RNG, D-211's
/// seed-derived-position promise intact). Columns wrap (equirectangular
/// globe, matching every other grid walk in this cascade); rows clamp
/// (poles). Returns `None` if no land cell exists within
/// [`max_land_search_ring`] — the caller's defined degradation is to skip
/// the synthetic attractor entirely (T-1206), never to fabricate a position.
/// Test-only since the spacing predicate landed (PR #218 round 2): production
/// always has a spacing constraint to apply, so it calls
/// [`nearest_cell_matching`] directly and nothing outside `cfg(test)` wants the
/// unconstrained form. Kept because the land-only behaviour is a contract worth
/// pinning on its own — the wrap/clamp/tie-break tests read far more clearly
/// against it than against a call with a `|_, _| true` predicate bolted on.
#[cfg(test)]
fn nearest_land_cell(ta: &TerrainAnalysis, row: u16, col: u16) -> Option<(u16, u16)> {
nearest_cell_matching(ta, row, col, |_, _| true)
}
/// Ring-expansion search for the nearest cell that is land AND satisfies a
/// caller-supplied acceptance test. The production entry point; `nearest_land_cell`
/// (test-only) is this with a trivial predicate.
///
/// Exists because land is not the only thing D-211 promises about a synthetic
/// position. Step 4 says the overflow attractor is placed "at a position that
/// respects minimum city spacing", and the spacing walk in
/// [`synthetic_attractor`] runs BEFORE this correction — so nudging an accepted
/// candidate up to `h/2` cells to reach land could quietly land it on top of a
/// city the walk had just cleared it against, trading one guarantee for the
/// other (PR #218 review, Tyre). Threading the caller's own predicate into the
/// same ring walk keeps both promises without a second copy of this geometry.
///
/// `accept` is consulted only for cells that are already land, and the walk
/// continues outward when it returns false — so the result is the nearest cell
/// satisfying BOTH conditions, with the identical tie-break order.
fn nearest_cell_matching(
ta: &TerrainAnalysis,
row: u16,
col: u16,
accept: impl Fn(u16, u16) -> bool,
) -> Option<(u16, u16)> {
let (w, h) = (ta.w as i32, ta.h as i32);
if !ta.is_ocean(row as usize, col as usize) && accept(row, col) {
return Some((row, col));
}
for ring in 1..=max_land_search_ring(ta) {
let ring_i = ring as i32;
let (r0, c0) = (row as i32, col as i32);
let mut candidates: Vec<(i32, i32)> = Vec::new();
// Top edge, left→right.
if r0 - ring_i >= 0 {
let nr = r0 - ring_i;
for dc in -ring_i..=ring_i {
candidates.push((nr, c0 + dc));
}
}
// Bottom edge, left→right.
if r0 + ring_i < h {
let nr = r0 + ring_i;
for dc in -ring_i..=ring_i {
candidates.push((nr, c0 + dc));
}
}
// Left/right edges (corners already covered above), top→bottom.
for dr in (-ring_i + 1)..ring_i {
let nr = r0 + dr;
if nr < 0 || nr >= h {
continue;
}
candidates.push((nr, c0 - ring_i));
candidates.push((nr, c0 + ring_i));
}
for (nr, nc) in candidates {
let wrapped_c = nc.rem_euclid(w) as u16;
if !ta.is_ocean(nr as usize, wrapped_c as usize) && accept(nr as u16, wrapped_c) {
return Some((nr as u16, wrapped_c));
}
}
}
None
}
/// Computes the D-211 Phase-4 synthetic-overflow position by pure grid
/// arithmetic (spacing-walk from grid center), then guards it against open
/// water (T-1206). `terrain` is `None` for callers without terrain data
/// (existing unit tests, any future caller that hasn't threaded it through) —
/// the guard is then a no-op and the arithmetic position passes straight
/// through UNCHANGED, exactly as before this ticket (byte-identical to the
/// pre-T-1206 behavior).
///
/// **Land-passthrough, water-only-correction (T-1206):** when `terrain` IS
/// supplied, a synthetic position that is already land is returned
/// UNTOUCHED — this is the compatibility invariant the fix is built around:
/// no currently-land synthetic-overflow placement moves on any existing body
/// (D-211's seed-derived-position promise). Only a water-arithmetic position
/// is nudged, via [`nearest_cell_matching`]'s deterministic ring-walk. If no land
/// cell exists within [`max_land_search_ring`] (an all-ocean region of the
/// grid), returns `None` — the caller's defined degradation is to skip this
/// synthetic attractor entirely (the city goes unplaced and is reported by
/// Phase 5's existing name-fulfillment warning) rather than fabricate a
/// water position or panic.
fn synthetic_attractor(
placed: &[CityPlacement],
grid_w: u32,
grid_h: u32,
terrain: Option<&TerrainAnalysis>,
) -> Option<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;
@@ -290,14 +447,38 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
}
}
GeographicAttractor {
if let Some(ta) = terrain {
// Carry the spacing constraint INTO the land search rather than
// applying it only to the arithmetic candidate above. The walk can move
// the position by up to h/2 cells, which is far enough to cross a
// neighbour it was just cleared against — D-211 step 4 promises land
// AND minimum spacing, and correcting one must not silently spend the
// other (PR #218 review, Tyre).
//
// Degradation is unchanged: if no cell satisfies both within the bound,
// this is `None` and the caller skips the synthetic attractor, exactly
// as it already did for the land-only case. A skipped city is reported
// by Phase 5's existing name-fulfillment warning; it is never placed in
// water, and now never placed on top of a neighbour either.
let (land_row, land_col) = nearest_cell_matching(ta, row, col, |r, c| {
placed.iter().all(|p| {
let dr = (p.position.0 as i32 - r as i32).unsigned_abs() as u16;
let dc = (p.position.1 as i32 - c as i32).unsigned_abs() as u16;
dr.max(dc) >= MIN_SPACING
})
})?;
row = land_row;
col = land_col;
}
Some(GeographicAttractor {
position: (row, col),
attractor_type: AttractorType::PlainCenter,
strength: 50,
sub_biome: SubBiomeVariant::TemperateGrassland,
terrain_modification_cost: 100,
water_bearing: NO_WATER_BEARING, // inland synthetic — no water direction
}
})
}
// ---------------------------------------------------------------------------
@@ -335,6 +516,12 @@ fn city_character(
(archetype, pattern, orientation)
}
/// `terrain` is the body's [`TerrainAnalysis`] (T-1206), consulted ONLY by the
/// Phase-4 synthetic-overflow path to keep a synthetic `PlainCenter` off open
/// water — see [`synthetic_attractor`]'s doc for the land-passthrough /
/// water-only-correction / bounded-search-then-skip contract. `None` (every
/// pre-T-1206 caller, and any test that doesn't need the guard) reproduces
/// the exact prior behavior: an ungated grid-arithmetic position.
pub fn match_cities(
cities: &[CityRecord],
attractors: &[GeographicAttractor],
@@ -344,6 +531,7 @@ pub fn match_cities(
grid_h: u32,
territorial_status: &TerritorialStatus,
seed: SeedChain,
terrain: Option<&TerrainAnalysis>,
) -> Vec<CityPlacement> {
let default_cost = vec![100i32; attractors.len()];
let costs = terrain_costs.unwrap_or(&default_cost);
@@ -497,7 +685,14 @@ pub fn match_cities(
if placed_ids.contains(&city.city_id) {
continue;
}
let synthetic = synthetic_attractor(&placements, grid_w, grid_h);
// T-1206: `None` here (all-ocean region beyond max_land_search_ring,
// or degenerate MIN_SPACING exhaustion) means the defined degradation
// is to skip this city's synthetic placement entirely — never
// fabricate a water position. Phase 5's name-fulfillment check below
// reports it (the same warning path an unplaced city already takes).
let Some(synthetic) = synthetic_attractor(&placements, grid_w, grid_h, terrain) else {
continue;
};
let score = cell_score(city, &synthetic, matrix, 100);
flag_mismatch(&city.name, score);
let (archetype, pattern, orientation) = city_character(
@@ -743,6 +938,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].city_id, 1);
@@ -777,6 +973,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 3);
@@ -818,6 +1015,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
@@ -841,6 +1039,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 2);
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
@@ -877,6 +1076,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
let major = placements.iter().find(|p| p.city_id == 1).unwrap();
let minor = placements.iter().find(|p| p.city_id == 2).unwrap();
@@ -908,6 +1108,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
let hero = placements2.iter().find(|p| p.city_id == 3).unwrap();
assert_eq!(
@@ -936,6 +1137,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 5, "all cities must be placed");
}
@@ -981,6 +1183,7 @@ mod tests {
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 2);
let farm = placements.iter().find(|p| p.city_id == 1).unwrap();
@@ -1119,4 +1322,650 @@ mod tests {
ArrangementPattern::HubAndSpoke
);
}
// -----------------------------------------------------------------------
// T-1206: synthetic-overflow ocean-mask guard
// -----------------------------------------------------------------------
/// Builds a minimal `TerrainAnalysis` with only `w`/`h`/`ocean_mask`
/// meaningfully populated — the only fields `nearest_land_cell`/`is_ocean`
/// read. `land_cells` are `(row, col)` positions that are land; every
/// other cell in the `w × h` grid is ocean.
fn ta_with_land(w: usize, h: usize, land_cells: &[(u16, u16)]) -> TerrainAnalysis {
let mut ocean_mask = vec![true; w * h];
for &(r, c) in land_cells {
ocean_mask[r as usize * w + c as usize] = false;
}
TerrainAnalysis {
w,
h,
ocean_mask,
lake_mask: vec![false; w * h],
water_dist: vec![0; w * h],
slope_deg: vec![0.0; w * h],
elev_pct: vec![0.0; w * h],
hydrology: None,
}
}
/// All-land terrain (every cell passes `!ocean_mask`) — a convenience for
/// tests that only care about the spacing-walk arithmetic, not the guard.
fn ta_all_land(w: usize, h: usize) -> TerrainAnalysis {
TerrainAnalysis {
w,
h,
ocean_mask: vec![false; w * h],
lake_mask: vec![false; w * h],
water_dist: vec![0; w * h],
slope_deg: vec![0.0; w * h],
elev_pct: vec![0.0; w * h],
hydrology: None,
}
}
/// **Compatibility invariant (T-1206, requirement 1):** a synthetic
/// position that is ALREADY LAND is returned byte-identical whether or
/// not terrain is supplied — the guard must never move an
/// already-land placement. This is the exact property that protects every
/// existing golden fixture: on a body where the arithmetic synthetic
/// position happens to be land (the common case, per the T-1206
/// verification scan), passing `Some(&terrain)` changes nothing.
#[test]
fn synthetic_attractor_land_passthrough_is_byte_identical_to_no_terrain() {
let placed: Vec<CityPlacement> = Vec::new();
let (grid_w, grid_h) = (512u32, 256u32);
// Every cell is land, so the guard is a guaranteed no-op and the only
// thing under test is passthrough.
//
// The returned position is (0, 0), not the (grid_h/2, grid_w/4)
// arithmetic default: with `placed` empty the spacing-walk's `.all()`
// is vacuously true on its very first candidate (dr=0, dc=0), so it
// breaks there and the default is never reached. That is exactly the
// production case the T-1206 scan found — an empty overflow list
// putting the first synthetic city at the grid origin, which on a real
// body is frequently open water.
let ta = ta_all_land(grid_w as usize, grid_h as usize);
let without_terrain = synthetic_attractor(&placed, grid_w, grid_h, None)
.expect("no terrain — always Some, ungated");
let with_terrain = synthetic_attractor(&placed, grid_w, grid_h, Some(&ta))
.expect("all-land terrain — guard is a no-op, must still be Some");
assert_eq!(
without_terrain.position, with_terrain.position,
"a land-arithmetic synthetic position must be untouched by the T-1206 guard"
);
assert_eq!(without_terrain.position, (0, 0));
}
/// **Water-only-correction (T-1206, requirement 1):** when the arithmetic
/// position IS water, the guard nudges it to the nearest land cell via
/// `nearest_land_cell`'s deterministic ring-walk — never to a fabricated
/// or RNG-derived position.
#[test]
fn synthetic_attractor_nudges_off_water_to_nearest_land() {
let placed: Vec<CityPlacement> = Vec::new();
let (grid_w, grid_h) = (64u32, 32u32);
// Arithmetic position (with empty `placed`) is (0,0) — make that
// water, with the nearest land cell at (2,0) (ring 2, straight south).
let ta = ta_with_land(grid_w as usize, grid_h as usize, &[(2, 0)]);
let guarded = synthetic_attractor(&placed, grid_w, grid_h, Some(&ta))
.expect("land exists within max_land_search_ring");
assert_eq!(
guarded.position,
(2, 0),
"must land exactly on the nearest land cell, not merely off water"
);
}
/// **Row-major ring tie-break determinism (T-1206, D-010):** when two
/// land cells are equidistant (same ring) from the water-arithmetic
/// position, `nearest_land_cell` always resolves to the same one — a
/// pure function of `(position, terrain)`, matching the T-1116
/// `nearest_passable_cell` precedent's tie-break order (top edge
/// left→right first).
#[test]
fn nearest_land_cell_tie_break_is_deterministic() {
let (w, h) = (32usize, 32usize);
// Two land cells on ring 1 from (5,5): (4,4) [top-edge, visited
// first] and (4,6) [also top-edge, visited after (4,4) — left→right
// scan order]. The top-edge candidate list is built left→right, so
// (4,4) must win over (4,6) even though both are ring-1/Chebyshev-1.
let ta = ta_with_land(w, h, &[(4, 4), (4, 6)]);
let found = nearest_land_cell(&ta, 5, 5);
assert_eq!(found, Some((4, 4)));
// Repeat many times — pure function, must be exactly reproducible.
for _ in 0..20 {
assert_eq!(nearest_land_cell(&ta, 5, 5), Some((4, 4)));
}
}
/// **Column wrap (T-1206):** the working grid is equirectangular — a
/// search that walks off the left/right edge wraps around, matching
/// every other grid walk in this cascade (`features.rs::wrap_col`,
/// `road_graph.rs::nearest_passable_cell`).
#[test]
fn nearest_land_cell_wraps_columns() {
let (w, h) = (16usize, 16usize);
// Land only at column 0 — from (5, 1) (one step from the right edge
// of the wrap, i.e. effectively adjacent to column 0 via wraparound
// is NOT the case here; instead test from col=0 neighbourhood
// directly) we confirm wrap by placing land at col (w-1) and
// searching from col 0, which should find it by wrapping left.
let ta = ta_with_land(w, h, &[(5, (w - 1) as u16)]);
let found = nearest_land_cell(&ta, 5, 0);
assert_eq!(
found,
Some((5, (w - 1) as u16)),
"search from col 0 must wrap left to find land at the opposite edge"
);
}
/// **Rows clamp, do not wrap (T-1206):** matches every other grid walk in
/// this cascade (poles are grid edges, not a torus in the row direction).
/// A ring whose top/bottom edge would fall outside `[0, h)` simply omits
/// that edge's candidates rather than wrapping to the opposite pole.
#[test]
fn nearest_land_cell_clamps_rows_no_wrap() {
let (w, h) = (16usize, 16usize);
// Land ONLY at the far pole (row h-1), searching from row 0.
//
// The two implementations disagree by an order of magnitude here.
// Clamped, the only route is straight down the column: ring 15.
// Wrapping, row 0 steps "up" to row 15 immediately: ring 1.
//
// Since the search bound became h/2 (PR #218 review — see
// `max_land_search_ring`), this grid bounds the walk at ring 8, which
// sits BETWEEN those two distances. That turns a position assertion
// into an absence assertion, and a sharper one: ring 15 is now out of
// reach by construction, so a correct clamping walk must give up and
// return None, while ANY `Some` result proves rows wrapped — there is
// no other way to reach row 15 within 8 rings.
//
// (Before the bound was derived it was a flat 128, far beyond this
// 16-row fixture, so both implementations returned Some((15, 0)) and
// the test could only pin the position. It now discriminates.)
let ta = ta_with_land(w, h, &[(15, 0)]);
let found = nearest_land_cell(&ta, 0, 0);
assert_eq!(
found, None,
"rows must clamp: the far pole is 15 rings away and the bound is \
h/2 = 8, so any Some(..) here means the walk wrapped rows"
);
}
/// **Degradation (T-1206, requirement 2): no land within
/// `max_land_search_ring` → skip, never fabricate or panic.** A tiny
/// all-ocean grid guarantees no land cell exists anywhere, so the guard
/// must return `None` — `match_cities`'s Phase 4 then skips this city
/// entirely (verified below via `match_cities` directly, matching Phase
/// 5's existing "unplaced city" warning path — no new error path).
#[test]
fn nearest_land_cell_returns_none_when_no_land_within_bound() {
let (w, h) = (16usize, 16usize);
let ta = TerrainAnalysis {
w,
h,
ocean_mask: vec![true; w * h], // every cell is water — no land at all
lake_mask: vec![false; w * h],
water_dist: vec![0; w * h],
slope_deg: vec![0.0; w * h],
elev_pct: vec![0.0; w * h],
hydrology: None,
};
assert_eq!(nearest_land_cell(&ta, 0, 0), None);
}
#[test]
fn synthetic_attractor_returns_none_on_all_ocean_terrain() {
let placed: Vec<CityPlacement> = Vec::new();
let (grid_w, grid_h) = (16u32, 16u32);
let ta = TerrainAnalysis {
w: grid_w as usize,
h: grid_h as usize,
ocean_mask: vec![true; (grid_w * grid_h) as usize],
lake_mask: vec![false; (grid_w * grid_h) as usize],
water_dist: vec![0; (grid_w * grid_h) as usize],
slope_deg: vec![0.0; (grid_w * grid_h) as usize],
elev_pct: vec![0.0; (grid_w * grid_h) as usize],
hydrology: None,
};
assert!(synthetic_attractor(&placed, grid_w, grid_h, Some(&ta)).is_none());
}
/// **End-to-end degradation through `match_cities` (T-1206, requirement
/// 2):** on an all-ocean grid, a city that would overflow to Phase 4
/// synthetic placement is instead SKIPPED — never placed in water, never
/// a panic. This mirrors the existing "atlas city was not placed" Phase-5
/// warning path (no new reporting mechanism needed).
#[test]
fn match_cities_skips_synthetic_overflow_on_all_ocean_body() {
let cities = vec![make_city(1, SettlementClass::PopulationBudget, 60_000)];
let attractors: Vec<GeographicAttractor> = Vec::new(); // forces Phase-4 overflow
let matrix = uniform_matrix();
let (grid_w, grid_h) = (16u32, 16u32);
let n = (grid_w * grid_h) as usize;
let ta = TerrainAnalysis {
w: grid_w as usize,
h: grid_h as usize,
ocean_mask: vec![true; n],
lake_mask: vec![false; n],
water_dist: vec![0; n],
slope_deg: vec![0.0; n],
elev_pct: vec![0.0; n],
hydrology: None,
};
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
grid_w,
grid_h,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
Some(&ta),
);
assert!(
placements.is_empty(),
"an all-ocean body must SKIP the overflow city, not fabricate a water placement"
);
}
/// **`match_cities` end-to-end land-guard (T-1206):** the exact
/// production shape — Phase-4 overflow on a body with real terrain —
/// never places a synthetic `CityPlacement` on an ocean cell.
#[test]
fn match_cities_synthetic_overflow_never_lands_in_water() {
let cities = vec![make_city(1, SettlementClass::PopulationBudget, 60_000)];
let attractors: Vec<GeographicAttractor> = Vec::new(); // forces Phase-4 overflow
let matrix = uniform_matrix();
let (grid_w, grid_h) = (64u32, 32u32);
// Water everywhere except a single land cell far from the arithmetic
// (0,0) default, forcing the guard to actually nudge the position.
let ta = ta_with_land(grid_w as usize, grid_h as usize, &[(10, 10)]);
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
grid_w,
grid_h,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
Some(&ta),
);
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].position, (10, 10));
assert!(placements[0].synthetic);
assert!(!ta.is_ocean(
placements[0].position.0 as usize,
placements[0].position.1 as usize
));
}
/// **`None` terrain reproduces the exact pre-T-1206 behavior
/// (compatibility):** every existing caller/test that doesn't supply
/// terrain gets the ungated grid-arithmetic position, unchanged.
#[test]
fn match_cities_with_no_terrain_is_ungated_like_before_t1206() {
let cities = vec![make_city(1, SettlementClass::PopulationBudget, 60_000)];
let attractors: Vec<GeographicAttractor> = Vec::new();
let matrix = uniform_matrix();
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
512,
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
None,
);
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].position, (0, 0));
}
/// Run the real cascade for `body` and hand back its placements together
/// with the `TerrainAnalysis` they must be checked against.
///
/// The re-derivation is not incidental — it is the whole reason this
/// helper exists. `CascadeSnapshot::terrain_analysis` is transient: the
/// cascade sets it to `None` the moment DistrictProfile and RoadGraph are
/// done with it (D-203/T-1048, ~2 MB a body), so by the time a completed
/// snapshot is handed back it is ALWAYS `None`, whatever
/// `cascade_snapshot_for_body`'s doc-comment implies. A caller that reads
/// it off the snapshot and skips when absent skips every body, silently,
/// and reports a clean run — which is exactly what the first draft of the
/// scan below did across all 267 bodies.
///
/// So re-derive it from the surviving heightmap through the SAME call the
/// cascade itself used (`run_layer1_with_moisture` with the body's own
/// moisture ceiling), which reproduces the identical grid the placements
/// were computed against.
#[cfg(test)]
fn cascade_placements_and_terrain(
body: &str,
seed: u64,
) -> Option<(Vec<CityPlacement>, TerrainAnalysis)> {
let (snapshot, params) =
crate::atlas::believability::cascade_snapshot_for_body(seed, body).ok()?;
let placements = snapshot.layer3.as_ref()?.placements.clone();
let (_l1, ta) = crate::atlas::layer1::run_layer1_with_moisture(
&snapshot.heightmap,
crate::atlas::district_profile::derive_moisture_ceiling_q(&params),
);
Some((placements, ta))
}
/// Directory of bodies carrying a committed heightmap, read off the wiki
/// tree rather than the DB (the tree IS the heightmap corpus, and this
/// avoids a second source of truth). Tries repo-root and `server/`-relative
/// paths, matching `believability::find_heightmap`'s own CWD tolerance.
#[cfg(test)]
fn bodies_with_heightmaps() -> Vec<String> {
let root = ["wiki/star-systems", "../wiki/star-systems"]
.iter()
.map(std::path::PathBuf::from)
.find(|p| p.is_dir());
let Some(root) = root else {
return Vec::new();
};
let mut out = Vec::new();
let Ok(systems) = std::fs::read_dir(&root) else {
return out;
};
for system in systems.flatten() {
let bodies_dir = system.path().join("bodies");
let Ok(bodies) = std::fs::read_dir(&bodies_dir) else {
continue;
};
for body in bodies.flatten() {
if body.path().join("heightmap.png").is_file() {
if let Some(name) = body.file_name().to_str() {
out.push(name.to_string());
}
}
}
}
out.sort();
out
}
/// **The T-1206 verification scan, committed (PR #218 review, Hoshe).**
///
/// The scan that motivated this fix — every heightmap body at world seed
/// 42, counting synthetic placements that land in water — was run once and
/// thrown away, which is precisely why its headline number (46 of 109 in
/// water) had nothing holding it. This is that scan, re-runnable:
///
/// ```text
/// cargo test --lib t1206_verification_scan -- --ignored --nocapture
/// ```
///
/// `#[ignore]` because it derives the full cascade for ~267 bodies — far
/// too slow for the default suite. The fast invariant lives in
/// `real_body_synthetic_placements_never_land_in_ocean` below, which pins
/// the bodies this scan identifies.
///
/// Re-run it when the body corpus or the working-grid resolution changes:
/// it reports both the ocean-guard invariant AND the distance-to-land
/// distribution that sizes `max_land_search_ring`.
#[test]
#[ignore = "full-corpus scan (~267 bodies) — run explicitly"]
fn t1206_verification_scan() {
let bodies = bodies_with_heightmaps();
assert!(!bodies.is_empty(), "no heightmap bodies found");
let (mut loaded, mut synthetic, mut in_water) = (0usize, 0usize, 0usize);
let mut too_close = 0usize;
let mut crowded: Vec<String> = Vec::new();
// Counted separately so a zero cannot be ambiguous: "no synthetic
// placements" and "Layer 3 never ran" would otherwise look identical,
// and the difference is the whole meaning of the result.
let (mut with_layer3, mut total_placements) = (0usize, 0usize);
let mut offenders: Vec<String> = Vec::new();
let mut with_synthetic: Vec<String> = Vec::new();
for body in &bodies {
let Some((placements, ta)) = cascade_placements_and_terrain(body, 42) else {
continue;
};
loaded += 1;
with_layer3 += 1;
total_placements += placements.len();
let mut body_synthetic = 0usize;
for p in placements.iter().filter(|p| p.synthetic) {
synthetic += 1;
body_synthetic += 1;
let (row, col) = p.position;
if ta.is_ocean(row as usize, col as usize) {
in_water += 1;
offenders.push(format!("{body} city {} @ {:?}", p.city_id, p.position));
}
}
// D-211 step 4's OTHER promise: minimum spacing.
//
// Scoped to pairs involving a SYNTHETIC placement, because that is
// the extent of the promise. Step 4 says the synthetic overflow
// attractor is generated "at a position that respects minimum city
// spacing"; matched placements (Tier A greedy, Tier B/C Hungarian)
// sit whereever their terrain attractor is and were never subject
// to it — two real river mouths 12 cells apart is legitimate
// geography, not a defect. An earlier draft of this check asserted
// across ALL pairs and duly found 13 such pairs corpus-wide, none
// of which the guard had anything to do with.
let synthetic_ids: std::collections::BTreeSet<u64> = placements
.iter()
.filter(|p| p.synthetic)
.map(|p| p.city_id)
.collect();
for (i, a) in placements.iter().enumerate() {
for b in placements.iter().skip(i + 1) {
if !synthetic_ids.contains(&a.city_id) && !synthetic_ids.contains(&b.city_id) {
continue;
}
let dr = (a.position.0 as i32 - b.position.0 as i32).unsigned_abs() as u16;
let dc = (a.position.1 as i32 - b.position.1 as i32).unsigned_abs() as u16;
if dr.max(dc) < MIN_SPACING {
too_close += 1;
crowded.push(format!(
"{body} cities {}/{} @ {:?}/{:?} (Chebyshev {})",
a.city_id,
b.city_id,
a.position,
b.position,
dr.max(dc)
));
}
}
}
if body_synthetic > 0 {
with_synthetic.push(format!("{body}({body_synthetic})"));
}
}
println!(
"T-1206 scan @ seed 42: {loaded} bodies loaded, {with_layer3} reached Layer 3, \
{total_placements} placements total, {synthetic} synthetic, {in_water} in water"
);
println!(
"bodies with synthetic placements: {}",
with_synthetic.join(" ")
);
println!("spacing violations (< {MIN_SPACING} cells Chebyshev): {too_close}");
for o in &offenders {
println!(" IN WATER: {o}");
}
for c in &crowded {
println!(" TOO CLOSE: {c}");
}
// Same vacuity guards as the fast test, and for the same reason: this
// is the one test whose whole point is being the re-runnable evidence,
// so it must not be the one that can go green over an empty set. Its
// first draft did exactly that across all 267 bodies (PR #218 review,
// Hoshe) — `!bodies.is_empty()` above only proves the directory listing
// worked, not that a single body loaded through the cascade.
assert!(
loaded > 0,
"{} bodies on disk but none loaded through the cascade — check CWD \
and systems.db; this test verified nothing",
bodies.len()
);
assert!(
synthetic > 0,
"{loaded} bodies loaded, {with_layer3} reached Layer 3, {total_placements} \
placements — but zero synthetic, so both assertions below are over an \
empty set and prove nothing"
);
assert_eq!(
in_water, 0,
"the ocean-mask guard has regressed on real data: {offenders:?}"
);
assert_eq!(
too_close, 0,
"D-211's minimum-spacing promise is violated on real data — the land \
correction may be nudging placements onto their neighbours: {crowded:?}"
);
}
/// **The claim this PR was built on, pinned to real data (PR #218 review,
/// Hoshe).**
///
/// Every other test above proves the guard on synthetic fixtures. But the
/// evidence that motivated T-1206 was a scan of production data — 46 of
/// 109 synthetic placements across the 267 real bodies sat in open water
/// at world seed 42 — and that scan was a throwaway. Nothing committed
/// held the line on the bodies that actually exhibited the bug, so a
/// regression would have been caught only by fixtures, never by the data
/// that revealed it.
///
/// This walks the real cascade over real bodies (the T-1116 precedent in
/// `road_graph.rs::real_body_coastal_settlements_produce_routable_edges`
/// — committed `systems.db` + `wiki/star-systems` heightmaps, entered
/// through the same loader production uses) and asserts the invariant
/// directly: NO synthetic placement may sit on an ocean cell.
///
/// `cascade_snapshot_for_body`, not `cascade_for_body`, because the
/// `TerrainAnalysis` carrying `ocean_mask` is transient and is dropped by
/// `into_body_world_state()` (D-203/T-1048 size budget) — the mask has to
/// be read off the snapshot before conversion.
///
/// Guarded against passing vacuously: the run must produce at least one
/// synthetic placement somewhere in the corpus, otherwise the assertion
/// below is over an empty set and proves nothing. If the body list ever
/// stops exercising overflow, that guard fails loudly rather than going
/// quietly green. It has already earned that — it caught both a body list
/// that exercised nothing and, worse, a terrain-source bug that made the
/// whole-corpus scan report a clean 267 bodies while checking none of them
/// (see `cascade_placements_and_terrain`).
///
/// Full-corpus figures at seed 42, from `t1206_verification_scan`:
/// **267 bodies, all reaching Layer 3, 344 placements, 109 of them
/// synthetic, 0 in water.** The synthetic count matches the number the
/// original T-1206 scan reported, so the fix's headline evidence is now
/// reproducible rather than anecdotal.
#[test]
fn real_body_synthetic_placements_never_land_in_ocean() {
// Every one of these DOES reach Phase-4 synthetic overflow at seed 42
// — taken from `t1206_verification_scan`'s own output, not guessed.
// That matters: the first draft of this list paired GJ903c with four
// headline bodies (GJ251c/GJ380c/GJ820Bc/GJ338Bd) that turn out to
// have no synthetic placements at all, so the assertion rested on a
// single body. GJ903c is the case the T-1206 scan named (a genuine
// polar ocean cell); GJ3737e-m2 is the only body carrying two.
// Three, not six. The helper re-derives layer 1 per body (the only way
// to recover the dropped TerrainAnalysis), so each body costs ~4.2s —
// double the `real_body_coastal_settlements_produce_routable_edges`
// precedent, and at six bodies this became the long pole of the whole
// --lib suite's wall clock (PR #218 review, Hoshe). Trimmed to ~13s.
//
// Not trimmed to one, though: `synthetic_seen > 0` needs only a single
// body, but a single body is how the first draft of this test ended up
// resting on GJ903c alone. Three keeps the invariant checked across
// independent terrain while paying a third less. The 267-body sweep
// remains available behind `--ignored`.
let bodies = ["GJ903c", "GJ3737e-m2", "GJ1245Bb"];
let mut synthetic_seen = 0usize;
let mut bodies_loaded = 0usize;
let mut total_placements = 0usize;
for body in bodies {
// A body may legitimately be absent from this checkout's data;
// skip rather than fail, matching the believability harness's own
// contract for the `*_for_body` loaders.
let Some((placements, ta)) = cascade_placements_and_terrain(body, 42) else {
continue;
};
bodies_loaded += 1;
total_placements += placements.len();
for p in placements.iter().filter(|p| p.synthetic) {
synthetic_seen += 1;
let (row, col) = p.position;
assert!(
!ta.is_ocean(row as usize, col as usize),
"{body}: synthetic placement for city {} sits in open water at \
{:?} — the T-1206 ocean-mask guard has regressed",
p.city_id,
p.position
);
}
// D-211 step 4 promises minimum spacing as well as land, and the
// land correction runs after the spacing walk — so a nudge toward
// shore could otherwise undo it (PR #218 review, Tyre + Hoshe,
// found independently by both).
//
// Only pairs involving a SYNTHETIC placement are asserted: the
// spacing promise is step 4's alone. Matched placements land on
// their terrain attractor and are not subject to it — corpus-wide
// there are 13 matched-pair proximities under 15 cells, all
// legitimate geography.
let synthetic_ids: std::collections::BTreeSet<u64> = placements
.iter()
.filter(|p| p.synthetic)
.map(|p| p.city_id)
.collect();
for (i, a) in placements.iter().enumerate() {
for b in placements.iter().skip(i + 1) {
if !synthetic_ids.contains(&a.city_id) && !synthetic_ids.contains(&b.city_id) {
continue;
}
let dr = (a.position.0 as i32 - b.position.0 as i32).unsigned_abs() as u16;
let dc = (a.position.1 as i32 - b.position.1 as i32).unsigned_abs() as u16;
assert!(
dr.max(dc) >= MIN_SPACING,
"{body}: cities {} and {} are {} cells apart at {:?}/{:?}, \
under the {MIN_SPACING}-cell minimum — the land correction \
has spent D-211's spacing guarantee to buy its land one",
a.city_id,
b.city_id,
dr.max(dc),
a.position,
b.position
);
}
}
}
assert!(
bodies_loaded > 0,
"no test body could be loaded — systems.db or the heightmaps are \
missing, so this test verified nothing"
);
assert!(
synthetic_seen > 0,
"{bodies_loaded} bodies loaded, {total_placements} placements among them, \
but not one synthetic — this test would pass vacuously; pick bodies that \
still reach Phase-4 overflow"
);
}
}
+22 -8
View File
@@ -613,15 +613,29 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState
.map(|(snapshot, _params)| snapshot.into_body_world_state())
}
/// Like [`cascade_for_body`], but returns the raw [`CascadeSnapshot`] — which still
/// carries the transient `TerrainAnalysis` — together with the body's [`BodyParams`].
/// Like [`cascade_for_body`], but returns the raw [`CascadeSnapshot`] together with
/// the body's [`BodyParams`], instead of converting to a `BodyWorldState`.
///
/// This is the entry point for callers that need the *on-demand* district-derivation
/// inputs (`district_profile::derive_district` wants `&TerrainAnalysis` +
/// `&BodyParams`), e.g. the aliveness probe's `--render` district-window mode
/// (T-1123). [`cascade_for_body`] is this plus the `BodyWorldState` conversion,
/// which drops the TerrainAnalysis per the D-203/T-1048 size budget — take it off
/// the snapshot *before* converting.
/// **The returned snapshot's `terrain_analysis` is `None`.** This function always runs
/// the cascade to `CascadeLayer::Region`, and `run_cascade_from_heightmap` nulls the
/// transient `TerrainAnalysis` as soon as DistrictProfile and RoadGraph have consumed
/// it (`cascade.rs`, D-203/T-1048 — it is ~2 MB a body and is deliberately not
/// persisted). An earlier version of this comment claimed the snapshot "still carries
/// the transient TerrainAnalysis"; it does not, and the claim was not harmless — a
/// caller that read the mask off the snapshot and skipped bodies where it was absent
/// skipped **every** body while reporting a clean run over all 267 of them (T-1206,
/// PR #218).
///
/// So a caller needing the terrain must **re-derive** it from `snapshot.heightmap`,
/// which does survive, via the same call the cascade itself used:
/// `layer1::run_layer1_with_moisture(&snapshot.heightmap,
/// district_profile::derive_moisture_ceiling_q(&params))`. That reproduces the exact
/// grid the cascade's own outputs were computed against.
///
/// This is still the entry point for callers that need the *on-demand*
/// district-derivation inputs (`district_profile::derive_district` wants
/// `&TerrainAnalysis` + `&BodyParams`), e.g. the aliveness probe's `--render`
/// district-window mode (T-1123) — they just have to re-derive rather than read.
pub fn cascade_snapshot_for_body(
world_seed: u64,
body_id: &str,
+14 -2
View File
@@ -196,6 +196,8 @@ impl CascadeSnapshot {
///
/// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed`
/// drive the per-settlement spatial-character enrichment (#956, D-213/214/215).
/// `terrain` (T-1206) gates the Phase-4 synthetic-overflow path against open
/// water — see [`match_cities`]'s doc.
fn run_layer3(
attractors: &[GeographicAttractor],
cities: &[CityRecord],
@@ -203,6 +205,7 @@ fn run_layer3(
seed: SeedChain,
grid_w: u32,
grid_h: u32,
terrain: Option<&TerrainAnalysis>,
) -> Layer3Output {
let matrix = CompatibilityMatrix::d195();
let placements = match_cities(
@@ -214,6 +217,7 @@ fn run_layer3(
grid_h,
territorial_status,
seed,
terrain,
);
Layer3Output { placements }
}
@@ -321,8 +325,15 @@ pub fn run_cascade_from_heightmap(
None => &[],
};
// cache seam: run_layer3 is a pure, deterministic function of
// (attractors, cities, territorial_status, seed) — wrap a persistent
// cache here when we add one (build-time bake or local cache; see #1021).
// (attractors, cities, territorial_status, seed, terrain) — wrap a
// persistent cache here when we add one (build-time bake or local
// cache; see #1021).
//
// T-1206: `snapshot.terrain_analysis` was just populated by the
// Topography block above (guaranteed `Some` here — Settlement >
// Topography in CascadeLayer's Ord, so the guard above always ran
// first) — passed by reference so the DistrictProfile/RoadGraph pass
// below still gets to consume (and drop) the same transient value.
let l3 = run_layer3(
attractors,
cities,
@@ -330,6 +341,7 @@ pub fn run_cascade_from_heightmap(
body_seed,
snapshot.heightmap.width,
snapshot.heightmap.height,
snapshot.terrain_analysis.as_ref(),
);
snapshot.layer3 = Some(l3);
}
+26 -12
View File
@@ -95,18 +95,32 @@ const MIN_CELL_COST: u32 = RIVER_COST;
/// edge touching it (the documented T-1116 bug: GJ251c land 0.55 / GJ380c land
/// 0.588 — land-majority BODIES with 0 routable edges).
///
/// **Known gap this relaxation also happens to cover, but does not fix at the
/// source:** D-211's Phase-4 **synthetic overflow** path
/// (`attractor_matching.rs::synthetic_attractor`, used when a body has more
/// cities than real attractors) picks a position by pure grid arithmetic
/// (`grid_h/2, grid_w/4` then a fixed spacing-walk) with **no terrain check at
/// all** — it takes no heightmap/`TerrainAnalysis` argument and never reads
/// `ocean_mask`. A synthetic-overflow settlement CAN land in open ocean. This
/// routing relaxation still degrades that case gracefully (anchors to the
/// nearest passable cell within `COASTAL_ANCHOR_MAX_RING`, or leaves it
/// unrouted beyond that), but the placement itself is not guaranteed land —
/// that gap belongs to D-211/Layer 3, not this file, and is reported rather
/// than silently patched over here (flagged in PR #215 review, not yet ticketed).
/// **Gap this relaxation also happened to cover, now CLOSED at the source
/// (T-1206, 2026-07-26):** D-211's Phase-4 **synthetic overflow** path
/// (`attractor_matching.rs::synthetic_attractor`) used to pick a position by
/// pure grid arithmetic (`grid_h/2, grid_w/4` then a fixed spacing-walk) with
/// **no terrain check at all** — a synthetic-overflow settlement could land
/// in open ocean (confirmed on 46 real bodies at world seed 42/"yolo" before
/// the fix). `synthetic_attractor` now takes an `Option<&TerrainAnalysis>`
/// and, when supplied, guards the arithmetic position: already-land stays
/// byte-identical (verified unmoved on all 63 real land-arithmetic
/// placements), water gets nudged to the nearest acceptable cell via a
/// deterministic ring-walk (`nearest_cell_matching`, the same tie-break
/// pattern as this file's own `nearest_passable_cell` below, at
/// native/working-grid resolution rather than the routing grid's downsample),
/// and a city with no such cell within the bounded search radius is SKIPPED
/// entirely (never fabricated, never a panic — reported via the existing
/// Phase-5 name-fulfillment warning). "Acceptable" is BOTH of D-211 step 4's
/// promises since the PR #218 round-2 fix: land AND at least `MIN_SPACING`
/// from every already-placed city, so correcting one can never quietly spend
/// the other. SKIP therefore also fires where land exists but none of it
/// clears spacing within the bound. This routing relaxation still degrades
/// gracefully for the coastal-cell/downsample case it was built for (anchors
/// to the nearest passable cell within `COASTAL_ANCHOR_MAX_RING`, or leaves
/// it unrouted beyond that) — the two fixes are independent and both apply
/// (T-1206 guarantees the placement pixel is land and correctly spaced, or
/// that the city is skipped; this relaxation still covers the routing CELL
/// being water-majority at downsample granularity).
///
/// The fix anchors routing to the NEAREST passable routing cell (ring-expansion
/// search, deterministic tie-break) rather than the settlement's own impassable