fix(simulation): derive at the octaves a rung can actually reconstruct (T-1240)

Region rendered as fine uniform stucco while District and Quarter, on identical
code, read as terrain. The cause was sampling: `min_wl_m` arrives as an LOD
request and defaults to 0, so every invented octave contributed at every rung.
MIN_WL_BANDS_M was meant to be the floor but is built from the rung's CELL SIZE
(2 x DISTRICT_M), which stopped being the sample spacing at the D-255 extent
inversion — a rung fixes EXTENT now and spacing falls out of the canvas size.
The bands were off by roughly the cell count, and the served path never consulted
them anyway.

The cutoff is now derived from the resolved spacing, which is what this ticket
asked for. Two things had to be measured rather than reasoned to get it right,
and both corrected me.

FIRST: the field was the culprit, not the renderer. I attributed the stucco to
the client stipple painting noise onto a smooth field. Surfacing the terrain
layer's own mean |relief_q gradient| in the capture readout settled it in one
shot: Region 18.24 steps per cell — 144 m of relief between NEIGHBOURING cells —
against District's 0.30 and Quarter's 0.07. The server was sending noise. That
diagnostic ships here for the same reason `plane_variety` did in T-1213: a noisy
field and a renderer inventing noise look identical, and one number separates
them.

SECOND: Nyquist is the wrong threshold. The first version floored at 2 x spacing,
the aliasing limit, and Region barely moved (56.16 -> 59.73 lum spread, gradient
still 18.24) because 2 samples per cycle is unaliased but renders jagged. The
rungs that already worked say what the real bar is: District reconstructs its
finest surviving octave at 34 samples per cycle, Quarter at 135. At 8x, Region
goes to 1.08 gradient and 70.01 spread, and shows ridges and valleys.

THE TRADE, taken deliberately and recorded in the tests: an 8x floor also
truncates the coast warp's 2,048 and 1,024 m octaves at Region, the band T-1160
added for "one coastline at every rung". An earlier test here asserted that band
must survive; it now asserts the opposite. Same reasoning as the relief: a
1,024 m coastline wiggle at 379.3 m per cell is 2.7 samples per cycle, so drawing
it draws noise rather than coastline character — a rung cannot show shape finer
than its own cell. The warp is amplitude-capped sub-pixel on the working grid, so
what is lost is small. If a future pass wants the warp exempt, the fix is a
relief-only floor threaded through derive_at_metres, NOT a lower multiple, which
takes the stucco back.

Global is exempt: its floor would be ~70 km and would truncate the whole warp
band, and it needs none — the orbital derive leaves relief_q flat at 50. District
(3.79 m spacing) and Quarter (0.948 m) floor below every octave in play and
derive byte-identically, which their own test pins.

