Merge remote-tracking branch 'origin/main' into t1181-step-canvas

This commit is contained in:
2026-07-25 03:13:18 +02:00
16 changed files with 1186 additions and 63 deletions
+15 -1
View File
@@ -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();
+374 -8
View File
@@ -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,
&params,
@@ -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,
&params,
@@ -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 01 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<MorphologyZone> = 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<MorphologyZone> = 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,
)
}
+104
View File
@@ -78,6 +78,62 @@ pub struct TerrainAnalysis {
pub slope_deg: Vec<f32>,
/// Elevation percentile [0,1] among land cells (ocean cells = 0.0).
pub elev_pct: Vec<f32>,
/// 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<HydrologySample>,
}
/// 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.
///
/// **Size + clone cost (PR #200 review, Hoshe finding 1):** two `Vec<f32>` at
/// the real 512×256 working grid = ~1.05 MB/entry, added on top of
/// `TerrainAnalysis`'s pre-existing ~1.57 MB of dense fields (~2.62 MB total,
/// ×1.67 growth, not quite a doubling) — see the corrected sizing comment on
/// `GenWorkItem::DeriveWindow` (`gen_queue.rs`) for the full accounting and
/// the `TerrainAnalysisCache` cache-HIT clone-cost note (every hit
/// deep-copies both these `Vec`s, not just the first miss/insert).
///
/// **`elevation` is a deliberate, provably-necessary redundant copy, not an
/// oversight.** `TerrainAnalysis` has no OTHER field that retains the raw
/// `[0,1]` heightmap: `elev_pct` is a RANK PERCENTILE (`rank(elev[i]) /
/// (land_cell_count - 1)`, `compute_elev_percentile`'s own doc/impl) —
/// mathematically a different quantity from absolute elevation, and NOT
/// safe to compare against `filled` (two cells at different true elevations
/// can share adjacent ranks; ocean cells are forced to `0.0` regardless of
/// their real depth). `HydrologyResult` itself carries no elevation field
/// either (`hydrology_equilibrium.rs`: `basins`, `filled_scaled`,
/// `channel_depth_scaled`, `cliff_edge` — no `original`/`elevation` member).
/// So there is no existing bit-identical grid this field could point at
/// instead — carrying its own copy is the only byte-safe option today.
#[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<f32>,
/// `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<f32>,
}
const WATER_DIST_CAP: u16 = 255;
@@ -115,9 +171,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<f32> = 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)]
+86 -29
View File
@@ -197,20 +197,58 @@ pub enum GenWorkItem {
/// `drain_generation_completions` documents).
///
/// **TerrainAnalysis availability (T-1137 binding decision, with numbers;
/// corrected 2026-07-21 per PR #187 review — Tyre C1):** `BodyWorldState`
/// does NOT retain `TerrainAnalysis` after cascade completion (T-1044
/// scoped its transient-carry fix to *within-cascade* reuse only —
/// `cascade.rs` drops it once `DistrictProfile`+`RoadGraph` finish; see the
/// doc on `CascadeSnapshot::terrain_analysis`). Caching it alongside every
/// `BodyWorldStateCache` entry would cost ~2 MB × 50-body capacity ≈
/// 100 MB of PERMANENT resident cost, paid by every cached body whether or
/// not a window is ever requested for it — the exact D-203 budget concern
/// T-1044's own ticket text guarded against. So `run_work_item` re-derives
/// via `run_layer1` (matching `aliveness_probe`'s existing `--render`
/// workaround) rather than persisting a field on `BodyWorldState` — but
/// NOT unconditionally on every work item: the actual model is a small
/// **per-body LRU** (`TerrainAnalysisCache`, capacity 8, ~16 MB worst
/// case), consulted before every re-derive. The FIRST `DeriveWindow` on a
/// corrected 2026-07-21 per PR #187 review — Tyre C1; RE-CORRECTED
/// 2026-07-25 per PR #200 review — Hoshe finding 1, T-1184's hydrology
/// field addition):** `BodyWorldState` does NOT retain `TerrainAnalysis`
/// after cascade completion (T-1044 scoped its transient-carry fix to
/// *within-cascade* reuse only — `cascade.rs` drops it once
/// `DistrictProfile`+`RoadGraph` finish; see the doc on
/// `CascadeSnapshot::terrain_analysis`). Caching it alongside every
/// `BodyWorldStateCache` entry would cost **~2.62 MB × 50-body capacity ≈
/// 131 MB** of PERMANENT resident cost (real per-entry figure below —
/// this multiplier is unchanged, only the per-entry base moved), paid by
/// every cached body whether or not a window is ever requested for it —
/// the exact D-203 budget concern T-1044's own ticket text guarded
/// against. So `run_work_item` re-derives via `run_layer1` (matching
/// `aliveness_probe`'s existing `--render` workaround) rather than
/// persisting a field on `BodyWorldState` — but NOT unconditionally on
/// every work item: the actual model is a small **per-body LRU**
/// (`TerrainAnalysisCache`, capacity 8), consulted before every
/// re-derive.
///
/// **Real per-entry size (T-1184, corrected from the stale "~2 MB"
/// figure the PR #187 pass computed before hydrology existed):** at the
/// real `GRID_W × GRID_H = 512 × 256` working grid (131,072 cells, the
/// SAME grid every `TerrainAnalysisCache` entry is built at — NOT the
/// 1024×512 stored-heightmap-PNG resolution, D-202, which is downsampled
/// away before `run_layer1` ever runs) — pre-T-1184 dense fields
/// (`ocean_mask`/`lake_mask`: `Vec<bool>`, 1 B/cell each; `water_dist`:
/// `Vec<u16>`, 2 B/cell; `slope_deg`/`elev_pct`: `Vec<f32>`, 4 B/cell
/// each) sum to **~1.57 MB**. T-1184's `HydrologySample` (`elevation` +
/// `filled`, both `Vec<f32>`, 4 B/cell) adds **~1.05 MB** — **not quite a
/// doubling** (×1.67, not ×2), but a real, non-trivial per-entry growth:
/// **post-T-1184 total ~2.62 MB/entry.** At capacity 8 that is **~21.0 MB
/// worst case** (was ~12.6 MB pre-T-1184; the old "~16 MB" comment was
/// itself a round-up of the pre-T-1184 figure, not a post-hydrology one).
/// Both this cache and `BodyWorldStateCache`'s counterfactual above use
/// the corrected ~2.62 MB base.
///
/// **Clone-on-HIT, not just insert (Hoshe finding 1, binding for the next
/// sizing decision):** `get_or_derive`'s cache-hit branch
/// (`return (l1.clone(), ta.clone())`) deep-copies the FULL
/// `TerrainAnalysis` — including both new `HydrologySample` `Vec<f32>`
/// fields — on EVERY hit, not merely on the first miss/insert. A
/// pan-heavy session hitting the same body's cache entry repeatedly pays
/// the ~2.62 MB clone cost per `DeriveWindow` work item, not once per
/// body. This was already true pre-T-1184 for the smaller ~1.57 MB
/// struct; T-1184 makes the per-hit clone cost ~1.67× larger, not a new
/// category of cost. **Not fixed this round** (an `Arc<TerrainAnalysis>`
/// refactor — sharing one heap allocation across hits instead of
/// deep-copying — is the obvious next step if hit-heavy clone cost ever
/// shows up in a profile, but is out of scope for T-1184; flagged for a
/// follow-up ticket rather than done speculatively here).
///
/// The FIRST `DeriveWindow` on a
/// body pays the full ~45 ms `run_layer1` cost and populates that body's
/// cache entry; EVERY SUBSEQUENT window on the SAME body (any `center`/`n`,
/// not just an exact repeat — that narrower case is what
@@ -795,18 +833,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;
@@ -815,7 +865,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
@@ -870,7 +926,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,
@@ -1072,10 +1128,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,
@@ -1815,11 +1872,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,
@@ -1849,16 +1906,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"),
+12
View File
@@ -178,6 +178,18 @@ pub struct HydrologyResult {
pub cliff_edge: Vec<bool>,
}
/// 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
+238 -1
View File
@@ -73,15 +73,74 @@ pub struct Layer1Output {
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
}
/// 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<GeographicAttractor> = raw
@@ -336,4 +395,182 @@ 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"
);
// PR #200 review, Hoshe finding 2: the assertion above only proves
// EXTENT is moisture-independent — it says nothing about whether the
// endorheic/overflow split (this test's OWN namesake claim) actually
// moves at all. `TerrainAnalysis.hydrology` doesn't carry
// `BasinOutcome` (only the continuous `elevation`/`filled` fields
// `derive_morphology_zone`'s lake gate needs — see `HydrologySample`'s
// doc), so re-solve directly via `hydrology_equilibrium::solve` to
// reach `Basin::outcome`. `moisture_q: 0` and `moisture_q: 100`
// straddle `ENDORHEIC_MOISTURE_CEILING = 60` on either side, so the
// SAME basin (same elevation geometry, same `bowl_hm`) must classify
// differently — a regression that makes the endorheic split dead code
// (e.g. `is_endorheic` always returning `false`, or `moisture_q` never
// threading through to it) would leave the `filled` assertion above
// green but SHOULD fail here.
let dry_result = crate::atlas::hydrology_equilibrium::solve(
&h.data,
h.width,
h.height,
h.sea_level,
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 0 },
);
let wet_result = crate::atlas::hydrology_equilibrium::solve(
&h.data,
h.width,
h.height,
h.sea_level,
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 100 },
);
assert_eq!(
dry_result.basins.len(),
1,
"fixture sanity: bowl_hm(64, 32, ...) must produce exactly one basin"
);
assert_eq!(
wet_result.basins.len(),
1,
"fixture sanity: same on the wet solve"
);
let dry_outcome = &dry_result.basins[0].outcome;
let wet_outcome = &wet_result.basins[0].outcome;
assert_ne!(
dry_outcome, wet_outcome,
"the SAME basin (identical elevation geometry) must classify \
differently at moisture_q=0 vs moisture_q=100 — these straddle \
ENDORHEIC_MOISTURE_CEILING=60, so a regression making the \
endorheic/overflow split dead code would collapse both to the \
same outcome here"
);
// Non-vacuous on BOTH halves of this test's own name: dry actually
// IS Endorheic, wet actually IS Overflow — not just "different from
// each other" (which alone couldn't rule out both being some other
// unrelated pair of values).
assert!(
matches!(
dry_outcome,
crate::atlas::hydrology_equilibrium::BasinOutcome::Endorheic { .. }
),
"moisture_q=0 (well below the ceiling) must classify Endorheic; got {dry_outcome:?}"
);
assert!(
matches!(
wet_outcome,
crate::atlas::hydrology_equilibrium::BasinOutcome::Overflow { .. }
),
"moisture_q=100 (well above the ceiling) must classify Overflow; got {wet_outcome:?}"
);
}
}
+14
View File
@@ -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.
+17 -3
View File
@@ -1654,9 +1654,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",