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
+83
View File
@@ -78,6 +78,41 @@ 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.
#[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 +150,57 @@ impl TerrainAnalysis {
water_dist,
slope_deg,
elev_pct,
hydrology: None,
}
}
/// Populate the settled-hydrology sourcing fields (T-1184, D-227
/// amendment (4) / D-255(f) mechanism B) from a solved
/// [`crate::atlas::hydrology_equilibrium::HydrologyResult`].
///
/// Builder-style (consumes and returns `self`) rather than a constructor
/// parameter on [`TerrainAnalysis::analyze`] — `analyze` has ~20 call
/// sites across production code and tests that have no hydrology input
/// (and, per D-227, don't need one: hydrology sourcing is a lake-specific
/// refinement, not a precondition for every other terrain field this
/// struct carries). Keeping `analyze`'s signature untouched means every
/// existing caller keeps working byte-identically; only the two
/// production sites that actually solve hydrology
/// (`layer1::run_layer1`, `gen_queue::TerrainAnalysisCache::get_or_derive`)
/// opt in by chaining this call.
///
/// Panics if `result`'s grids aren't `self.w * self.h` cells — a
/// programmer error (mismatched working-grid resolution between the
/// heightmap this `TerrainAnalysis` was built from and the elevation grid
/// `solve()` was called on), never a legitimate runtime state.
pub fn with_hydrology(
mut self,
elevation: &[f32],
result: &crate::atlas::hydrology_equilibrium::HydrologyResult,
) -> TerrainAnalysis {
let n = self.w * self.h;
assert_eq!(
elevation.len(),
n,
"with_hydrology: elevation grid size does not match TerrainAnalysis dims"
);
assert_eq!(
result.filled_scaled.len(),
n,
"with_hydrology: HydrologyResult grid size does not match TerrainAnalysis dims"
);
let filled: Vec<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)]