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:
2026-07-25 02:26:22 +02:00
co-authored by Claude Fable 5
parent 5151c9010f
commit 24fad7090f
12 changed files with 1017 additions and 49 deletions
+171 -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,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"
);
}
}