diff --git a/server/src/atlas/coast_invention.rs b/server/src/atlas/coast_invention.rs index 006bc2d87..3c5d37a06 100644 --- a/server/src/atlas/coast_invention.rs +++ b/server/src/atlas/coast_invention.rs @@ -382,7 +382,7 @@ mod tests { ); let ch = coast_character_at(&env, 42, 1e6, 1e6, 20.0, GlaciationGrade::None, 60); let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch); - let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5); + let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5, 0.0); assert_ne!(wdx, scatter); } } diff --git a/server/src/atlas/detail_scatter.rs b/server/src/atlas/detail_scatter.rs index 54d9280f2..7f2e7d29f 100644 --- a/server/src/atlas/detail_scatter.rs +++ b/server/src/atlas/detail_scatter.rs @@ -78,8 +78,29 @@ pub(crate) fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 /// /// Returns roughly `[-envelope, +envelope]`, smaller and smoother as `ruggedness` /// drops toward 0 (gentle flats), larger and ridged as it rises toward 1. -pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 { - enveloped_fbm(seed, wx, wy, envelope, ruggedness, &OCTAVE_WAVELENGTHS_M) +/// +/// `min_wavelength_m` (T-1149, zoom ladder §2): octaves whose wavelength is +/// below this cutoff are skipped entirely — the Nyquist truncation a coarse +/// sample density needs (no point paying for detail finer than the sample +/// spacing can resolve). `0.0` = no cutoff = every octave, byte-identical to +/// pre-T-1149 behavior. +pub fn terrain_detail( + seed: u64, + wx: f64, + wy: f64, + envelope: f64, + ruggedness: f64, + min_wavelength_m: f64, +) -> f64 { + enveloped_fbm( + seed, + wx, + wy, + envelope, + ruggedness, + &OCTAVE_WAVELENGTHS_M, + min_wavelength_m, + ) } /// The voxel-tier mid-scale relief perturbation in `[0,1]`-normalized units @@ -91,7 +112,16 @@ pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f6 /// Seed must be **body-global** (constant across the body) — the position /// `(wx, wy)` carries the variation. A per-voxel seed would make every voxel a /// fresh lattice (white noise, not smooth hills). -pub fn voxel_relief(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 { +/// +/// `min_wavelength_m` — see [`terrain_detail`]; `0.0` = no cutoff. +pub fn voxel_relief( + seed: u64, + wx: f64, + wy: f64, + envelope: f64, + ruggedness: f64, + min_wavelength_m: f64, +) -> f64 { enveloped_fbm( seed, wx, @@ -99,6 +129,7 @@ pub fn voxel_relief(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) envelope, ruggedness, &VOXEL_OCTAVE_WAVELENGTHS_M, + min_wavelength_m, ) } @@ -134,6 +165,23 @@ pub fn voxel_mosaic(seed: u64, wx: f64, wy: f64) -> f64 { /// Shared adaptive-fBm core for [`terrain_detail`] and [`voxel_relief`] — the only /// difference between the two tiers is the octave wavelength band. Returns the /// enveloped, ruggedness-modulated perturbation (roughly `[-envelope, +envelope]`). +/// +/// `min_wavelength_m` (T-1149): octaves with `wl < min_wavelength_m` are +/// skipped — HARD TRUNCATE, not amplitude-faded. `amp`/`norm` still advance +/// through the skipped octave's weight step (`i` keeps its position in the +/// wavelength array for the seed-salt term), so the surviving octaves keep +/// their same relative weighting as if the truncated tail were simply cut +/// off the sum, not renormalized against a smaller octave count. `0.0` = no +/// octave is ever skipped = today's behavior, byte-for-byte. +/// +/// **Seam (not built, see T-1149/design doc §2 + §9 R1):** a hard truncate can +/// pop when the sample density crosses an octave boundary between two +/// requests (an octave present at one zoom step vanishes at the next, +/// discontinuously). The documented fix is fading the highest surviving +/// octave's amplitude toward zero as `wl` approaches `min_wavelength_m` from +/// above, rather than an on/off cut. Not implemented here — it needs a +/// visual A/B against a real client zoom ladder, which does not exist yet +/// (T-1150/T-1153); building it speculatively risks tuning against nothing. fn enveloped_fbm( seed: u64, wx: f64, @@ -141,6 +189,7 @@ fn enveloped_fbm( envelope: f64, ruggedness: f64, wavelengths: &[f64], + min_wavelength_m: f64, ) -> f64 { let env = envelope.clamp(0.0, 1.0); let rug = ruggedness.clamp(0.0, 1.0); @@ -152,6 +201,14 @@ fn enveloped_fbm( let mut amp = 1.0; let mut norm = 0.0; for (i, &wl) in wavelengths.iter().enumerate() { + if wl < min_wavelength_m { + // Below the per-sample Nyquist cutoff — skip the term entirely + // (hard truncate), but still advance amp so later octaves (there + // are none finer in these const arrays, but the rule is general) + // keep their intended relative weight. + amp *= 0.5 + 0.35 * rug; + continue; + } let mut n = value_noise( seed.wrapping_add((i as u64).wrapping_mul(0x1000)), wx, @@ -170,6 +227,9 @@ fn enveloped_fbm( // them toward a single gentle swell. amp *= 0.5 + 0.35 * rug; } + if norm == 0.0 { + return 0.0; // every octave cut by the cutoff → no invented relief left + } let fbm = sum / norm; // ≈ [-1, 1] // Envelope caps the amplitude; within the cap, ruggedness scales how much of @@ -183,8 +243,8 @@ mod tests { #[test] fn deterministic() { - let a = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5); - let b = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5); + let a = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0); + let b = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0); assert_eq!(a, b); } @@ -193,7 +253,7 @@ mod tests { // envelope = 0 → the authored heightmap is flat here → no relief (the // envelope rule: never sprout terrain on an authored plain). for &rug in &[0.0, 0.5, 1.0] { - assert_eq!(terrain_detail(7, 1000.0, 2000.0, 0.0, rug), 0.0); + assert_eq!(terrain_detail(7, 1000.0, 2000.0, 0.0, rug, 0.0), 0.0); } } @@ -203,7 +263,7 @@ mod tests { for i in 0..400 { let wx = (i as f64) * 137.0; let wy = (i as f64) * -91.0; - let v = terrain_detail(99, wx, wy, 0.5, 1.0); + let v = terrain_detail(99, wx, wy, 0.5, 1.0, 0.0); assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}"); } } @@ -215,7 +275,7 @@ mod tests { let mean_abs = |rug: f64| -> f64 { let n = 500; (0..n) - .map(|i| terrain_detail(3, i as f64 * 53.0, i as f64 * 71.0, 0.7, rug).abs()) + .map(|i| terrain_detail(3, i as f64 * 53.0, i as f64 * 71.0, 0.7, rug, 0.0).abs()) .sum::() / n as f64 }; @@ -229,8 +289,8 @@ mod tests { fn continuous_no_creases() { // Small position steps produce small output changes (C¹ value noise) — no // lattice creases that would read as grid artifacts. - let base = terrain_detail(11, 5_000.0, 5_000.0, 0.8, 0.6); - let near = terrain_detail(11, 5_000.5, 5_000.0, 0.8, 0.6); + let base = terrain_detail(11, 5_000.0, 5_000.0, 0.8, 0.6, 0.0); + let near = terrain_detail(11, 5_000.5, 5_000.0, 0.8, 0.6, 0.0); assert!( (base - near).abs() < 0.05, "0.5 m step jumped by {}", @@ -238,17 +298,56 @@ mod tests { ); } + // ── min_wavelength_m cutoff (T-1149, zoom ladder §2) ────────────────────── + + #[test] + fn cutoff_zero_matches_pre_t1149_behavior() { + // 0.0 = no cutoff = every octave — this is the compatibility contract + // every existing caller (derive_district's default) relies on. + for i in 0..200 { + let wx = i as f64 * 91.0; + let wy = i as f64 * -53.0; + let with_explicit_zero = terrain_detail(21, wx, wy, 0.6, 0.5, 0.0); + // The finest OCTAVE_WAVELENGTHS_M entry is 4_096.0 — a cutoff below + // that admits every octave too, and must agree exactly. + let with_below_finest = terrain_detail(21, wx, wy, 0.6, 0.5, 1.0); + assert_eq!(with_explicit_zero, with_below_finest); + } + } + + #[test] + fn cutoff_truncates_octaves_below_it() { + // A cutoff placed above the coarsest OCTAVE_WAVELENGTHS_M entry + // (32_768.0) must skip every octave and fall back to 0.0 (the + // norm==0.0 empty-sum guard), same as the flat_envelope_invents_nothing + // envelope==0 case but reached via the cutoff instead. + assert_eq!(terrain_detail(5, 1_000.0, 2_000.0, 0.6, 0.5, 100_000.0), 0.0); + } + + #[test] + fn cutoff_changes_output_relative_to_uncut() { + // A mid-band cutoff (drops the two finest octaves: 8_192.0, 4_096.0) + // must produce DIFFERENT output than the uncut derive at the same + // position — otherwise the cutoff parameter would be a no-op. + let uncut = terrain_detail(17, 12_000.0, 9_000.0, 0.7, 0.6, 0.0); + let cut = terrain_detail(17, 12_000.0, 9_000.0, 0.7, 0.6, 8_193.0); + assert_ne!( + uncut, cut, + "a mid-band cutoff must change the derived output" + ); + } + // ── voxel_relief (T-1081): same contract, sub-district band ────────────── #[test] fn voxel_relief_deterministic_and_bounded() { - let a = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5); - let b = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5); + let a = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0); + let b = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0); assert_eq!(a, b); // Same envelope rule + amplitude ceiling as terrain_detail. - assert_eq!(voxel_relief(7, 1_000.0, 2_000.0, 0.0, 1.0), 0.0); + assert_eq!(voxel_relief(7, 1_000.0, 2_000.0, 0.0, 1.0, 0.0), 0.0); for i in 0..400 { - let v = voxel_relief(99, i as f64 * 137.0, i as f64 * -91.0, 0.5, 1.0); + let v = voxel_relief(99, i as f64 * 137.0, i as f64 * -91.0, 0.5, 1.0, 0.0); assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}"); } } @@ -261,7 +360,7 @@ mod tests { // are deliberately non-aligned with the octave wavelengths to avoid aliasing. let seed = 1234; let vals: Vec = (0..16) - .map(|i| voxel_relief(seed, i as f64 * 137.0, i as f64 * 89.0, 0.7, 0.6)) + .map(|i| voxel_relief(seed, i as f64 * 137.0, i as f64 * 89.0, 0.7, 0.6, 0.0)) .collect(); let min = vals.iter().cloned().fold(f64::INFINITY, f64::min); let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max); diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index fe9887738..36dce66eb 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -1088,6 +1088,10 @@ struct InventedPrimitives { /// low-relief coasts); `ridge` carries fjord/tectonic sharpness. /// /// `body_params` must already carry the district's `latitude_deg`. +/// +/// `min_wavelength_m` (T-1149, zoom ladder §2): threaded straight to the +/// `terrain_detail` scatter call — octaves finer than this are truncated. +/// `0.0` = no cutoff = today's behavior. #[allow(clippy::too_many_arguments)] fn invent_primitives( seed: SeedChain, @@ -1099,6 +1103,7 @@ fn invent_primitives( world_x_m: f64, world_y_m: f64, region_baseline_c: Option, + min_wavelength_m: f64, ) -> InventedPrimitives { // ── 1. Driver tier: UNWARPED raw-bilinear climate (one-step-stale). ───── let raw_elev_q = @@ -1147,6 +1152,7 @@ fn invent_primitives( world_y_m, env_amp, ruggedness, + min_wavelength_m, ); // Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut @@ -1272,6 +1278,7 @@ pub fn derive_district_profile( world_x_m, world_y_m, region_baseline_c, + 0.0, // batch path — no octave cutoff, matches derive_district's default ); build_district_profile( @@ -1421,15 +1428,55 @@ pub fn derive_district( climate: &ClimateConstants, ) -> DistrictProfile { let (dx, dy) = district_pos; + let dm = scale::DISTRICT_M as f64; + // Thin wrapper (T-1149): quantize DistrictPos -> world metres, then hand off + // to the metres-addressable interior. `min_wavelength_m = 0.0` = no octave + // cutoff, preserving this function's output byte-for-byte. + derive_at_metres( + seed, + body_id, + body_params, + ta, + dx as f64 * dm, + dy as f64 * dm, + climate, + 0.0, + ) +} - // District → fractional heightmap pixel + world-metre coordinate + latitude. +/// The metres-addressable derivation interior (T-1149, zoom ladder keystone, +/// design doc §2/§8 step 1) — `derive_district`'s former inline body, extracted +/// so a fractional-metres position (not just an integer [`DistrictPos`]) can be +/// classified. This is what makes the quarter rung (512 m spacing, T-1150) +/// possible without a second derivation pipeline: same function, finer step. +/// +/// `wx`/`wy` are absolute world metres — NOT required to fall on a district-grid +/// multiple of [`scale::DISTRICT_M`]; any fractional position is legal. +/// +/// `min_wavelength_m` (§2): forwarded to the `terrain_detail` octave sum inside +/// [`invent_primitives`] — octaves finer than this cutoff are truncated. `0.0` +/// = no cutoff = [`derive_district`]'s existing behavior. +/// +/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation. +#[allow(clippy::too_many_arguments)] +pub fn derive_at_metres( + seed: SeedChain, + body_id: &str, + body_params: &BodyParams, + ta: &TerrainAnalysis, + wx: f64, + wy: f64, + climate: &ClimateConstants, + min_wavelength_m: f64, +) -> DistrictProfile { + // World metres -> fractional heightmap pixel + latitude. Mirrors + // `derive_district`'s former inline mapping exactly, just keyed on + // fractional (wx, wy) instead of an integer DistrictPos scaled up first. let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km { Some(r_km) if r_km > 0.0 => { let circumference_m = std::f64::consts::TAU * r_km * 1000.0; let meridian_m = std::f64::consts::PI * r_km * 1000.0; - let wx = dx as f64 * scale::DISTRICT_M as f64; - let wy = dy as f64 * scale::DISTRICT_M as f64; - // Longitude wraps; district (0,0) sits at lon 0 / the equator. + // Longitude wraps; (0,0) sits at lon 0 / the equator. let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64; // Latitude: equator at py = h/2, clamped at the poles. let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); // −0.5 = N pole, +0.5 = S @@ -1437,15 +1484,17 @@ pub fn derive_district( (px, py, wx, wy, -lat_frac * 180.0) } _ => { - // No radius: the district grid IS the heightmap grid (tiny test bodies). - let px = (dx as f64).clamp(0.0, ta.w.saturating_sub(1) as f64); - let py = (dy as f64).clamp(0.0, ta.h.saturating_sub(1) as f64); + // No radius: the working grid IS the metre grid (tiny test bodies), + // 1 DISTRICT_M = 1 heightmap pixel — the inverse of + // `pixel_to_world_m`'s own no-radius convention. + let dm = scale::DISTRICT_M as f64; + let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64); + let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64); let lat_deg = if ta.h > 1 { 90.0 - (py / (ta.h - 1) as f64) * 180.0 } else { 0.0 }; - let dm = scale::DISTRICT_M as f64; (px, py, px * dm, py * dm, lat_deg) } }; @@ -1456,11 +1505,24 @@ pub fn derive_district( }; // D-243 §3/§4: compute the edge-fuzz-blended region baseline on-the-fly for - // this district. No pre-built cache here — the on-demand path derives the four - // surrounding region baselines directly. Pure, deterministic, cheap. + // this position. No pre-built cache here — the on-demand path derives the + // four surrounding region baselines directly. Pure, deterministic, cheap. // `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation. // Hoisted above the primitives (T-1125): the invention's driver tier needs // the baseline for its one-step-stale climate estimate. + // + // `region_baseline_at_district` keys on the CONTAINING DistrictPos (via + // `rem_euclid` inside `region_profile.rs`), not on fractional metres — so a + // sub-district sample (e.g. a quarter, T-1150) floor-divides down to its + // containing district here. This is D-243's design intent (climate is a + // district-tier field, R2/zoom-ladder-design-doc §9): temperature is a hard + // step at every district boundary at every rung, by construction — it does + // not refine continuously the way elevation/slope do under a finer + // min_wavelength_m. + let district_pos: DistrictPos = ( + (wx / scale::DISTRICT_M as f64).floor() as i32, + (wy / scale::DISTRICT_M as f64).floor() as i32, + ); let region_baseline_c = region_profile::region_baseline_at_district( seed.seed(), body_id, @@ -1484,15 +1546,17 @@ pub fn derive_district( world_x_m, world_y_m, region_baseline_c, + min_wavelength_m, ); - // derive_district is the on-demand path (arbitrary DistrictPos, no L1 working - // grid). basin_direction is an ACCEPTED LIMITATION here: it defaults to North - // (a fallback, not a computed value) because the D8 thalweg is only available - // from the L1 fdir grid the batch path holds. Production voxel generation runs - // through the batch path (derive_all_districts), which threads the true D8 - // direction from L1; this on-demand path is the fallback for districts derived - // outside that pass, where a meaningful basin_direction isn't available. + // derive_at_metres is the on-demand path (arbitrary world position, no L1 + // working grid). basin_direction is an ACCEPTED LIMITATION here: it + // defaults to North (a fallback, not a computed value) because the D8 + // thalweg is only available from the L1 fdir grid the batch path holds. + // Production voxel generation runs through the batch path + // (derive_all_districts), which threads the true D8 direction from L1; + // this on-demand path is the fallback for positions derived outside that + // pass, where a meaningful basin_direction isn't available. build_district_profile( seed, ¶ms, @@ -1857,6 +1921,152 @@ mod tests { assert!((0..=100).contains(&a.elev_q) && (0..=100).contains(&a.slope_q)); } + // --- derive_at_metres (T-1149 keystone extraction) ------------------------- + + /// `derive_district` is a thin wrapper: at an exact district-aligned metre + /// position, with `min_wavelength_m = 0.0`, it must be BIT-IDENTICAL to + /// calling `derive_at_metres` directly (the acceptance criterion the + /// ticket names explicitly — existing callers see byte-identical output). + #[test] + fn derive_at_metres_matches_derive_district_at_aligned_position_zero_cutoff() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dp = (1234, -567); + let dm = scale::DISTRICT_M as f64; + + let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate); + let via_metres = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + dp.0 as f64 * dm, + dp.1 as f64 * dm, + &climate, + 0.0, + ); + assert_district_profiles_eq(&via_wrapper, &via_metres); + } + + /// Same equivalence check on the no-radius (tiny test body) branch — the + /// two derivation paths diverge internally (fractional-pixel clamp vs. + /// direct district indexing) and must be checked independently. + #[test] + fn derive_at_metres_matches_derive_district_no_radius() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = BodyParams { + planet_class: Some("temperate".into()), + atmosphere: Some("breathable".into()), + ..Default::default() // body_radius_km: None + }; + let dp = (20, 10); + let dm = scale::DISTRICT_M as f64; + + let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate); + let via_metres = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + dp.0 as f64 * dm, + dp.1 as f64 * dm, + &climate, + 0.0, + ); + assert_district_profiles_eq(&via_wrapper, &via_metres); + } + + /// Field-by-field `DistrictProfile` equality — the struct has no + /// `PartialEq` derive (production type, not test-only), so the + /// bit-identical acceptance checks above compare fields directly instead + /// of adding a derive to non-test code for test convenience. + fn assert_district_profiles_eq(a: &DistrictProfile, b: &DistrictProfile) { + assert_eq!(a.morphology_zone as u8, b.morphology_zone as u8); + assert_eq!(a.tectonic_class as u8, b.tectonic_class as u8); + assert_eq!(a.glaciation_grade as u8, b.glaciation_grade as u8); + assert_eq!(a.precipitation_class as u8, b.precipitation_class as u8); + assert_eq!(a.slope_q, b.slope_q); + assert_eq!(a.elev_q, b.elev_q); + assert_eq!(a.ocean_fraction_q, b.ocean_fraction_q); + assert_eq!(a.river_threshold, b.river_threshold); + assert_eq!(a.temperature_c, b.temperature_c); + assert_eq!(a.moisture_q, b.moisture_q); + assert_eq!(a.vegetation_class as u8, b.vegetation_class as u8); + assert_eq!(a.basin_direction as u8, b.basin_direction as u8); + } + + /// A non-district-aligned fractional metre position (e.g. a quarter-grid + /// sample, T-1150) must derive without panicking and stay within the same + /// value ranges as the district-aligned case — the whole point of the + /// extraction is that ANY fractional world position is now legal input, + /// not just integer DistrictPos multiples. + #[test] + fn derive_at_metres_accepts_fractional_sub_district_position() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::DISTRICT_M as f64; + + // A quarter-grid offset (512 m, D-243) inside district (1234, -567). + let prof = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + 1234.0 * dm + 512.0, + -567.0 * dm + 512.0, + &climate, + 512.0, + ); + assert!((0..=100).contains(&prof.elev_q)); + assert!((0..=100).contains(&prof.slope_q)); + } + + /// A `min_wavelength_m` cutoff must actually change the invented terrain + /// primitives relative to the uncut (0.0) derive at the SAME position — + /// otherwise the parameter would be silently inert at this layer (the + /// enveloped_fbm-level test already covers the raw scatter function; this + /// confirms the wiring survives through invent_primitives/derive_at_metres). + #[test] + fn derive_at_metres_cutoff_changes_invented_primitives() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::DISTRICT_M as f64; + + let mut any_differs = false; + for i in 0..20 { + let wx = (100 + i * 37) as f64 * dm; + let wy = (100 + i * 53) as f64 * dm; + let uncut = + derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0); + let cut = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 8_193.0, // above the two finest OCTAVE_WAVELENGTHS_M entries + ); + if uncut.elev_q != cut.elev_q || uncut.slope_q != cut.slope_q { + any_differs = true; + } + } + assert!( + any_differs, + "a mid-band min_wavelength_m cutoff must change invented terrain \ + at at least one sampled position" + ); + } + #[test] fn derive_district_profile_is_deterministic() { let hm = test_hm(); diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index 3240d7ecf..a70369996 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -511,6 +511,7 @@ pub fn derive_voxel_column( voxel_y as f64, signal, signal, + 0.0, // voxel fill path — no octave cutoff (T-1149's cutoff is Atlas-serving only) ); let relief_m = (relief * VOXEL_RELIEF_SPAN_M as f64) as i32; // truncate (D-010) column.elevation_m = (column.elevation_m + relief_m).max(0); diff --git a/server/tests/zoom_ladder_bench.rs b/server/tests/zoom_ladder_bench.rs new file mode 100644 index 000000000..d2db9e3d7 --- /dev/null +++ b/server/tests/zoom_ladder_bench.rs @@ -0,0 +1,175 @@ +//! Zoom-ladder derivation benchmarks (T-1149, design doc §2/§7/§8 step 1). +//! +//! Measures `derive_at_metres` per-cell cost at district spacing (2,048 m) and +//! quarter spacing (512 m), with and without a `min_wavelength_m` octave +//! cutoff — the exact numbers the design doc flags as UNBUILT/UNMEASURED +//! (§7: "Octave-cutoff derive (min_wavelength_m-bearing) ... not measured"). +//! +//! Manual `Instant`-based timing, matching every other bench in this repo +//! (`shadowcast_bench.rs`, `perf_bench.rs`) and the same technique +//! `aliveness_probe --render` used to produce the ~1.2–1.4 µs/district +//! release figure the design doc cites — no criterion dependency exists here. +//! +//! Run: `cargo test --release --test zoom_ladder_bench -- --ignored --nocapture` +//! (debug numbers are ~5x slower and not representative of the design doc's +//! release-build figures; run `--release` for numbers worth recording). + +use std::time::Instant; + +use settled_reach_server::atlas::district_profile::{ + derive_at_metres, BodyParams, ClimateConstants, +}; +use settled_reach_server::atlas::drainage; +use settled_reach_server::atlas::features::TerrainAnalysis; +use settled_reach_server::atlas::heightmap::BodyHeightmap; +use settled_reach_server::atlas::scale; +use settled_reach_server::seed::{SeedChain, SeedDomain}; + +fn bench_hm() -> BodyHeightmap { + // Same shape as district_profile.rs's own test_hm/window_test_hm fixtures + // — a smooth gradient, deterministic, no PNG I/O. + let (w, h) = (128u32, 64u32); + let n = (w * h) as usize; + let data = (0..n) + .map(|i| { + let r = (i / w as usize) as f32 / h as f32; + let c = (i % w as usize) as f32 / w as f32; + (r * 0.6 + c * 0.4).min(1.0) + }) + .collect(); + BodyHeightmap { + body_id: "bench".into(), + width: w, + height: h, + data, + sea_level: 0.3, + } +} + +fn bench_ta(hm: &BodyHeightmap) -> TerrainAnalysis { + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) +} + +fn bench_params() -> BodyParams { + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + } +} + +/// Time `n_cells` sequential `derive_at_metres` calls on a spacing-`step_m` +/// grid starting at world origin, with the given octave cutoff. Returns +/// (total_elapsed, per_cell_ns). +fn time_derive_sweep( + seed: SeedChain, + body_id: &str, + params: &BodyParams, + ta: &TerrainAnalysis, + climate: &ClimateConstants, + grid_side: u32, + step_m: f64, + min_wavelength_m: f64, +) -> (std::time::Duration, f64) { + let n_cells = (grid_side * grid_side) as u64; + let t0 = Instant::now(); + for row in 0..grid_side { + for col in 0..grid_side { + let wx = col as f64 * step_m; + let wy = row as f64 * step_m; + let prof = derive_at_metres( + seed, + body_id, + params, + ta, + wx, + wy, + climate, + min_wavelength_m, + ); + // Prevent the optimizer from hoisting the call out of the loop. + std::hint::black_box(prof.elev_q); + } + } + let elapsed = t0.elapsed(); + let per_cell_ns = elapsed.as_secs_f64() * 1e9 / n_cells as f64; + (elapsed, per_cell_ns) +} + +#[test] +#[ignore] +fn bench_derive_at_metres_district_and_quarter_spacing() { + let hm = bench_hm(); + let ta = bench_ta(&hm); + let params = bench_params(); + let climate = ClimateConstants::default(); + let seed = SeedChain::root(99).derive(SeedDomain::Body, 1); + let grid_side = 64u32; // 4,096 cells per sweep — matches the D-226 window cap + + println!("\n=== T-1149 zoom-ladder derive_at_metres benchmark ==="); + println!( + "grid: {grid_side}x{grid_side} = {} cells/sweep\n", + grid_side * grid_side + ); + + let district_m = scale::DISTRICT_M as f64; + let quarter_m = scale::QUARTER_M as f64; + + // District spacing (2,048 m), cutoff 0 — today's uncut behavior. + let (elapsed, per_cell_ns) = time_derive_sweep( + seed, "bench", ¶ms, &ta, &climate, grid_side, district_m, 0.0, + ); + println!( + "district spacing, cutoff=0: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)", + elapsed.as_secs_f64() * 1000.0, + per_cell_ns, + per_cell_ns / 1000.0 + ); + + // District spacing, cutoff 2,048 m — truncates every OCTAVE_WAVELENGTHS_M + // entry below the district's own spacing (finest is 4,096 m, so this + // cutoff is BELOW that — confirms the cutoff plumbing at district scale + // without changing which octaves survive, since 2,048 < 4,096 admits all + // of them; recorded for the design doc's requested (district, cutoff + // 2048) combination regardless). + let (elapsed, per_cell_ns) = time_derive_sweep( + seed, + "bench", + ¶ms, + &ta, + &climate, + grid_side, + district_m, + 2_048.0, + ); + println!( + "district spacing, cutoff=2048m: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)", + elapsed.as_secs_f64() * 1000.0, + per_cell_ns, + per_cell_ns / 1000.0 + ); + + // Quarter spacing (512 m), cutoff 512 m — the T-1150 Option B rung: full + // reclassification at quarter spacing with the matching octave cutoff. + let (elapsed, per_cell_ns) = time_derive_sweep( + seed, + "bench", + ¶ms, + &ta, + &climate, + grid_side, + quarter_m, + 512.0, + ); + println!( + "quarter spacing, cutoff=512m: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)", + elapsed.as_secs_f64() * 1000.0, + per_cell_ns, + per_cell_ns / 1000.0 + ); + + println!(); +}