feat(simulation): lakes from settled hydrology (D-227, T-1184)
Productionizes the T-1177 equilibrium solver: run_layer1 now solves hydrology once per body (~24ms, mirrors drainage::analyze) and carries it as TerrainAnalysis.hydrology; run_layer1_with_moisture threads the real body moisture ceiling (extracted derive_moisture_ceiling_q), with the T-1177 population-survey default as fallback. The resident rung-0 global tier does not exist yet (T-1181's scope) — hydrology rides TerrainAnalysis and lands in that tier for free when it is built (deviation recorded on the ticket). MorphologyZone::Lake is now sourced from the settled solver at derive time: a gridunit is Lake when bilinear-sampled filled surface exceeds bilinear-sampled original elevation at the sample's own (px, py) — the continuous comparison, so lake edges refine with rung like coastlines; never a discrete basin-cell projection. The gate sits strictly between OpenOcean (>= 80) and the old ocean_fraction heuristic (>= 60), which survives as the derive-fresh fallback when no solve is attached — byte-identical to pre-T-1184 output in that case. Static classification, distinct from the sim-state flooded plane; no endorheic bit (the drains-vs-closed cue is T-1185's outlet-course presence, per the D-227 amendment (4) sequencing). Zero new wire bytes. Acceptance: lake_classification_cache_hit_equals_cache_miss (solve twice independently, byte-identical zones, non-vacuous Lake hit) plus hydrology determinism tests. Golden fidelity: the window golden fixture now builds TerrainAnalysis through the production entry point (run_layer1_with_moisture, per-body), and a dedicated lake_bowl golden body pins the hydrology-sourced Lake path (morphology 1 at ocean_fraction_q 0 — provably not the heuristic); the 108 pre-existing golden rows are byte-identical (pure append). believability.json moved by one lake-shaped line (GJ338Bd voxel_relief_m 27->28, a correctly reclassified lake district leaving the dry-relief sample set). river_course and derivation-harness goldens unchanged. Full suite: 2114 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -684,18 +684,30 @@ impl TerrainAnalysisCache {
|
||||
}
|
||||
|
||||
/// 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").
|
||||
/// re-deriving via `run_layer1_with_moisture` 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.
|
||||
///
|
||||
/// `body_params` (T-1184) is `Option` — `None` when the caller has no DB
|
||||
/// row for this body, matching the same "params absent → fall back"
|
||||
/// posture every other `body_params: Option<&BodyParams>` consumer in this
|
||||
/// module already has (`resolve_settlement_morphology_zone`). Falling
|
||||
/// through to `run_layer1`'s body-agnostic moisture default in that case
|
||||
/// is a body-classification-quality concern (which basins read Endorheic
|
||||
/// vs. Overflow), never a correctness one — lake EXTENT never depends on
|
||||
/// moisture (only the elevation-geometry-gated filled-surface comparison
|
||||
/// does; see `district_profile::derive_morphology_zone`'s lake tier).
|
||||
fn get_or_derive(
|
||||
&mut self,
|
||||
body_id: &str,
|
||||
heightmap: &crate::atlas::heightmap::BodyHeightmap,
|
||||
body_params: Option<&BodyParams>,
|
||||
) -> (Layer1Output, TerrainAnalysis) {
|
||||
self.clock += 1;
|
||||
let now = self.clock;
|
||||
@@ -704,7 +716,13 @@ impl TerrainAnalysisCache {
|
||||
return (l1.clone(), ta.clone());
|
||||
}
|
||||
|
||||
let (l1, ta) = crate::atlas::layer1::run_layer1(heightmap);
|
||||
let (l1, ta) = match body_params {
|
||||
Some(params) => crate::atlas::layer1::run_layer1_with_moisture(
|
||||
heightmap,
|
||||
crate::atlas::district_profile::derive_moisture_ceiling_q(params),
|
||||
),
|
||||
None => crate::atlas::layer1::run_layer1(heightmap),
|
||||
};
|
||||
|
||||
if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) {
|
||||
if let Some(victim) = self
|
||||
@@ -759,7 +777,7 @@ pub(crate) fn resolve_settlement_morphology_zone(
|
||||
let (_l1, ta) = terrain_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_or_derive(body_id, heightmap);
|
||||
.get_or_derive(body_id, heightmap, Some(params));
|
||||
let climate = ClimateConstants::default();
|
||||
let profile = crate::atlas::district_profile::derive_at_metres(
|
||||
body_seed,
|
||||
@@ -961,10 +979,11 @@ fn run_work_item(
|
||||
// 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);
|
||||
let (l1, ta) = terrain_cache.lock().unwrap().get_or_derive(
|
||||
body_id,
|
||||
&working,
|
||||
Some(body_params),
|
||||
);
|
||||
let climate = ClimateConstants::default();
|
||||
let layer = build_district_window_layer(
|
||||
*body_seed,
|
||||
@@ -1644,11 +1663,11 @@ mod tests {
|
||||
let hm = window_test_hm();
|
||||
|
||||
assert!(!cache.contains("BodyA"));
|
||||
let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm);
|
||||
let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm, None);
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(cache.contains("BodyA"));
|
||||
|
||||
let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm);
|
||||
let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm, None);
|
||||
assert_eq!(
|
||||
cache.len(),
|
||||
1,
|
||||
@@ -1678,16 +1697,16 @@ mod tests {
|
||||
let mut cache = TerrainAnalysisCache::new(2);
|
||||
let hm = window_test_hm();
|
||||
|
||||
cache.get_or_derive("BodyA", &hm);
|
||||
cache.get_or_derive("BodyB", &hm);
|
||||
cache.get_or_derive("BodyA", &hm, None);
|
||||
cache.get_or_derive("BodyB", &hm, None);
|
||||
assert_eq!(cache.len(), 2);
|
||||
|
||||
// Touch BodyA again — it is now the MOST recently used, so BodyB
|
||||
// (untouched since its own insert) is the true LRU victim.
|
||||
cache.get_or_derive("BodyA", &hm);
|
||||
cache.get_or_derive("BodyA", &hm, None);
|
||||
|
||||
// Insert a third body — capacity 2 forces an eviction.
|
||||
cache.get_or_derive("BodyC", &hm);
|
||||
cache.get_or_derive("BodyC", &hm, None);
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert!(
|
||||
cache.contains("BodyA"),
|
||||
|
||||
Reference in New Issue
Block a user