feat(simulation): lake_margin_q depth band — lake shorelines gain gradient vocabulary (T-1188)

Lake edges rendered as hard step-edges while ocean coasts got multi-tone
transition bands: every coastal-transition morphology gate keys on
ocean_fraction_q, definitionally 0 inside a lake basin (hypothesis (b)
of the ticket; (a) disproven first — a shoreline-crossing sweep at
2048/512/128m plus a 10m fine sweep all land on the same continuous
crossing, so positional refinement was never broken). New
DistrictProfile.lake_margin_q (0-100 settled-hydrology depth band, from
the same bilinear filled/elevation pair the lake test already samples;
ceiling calibrated just above the observed p90 depth on GJ338Bd's 5,043
flooded cells), threaded through both derive paths onto
EncodedStepCanvas (serde-default for shape tolerance) and down the
client: protocol decode, terrain-layer plane, colorize shades Lake cells
by depth band instead of elev_q (bedrock-under-water, the wrong signal).
project.yaml 0.4.0 -> 0.4.1: the new wire field must invalidate the
client disk cache via its version tag (T-1183's D-192 mechanism).
Acceptance gates green with the new field (lossless round-trip,
cache-hit==cache-miss, every rung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:08:44 +02:00
co-authored by Claude Fable 5
parent 07f0fa307f
commit dec5b0bc43
12 changed files with 218 additions and 33 deletions
+94 -20
View File
@@ -226,6 +226,24 @@ pub struct DistrictProfile {
/// Ocean fraction for this district (0–100 scale, integer).
pub ocean_fraction_q: i32,
/// 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.
#[serde(default)]
pub lake_margin_q: i32,
/// Per-district river threshold (D-239 §1). Replaces the global
/// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant
/// itself is unchanged — this value is what `DistrictProfile` carries downstream.
@@ -1522,15 +1540,19 @@ pub fn derive_district_profile(
/// 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 sourcing (T-1184, D-227 amendment (4); T-1188 depth band)
///
/// `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.
/// `ocean_fraction_q >= 60` heuristic. `lake_margin_q` is the SAME call's
/// depth-band quantization, threaded straight onto the output profile
/// (T-1188 — no further derivation needed; see `lake_from_hydrology_at`'s
/// doc for what it represents and why lakes need it where oceans don't).
/// 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,
@@ -1546,6 +1568,7 @@ fn build_district_profile(
min_wavelength_m: f64,
near_perennial_water: bool,
lake_from_hydrology: bool,
lake_margin_q: i32,
) -> DistrictProfile {
let tectonic_class = derive_tectonic_class(body_params);
@@ -1635,6 +1658,7 @@ fn build_district_profile(
slope_q,
elev_q,
ocean_fraction_q,
lake_margin_q,
river_threshold,
temperature_c,
moisture_q,
@@ -1876,7 +1900,9 @@ fn derive_at_metres_with_riparian(
// 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);
// T-1188: the same sample also yields the depth-band tone source
// (`lake_margin_q`) lake shorelines were missing.
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
build_district_profile(
seed,
@@ -1892,6 +1918,7 @@ fn derive_at_metres_with_riparian(
min_wavelength_m,
near_perennial_water,
lake_from_hydrology,
lake_margin_q,
)
}
@@ -2008,7 +2035,8 @@ pub fn derive_orbital_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);
// T-1188: the same sample also yields the depth-band tone source.
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
build_district_profile(
seed,
@@ -2039,6 +2067,7 @@ pub fn derive_orbital_at_metres(
// spacing and could never fire (Ruling 4e).
false,
lake_from_hydrology,
lake_margin_q,
)
}
@@ -2072,29 +2101,74 @@ 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 0–100 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;
/// 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).
/// mechanism B) PLUS its T-1188 depth-band extension. Returns
/// `(is_lake, lake_margin_q)`:
///
/// - `is_lake` — `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).
/// - `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 —
/// `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,
/// Estuarine — all keyed on `ocean_fraction_q >= N`) is structurally
/// unreachable at a lake edge even though the underlying position
/// sampling refines correctly with rung (verified: a shoreline-crossing
/// sweep at district/quarter/block spacing lands on the exact same
/// continuous world-metres crossing at every rung, and a 10 m fine sweep
/// confirms sub-block precision — the T-1188 hypothesis (a) positional
/// 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).
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> bool {
/// 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.
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) {
let Some(h) = ta.hydrology.as_ref() else {
return false;
return (false, 0);
};
let filled = bilinear(&h.filled, ta.w, ta.h, px, py);
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
filled > original
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
} else {
0
};
(is_lake, lake_margin_q)
}
/// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage).