feat(simulation): T-1170 A2/A3 + T-1168 A5 — course inventor, coast termination, riparian signal

A2 (river_course.rs): Stage A rung-independent valley-seeking control
path (chord/8 stations, k=5 bilinear-scored candidates + continuity
penalty); Stage B rung-indexed perpendicular warp on GLOBAL arc-length
(window-independence, Ruling 1e), band chord/2 down to min_wavelength_m
hard-truncate, sine taper to zero at anchors, amplitude min(8% chord,
half-cell) slope/class-scaled. SeedDomain::RiverCourse=17, distinct
salt. Wire: RiverCourse{edge_id,class,points,terminus} on
DistrictWindowLayer.courses (serde-default); bbox-culled, window-
cropped +1 station. TerrainAnalysisCache retains Layer1Output (the
gen_queue:626 discard, Ruling 4b). A3: mouth termination walks
stations sampling the window's OWN rung-consistent morphology verdict,
6-iteration bisect; land-at-anchor probes one segment then None;
EdgeDrain never probes. A5: near_perennial_water point-to-segment
predicate (D-239 §8 governed bands 1-3m class-scaled) threaded through
both batch and window paths; Region always false; never touches
moisture_q. Discipline closed: dormant zoom-ladder bench run + numbers
recorded in the design doc (District 3.009/1.785, Quarter 1.823
us/cell, Region window 0.617ms); course-cost bench CAUGHT a real
+12-36% per-cell riparian scan regression -> precomputed bbox O(1)
reject (60ns->2.2ns/call), final delta +3.4-6.9% at budget; three
determinism tests (overlapping-window byte-identity, cross-rung
amplitude bound, warp-stream cross-correlation r<0.3); goldens: window
sweep gained a verified course-bearing position (pure append), new
river_course golden at both rungs, believability verified unchanged.
Revert-verification discovered the pole-row branch is structurally
unreachable (flow_direction bounds-check) — the real edge-drain path
is k<0 flat-plateau; test fixture rewritten to exercise reality.
scale.rs stale comment fixed. Full cargo test green.

