fix(simulation): PR #215 review fixes — hop-unit surcharge, D-210 amendment, citation + gap

The surcharge is now COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING=1 added
directly to length_cells (a pure hop count) — the old cost-unit
constant div_ceil'd through MIN_CELL_COST silently produced 4 hops per
ring, worst-case +24 (double the waypoint threshold) for physically
short edges; worst case is now 6. A formula-pinning test asserts both
the arithmetic and the constant. GJ251c's repro tightened to the
documented 2 edges. The always-land citation now points at the real
guarantee (features.rs::extract_attractors, D-209) — and checking the
D-211 Phase-4 synthetic-overflow path exposed a real gap: it has no
ocean-mask guard at all (T-1206 filed); documented, not papered over.
D-210 gains a dated amendment recording the surrogate-anchor-at-cost
carve-out and the relaxation-over-nudge adjudication. The bare 100
dependency dissolved with the unit fix. Edge counts on both repro
bodies verified unchanged (reachability was never affected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:34:43 +02:00
co-authored by Claude Fable 5
parent 0039bda184
commit de8bcf4ebb
2 changed files with 138 additions and 37 deletions
+136 -37
View File
@@ -84,30 +84,52 @@ const MIN_CELL_COST: u32 = RIVER_COST;
/// T-1116 — coastal-cell routing relaxation. A settlement's OWN routing cell can
/// be majority-water (`RouteGrid::build`'s `water_count * 2 > total_count` rule)
/// even though the settlement's exact placement pixel is always land (D-211's
/// attractor extraction filters `!ocean_mask`, D-209/attractor_matching.rs) —
/// this is pure coastal-cell/downsample granularity (a routing cell spans up to
/// `scale`² native pixels, ROUTE_W=64 on a 512-wide grid ⇒ up to 8² = 64 native
/// cells folded into one routing cell). Without this, one water-majority routing
/// cell under a coastal city hard-fails A* for every edge touching it (the
/// documented T-1116 bug: GJ251c land 0.55 / GJ380c land 0.588 — land-majority
/// BODIES with 0 routable edges).
/// even though the settlement's exact placement pixel is (almost) always land:
/// every REAL attractor type — including `PlainCenter` — is extracted with an
/// explicit `!ta.ocean_mask[i]` guard (D-209, `features.rs::extract_attractors`,
/// e.g. the `CoastalAccess` filter at line 544 and the `PlainCenter` filter at
/// line 629). This is pure coastal-cell/downsample granularity (a routing cell
/// spans up to `scale`² native pixels, ROUTE_W=64 on a 512-wide grid ⇒ up to
/// 8² = 64 native cells folded into one routing cell). Without this relaxation,
/// one water-majority routing cell under a coastal city hard-fails A* for every
/// 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).
///
/// The fix anchors routing to the NEAREST passable routing cell (ring-expansion
/// search, deterministic tie-break) rather than the settlement's own impassable
/// cell, and prices the gap as a short access-road surcharge
/// (`COASTAL_ACCESS_COST_PER_RING` per ring) — a real quay/causeway link is
/// modeled as more expensive than being fully inland, never free. This keeps
/// `IMPASSABLE` an honest ocean/lake fact everywhere else in the cost field
/// (D-210's terrain_modification_cost else-branch is untouched) — only the
/// anchor LOOKUP for a start/goal settlement is relaxed, not open-ocean transit.
/// (`COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING` per ring) — a real quay/causeway
/// link is modeled as more expensive than being fully inland, never free. This
/// keeps `IMPASSABLE` an honest ocean/lake fact everywhere else in the cost
/// field (D-210's terrain_modification_cost else-branch is untouched) — only
/// the anchor LOOKUP for a start/goal settlement is relaxed, not open-ocean
/// transit.
const COASTAL_ANCHOR_MAX_RING: usize = 3;
/// Surcharge added to a routed edge's cost per ring-cell of coastal anchor
/// search (T-1116). One ring ≈ one `ORTHO` step at grassland baseline (100),
/// so a ring-1 anchor costs the same as one extra plain grassland hop —
/// noticeable in tie-breaks between near-identical routes, never prohibitive.
const COASTAL_ACCESS_COST_PER_RING: u32 = ORTHO * 100;
/// Surcharge added to a routed edge's reported `length_cells` per ring-cell of
/// coastal anchor search (T-1116). `length_cells` is a pure HOP COUNT (the
/// number of routing-cell steps in the path, [`RouteGrid::reconstruct`]), NOT
/// a cost-unit quantity — so this constant is defined directly in hop units,
/// not derived from `ORTHO`/cell-cost scale. One ring = one extra hop: a
/// ring-1 anchor reports as if the path took one additional plain step,
/// noticeable in tie-breaks between near-identical routes and in
/// [`WAYPOINT_THRESHOLD_CELLS`], never prohibitive (worst case, both anchors
/// at [`COASTAL_ANCHOR_MAX_RING`], is `2 * 3 = 6` extra hops — half the
/// waypoint threshold, not double it).
const COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING: u32 = 1;
/// A routed edge longer than this (in routing cells) earns a midpoint waypoint
/// (tyre-round1.md: "edge length > ~12 regional cells").
@@ -1145,23 +1167,25 @@ impl RouteGrid {
/// T-1116: if `start`'s or `goal`'s own routing cell is water-majority
/// (coastal-cell granularity — the settlement's exact pixel is always
/// land, D-211/D-209), route to/from the nearest passable cell instead
/// of hard-failing, and add a [`COASTAL_ACCESS_COST_PER_RING`] surcharge
/// per ring of search distance to `length_cells` (an honest short
/// access-road cost, not a free pass through water). Still returns `None`
/// when no passable cell exists within [`COASTAL_ANCHOR_MAX_RING`] of
/// either endpoint (fully water-locked at this granularity) or when no
/// path connects the two anchors (genuinely separated by ocean).
/// of hard-failing, and add [`COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING`] hop
/// to `length_cells` per ring of search distance (an honest short
/// access-road cost, not a free pass through water — `length_cells` is a
/// hop count, so the surcharge is expressed directly in hops, never
/// converted through cost units). Still returns `None` when no passable
/// cell exists within [`COASTAL_ANCHOR_MAX_RING`] of either endpoint
/// (fully water-locked at this granularity) or when no path connects the
/// two anchors (genuinely separated by ocean).
fn astar(&self, start: (f64, f64), goal: (f64, f64)) -> Option<(Vec<(usize, usize)>, u32)> {
let (sr0, sc0) = self.to_route_cell(start);
let (gr0, gc0) = self.to_route_cell(goal);
let (sr, sc, s_ring) = self.nearest_passable_cell(sr0, sc0)?;
let (gr, gc, g_ring) = self.nearest_passable_cell(gr0, gc0)?;
let surcharge =
(s_ring as u32 + g_ring as u32).saturating_mul(COASTAL_ACCESS_COST_PER_RING);
let surcharge_hops =
(s_ring as u32 + g_ring as u32).saturating_mul(COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING);
let s = sr * self.rw + sc;
let g = gr * self.rw + gc;
if s == g {
return Some((vec![(sr, sc)], surcharge.div_ceil(MIN_CELL_COST)));
return Some((vec![(sr, sc)], surcharge_hops));
}
let n = self.rw * self.rh;
@@ -1175,11 +1199,10 @@ impl RouteGrid {
while let Some(Reverse((_, cur))) = open.pop() {
if cur == g {
let (path, length_cells) = self.reconstruct(&came, g);
// Surcharge folded into the reported length (routing cells
// are the shared unit `length_cells` already carries — see
// WAYPOINT_THRESHOLD_CELLS/maintenance_authority callers).
let surcharge_cells = surcharge.div_ceil(MIN_CELL_COST);
return Some((path, length_cells.saturating_add(surcharge_cells)));
// Surcharge folded into the reported length (already in hop
// units — see WAYPOINT_THRESHOLD_CELLS/maintenance_authority
// callers, which both read `length_cells` as a hop count).
return Some((path, length_cells.saturating_add(surcharge_hops)));
}
let cr = cur / self.rw;
let cc = cur % self.rw;
@@ -2068,8 +2091,13 @@ mod tests {
/// (`cascade.rs:405`), not a synthetic harness.
#[test]
fn real_body_coastal_settlements_produce_routable_edges() {
// (body, expected node count, minimum edges after the fix).
let cases = [("GJ251c", 3usize, 1usize), ("GJ380c", 3usize, 1usize)];
// (body, expected node count, minimum edges after the fix). GJ251c's
// documented post-fix count is 2 edges (all 3 placements land in
// IMPASSABLE cells pre-fix); GJ380c's is 1 (2 of 3 placements are
// already passable, only Sethvale needed the relaxation) — tightened
// to the exact documented counts so a regression to fewer edges
// fails loudly (PR #215 review finding 2).
let cases = [("GJ251c", 3usize, 2usize), ("GJ380c", 3usize, 1usize)];
for (body, min_placements, min_edges) in cases {
let bws = crate::atlas::believability::cascade_for_body(42, body)
.unwrap_or_else(|e| panic!("{body}: {e}"));
@@ -2299,11 +2327,82 @@ mod tests {
cell with no relaxation involved: got coastal={coastal_len} \
surrogate={surrogate_len}"
);
let surcharge_cells = coastal_len - surrogate_len;
let surcharge_hops = coastal_len - surrogate_len;
assert!(
surcharge_cells > 0,
surcharge_hops > 0,
"the coastal surcharge must be a strictly positive number of \
routing-cell-equivalents, never zero (never free)"
hops, never zero (never free)"
);
// PR #215 review finding 1: `length_cells` is a pure HOP COUNT, so the
// surcharge must be expressed directly in hops — pin the exact value
// (ring * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING) so a future unit
// mismatch (e.g. reintroducing a cost-unit conversion) fails loudly
// instead of merely "some positive number".
assert_eq!(
surcharge_hops,
ring as u32 * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING,
"the goal-side surcharge must be EXACTLY ring * hops-per-ring \
(the start side contributes 0 here — start is ring 0)"
);
}
/// PR #215 review finding 1 — pins the surcharge formula directly (no
/// terrain, no A*, just the arithmetic in `astar`): the reported
/// `length_cells` surcharge for a start/goal pair is EXACTLY
/// `(start_ring + goal_ring) * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING`
/// hops — never a cost-unit quantity in disguise. Uses the `s == g`
/// short-circuit branch in `astar` (same routing cell for start and
/// goal) to read the surcharge in complete isolation from pathfinding.
#[test]
fn coastal_surcharge_is_exactly_hops_per_ring_times_ring_count() {
// Same 180-wide/scale-3 body as the other boundary tests: cell (0,3)
// is IMPASSABLE (2-of-3 majority), cell (0,6) is passable (1-of-3,
// stays land under the strict `>` rule).
let w = 180u32;
let h = 1u32;
let mut hm = BodyHeightmap {
body_id: "surcharge_formula_test".into(),
width: w,
height: h,
data: vec![0.6; (w * h) as usize],
sea_level: 0.3,
};
let idx = |r: u32, c: u32| (r * w + c) as usize;
hm.data[idx(0, 9)] = 0.1;
hm.data[idx(0, 10)] = 0.1;
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta = TerrainAnalysis::analyze(&hm, &dr);
let grid = RouteGrid::build(&ta, &[], w, h);
assert_eq!(
grid.cost[3], IMPASSABLE,
"sanity: cell (0,3) must be water-majority"
);
// Start AND goal both land in the same impassable cell (native cols
// 9 and 11, both inside routing cell (0,3)) — astar's `s == g`
// branch returns `surcharge_hops` directly with zero path-length
// noise, isolating the formula from A*'s own hop accounting.
let (_, ring) = {
let (r, c, ring) = grid.nearest_passable_cell(0, 3).unwrap();
((r, c), ring)
};
let (_, reported_hops) = grid
.astar((0.0, 9.0), (0.0, 11.0))
.expect("same-cell start/goal must resolve via the shared surrogate");
assert_eq!(
reported_hops,
(ring as u32) * 2 * COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING,
"start and goal share the same impassable cell -> same ring on \
both sides -> surcharge = 2 * ring * hops-per-ring, with ZERO \
actual path hops (s == g after anchoring)"
);
// With the constant fixed at 1 hop/ring (finding 1's chosen value),
// this resolves to a concrete number — pin it so a future change to
// COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING is a deliberate, visible act.
assert_eq!(
COASTAL_ACCESS_SURCHARGE_HOPS_PER_RING, 1,
"if this changes, the assertion above must be re-derived, not \
just relaxed"
);
}
}