feat(simulation): lakes from settled hydrology (D-227, T-1184)
Productionizes the T-1177 equilibrium solver: run_layer1 now solves hydrology once per body (~24ms, mirrors drainage::analyze) and carries it as TerrainAnalysis.hydrology; run_layer1_with_moisture threads the real body moisture ceiling (extracted derive_moisture_ceiling_q), with the T-1177 population-survey default as fallback. The resident rung-0 global tier does not exist yet (T-1181's scope) — hydrology rides TerrainAnalysis and lands in that tier for free when it is built (deviation recorded on the ticket). MorphologyZone::Lake is now sourced from the settled solver at derive time: a gridunit is Lake when bilinear-sampled filled surface exceeds bilinear-sampled original elevation at the sample's own (px, py) — the continuous comparison, so lake edges refine with rung like coastlines; never a discrete basin-cell projection. The gate sits strictly between OpenOcean (>= 80) and the old ocean_fraction heuristic (>= 60), which survives as the derive-fresh fallback when no solve is attached — byte-identical to pre-T-1184 output in that case. Static classification, distinct from the sim-state flooded plane; no endorheic bit (the drains-vs-closed cue is T-1185's outlet-course presence, per the D-227 amendment (4) sequencing). Zero new wire bytes. Acceptance: lake_classification_cache_hit_equals_cache_miss (solve twice independently, byte-identical zones, non-vacuous Lake hit) plus hydrology determinism tests. Golden fidelity: the window golden fixture now builds TerrainAnalysis through the production entry point (run_layer1_with_moisture, per-body), and a dedicated lake_bowl golden body pins the hydrology-sourced Lake path (morphology 1 at ocean_fraction_q 0 — provably not the heuristic); the 108 pre-existing golden rows are byte-identical (pure append). believability.json moved by one lake-shaped line (GJ338Bd voxel_relief_m 27->28, a correctly reclassified lake district leaving the dry-relief sample set). river_course and derivation-harness goldens unchanged. Full suite: 2114 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user