Tickets: T-1170, T-1168

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:36:16 +02:00
co-authored by Claude Fable 5
parent c31cc6220e
commit 4320df9b60
16 changed files with 4353 additions and 78 deletions
+66 -30
View File
@@ -34,6 +34,7 @@ use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos};
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
use crate::atlas::layer1::Layer1Output;
use crate::atlas::layer_proxy::{
build_district_window_layer, DistrictWindowLayer, WindowGranularity,
};
@@ -585,9 +586,23 @@ impl Default for GenerationQueue {
/// D-227-pure derived window (valid forever, no recency signal to track),
/// which body a player keeps panning around IS a recency signal, so
/// access-order eviction is the right fit here.
///
/// **T-1170 (Ruling 4b) — also retains the `Layer1Output` produced alongside
/// `TerrainAnalysis`, not just the latter.** The original entry only kept
/// `TerrainAnalysis` and discarded `run_layer1`'s `Layer1Output` half (the
/// `let (_, ta) = run_layer1(...)` at the old call site) — fine for the
/// six-array district/quarter/vegetation classification the window path
/// used before this ticket, but it meant the window derive path had no way
/// to know which river edges exist near a window without re-running the
/// whole ~45 ms drainage pass a second time. Since this cache already pays
/// that cost once per body and holds the result for the session, keeping
/// BOTH halves of `run_layer1`'s return value is free — `Layer1Output`
/// itself is small (a `RiverNetwork` + basin list + attractor list, not the
/// full grid) relative to `TerrainAnalysis`'s ~1.52 MB of dense per-cell
/// Vecs.
#[derive(Debug)]
struct TerrainAnalysisCache {
entries: std::collections::BTreeMap<String, (TerrainAnalysis, u64)>,
entries: std::collections::BTreeMap<String, (Layer1Output, TerrainAnalysis, u64)>,
/// Monotonic access counter (substitutes for `BodyWorldStateCache`'s
/// `SimTick` — there is no tick concept on a background Rayon thread).
clock: u64,
@@ -607,36 +622,42 @@ impl TerrainAnalysisCache {
}
}
/// Look up a cached `TerrainAnalysis` for `body_id`, re-deriving via
/// `run_layer1` on a miss and inserting the result (evicting the LRU
/// entry first if at capacity). Bumps the access clock on both a hit and
/// a fresh insert (both are "this body was just used").
/// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`,
/// re-deriving via `run_layer1` on a miss and inserting the result
/// (evicting the LRU entry first if at capacity). Bumps the access clock
/// on both a hit and a fresh insert (both are "this body was just used").
///
/// Returns both halves of `run_layer1`'s output (T-1170 Ruling 4b) — the
/// window derive path (`GenWorkItem::DeriveWindow`) needs `Layer1Output`'s
/// `RiverNetwork` to know which river edges exist near the requested
/// window, in addition to the `TerrainAnalysis` it always needed.
fn get_or_derive(
&mut self,
body_id: &str,
heightmap: &crate::atlas::heightmap::BodyHeightmap,
) -> TerrainAnalysis {
) -> (Layer1Output, TerrainAnalysis) {
self.clock += 1;
let now = self.clock;
if let Some((ta, last_used)) = self.entries.get_mut(body_id) {
if let Some((l1, ta, last_used)) = self.entries.get_mut(body_id) {
*last_used = now;
return ta.clone();
return (l1.clone(), ta.clone());
}
let (_, ta) = crate::atlas::layer1::run_layer1(heightmap);
let (l1, ta) = crate::atlas::layer1::run_layer1(heightmap);
if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) {
if let Some(victim) = self
.entries
.iter()
.min_by_key(|(_, (_, last_used))| *last_used)
.min_by_key(|(_, (_, _, last_used))| *last_used)
.map(|(id, _)| id.clone())
{
self.entries.remove(&victim);
}
}
self.entries.insert(body_id.to_string(), (ta.clone(), now));
ta
self.entries
.insert(body_id.to_string(), (l1.clone(), ta.clone(), now));
(l1, ta)
}
#[cfg(test)]
@@ -794,15 +815,22 @@ fn run_work_item(
} else {
hm
};
// TerrainAnalysis via the per-body LRU (T-1137 binding decision +
// PR #187 review C1): first window on a body pays the ~45 ms
// run_layer1 re-derive and populates the cache entry; every
// subsequent window on the SAME body (until eviction) hits the
// cache and skips straight to the ~729 ms per-window pack below.
// This is the memoized form of the SAME workaround
// aliveness_probe --render uses when CascadeSnapshot.terrain_analysis
// is None (it has no cache — a one-shot CLI run doesn't need one).
let ta = terrain_cache
// (Layer1Output, TerrainAnalysis) via the per-body LRU (T-1137
// binding decision + PR #187 review C1, extended T-1170 Ruling
// 4b to retain Layer1Output too): first window on a body pays
// the ~45 ms run_layer1 re-derive and populates the cache
// entry; every subsequent window on the SAME body (until
// eviction) hits the cache and skips straight to the ~729 ms
// per-window pack below. This is the memoized form of the SAME
// workaround aliveness_probe --render uses when
// CascadeSnapshot.terrain_analysis is None (it has no cache —
// a one-shot CLI run doesn't need one).
//
// `l1.river_network` is what lets the window derive know which
// river edges exist near this window (T-1170 A2) without a
// second drainage pass — the fix for the former
// `let (_, ta) = run_layer1(...)` discard (Ruling 4b).
let (l1, ta) = terrain_cache
.lock()
.unwrap()
.get_or_derive(body_id, &working);
@@ -812,6 +840,7 @@ fn run_work_item(
body_id,
body_params,
&ta,
&l1.river_network,
*center,
*n,
&climate,
@@ -1459,32 +1488,39 @@ mod tests {
}
/// A miss re-derives and populates the entry; a subsequent hit for the
/// SAME body returns an equal `TerrainAnalysis` (D-227: the same
/// heightmap always derives to the same analysis) WITHOUT growing the
/// cache — `len()` stays at 1, proving the second call short-circuited
/// past `run_layer1` rather than deriving-then-overwriting.
/// SAME body returns an equal `(Layer1Output, TerrainAnalysis)` pair
/// (D-227: the same heightmap always derives to the same analysis)
/// WITHOUT growing the cache — `len()` stays at 1, proving the second
/// call short-circuited past `run_layer1` rather than
/// deriving-then-overwriting.
#[test]
fn terrain_analysis_cache_hit_reuses_entry() {
let mut cache = TerrainAnalysisCache::new(8);
let hm = window_test_hm();
assert!(!cache.contains("BodyA"));
let first = cache.get_or_derive("BodyA", &hm);
let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm);
assert_eq!(cache.len(), 1);
assert!(cache.contains("BodyA"));
let second = cache.get_or_derive("BodyA", &hm);
let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm);
assert_eq!(
cache.len(),
1,
"a hit must not insert a second entry for the same body"
);
assert_eq!(
first.ocean_mask, second.ocean_mask,
ta_first.ocean_mask, ta_second.ocean_mask,
"same heightmap → identical re-derived analysis (D-227)"
);
assert_eq!(first.slope_deg, second.slope_deg);
assert_eq!(first.elev_pct, second.elev_pct);
assert_eq!(ta_first.slope_deg, ta_second.slope_deg);
assert_eq!(ta_first.elev_pct, ta_second.elev_pct);
// T-1170 Ruling 4b: Layer1Output (river_network in particular) is
// ALSO retained and identically re-derived, not just TerrainAnalysis.
assert_eq!(
l1_first.river_network.river_cells, l1_second.river_network.river_cells,
"Layer1Output.river_network must also be cached/reused, not just TerrainAnalysis"
);
}
/// Different bodies get independent entries, and a capacity-2 cache