Cache-safe by construction: the floor is a pure function of (rung, extent,
body_radius), all three already in the step-canvas cache key. 0.4.12 is required
anyway — this changes derived BYTES at Region, so a 0.4.11 entry holds a field
this build would never produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:43:46 +02:00
co-authored by Claude Opus 5
parent 6d4c9e92c2
commit 9b146f9e1f
8 changed files with 299 additions and 54 deletions
+169 -1
View File
@@ -469,6 +469,80 @@ pub fn quantize_min_wl_m_for_rung(raw: u32) -> u32 {
crate::atlas::layer_proxy::quantize_min_wl_m(raw)
}
/// The octave cutoff a canvas actually derives at: the wire request, floored by
/// what this canvas's own SAMPLE SPACING can carry (T-1240).
///
/// # Why the wire value alone was wrong
///
/// `min_wl_m` arrives as an LOD request and defaults to 0 — no cutoff — so every
/// invented octave contributed at every rung regardless of whether the canvas
/// could resolve it. `MIN_WL_BANDS_M` was supposed to be the floor, but it is
/// built from the rung's CELL SIZE (`2 × DISTRICT_M`), which stopped being the
/// sample spacing at the D-255 extent inversion: a rung now fixes EXTENT and the
/// spacing falls out of the canvas size. The bands are off by roughly the cell
/// count, and the served path never consulted them anyway.
///
/// # What that cost, measured
///
/// Region samples every 379.3 m while `voxel_relief`'s band runs 1,024/512/256/
/// 128 m. Three of those four octaves are below Nyquist, so neighbouring cells
/// drew essentially independent values and the T-1213 hillshade read ALIASING
/// rather than slope — Region rendered as fine uniform stucco while District and
/// Quarter, on identical code, read as terrain.
///
/// # Why this is a floor and not a replacement
///
/// `max`, so a caller asking for something coarser still gets it: the wire value
/// is a legitimate "give me less detail" knob, while this is a physical limit on
/// what the canvas can represent. Only the limit is non-negotiable.
///
/// # Why Global is exempt
///
/// Global's floor would be ~70 km, which would truncate the ENTIRE coast-warp
/// band (`WARP_OCTAVE_WAVELENGTHS_M`, finest 1,024 m) and undo T-1160's "one
/// coastline at every rung". It needs no floor regardless: the orbital rungs skip
/// the sub-district derive, so `relief_q` is a flat 50 there and there is no
/// fine band to alias.
///
/// # What this actually changes
///
/// Only Region. Its 758.6 m floor truncates relief's 512/256/128 m octaves while
/// leaving the 1,024 m one — and leaves the coast warp untouched, whose finest
/// octave is exactly 1,024 m. District's floor is 7.6 m and Quarter's 1.9 m,
/// both below every octave in play, so those rungs derive byte-identically.
///
/// Cache-safe by construction: this is a pure function of `(rung, extent,
/// body_radius)`, and all three are already in the step-canvas cache key, so the
/// same key still means the same bytes.
pub fn effective_min_wavelength_m(rung: StepCanvasRung, min_wl_m: u32, step_m: f64) -> f64 {
if rung.is_global() {
return min_wl_m as f64;
}
(min_wl_m as f64).max(SMOOTH_SAMPLES_PER_WAVELENGTH * step_m)
}
/// Samples per wavelength the finest surviving octave must get.
///
/// NOT 2. Nyquist (2 samples per cycle) is the ALIASING limit — the point below
/// which a wave is indistinguishable from a slower one — and it was the first
/// value tried here. It did not work, and the measurement says why: at 2x, a
/// Region canvas (379.3 m spacing) keeps the 1,024 m relief octave at 2.7
/// samples per cycle, and the mean cell-to-cell relief gradient measured 18.24
/// steps — 144 m of relief between neighbouring cells. Technically unaliased,
/// visually noise, and the hillshade drew it as stucco.
///
/// The rungs that read as terrain say what the real threshold is. District keeps
/// a 128 m finest octave at 3.79 m spacing — 34 samples per cycle — and measures
/// a 0.30 gradient; Quarter gets 135 and measures 0.07. Reconstruction has to be
/// oversampled by an order of magnitude before a sampled field reads as a
/// surface rather than as a field of independent draws.
///
/// 8 is the compromise: at Region it cuts the 1,024 m octave and leaves 4,096 m
/// at ~10.8 samples per cycle. Higher would be smoother still but starts
/// discarding real structure the rung could legitimately show; the acceptance
/// bar is the captured ladder, not this number.
const SMOOTH_SAMPLES_PER_WAVELENGTH: f64 = 8.0;
// ---------------------------------------------------------------------------
// Derive core — one row-chunked parallel pass per rung (D-255(f) seed-chaining:
// independent re-derivation, fallback path; mechanism-B acceleration lives in
@@ -1041,10 +1115,10 @@ pub fn build_step_canvas(
let body_radius_km = params.body_radius_km.unwrap_or(0.0);
let (width, height) = resolve_canvas_extent(rung, extent, body_radius_km);
let min_wavelength_m = min_wl_m as f64;
// RESOLVED dims, not the requested `extent` — a clamped canvas covers the
// same ground at a coarser pitch (see StepCanvasRung::spacing_m).
let step_m = rung.spacing_m(width, height, body_radius_km);
let min_wavelength_m = effective_min_wavelength_m(rung, min_wl_m, step_m);
let cells = (width * height) as usize;
let half_w = (width / 2) as i32;
@@ -2338,6 +2412,100 @@ mod tests {
// Quantization reuse
// -----------------------------------------------------------------
// -----------------------------------------------------------------
// Spacing-derived octave floor (T-1240)
// -----------------------------------------------------------------
/// Ferrath's Region spacing on the 1290x540 canvas the descent ladder shoots.
const REGION_STEP_M: f64 = 379.3;
#[test]
fn floor_truncates_the_octaves_region_cannot_reconstruct() {
let floor = effective_min_wavelength_m(StepCanvasRung::Region, 0, REGION_STEP_M);
// 8 samples per wavelength at 379.3 m spacing.
assert!(floor > 3_000.0 && floor < 3_100.0, "floor was {floor}");
// The whole voxel-relief band goes: even its coarsest entry (1,024 m)
// is only 2.7 samples per cycle here, which measured as an 18.24
// step-per-cell gradient and rendered as stucco.
for wl in [1_024.0, 512.0, 256.0, 128.0] {
assert!(wl < floor, "octave {wl} must be truncated at Region");
}
// What remains is the terrain_detail band (>= 4,096 m), which Region
// samples ~10.8 times per cycle — the ridge-and-valley form it now shows.
assert!(4_096.0 > floor, "the 4,096 m terrain octave must survive");
}
/// THE TRADE, recorded rather than hidden. An 8x reconstruction floor also
/// truncates the coast warp's two finest octaves (2,048 and 1,024 m) at
/// Region, and T-1160 added that band so there is "one coastline at every
/// rung". An earlier version of this test asserted the opposite — that the
/// 1,024 m warp octave must survive — and a 2x Nyquist floor honoured it.
///
/// It was changed deliberately, on the same reasoning that fixed the relief:
/// a 1,024 m coastline wiggle at 379.3 m per cell is 2.7 samples per cycle,
/// so drawing it draws NOISE, not coastline character. A rung cannot show
/// shape finer than its own cell, and pretending otherwise is what made this
/// rung unreadable. The warp is amplitude-capped sub-pixel on the working
/// grid (`WARP_AMPLITUDE_CAP_PX`), so what is lost at Region is small.
///
/// If a future pass wants the warp exempt, the fix is a relief-only floor
/// threaded through `derive_at_metres` — not a lower global multiple, which
/// would take the stucco back.
#[test]
fn coast_warp_fine_octaves_are_deliberately_truncated_at_region() {
let floor = effective_min_wavelength_m(StepCanvasRung::Region, 0, REGION_STEP_M);
for wl in [2_048.0, 1_024.0] {
assert!(
wl < floor,
"coast-warp octave {wl} survives Region's floor {floor} — if this \
fires, the multiple was lowered and the stucco is back"
);
}
// The coarse warp octaves — the ones that carry the coastline's actual
// shape at this scale — must still contribute.
assert!(
4_096.0 > floor,
"the coast warp must keep every octave Region can reconstruct"
);
}
/// Global is exempt: its floor would be ~70 km and would truncate the whole
/// coast-warp band. It needs none — the orbital derive leaves relief_q flat.
#[test]
fn global_takes_no_spacing_floor() {
let step_m = 35_267.0; // Ferrath Global
assert_eq!(
effective_min_wavelength_m(StepCanvasRung::Global, 0, step_m),
0.0
);
}
/// District and Quarter sample far finer than any octave in play, so the
/// floor must be inert there — those rungs already read as terrain and this
/// change must not move them.
#[test]
fn floor_is_inert_at_the_rungs_that_already_resolve_their_band() {
for (rung, step_m) in [
(StepCanvasRung::District, 3.79_f64),
(StepCanvasRung::Quarter, 0.948_f64),
] {
let floor = effective_min_wavelength_m(rung, 0, step_m);
assert!(
floor < 128.0,
"{rung:?} floor {floor} would truncate the finest relief octave \
(128 m); this rung resolves its whole band and must not change"
);
}
}
/// The wire value is a coarser-detail REQUEST and still wins when it asks
/// for more truncation than the spacing requires.
#[test]
fn an_explicit_coarser_request_is_honoured_over_the_floor() {
let floor = effective_min_wavelength_m(StepCanvasRung::District, 4_096, 3.79);
assert_eq!(floor, 4_096.0);
}
#[test]
fn quantize_min_wl_m_for_rung_matches_layer_proxy_quantization() {
// Reusing the SAME banding function as the legacy carrier (see this