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:
+171
-1
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user