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:
@@ -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.5–2 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 ~7–29 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 ~7–29 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
|
||||
|
||||
Reference in New Issue
Block a user