diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 6436e01a8..d19953f13 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -252,8 +252,22 @@ pub fn run_cascade_from_heightmap( // run_layer1 now returns (Layer1Output, TerrainAnalysis); the TerrainAnalysis // is carried transiently on the snapshot so DistrictProfile + RoadGraph can // reuse it without the former ~45 ms redundant drainage re-run (T-1044). + // + // T-1184: settled-equilibrium hydrology solves inside run_layer1 as part + // of this same pass (D-227 amendment (4), the AnalyzeBody cascade populate + // point). When real BodyParams are available, derive the body's actual + // moisture ceiling (hydrosphere/atmosphere) for the endorheic-vs-overflow + // split rather than falling back to run_layer1's body-agnostic default — + // this cascade entry point always has body_params in scope when the + // caller supplied one, so there is no reason to leave it on the fallback. if up_to >= CascadeLayer::Topography { - let (mut l1, ta) = layer1::run_layer1(&snapshot.heightmap); + let (mut l1, ta) = match body_params { + Some(params) => layer1::run_layer1_with_moisture( + &snapshot.heightmap, + district_profile::derive_moisture_ceiling_q(params), + ), + None => layer1::run_layer1(&snapshot.heightmap), + }; // Stamp the province TerritorialStatus (D-212) onto each basin. for basin in &mut l1.drainage_basins { basin.territorial_status = territorial_status.clone(); diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 6cb3a8932..bd547d9d8 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -551,6 +551,23 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas /// - BraidedPlain (§6) is NOT emitted at district scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) DistrictProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note). /// /// D-010: all gates are integer comparisons. No float arithmetic in this function. +/// +/// `lake_from_hydrology` (T-1184, D-227 amendment (4) / D-255(f) mechanism B): +/// the caller-computed settled-hydrology basin verdict at this exact +/// position — `true` when a bilinear sample of `HydrologyResult.filled_scaled` +/// exceeds a bilinear sample of the original elevation at the SAME position +/// (the continuous filled-surface comparison; never a discrete basin-cell +/// membership lookup, which would give a blocky, non-refining lake edge). +/// `false` both when hydrology genuinely found no lake here AND when no +/// `HydrologyResult` is available at all (`TerrainAnalysis.hydrology == +/// None`) — both cases fall through to the pre-existing `ocean_fraction_q` +/// heuristic below unchanged, so a caller with no hydrology data reproduces +/// today's behaviour byte-for-byte. This is a MORE AUTHORITATIVE trigger +/// checked AHEAD OF the heuristic (per the araminta-round2.md §(e) ruling: +/// "Sea vs. Lake stays exactly as today... the `Lake` emission site gains a +/// second, more-authoritative trigger ahead of the existing heuristic +/// fallback") — it never touches the `ocean_fraction_q >= 80` open-ocean +/// tier, which stays exactly as before. pub fn derive_morphology_zone( tectonic: TectonicClass, glaciation: GlaciationGrade, @@ -558,14 +575,26 @@ pub fn derive_morphology_zone( elev_q: i32, ocean_fraction_q: i32, moisture_q: i32, + lake_from_hydrology: bool, ) -> MorphologyZone { // ── Tier 0: fully submerged ────────────────────────────────────────────── if ocean_fraction_q >= 80 { // Very high ocean fraction: open ocean or lake depending on context. // No body-scale salinity signal at district level yet; treat all as OpenOcean. // Lake differentiation lives at ChunkContext (D-239 §10). + // + // Unchanged by T-1184: a settled-hydrology lake basin can never + // reclassify a cell the raw heightmap already reads as ≥80% below + // sea level back OUT of OpenOcean — hydrology only ever ADDS Lake + // coverage the heuristic was missing, never removes the open-ocean + // floor. (Also structurally moot: `HydrologyResult`'s priority-flood + // seeds from below-sea-level cells, so a genuine open-ocean cell's + // `filled == original` there — no lake basin ever covers it.) return MorphologyZone::OpenOcean; } + if lake_from_hydrology { + return MorphologyZone::Lake; + } if ocean_fraction_q >= 60 { return MorphologyZone::Lake; } @@ -1013,12 +1042,16 @@ pub fn derive_district_temperature_c( /// The `hydro` arms use the **actual `bodies.hydrosphere` vocabulary in systems.db** /// — same set the `[hydrosphere_maritime]` table (D-240) keys on — grouped by available /// surface moisture (T-1034). -pub fn derive_moisture_q( - params: &BodyParams, - elev_q: i32, - ocean_fraction_q: i32, - climate: &ClimateConstants, -) -> i32 { +/// Body-wide moisture ceiling — the wettest a district on this body can be, +/// from `hydrosphere` + `atmosphere` alone (T-1080's `ceiling` term, before +/// the per-district latitude/elevation/continentality gradient). Extracted +/// (T-1184) so a body-level-only consumer — [`crate::atlas::hydrology_equilibrium`]'s +/// `ClimateInputs::moisture_q`, which needs exactly this single scalar and +/// nothing position-specific — can share the vocabulary table with +/// [`derive_moisture_q`] instead of re-deriving a parallel one that could +/// silently drift from it. Byte-identical to the `ceiling` local this +/// function's caller computed inline before the extraction. +pub fn derive_moisture_ceiling_q(params: &BodyParams) -> i32 { let hydro = params.hydrosphere.as_deref().unwrap_or("none"); let atmo = params.atmosphere.as_deref().unwrap_or("none"); @@ -1046,8 +1079,17 @@ pub fn derive_moisture_q( "dense" => 15, _ => 0, }; + (base + atmo_boost).clamp(0, 100) +} + +pub fn derive_moisture_q( + params: &BodyParams, + elev_q: i32, + ocean_fraction_q: i32, + climate: &ClimateConstants, +) -> i32 { // Body moisture ceiling — the wettest a district on this body can be. - let ceiling = (base + atmo_boost).clamp(0, 100); + let ceiling = derive_moisture_ceiling_q(params); // ── Per-district spatial gradient (T-1080) ──────────────────────────────── // Latitude: equator (0) wet → pole (90) dry. `latitude_deg` is per-district. @@ -1479,6 +1521,16 @@ pub fn derive_district_profile( /// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it /// is threaded straight through to `derive_vegetation` unchanged, after every /// moisture/temperature/morphology field above it has already been resolved. +/// +/// ## Lake sourcing (T-1184, D-227 amendment (4)) +/// +/// `lake_from_hydrology` is the caller-computed [`lake_from_hydrology_at`] +/// verdict for this position — threaded straight into +/// [`derive_morphology_zone`]'s new gate, ahead of its pre-existing +/// `ocean_fraction_q >= 60` heuristic. Computed by the caller (not here) for +/// the same reason `near_perennial_water` is: this function stays free of +/// `TerrainAnalysis`/pixel-position concerns, taking only the already-reduced +/// per-position signals every other field here consumes. #[allow(clippy::too_many_arguments)] fn build_district_profile( seed: SeedChain, @@ -1493,6 +1545,7 @@ fn build_district_profile( world_y_m: f64, min_wavelength_m: f64, near_perennial_water: bool, + lake_from_hydrology: bool, ) -> DistrictProfile { let tectonic_class = derive_tectonic_class(body_params); @@ -1555,6 +1608,7 @@ fn build_district_profile( elev_q, ocean_fraction_q, moisture_q, + lake_from_hydrology, ); // Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is @@ -1819,6 +1873,11 @@ fn derive_at_metres_with_riparian( min_wavelength_m, ); + // T-1184: the settled-hydrology lake test, sampled at the SAME (px, py) + // fractional working-grid position every other envelope field here reads + // — the continuous filled-surface comparison (D-227 amendment (4)). + let lake_from_hydrology = lake_from_hydrology_at(ta, px, py); + build_district_profile( seed, ¶ms, @@ -1832,6 +1891,7 @@ fn derive_at_metres_with_riparian( world_y_m, min_wavelength_m, near_perennial_water, + lake_from_hydrology, ) } @@ -1945,6 +2005,11 @@ pub fn derive_orbital_at_metres( None, // no pre-built cache; derive on-the-fly, same posture as derive_at_metres ); + // T-1184: same continuous filled-surface comparison every rung samples, + // at the orbital rung's own (px, py) — lake edges refine at Region + // spacing exactly as they do at every finer rung (D-227 amendment (4)). + let lake_from_hydrology = lake_from_hydrology_at(ta, px, py); + build_district_profile( seed, ¶ms, @@ -1973,6 +2038,7 @@ pub fn derive_orbital_at_metres( // riparian band is many orders of magnitude below Region's ~205 km // spacing and could never fire (Ruling 4e). false, + lake_from_hydrology, ) } @@ -2006,6 +2072,31 @@ pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f a + (b - a) * ty } +/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f) +/// mechanism B): `true` when a bilinear sample of the settled filled-surface +/// field strictly exceeds a bilinear sample of the original elevation at the +/// SAME fractional working-grid position — the continuous comparison that +/// makes lake edges refine with rung exactly like coastlines, rather than +/// projecting `HydrologyResult.basins[*].cells` membership as a discrete, +/// non-refining lookup (explicitly rejected, see this function's callers' +/// docs). `false` when `ta.hydrology` is `None` (no solve available for this +/// analysis — every caller must already treat `false` here as "fall through +/// to the `ocean_fraction_q` heuristic", never as an error). +/// +/// Both `elevation` and `filled` are sampled via the SAME `bilinear` helper +/// `ocean_fraction_q`'s own `ta.elev_pct`/`ta.ocean_mask` reads already use at +/// every derive-core call site (T-1178/T-1154's per-cell rate numbers already +/// include equivalent-cost sampling in the measured per-rung budget — no new +/// cost category, per the workshop's own pipeline-slot ruling). +fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> bool { + let Some(h) = ta.hydrology.as_ref() else { + return false; + }; + let filled = bilinear(&h.filled, ta.w, ta.h, px, py); + let original = bilinear(&h.elevation, ta.w, ta.h, px, py); + filled > original +} + /// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage). fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 { if w == 0 || h == 0 { @@ -2491,6 +2582,277 @@ mod tests { assert_eq!(a.basin_direction as u8, b.basin_direction as u8); } + // ------------------------------------------------------------------- + // T-1184 — lake sourcing from settled hydrology (D-227 amendment (4)) + // ------------------------------------------------------------------- + + /// Bowl-shaped heightmap (high rim, low centre) — same fixture shape as + /// `hydrology_equilibrium.rs`'s own `bowl_grid` and `layer1.rs`'s + /// `bowl_hm`, reproduced locally (both are `#[cfg(test)]`-private to + /// their own modules) so this module's tests can build a + /// `TerrainAnalysis` with real hydrology attached via + /// `with_hydrology` without depending on solver-internal or + /// layer1-internal test helpers. `sea_level: 0.0` keeps the ENTIRE grid + /// dry land except the filled basin, so `ocean_fraction_q` can never + /// independently trigger the pre-existing `>= 60` heuristic — any + /// `Lake` verdict this test observes can only come from the hydrology + /// gate. + fn bowl_hm_no_ocean() -> BodyHeightmap { + let (w, h) = (64u32, 32u32); + let n = (w * h) as usize; + let cx = w as f32 / 2.0; + let cy = h as f32 / 2.0; + let max_r = cx.min(cy).max(1.0); + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32; + let c = (i % w as usize) as f32; + let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0); + 0.1 + d * 0.8 + }) + .collect(); + BodyHeightmap { + body_id: "bowl_test".into(), + width: w, + height: h, + data, + sea_level: 0.0, + } + } + + /// Real end-to-end wiring: solve hydrology on the bowl fixture, attach it + /// via `with_hydrology` (the same call `layer1::run_layer1` makes in + /// production), and confirm `derive_at_metres` classifies the bowl + /// CENTRE as `Lake` — sourced from the hydrology gate, not the + /// `ocean_fraction_q` heuristic (impossible here: `sea_level == 0.0` + /// means `ocean_fraction_q` is always 0 on this fixture). + #[test] + fn derive_at_metres_sources_lake_from_hydrology_at_bowl_centre() { + let hm = bowl_hm_no_ocean(); + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let base_ta = TerrainAnalysis::analyze(&hm, &dr); + let hydrology = crate::atlas::hydrology_equilibrium::solve( + &hm.data, + hm.width, + hm.height, + hm.sea_level, + crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 }, + ); + let ta = base_ta.with_hydrology(&hm.data, &hydrology); + + let climate = ClimateConstants::default(); + let p = BodyParams { + planet_class: Some("temperate".into()), + atmosphere: Some("breathable".into()), + ..Default::default() // body_radius_km: None -> 1 DISTRICT_M = 1 px + }; + let dm = scale::DISTRICT_M as f64; + // Bowl centre in pixel space is (32, 16); no-radius mode maps + // DistrictPos 1:1 onto heightmap pixels. + let prof = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + 32.0 * dm, + 16.0 * dm, + &climate, + 0.0, + &[], + ); + assert_eq!( + prof.morphology_zone, + MorphologyZone::Lake, + "bowl centre must classify Lake via the hydrology-sourced gate; \ + ocean_fraction_q is always 0 on this fixture (sea_level=0.0), so \ + this cannot be the pre-existing heuristic" + ); + assert_eq!( + prof.ocean_fraction_q, 0, + "sanity: heuristic gate never fires here" + ); + } + + /// The same bowl centre, sampled via `derive_orbital_at_metres` (Region + /// rung) — confirms the hydrology gate is wired into BOTH derive paths + /// through the shared `build_district_profile` tail, not just the + /// district/quarter/chunk path. + #[test] + fn derive_orbital_at_metres_sources_lake_from_hydrology_at_bowl_centre() { + let hm = bowl_hm_no_ocean(); + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let base_ta = TerrainAnalysis::analyze(&hm, &dr); + let hydrology = crate::atlas::hydrology_equilibrium::solve( + &hm.data, + hm.width, + hm.height, + hm.sea_level, + crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 }, + ); + let ta = base_ta.with_hydrology(&hm.data, &hydrology); + + let climate = ClimateConstants::default(); + let p = BodyParams { + planet_class: Some("temperate".into()), + atmosphere: Some("breathable".into()), + ..Default::default() + }; + let dm = scale::DISTRICT_M as f64; + let prof = derive_orbital_at_metres( + test_seed(), + "test_body", + &p, + &ta, + 32.0 * dm, + 16.0 * dm, + &climate, + ); + assert_eq!( + prof.morphology_zone, + MorphologyZone::Lake, + "orbital rung must also source Lake from hydrology at the bowl centre" + ); + } + + /// No hydrology attached (`ta.hydrology == None`, the state every + /// pre-T-1184 caller and every OTHER test in this module is already in) + /// must fall through to the pre-existing `ocean_fraction_q` heuristic + /// byte-identically — the whole point of making `with_hydrology` an + /// opt-in builder rather than changing `analyze`'s default output. + #[test] + fn derive_at_metres_without_hydrology_falls_back_to_heuristic() { + let hm = bowl_hm_no_ocean(); + let ta = test_ta(&hm); // no with_hydrology call — ta.hydrology stays None + assert!(ta.hydrology.is_none()); + + let climate = ClimateConstants::default(); + let p = BodyParams { + planet_class: Some("temperate".into()), + atmosphere: Some("breathable".into()), + ..Default::default() + }; + let dm = scale::DISTRICT_M as f64; + let prof = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + 32.0 * dm, + 16.0 * dm, + &climate, + 0.0, + &[], + ); + // sea_level=0.0 on this fixture means ocean_fraction_q is always 0, + // so without hydrology the bowl centre must NOT classify Lake (no + // trigger available at all) — proving the fallback path is inert, + // not silently finding a lake some other way. + assert_ne!( + prof.morphology_zone, + MorphologyZone::Lake, + "without hydrology data, the bowl centre must not classify Lake — \ + confirms with_hydrology is what supplies the signal, not some \ + other implicit path" + ); + } + + /// The D-255(f) mandatory cache-hit == cache-miss determinism gate, + /// applied to lake classification specifically: deriving the SAME + /// position through the SAME `HydrologyResult` (as if reading a resident + /// coarser canvas) must be byte-identical to solving hydrology fresh a + /// second time and deriving again (as if the cache had been evicted and + /// hydrology re-solved) — D-227's "evict -> recompute -> byte-identical" + /// test, instantiated for the hydrology-sourced `morphology_zone` gate + /// this ticket adds. + #[test] + fn lake_classification_cache_hit_equals_cache_miss() { + let hm = bowl_hm_no_ocean(); + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let climate_inputs = crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 }; + + // "Cache hit" path: solve once, reuse the SAME HydrologyResult for + // every sample (mirrors a resident coarser canvas served from cache). + let hydrology_cached = crate::atlas::hydrology_equilibrium::solve( + &hm.data, + hm.width, + hm.height, + hm.sea_level, + climate_inputs, + ); + let ta_hit = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_cached); + + let climate = ClimateConstants::default(); + let p = BodyParams { + planet_class: Some("temperate".into()), + atmosphere: Some("breathable".into()), + ..Default::default() + }; + let dm = scale::DISTRICT_M as f64; + + // Sample several positions (centre, rim, corner) through the "hit" path. + let positions = [(32.0, 16.0), (5.0, 5.0), (60.0, 28.0), (32.0, 4.0)]; + let hit_zones: Vec = positions + .iter() + .map(|&(px, py)| { + derive_at_metres( + test_seed(), + "test_body", + &p, + &ta_hit, + px * dm, + py * dm, + &climate, + 0.0, + &[], + ) + .morphology_zone + }) + .collect(); + + // "Cache miss" path: re-solve hydrology fresh (a second, independent + // solve() call — D-227's eviction/recompute case) and re-derive the + // SAME positions. + let hydrology_fresh = crate::atlas::hydrology_equilibrium::solve( + &hm.data, + hm.width, + hm.height, + hm.sea_level, + climate_inputs, + ); + let ta_miss = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_fresh); + let miss_zones: Vec = positions + .iter() + .map(|&(px, py)| { + derive_at_metres( + test_seed(), + "test_body", + &p, + &ta_miss, + px * dm, + py * dm, + &climate, + 0.0, + &[], + ) + .morphology_zone + }) + .collect(); + + assert_eq!( + hit_zones, miss_zones, + "cache-hit path (reused HydrologyResult) and cache-miss path \ + (freshly re-solved HydrologyResult) must classify byte-identically \ + at every sampled position (D-227 / D-255(f))" + ); + // Non-vacuous: at least the centre position must actually be a lake, + // so this test is exercising the gate, not trivially passing because + // nothing ever classified Lake. + assert!( + hit_zones.contains(&MorphologyZone::Lake), + "sanity: the position sweep must include at least one Lake cell" + ); + } + /// A non-district-aligned fractional metre position (e.g. a quarter-grid /// sample, T-1150) must derive without panicking and stay within the same /// value ranges as the district-aligned case — the whole point of the @@ -3817,7 +4179,10 @@ mod tests { // ----------------------------------------------------------------------- /// Helper to call `derive_morphology_zone` with a complete set of defaults, - /// overriding only the parameters relevant to the test. + /// overriding only the parameters relevant to the test. `lake_from_hydrology` + /// defaults to `false` (T-1184) — no existing caller of this helper tests + /// the hydrology-sourced lake gate; see `lake_from_hydrology_true_wins_...` + /// below for the dedicated hydrology-path tests. fn zone( tectonic: TectonicClass, glaciation: GlaciationGrade, @@ -3833,6 +4198,7 @@ mod tests { elev_q, ocean_fraction_q, moisture_q, + false, ) } diff --git a/server/src/atlas/features.rs b/server/src/atlas/features.rs index 1897a08f6..f7c985e8e 100644 --- a/server/src/atlas/features.rs +++ b/server/src/atlas/features.rs @@ -78,6 +78,41 @@ pub struct TerrainAnalysis { pub slope_deg: Vec, /// Elevation percentile [0,1] among land cells (ocean cells = 0.0). pub elev_pct: Vec, + /// Settled-equilibrium hydrology sourcing (T-1184, D-227 amendment (4) / + /// D-255(f) seed-chaining mechanism B). `None` when hydrology hasn't been + /// solved for this analysis (e.g. every pre-T-1184 call site still using + /// bare [`TerrainAnalysis::analyze`], and every unit test that constructs + /// a `TerrainAnalysis` directly without going through the hydrology-aware + /// entry point) — callers MUST treat `None` as "fall through to the + /// `ocean_fraction_q` heuristic", never as an error. `Some` when + /// [`TerrainAnalysis::with_hydrology`] populated it from a real + /// [`crate::atlas::hydrology_equilibrium::HydrologyResult`]. + pub hydrology: Option, +} + +/// The two continuous working-grid fields `derive_morphology_zone`'s lake +/// sourcing bilinearly samples (T-1184) — never a discrete basin-membership +/// lookup (that gives blocky, non-refining lake edges, the exact D-166 +/// magnified-composite artifact this design avoids; see D-227 amendment (4) +/// / D-255(f) mechanism B). Both fields are row-major, `w × h`, in the SAME +/// `[0.0, 1.0]` normalized domain the raw heightmap and `sea_level` already +/// share — so a bilinear sample of one is directly comparable to a bilinear +/// sample of the other, no rescaling at the call site. +#[derive(Debug, Clone)] +pub struct HydrologySample { + /// The original (unfilled) heightmap elevation, `[0.0, 1.0]`. Not stored + /// anywhere else on `TerrainAnalysis` (`elev_pct` is a land-cell RANK + /// percentile, a different quantity — see its own doc) — this is the + /// literal `hm.data` the solver's `original` array was built from, + /// carried alongside `filled` so both halves of the lake comparison + /// sample from the identical grid at the identical resolution. + pub elevation: Vec, + /// `HydrologyResult.filled_scaled`, rescaled back from the solver's + /// `i64`-scaled integer domain to `[0.0, 1.0]` (dividing by the same + /// `ELEV_SCALE` the solver used to go the other way) — the settled + /// water-surface height at every working-grid cell (equal to + /// `elevation` wherever no lake exists). + pub filled: Vec, } const WATER_DIST_CAP: u16 = 255; @@ -115,9 +150,57 @@ impl TerrainAnalysis { water_dist, slope_deg, elev_pct, + hydrology: None, } } + /// Populate the settled-hydrology sourcing fields (T-1184, D-227 + /// amendment (4) / D-255(f) mechanism B) from a solved + /// [`crate::atlas::hydrology_equilibrium::HydrologyResult`]. + /// + /// Builder-style (consumes and returns `self`) rather than a constructor + /// parameter on [`TerrainAnalysis::analyze`] — `analyze` has ~20 call + /// sites across production code and tests that have no hydrology input + /// (and, per D-227, don't need one: hydrology sourcing is a lake-specific + /// refinement, not a precondition for every other terrain field this + /// struct carries). Keeping `analyze`'s signature untouched means every + /// existing caller keeps working byte-identically; only the two + /// production sites that actually solve hydrology + /// (`layer1::run_layer1`, `gen_queue::TerrainAnalysisCache::get_or_derive`) + /// opt in by chaining this call. + /// + /// Panics if `result`'s grids aren't `self.w * self.h` cells — a + /// programmer error (mismatched working-grid resolution between the + /// heightmap this `TerrainAnalysis` was built from and the elevation grid + /// `solve()` was called on), never a legitimate runtime state. + pub fn with_hydrology( + mut self, + elevation: &[f32], + result: &crate::atlas::hydrology_equilibrium::HydrologyResult, + ) -> TerrainAnalysis { + let n = self.w * self.h; + assert_eq!( + elevation.len(), + n, + "with_hydrology: elevation grid size does not match TerrainAnalysis dims" + ); + assert_eq!( + result.filled_scaled.len(), + n, + "with_hydrology: HydrologyResult grid size does not match TerrainAnalysis dims" + ); + let filled: Vec = result + .filled_scaled + .iter() + .map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s)) + .collect(); + self.hydrology = Some(HydrologySample { + elevation: elevation.to_vec(), + filled, + }); + self + } + #[inline] pub fn is_ocean(&self, r: usize, c: usize) -> bool { self.ocean_mask[idx(r, c, self.w)] diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 8e5ba7bbb..6bb45f9fc 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -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"), diff --git a/server/src/atlas/hydrology_equilibrium.rs b/server/src/atlas/hydrology_equilibrium.rs index 7f581d552..50b963990 100644 --- a/server/src/atlas/hydrology_equilibrium.rs +++ b/server/src/atlas/hydrology_equilibrium.rs @@ -178,6 +178,18 @@ pub struct HydrologyResult { pub cliff_edge: Vec, } +/// Convert an `i64`-scaled elevation value (as carried on +/// [`HydrologyResult::filled_scaled`] and friends) back to the `[0.0, 1.0]` +/// normalized fraction the raw heightmap and `sea_level` are expressed in — +/// the exact inverse of the `(e as f64 * ELEV_SCALE) as i64` conversion +/// `solve()` applies at its own entry point. `pub(crate)` so callers outside +/// this module (T-1184: [`crate::atlas::features::TerrainAnalysis::with_hydrology`]) +/// never need to know or duplicate the scale constant — the module that owns +/// the scaling owns the inverse too. +pub(crate) fn scaled_to_fraction(scaled: i64) -> f32 { + (scaled as f64 / ELEV_SCALE) as f32 +} + /// Moisture/climate inputs governing the endorheic-vs-overflow decision. /// Deliberately minimal and explicitly tunable — see the module docs and the /// results doc's "endorheic criterion" section for the rationale and the diff --git a/server/src/atlas/layer1.rs b/server/src/atlas/layer1.rs index 6d60c2d05..5f365c5ae 100644 --- a/server/src/atlas/layer1.rs +++ b/server/src/atlas/layer1.rs @@ -73,15 +73,74 @@ pub struct Layer1Output { pub survey_basin_dirs: BTreeMap, } +/// Body-wide moisture ceiling fallback for [`run_layer1`]'s hydrology solve +/// when no [`crate::atlas::district_profile::BodyParams`] is available to +/// derive a real one from (`run_layer1`'s signature is heightmap-only, matching +/// `drainage::analyze`'s own "same way it already runs once per body today" +/// shape per T-1177's scope). Matches the T-1177 prototype's own +/// `ClimateInputs { moisture_q: 55 }` population-survey default (moderate +/// hydrosphere, breathable atmosphere) — a reasonable body-agnostic guess, +/// used ONLY by [`run_layer1`]'s two-arg form; [`run_layer1_with_moisture`] +/// (called by every production site that has real `BodyParams` in scope) never +/// reaches this constant. +const DEFAULT_HYDROLOGY_MOISTURE_Q: i32 = 55; + /// Run the Layer-1 topography pipeline for a single body. /// /// Returns `(Layer1Output, TerrainAnalysis)`. The `TerrainAnalysis` is carried /// transiently on `CascadeSnapshot.terrain_analysis` so `cascade.rs` can pass /// it to `derive_all_districts` and `build_road_graph` without re-running the /// full D8 drainage pass (T-1044 — eliminates the PERF/TODO re-run). +/// +/// Solves settled-equilibrium hydrology (T-1177/T-1184, D-227 amendment (4)) +/// once per body as part of this same pass, using +/// [`DEFAULT_HYDROLOGY_MOISTURE_Q`] as the body-wide moisture ceiling — this +/// two-arg form has no `BodyParams` to derive a real one from. Every +/// production call site that DOES have `BodyParams` in scope +/// (`cascade::run_cascade_from_heightmap`, +/// `gen_queue::TerrainAnalysisCache::get_or_derive`) calls +/// [`run_layer1_with_moisture`] instead, so this fallback is only ever +/// exercised by call sites (mostly tests) that never had body params to begin +/// with — never a silent downgrade of a real value. pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) { + run_layer1_with_moisture(hm, DEFAULT_HYDROLOGY_MOISTURE_Q) +} + +/// [`run_layer1`], with the body-wide hydrology moisture ceiling +/// (`ClimateInputs::moisture_q`, T-1177) supplied explicitly rather than +/// defaulted. Callers with a real [`crate::atlas::district_profile::BodyParams`] +/// in scope should derive it via +/// [`crate::atlas::district_profile::derive_moisture_ceiling_q`] and pass the +/// result here, so the endorheic-vs-overflow basin split reflects the body's +/// actual hydrosphere/atmosphere instead of the fallback constant. +/// +/// Determinism (D-010): pure function of `(hm, moisture_q)` — same inputs, +/// byte-identical `TerrainAnalysis.hydrology` every time (inherits +/// `hydrology_equilibrium::solve`'s own determinism guarantee). +pub fn run_layer1_with_moisture( + hm: &BodyHeightmap, + moisture_q: i32, +) -> (Layer1Output, TerrainAnalysis) { let drainage: DrainageResult = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); - let ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage); + let mut ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage); + + // T-1184: solve settled-equilibrium hydrology once per body (the + // AnalyzeBody cascade populate point, D-227 amendment (4)) and fold the + // continuous filled-surface field into this TerrainAnalysis so every + // derive-core caller downstream (`derive_at_metres_with_riparian`) can + // bilinearly sample it for lake sourcing — mechanism B, D-255(f): a + // coarse continuous primitive computed once, sampled fresh at every rung, + // never re-solved. `hm.data` (the raw [0,1] elevation this analysis was + // built from) is the SAME grid the solver runs on, so the two fields + // `with_hydrology` stores are always the correct pairing. + let hydrology = crate::atlas::hydrology_equilibrium::solve( + &hm.data, + hm.width, + hm.height, + hm.sea_level, + crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q }, + ); + ta = ta.with_hydrology(&hm.data, &hydrology); let raw = features::extract_attractors(hm, &drainage, &ta); let attractors: Vec = raw @@ -336,4 +395,115 @@ mod tests { let (rivers, _mtn) = attach_feature_names(&o, &names, &[]); assert!(rivers.len() <= names.len()); } + + // ------------------------------------------------------------------- + // T-1184 — hydrology productionization + // ------------------------------------------------------------------- + + /// Bowl-shaped fixture (high rim, low centre) — the same shape + /// `hydrology_equilibrium.rs`'s own `bowl_grid` test fixture uses, + /// reproduced here (not imported — that one is `#[cfg(test)]`-private to + /// its own module) so `run_layer1`'s hydrology wiring can be exercised + /// end-to-end without depending on solver-internal test helpers. + /// `sea_level: 0.0` keeps the whole grid land except the filled basin, so + /// a resulting `MorphologyZone::Lake` can only be hydrology-sourced, never + /// the `ocean_fraction_q` heuristic fallback. + fn bowl_hm(w: u32, h: u32, body_id: &str) -> BodyHeightmap { + let n = (w * h) as usize; + let cx = w as f32 / 2.0; + let cy = h as f32 / 2.0; + let max_r = cx.min(cy).max(1.0); + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32; + let c = (i % w as usize) as f32; + let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0); + 0.1 + d * 0.8 + }) + .collect(); + BodyHeightmap { + body_id: body_id.into(), + width: w, + height: h, + data, + sea_level: 0.0, + } + } + + #[test] + fn run_layer1_populates_hydrology_on_terrain_analysis() { + let h = bowl_hm(64, 32, "BowlBody"); + let (_o, ta) = run_layer1(&h); + let hydro = ta + .hydrology + .as_ref() + .expect("run_layer1 must populate TerrainAnalysis.hydrology (T-1184)"); + assert_eq!(hydro.elevation.len(), (64 * 32) as usize); + assert_eq!(hydro.filled.len(), (64 * 32) as usize); + // The bowl centre must be a lake cell: filled strictly exceeds original. + let centre_idx = (16 * 64 + 32) as usize; // row 16, col 32 — the bowl centre + assert!( + hydro.filled[centre_idx] > hydro.elevation[centre_idx], + "bowl centre must be filled above its original elevation" + ); + } + + #[test] + fn run_layer1_hydrology_is_deterministic() { + let h = bowl_hm(64, 32, "BowlBody"); + let (_o1, ta1) = run_layer1(&h); + let (_o2, ta2) = run_layer1(&h); + let h1 = ta1.hydrology.expect("first run must populate hydrology"); + let h2 = ta2.hydrology.expect("second run must populate hydrology"); + assert_eq!( + h1.elevation, h2.elevation, + "D-010: identical inputs must produce byte-identical elevation carry" + ); + assert_eq!( + h1.filled, h2.filled, + "D-010: identical inputs must produce byte-identical filled-surface field" + ); + } + + /// The D-255(f) mandatory determinism gate: whether hydrology is solved + /// with the fallback default moisture (`run_layer1`) or an explicit + /// caller-supplied moisture that happens to equal the default + /// (`run_layer1_with_moisture`), the two code paths must produce + /// byte-identical `TerrainAnalysis.hydrology` output — the "cache-hit + /// path == cache-miss path" shape applied to the two entry points that + /// stand in for it here (both are genuinely fresh `derive()` calls; T-1184 + /// has no separate cached-coarser-canvas to compare against yet, since + /// that tier is T-1181's rung-0 scope — this test instead pins that + /// `run_layer1`'s convenience wrapper and its explicit-moisture sibling + /// never silently diverge, which is the property the next ticket's + /// resident-cache read will depend on staying true). + #[test] + fn run_layer1_default_and_explicit_moisture_agree_at_the_default_value() { + let h = bowl_hm(64, 32, "BowlBody"); + let (_o1, ta1) = run_layer1(&h); + let (_o2, ta2) = run_layer1_with_moisture(&h, DEFAULT_HYDROLOGY_MOISTURE_Q); + let h1 = ta1.hydrology.expect("run_layer1 must populate hydrology"); + let h2 = ta2 + .hydrology + .expect("run_layer1_with_moisture must populate hydrology"); + assert_eq!(h1.elevation, h2.elevation); + assert_eq!(h1.filled, h2.filled); + } + + #[test] + fn run_layer1_with_moisture_changes_endorheic_split_not_lake_extent() { + // T-1184 scope note (ticket text): moisture affects the + // endorheic-vs-overflow split only, never lake EXTENT (filled_scaled + // is a pure function of elevation/sea_level, moisture-independent). + let h = bowl_hm(64, 32, "BowlBody"); + let (_o_dry, ta_dry) = run_layer1_with_moisture(&h, 0); + let (_o_wet, ta_wet) = run_layer1_with_moisture(&h, 100); + let hydro_dry = ta_dry.hydrology.expect("dry run must populate hydrology"); + let hydro_wet = ta_wet.hydrology.expect("wet run must populate hydrology"); + assert_eq!( + hydro_dry.filled, hydro_wet.filled, + "lake extent (filled_scaled) must be moisture-independent — only \ + the endorheic/overflow split may vary with moisture_q" + ); + } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 4a1e70f41..72698da36 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -3830,6 +3830,20 @@ mod tests { assert_eq!(ta_pass1.water_dist, ta_pass2.water_dist); assert_eq!(ta_pass1.slope_deg, ta_pass2.slope_deg); assert_eq!(ta_pass1.elev_pct, ta_pass2.elev_pct); + // T-1184: two independent hydrology solves (each run_layer1 call + // solves fresh — no shared HydrologyResult) must also agree + // byte-for-byte, extending this test's own "weakest link in the + // determinism chain" rationale to the newest field on TerrainAnalysis. + let hydro1 = ta_pass1 + .hydrology + .as_ref() + .expect("run_layer1 must populate hydrology"); + let hydro2 = ta_pass2 + .hydrology + .as_ref() + .expect("run_layer1 must populate hydrology"); + assert_eq!(hydro1.elevation, hydro2.elevation); + assert_eq!(hydro1.filled, hydro2.filled); // Now the FULL path: pack a DistrictWindowLayer from each independent // TerrainAnalysis and confirm the complete served payload agrees. diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index a4c2cd0e2..e61641890 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -1554,9 +1554,23 @@ mod tests { ); // Ground truth: derive_at_metres at the SAME settlement world metres, - // via the terrain cache's own re-derive path (run_layer1) so the - // TerrainAnalysis is byte-identical to what the resolver used. - let (_l1, ta) = crate::atlas::layer1::run_layer1(&heightmap); + // via the terrain cache's own re-derive path so the TerrainAnalysis is + // byte-identical to what the resolver used — INCLUDING which hydrology + // moisture ceiling gets solved with. `resolve_settlement_morphology_zone` + // threads `Some(&body_params)` into `get_or_derive` (T-1184), which + // derives the body's real moisture ceiling rather than falling back to + // `run_layer1`'s body-agnostic default; this ground truth must use the + // SAME `run_layer1_with_moisture` path (not bare `run_layer1`) or the + // two `TerrainAnalysis`es solve hydrology at different moisture inputs + // — moot for this fixture's LAKE EXTENT (moisture-independent, see + // `run_layer1_with_moisture_changes_endorheic_split_not_lake_extent`), + // but the two paths must agree by construction, not by coincidence of + // this specific body having no moisture-sensitive basin near the + // sampled position. + let (_l1, ta) = crate::atlas::layer1::run_layer1_with_moisture( + &heightmap, + district_profile::derive_moisture_ceiling_q(&body_params), + ); let expected = district_profile::derive_at_metres( body_seed, "TestBody", diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index d52873993..a6c117a85 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -753,6 +753,7 @@ fn law_glaciation_grade_0_never_produces_fjord() { 60, ocean_q, 55, + false, // T-1184: no hydrology solve in this synthetic-gate sweep ); assert_ne!( zone, @@ -776,6 +777,7 @@ fn law_glaciation_grade_1_never_produces_fjord() { 60, ocean_q, 55, + false, // T-1184: no hydrology solve in this synthetic-gate sweep ); assert_ne!( zone, @@ -798,6 +800,7 @@ fn law_glaciation_grade_2_enables_fjord_with_correct_params() { 60, 25, // coastal 55, + false, // T-1184: no hydrology solve in this synthetic-gate test ); assert_eq!( zone, @@ -817,6 +820,7 @@ fn law_glaciation_grade_4_also_enables_fjord() { 60, 25, 55, + false, // T-1184: no hydrology solve in this synthetic-gate test ); assert_eq!( zone, diff --git a/server/tests/golden/believability.json b/server/tests/golden/believability.json index f88451727..2711991a6 100644 --- a/server/tests/golden/believability.json +++ b/server/tests/golden/believability.json @@ -28,7 +28,7 @@ "morphology_zones": 9, "vegetation_classes": 4, "terrain_materials": 4, - "voxel_relief_m": 27, + "voxel_relief_m": 28, "micro_habitat_distinct": 2 }, "coherence": { diff --git a/server/tests/golden/window_derivation_golden.json b/server/tests/golden/window_derivation_golden.json index 8525ba0da..2470c00fb 100644 --- a/server/tests/golden/window_derivation_golden.json +++ b/server/tests/golden/window_derivation_golden.json @@ -916,5 +916,107 @@ "temperature_dc": 480, "moisture_q": 47, "vegetation": 3 + }, + { + "label": "lake_bowl/lake_centre", + "rung": "district", + "wx_m": 20015086, + "wy_m": 158849, + "min_wl_m": 4096, + "morphology": 1, + "tectonic": 0, + "glaciation": 0, + "precipitation": 3, + "slope_q": 4, + "elev_q": 0, + "ocean_fraction_q": 0, + "temperature_dc": 62, + "moisture_q": 56, + "vegetation": 6 + }, + { + "label": "lake_bowl/lake_centre", + "rung": "quarter", + "wx_m": 20015086, + "wy_m": 158849, + "min_wl_m": 1024, + "morphology": 1, + "tectonic": 0, + "glaciation": 0, + "precipitation": 3, + "slope_q": 5, + "elev_q": 0, + "ocean_fraction_q": 0, + "temperature_dc": 62, + "moisture_q": 56, + "vegetation": 6 + }, + { + "label": "lake_bowl/lake_centre", + "rung": "region", + "wx_m": 20015086, + "wy_m": 158849, + "min_wl_m": 0, + "morphology": 1, + "tectonic": 0, + "glaciation": 0, + "precipitation": 3, + "slope_q": 0, + "elev_q": 0, + "ocean_fraction_q": 0, + "temperature_dc": 62, + "moisture_q": 56, + "vegetation": 6 + }, + { + "label": "lake_bowl/lake_rim", + "rung": "district", + "wx_m": 1250942, + "wy_m": 158849, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 1, + "slope_q": 0, + "elev_q": 71, + "ocean_fraction_q": 0, + "temperature_dc": -120, + "moisture_q": 55, + "vegetation": 1 + }, + { + "label": "lake_bowl/lake_rim", + "rung": "quarter", + "wx_m": 1250942, + "wy_m": 158849, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 1, + "slope_q": 2, + "elev_q": 75, + "ocean_fraction_q": 0, + "temperature_dc": -120, + "moisture_q": 54, + "vegetation": 1 + }, + { + "label": "lake_bowl/lake_rim", + "rung": "region", + "wx_m": 1250942, + "wy_m": 158849, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 1, + "slope_q": 0, + "elev_q": 70, + "ocean_fraction_q": 0, + "temperature_dc": -120, + "moisture_q": 54, + "vegetation": 1 } ] diff --git a/server/tests/window_derivation_golden.rs b/server/tests/window_derivation_golden.rs index 6b6f10137..777ce32ba 100644 --- a/server/tests/window_derivation_golden.rs +++ b/server/tests/window_derivation_golden.rs @@ -28,17 +28,38 @@ //! original body's rows (never interleaved), so the original rows stay //! byte-identical across the I4 regen — see `body_sweep_samples`'s doc. //! +//! **T-1184 fixture fidelity fix (lead review, post-hydrology-productionization):** +//! `sample_ta` now builds its `TerrainAnalysis` via `run_layer1_with_moisture` +//! (solving settled hydrology and attaching it, exactly what +//! `TerrainAnalysisCache::get_or_derive` does in production once `body_params` +//! is `Some` — the ONLY path `derive_window_cell`'s callers reach post-T-1184), +//! not the bare `drainage::analyze` + `TerrainAnalysis::analyze` construction +//! this file used before, which silently pinned the fallback heuristic path +//! (`ta.hydrology == None`) that production no longer takes. Each body in the +//! sweep gets its OWN `ta`, solved with ITS OWN moisture ceiling +//! (`derive_moisture_ceiling_q(params)`) — mirrors production's per-`body_id` +//! `TerrainAnalysisCache` keying, since hydrology's endorheic/overflow split +//! (not lake EXTENT, which is moisture-independent) depends on the body's own +//! params. A FOURTH body (`lake_bowl`, a dedicated bowl-shaped heightmap with a +//! real filled basin at its centre) is appended for exactly this reason: the +//! original three bodies' `sample_hm()` gradient-plus-ripple heightmap +//! produces ZERO filled basins anywhere (verified before this fix — see the +//! coordinator's review), so without a dedicated lake fixture the hydrology +//! lake-sourcing gate would be wired but never pinned by any golden row. +//! //! Run: `cargo test --test window_derivation_golden` //! Regenerate: `UPDATE_GOLDEN=1 cargo test --test window_derivation_golden` use std::path::PathBuf; use settled_reach_server::atlas::district_profile::{ - derive_at_metres, derive_orbital_at_metres, BodyParams, ClimateConstants, + derive_at_metres, derive_moisture_ceiling_q, derive_orbital_at_metres, BodyParams, + ClimateConstants, }; use settled_reach_server::atlas::drainage; use settled_reach_server::atlas::features::TerrainAnalysis; use settled_reach_server::atlas::heightmap::BodyHeightmap; +use settled_reach_server::atlas::layer1::run_layer1_with_moisture; use settled_reach_server::atlas::river_course; use settled_reach_server::atlas::scale; use settled_reach_server::seed::{SeedChain, SeedDomain}; @@ -101,9 +122,22 @@ fn sample_hm() -> BodyHeightmap { } } -fn sample_ta(hm: &BodyHeightmap) -> TerrainAnalysis { - let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); - TerrainAnalysis::analyze(hm, &dr) +/// Builds a `TerrainAnalysis` the PRODUCTION way (T-1184 fixture-fidelity +/// fix): `run_layer1_with_moisture`, solving settled hydrology and attaching +/// it via `with_hydrology`, using the SAME moisture ceiling +/// `TerrainAnalysisCache::get_or_derive` derives from `body_params` in +/// production (`derive_moisture_ceiling_q(params)`) — not the bare +/// `drainage::analyze` + `TerrainAnalysis::analyze` construction this +/// function used before, which left `ta.hydrology == None` and silently +/// pinned the fallback heuristic path production no longer takes once +/// `body_params` is `Some` (the only case `derive_window_cell`'s callers +/// reach: `resolve_settlement_morphology_zone` short-circuits to `None` on a +/// missing `body_params`, and `DeriveWindow`'s `body_params` field is a +/// non-`Option` `Box`). +fn sample_ta(hm: &BodyHeightmap, params: &BodyParams) -> TerrainAnalysis { + let moisture_q = derive_moisture_ceiling_q(params); + let (_l1, ta) = run_layer1_with_moisture(hm, moisture_q); + ta } fn sample_params() -> BodyParams { @@ -153,6 +187,88 @@ fn volcanic_coast_params() -> BodyParams { } } +/// T-1184 (lead review fixture-fidelity fix): a dedicated bowl-shaped +/// heightmap — high rim (0.9), low centre (0.15), same 128×64 dims as +/// `sample_hm()` — so the settled-hydrology solver produces a real, single +/// filled lake basin at the bowl's geometric centre. `sea_level = 0.05` +/// (well below the bowl's own lowest point, 0.15) keeps the ENTIRE grid dry +/// by the raw heightmap threshold, so a `Lake` verdict at the centre can only +/// be hydrology-sourced, never the pre-existing `ocean_fraction_q` heuristic. +/// +/// Needed because NONE of `sample_hm()`/`airless_dry_params()`'s/ +/// `volcanic_coast_params()`'s shared gradient-plus-ripple heightmap ever +/// forms an enclosed depression (verified: `solve()` returns zero basins on +/// it at any moisture input) — without this fixture, T-1184's hydrology +/// lake-sourcing gate would be wired into the derive core but pinned by no +/// golden row at all, exactly the "unpinned production path" gap the review +/// flagged. +fn lake_bowl_hm() -> BodyHeightmap { + let (w, h) = (128u32, 64u32); + let n = (w * h) as usize; + let cx = w as f32 / 2.0; + let cy = h as f32 / 2.0; + let max_r = cx.min(cy); + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32; + let c = (i % w as usize) as f32; + let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0); + 0.15 + d * 0.75 + }) + .collect(); + BodyHeightmap { + body_id: "golden_body_lake_bowl".into(), + width: w, + height: h, + data, + sea_level: 0.05, + } +} + +/// Params for the lake-bowl body — ocean/breathable/temperate at Earth +/// radius (matches `sample_params()`'s class so the lake row exercises the +/// same moisture ceiling as the original body, isolating the bowl geometry +/// as the one variable). +fn lake_bowl_params() -> BodyParams { + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + } +} + +/// Pixel → world-metres, reproducing `district_profile::pixel_to_world_m`'s +/// formula (that function is `pub(crate)`, unreachable from this integration +/// test) — the SAME mapping `derive_at_metres`/`derive_orbital_at_metres` +/// compute inline for their own `(px, py)` derivation. +fn golden_pixel_to_world_m(px: f64, py: f64, w: u32, h: u32, radius_km: f64) -> (f64, f64) { + let wx = px / w as f64 * (std::f64::consts::TAU * radius_km * 1000.0); + let lat_frac = py / (h as f64 - 1.0) - 0.5; + (wx, lat_frac * (std::f64::consts::PI * radius_km * 1000.0)) +} + +/// Sweep positions for the lake-bowl body — just the bowl centre (world +/// metres for pixel (64, 32) at `body_radius_km = 6371.0`) plus a rim point +/// clearly outside the basin, so the golden also pins the "near but not in a +/// lake" boundary case at this fixture. Computed once via +/// `golden_pixel_to_world_m` so the position always matches the ACTUAL bowl +/// centre even if the bowl dimensions ever change, rather than a hand-copied +/// literal. +fn lake_bowl_sweep_positions() -> Vec<(&'static str, f64, f64)> { + let (w, h) = (128u32, 64u32); + let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); + let (centre_x, centre_y) = golden_pixel_to_world_m(cx, cy, w, h, 6371.0); + // A rim pixel (near the bowl edge, outside the filled basin) — same row, + // near the left edge of the bowl's radius. + let (rim_x, rim_y) = golden_pixel_to_world_m(4.0, cy, w, h, 6371.0); + vec![ + ("lake_centre", centre_x, centre_y), + ("lake_rim", rim_x, rim_y), + ] +} + /// Fixed sweep positions (world metres from origin) — a handful of points /// spanning a coastal stretch (per the heightmap's ripple) plus a couple of /// clearly inland/high-latitude points, so the golden exercises coast warp, @@ -244,6 +360,12 @@ const QUARTER_MIN_WL_M: f64 = 1_024.0; /// body, non-empty for the I4 additions) is prepended to each row's `label` /// so multi-body output stays distinguishable without a new struct field /// (see [`GoldenSample`]'s doc on why no `body` field was added). +/// +/// `positions` is now a parameter (T-1184) rather than always calling +/// `sweep_positions()` internally — the lake-bowl body needs its OWN sweep +/// (`lake_bowl_sweep_positions()`, a position that's actually inside its +/// basin), not the original three bodies' coastal/inland/river sweep, which +/// means nothing on the bowl fixture's geometry. #[allow(clippy::too_many_arguments)] fn body_sweep_samples( label_prefix: &str, @@ -252,9 +374,10 @@ fn body_sweep_samples( params: &BodyParams, ta: &TerrainAnalysis, climate: &ClimateConstants, + positions: &[(&'static str, f64, f64)], ) -> Vec { let mut out = Vec::new(); - for (label, wx, wy) in sweep_positions() { + for &(label, wx, wy) in positions { let label = format!("{label_prefix}{label}"); out.push(derive_golden_sample( &label, @@ -290,46 +413,93 @@ fn body_sweep_samples( } /// Build the full golden sample set: the ORIGINAL temperate/ocean/breathable -/// body's sweep first (byte-identical inputs to the pre-I4 `golden_samples` -/// — same seed, same `body_id`, same unprefixed labels, so its rows are -/// byte-identical in the regenerated fixture), THEN the two I4 body rows -/// appended after (never interleaved) so the diff against the pre-I4 golden -/// is a pure append, not a reshuffle. +/// body's sweep first (byte-identical POSITIONS/seed/label to the pre-I4 +/// `golden_samples` — the VALUES move under the T-1184 fixture-fidelity fix, +/// see below), THEN the I4 body rows, THEN the T-1184 lake-bowl body +/// (appended last, never interleaved) so the diff against the pre-fix golden +/// is a pure value-update-plus-append, not a reshuffle. +/// +/// **T-1184: each body now gets its OWN `TerrainAnalysis`**, built via +/// `sample_ta(&hm, params)` — production-faithful (`run_layer1_with_moisture` +/// solving hydrology with THIS body's own moisture ceiling), mirroring +/// `TerrainAnalysisCache`'s real per-`body_id` keying. Before this fix all +/// three bodies shared ONE `ta` built from ONE call with no hydrology +/// attached at all — cheap to share when `TerrainAnalysis` was a pure +/// function of the heightmap alone, no longer correct now that hydrology's +/// endorheic/overflow split depends on the body's own `BodyParams`. This +/// doesn't change any EXISTING row's values on `sample_hm()` bodies (that +/// fixture has zero filled basins at any moisture input — hydrology only +/// changes the fallback-vs-sourced CODE PATH taken, not the OUTPUT, when +/// there's nothing to source) but is required for correctness going forward +/// and is what the lake-bowl body's own per-body `ta` depends on. fn golden_samples() -> Vec { - let hm = sample_hm(); - let ta = sample_ta(&hm); let climate = ClimateConstants::default(); - let mut out = Vec::new(); - // Original body — UNCHANGED inputs from pre-I4 (T-1162 initial landing). + // Original body — same seed/body_id/labels/positions as pre-T-1184; `ta` + // is now built production-faithfully (see doc above) but this fixture has + // no basins, so the row VALUES are unaffected. + let hm = sample_hm(); + let params = sample_params(); + let ta = sample_ta(&hm, ¶ms); out.extend(body_sweep_samples( "", SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7), "golden_body", - &sample_params(), + ¶ms, &ta, &climate, + &sweep_positions(), )); // I4 addition 1: airless/dry — ceiling_q == 0 vegetation short-circuit. + // Same heightmap (sample_hm()) as the original body, but its OWN ta + // (moisture ceiling for an airless/dry body is 0, not the ocean body's + // moisture — matters for endorheic/overflow classification even though, + // again, this fixture has no basins to classify). + let airless_params = airless_dry_params(); + let airless_ta = sample_ta(&hm, &airless_params); out.extend(body_sweep_samples( "airless_dry/", SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 8), "golden_body_airless_dry", - &airless_dry_params(), - &ta, + &airless_params, + &airless_ta, &climate, + &sweep_positions(), )); // I4 addition 2: volcanic/high-tectonic coast — ridged warp, wide scatter_floor. + let volcanic_params = volcanic_coast_params(); + let volcanic_ta = sample_ta(&hm, &volcanic_params); out.extend(body_sweep_samples( "volcanic_coast/", SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 9), "golden_body_volcanic_coast", - &volcanic_coast_params(), - &ta, + &volcanic_params, + &volcanic_ta, &climate, + &sweep_positions(), + )); + + // T-1184 addition: lake-bowl body — a dedicated heightmap WITH a real + // filled basin, so the settled-hydrology lake-sourcing gate this ticket + // adds is pinned by at least one golden row (the original three bodies' + // shared heightmap has zero basins at any sampled position — see this + // function's and `lake_bowl_hm`'s docs). Own heightmap, own params, own + // seed, own sweep (the bowl centre + a rim point, not the coastal/inland + // positions that mean nothing on this fixture's geometry). + let lake_hm = lake_bowl_hm(); + let lake_params = lake_bowl_params(); + let lake_ta = sample_ta(&lake_hm, &lake_params); + out.extend(body_sweep_samples( + "lake_bowl/", + SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 10), + "golden_body_lake_bowl", + &lake_params, + &lake_ta, + &climate, + &lake_bowl_sweep_positions(), )); out @@ -475,8 +645,8 @@ struct GoldenCourseSample { fn river_course_golden_samples() -> Vec { let hm = sample_hm(); - let ta = sample_ta(&hm); let params = sample_params(); + let ta = sample_ta(&hm, ¶ms); let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);