fix(simulation): lake_margin_q normalizes per basin — full-range depth gradient (T-1188)

The PR #206 eyeball caught what every numeric gate passed: the depth
signal was visually flat on both test lakes (GJ1c lmq=0 across the whole
basin; GJ338Bd 0-13 of 100). Two compounding causes: a fixed absolute
ceiling (one body's p90 cell depth) compressing skewed depth
distributions into single digits, and heightmap-pitch depth variation
being sub-texel-tiny within most basins. lake_margin_q is now
depth / the basin's own maximum settled depth: HydrologyResult grows
basin_max_depth_scaled (computed in solve() from existing basin_cells
membership, broadcast per basin), threaded through
HydrologySample.basin_max_depth, normalized in lake_from_hydrology_at
with a degenerate-basin epsilon guard (a genuinely uniform pond shades
flat — honest, not forced). Lake EXISTENCE (filled > original) is
untouched — only tone changes. Measured at district spacing:
GJ1c min=0 p50=33 max=84; GJ338Bd min=8 p50=38 max=70 — full-range
shore-to-deep ramps on both. No perceptual curve added: the linear
per-basin ramp is already well-quartiled. project.yaml 0.4.1 -> 0.4.2
(0.4.1-tagged canvases carrying flat-lmq semantics reached real disk
caches during eyeball runs and must miss). Acceptance gates green;
zero golden churn (lake_margin_q not captured by either golden shape).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:23:11 +02:00
co-authored by Claude Fable 5
parent b39dd49950
commit 39f0fd8c51
5 changed files with 124 additions and 42 deletions
+65 -40
View File
@@ -229,18 +229,23 @@ pub struct DistrictProfile {
/// Settled-hydrology lake-margin depth band (T-1188, D-227 amendment (4)
/// continued): `0` at/near the shoreline (the `filled == elevation`
/// crossing `lake_from_hydrology_at` gates on), ramping toward `100` as
/// the settled water surface sits deeper above the original bedrock
/// quantized `((filled - elevation) / LAKE_MARGIN_DEPTH_CEILING * 100)`,
/// clamped. `0` for every non-lake cell (never negative — a cell with no
/// settled water above it has no margin to shade). This is the lake
/// counterpart to `ocean_fraction_q`'s coastal transition-zone gradient:
/// `ocean_fraction_q` is always `0` inside a lake basin (lakes sit ABOVE
/// sea level; `ta.ocean_mask` never fires there), so the existing
/// TidalFlat/DuneStrand/CliffCoast/Estuarine morphology gates — every one
/// keyed on `ocean_fraction_q` — are structurally unreachable at a lake
/// edge. `lake_margin_q` gives the client a continuous tone source for
/// lake shorelines without inventing a second morphology-classification
/// path; see `derive_lake_margin_q`'s doc for the full rationale.
/// the settled water surface sits deeper above the original bedrock,
/// relative to THIS BASIN's own maximum depth — quantized
/// `((filled - elevation) / basin_max_depth * 100)`, clamped, `0` when
/// the basin's own max depth is degenerate (see
/// `LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`). `0` for every non-lake cell
/// (never negative — a cell with no settled water above it has no
/// margin to shade). This is the lake counterpart to `ocean_fraction_q`'s
/// coastal transition-zone gradient: `ocean_fraction_q` is always `0`
/// inside a lake basin (lakes sit ABOVE sea level; `ta.ocean_mask` never
/// fires there), so the existing TidalFlat/DuneStrand/CliffCoast/
/// Estuarine morphology gates — every one keyed on `ocean_fraction_q` —
/// are structurally unreachable at a lake edge. `lake_margin_q` gives
/// the client a continuous tone source for lake shorelines without
/// inventing a second morphology-classification path; see
/// `lake_from_hydrology_at`'s doc for the full per-basin-normalization
/// rationale (PR #206 eyeball finding: a fixed absolute ceiling read
/// visually flat on real lakes).
#[serde(default)]
pub lake_margin_q: i32,
@@ -2101,21 +2106,17 @@ pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f
a + (b - a) * ty
}
/// Saturation ceiling for [`lake_from_hydrology_at`]'s depth-band quantization,
/// in the SAME `[0.0, 1.0]` normalized elevation-fraction units as
/// `HydrologySample.filled`/`elevation` (NOT metres — no per-body elevation
/// span is threaded to this call site, matching `ocean_fraction_q`'s own
/// fraction-domain quantization one scope up). T-1188 calibration (GJ338Bd's
/// `wiki`-committed lake, `believability-v1` seed): sampled 5,043 flooded
/// working-grid cells, depth (`filled - elevation`) p50 ≈ 0.0083, p90 ≈
/// 0.058, max ≈ 0.116 — this ceiling sits just above the observed p90 so
/// most real basins use the full 0100 range instead of clipping early
/// (a shallow margin near the shore reads near-0, a basin's deep centre
/// saturates to 100 — exactly the "deep water reads darker/richer" reading
/// the ocean-side `ocean_fraction_q >= 80` OpenOcean floor already assumes).
/// A tuning constant, not a measured physical limit — revisit if a
/// lore-anchored body ships a dramatically deeper basin.
const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
/// Degenerate-basin guard for [`lake_from_hydrology_at`]'s per-basin
/// normalization: a basin whose own max depth is at or below this floor
/// (in the same `[0.0, 1.0]` normalized elevation-fraction units as
/// `HydrologySample`) is treated as uniformly shallow — `lake_margin_q`
/// reads `0` everywhere in it rather than dividing by a near-zero
/// denominator (which would amplify heightmap sampling noise into an
/// artificial, meaningless gradient). `1e-5` is ~17× smaller than the
/// smallest genuinely-flooded per-cell depth observed in the T-1188
/// calibration survey (below), well inside "this basin has no real depth
/// signal at this heightmap resolution" territory.
const LAKE_MARGIN_DEGENERATE_BASIN_EPSILON: f32 = 1e-5;
/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f)
/// mechanism B) PLUS its T-1188 depth-band extension. Returns
@@ -2132,9 +2133,30 @@ const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
/// here as "fall through to the `ocean_fraction_q` heuristic", never as an
/// error).
/// - `lake_margin_q` — `0` when `!is_lake` (a non-lake cell has no margin to
/// shade); otherwise the settled depth `(filled - original)`, quantized
/// against [`LAKE_MARGIN_DEPTH_CEILING`] to `[0, 100]`. T-1188: this is
/// the continuous tone source lake shorelines were missing —
/// shade); otherwise the settled depth `(filled - original)` at this
/// position, normalized against THIS BASIN's own maximum depth
/// (`HydrologySample.basin_max_depth`, bilinear-sampled at the SAME
/// position — see that field's doc), then quantized to `[0, 100]`.
///
/// **Per-basin, not a fixed absolute ceiling (PR #206 eyeball finding,
/// T-1188 round 2):** the original design used one fixed absolute-depth
/// ceiling calibrated against a single body's p90 depth. Two compounding
/// effects made that read visually flat on real lakes: (1) a linear
/// absolute scale compresses the bulk of any MORE-skewed basin's depth
/// distribution into single-digit values; (2) heightmap resolution
/// (~4078 km/px) means within-basin absolute-depth variation is often
/// sub-texel-tiny (GJ1c's test basin measured a full-basin depth spread
/// under 0.0003 normalized units — genuinely below what a fixed ceiling
/// calibrated for a DIFFERENT body's deeper lakes could ever resolve).
/// Normalizing against each basin's own max depth fixes both: every
/// non-degenerate basin uses the full 0100 range on ITS OWN terms,
/// independent of the body's absolute elevation scale or any other
/// basin's depth. A basin at or below
/// [`LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`] max depth reads `0`
/// everywhere (a genuinely uniform shallow pond shades flat — honest,
/// not forced) rather than dividing by ~zero.
///
/// This is the continuous tone source lake shorelines were missing —
/// `ocean_fraction_q` is definitionally `0` throughout a lake basin (lakes
/// sit above sea level; `ta.ocean_mask` never fires there), so every
/// coastal-transition morphology gate (TidalFlat, DuneStrand, CliffCoast,
@@ -2147,13 +2169,12 @@ const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
/// check). `lake_margin_q` fixes the PRESENTATION gap (hypothesis (b))
/// without touching that already-correct positional refinement.
///
/// 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); computing
/// both `is_lake` and `lake_margin_q` from the one bilinear pair costs
/// nothing beyond the pre-existing sample.
/// All three fields (`elevation`, `filled`, `basin_max_depth`) 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 — one more bilinear sample
/// is not a new cost category, per the workshop's own pipeline-slot ruling).
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) {
let Some(h) = ta.hydrology.as_ref() else {
return (false, 0);
@@ -2162,9 +2183,13 @@ fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32)
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
let is_lake = filled > original;
let lake_margin_q = if is_lake {
(((filled - original) / LAKE_MARGIN_DEPTH_CEILING) * 100.0)
.round()
.clamp(0.0, 100.0) as i32
let basin_max_depth = bilinear(&h.basin_max_depth, ta.w, ta.h, px, py);
if basin_max_depth <= LAKE_MARGIN_DEGENERATE_BASIN_EPSILON {
0
} else {
let depth = (filled - original).max(0.0);
((depth / basin_max_depth) * 100.0).round().clamp(0.0, 100.0) as i32
}
} else {
0
};
+14
View File
@@ -134,6 +134,14 @@ pub struct HydrologySample {
/// water-surface height at every working-grid cell (equal to
/// `elevation` wherever no lake exists).
pub filled: Vec<f32>,
/// `HydrologyResult.basin_max_depth_scaled`, rescaled back to `[0.0,
/// 1.0]` fraction units (T-1188): the MAXIMUM settled depth anywhere in
/// this cell's basin, broadcast to every cell in that basin, `0.0` for
/// non-lake cells. Used to normalize `lake_margin_q` per-basin instead
/// of against a single fixed absolute ceiling — see
/// `district_profile::lake_from_hydrology_at`'s doc for the full
/// rationale (the PR #206 eyeball finding that motivated this field).
pub basin_max_depth: Vec<f32>,
}
const WATER_DIST_CAP: u16 = 255;
@@ -215,9 +223,15 @@ impl TerrainAnalysis {
.iter()
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
.collect();
let basin_max_depth: Vec<f32> = result
.basin_max_depth_scaled
.iter()
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
.collect();
self.hydrology = Some(HydrologySample {
elevation: elevation.to_vec(),
filled,
basin_max_depth,
});
self
}
+40
View File
@@ -176,6 +176,29 @@ pub struct HydrologyResult {
/// edge — the rim/floor discontinuity the workshop's red flag 4 asks
/// about). Row-major, `w × h`.
pub cliff_edge: Vec<bool>,
/// T-1188 lake-margin normalization: for every lake cell, the MAXIMUM
/// settled depth (`filled_scaled[c] - original_scaled[c]`, i64-scaled)
/// anywhere in that cell's basin — broadcast to every cell in the basin
/// (not just the deepest one), `0` for non-lake cells. Row-major, `w ×
/// h`, computed once here (basin membership — `Basin.cells` — and both
/// elevation fields are already in scope at the end of `solve()`) rather
/// than re-derived by every caller.
///
/// **Why per-basin, not a single body-wide constant:** the PR #206
/// eyeball found `district_profile`'s original absolute-depth
/// `LAKE_MARGIN_DEPTH_CEILING` (a single fixed fraction calibrated
/// against one body's p90) compressed the bulk of any more-skewed
/// depth distribution into single-digit `lake_margin_q` values — and,
/// separately, that heightmap resolution (~4078 km/px) means
/// within-basin absolute-depth variation is sometimes sub-texel-tiny
/// (a real basin can be "shallow" in absolute normalized-elevation
/// terms while still having a perfectly good internal shore→deep
/// gradient relative to ITS OWN range). Normalizing against each
/// basin's own max depth makes every non-degenerate lake use the full
/// 0100 tone range regardless of the body's absolute elevation scale —
/// the perceptual point of the ticket (a lake's edge should read
/// lighter than its centre, on every lake, not just deep ones).
pub basin_max_depth_scaled: Vec<i64>,
}
/// Convert an `i64`-scaled elevation value (as carried on
@@ -389,11 +412,28 @@ pub fn solve(
})
.collect();
// T-1188: per-basin max depth, broadcast to every cell in that basin.
// `basin_cells[b]` is exactly the membership list `Basin.cells` above is
// built from — walked directly here rather than through `basins` to
// avoid a second clone of every basin's cell list.
let mut basin_max_depth_scaled = vec![0i64; n];
for b in 0..basin_count {
let max_depth = basin_cells[b]
.iter()
.map(|&c| (filled[c] - original[c]).max(0))
.max()
.unwrap_or(0);
for &c in &basin_cells[b] {
basin_max_depth_scaled[c] = max_depth;
}
}
HydrologyResult {
basins,
filled_scaled: filled,
channel_depth_scaled,
cliff_edge,
basin_max_depth_scaled,
}
}