From afff859a2c5249b5f0750f07dc5d409dcd7fa050 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 00:27:21 +0200 Subject: [PATCH 1/4] feat(simulation): T-1149 derive_at_metres extraction + min_wavelength octave cutoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract derive_district's fractional-metres interior into the metres- addressable derive_at_metres(seed, body, params, ta, wx, wy, climate, min_wavelength_m) — the zoom ladder's keystone (design doc §8 step 1). derive_district is now a thin DistrictPos-quantizing wrapper calling it with cutoff 0.0; all four golden/believability/derivation harnesses pass byte-identical. enveloped_fbm/terrain_detail/voxel_relief gain min_wavelength_m: octaves below the cutoff are hard-truncated (amp still advances so surviving octaves keep relative weight; norm==0 guarded). Amplitude-fade-near- cutoff is documented as a seam, not built — the pop-risk A/B needs the client ladder (T-1153). Cutoff 0.0 is bit-identical to pre-change output, asserted by test. Measured (release, 4096-cell sweeps, zoom_ladder_bench.rs): district spacing 1.200µs/cell (cutoff 0) / 1.201µs (cutoff 2048m — all octaves survive, plumbing check); quarter spacing 1.165µs/cell (cutoff 512m). Matches the design doc's ~1.2-1.4µs/cell release estimate. --- server/src/atlas/coast_invention.rs | 2 +- server/src/atlas/detail_scatter.rs | 129 ++++++++++++-- server/src/atlas/district_profile.rs | 244 +++++++++++++++++++++++++-- server/src/atlas/voxel.rs | 1 + server/tests/zoom_ladder_bench.rs | 175 +++++++++++++++++++ 5 files changed, 518 insertions(+), 33 deletions(-) create mode 100644 server/tests/zoom_ladder_bench.rs 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!(); +} From 3e87fd5b4ff7ae9fd3fb70ac5b120817e50545dc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 00:27:40 +0200 Subject: [PATCH 2/4] feat(simulation): T-1151 window par_iter + T-1150 granularity carrier (five touch points + aliasing tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-1151: build_district_window_layer dispatches one Rayon task per row (pure derive_window_cell via derive_at_metres), scattered row-major into the flat arrays; a cfg(test) serial path backs the bit-identical parallel-vs-serial golden. T-1150: serde-default window_granularity (1=district, 4=quarter) + window_min_wl_m on AtlasLayerRequest — additive, no sixth demux shape, old frames decode unchanged (tested). Quarter mode = full reclassification at 512m spacing over the same world rect ((4n)x(4n) cells); WIRE_CAP_CELLS=4096 enforces n*granularity <= cap (quarter clamps n to 16, the design doc's worked example). Granularity + min_wl key ALL five touch points: DistrictWindowLayer echo, server FIFO-256 cache key (now a 5-tuple), per-connection coalescing key, client request codec (omitted-at-default wire fields), client LRU key. Mandatory aliasing regressions on both ends: identical (body, center, n) at granularity 1 vs 4 produce distinct cache entries and correct per-granularity payload shapes (server, 3-thread queue to avoid the AnalyzeBody thread contention found while writing it) and distinct client cache keys (gdUnit). Replay fixture regenerated — the layer struct grew two echoed fields (231->254 bytes, content verified). Client requests stay district-granularity by default — quarter requests arrive with T-1153's rung selection. --- client/scripts/autoloads/sim_bridge.gd | 16 +- client/scripts/protocol/atlas_map_protocol.gd | 16 +- client/scripts/protocol/protocol.gd | 11 +- .../atlas_response_ready_with_window.msgpack | Bin 231 -> 254 bytes client/tests/test_atlas_data_delivery.gd | 28 + client/tests/test_atlas_window_cache.gd | 61 ++ .../implant/apps/atlas/atlas_window_cache.gd | 83 +- .../apps/atlas/atlas_window_request.gd | 43 +- server/src/atlas/gen_queue.rs | 50 +- server/src/atlas/layer_proxy.rs | 821 ++++++++++++++++-- server/src/atlas/plugin.rs | 7 +- server/src/bridge/mod.rs | 4 + server/tests/bridge_tcp.rs | 2 + server/tests/gen_fixtures.rs | 3 + 14 files changed, 1027 insertions(+), 118 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 8137afc9b..3a706fbfe 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -432,12 +432,24 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void ## not a specific whole-body layer to be cached first). Omitted by every ## whole-body-layer caller (show_body()'s existing request), so their wire ## traffic is byte-unchanged. +## +## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the +## zoom-ladder quarter rung — district (0/omitted) stays the default for +## every caller in this codebase today; requesting quarter granularity is +## T-1153's job, not wired here. func request_atlas_layers( - body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0 + body_id: String, + up_to: String = "Topography", + window_center: Variant = null, + window_n: int = 0, + window_granularity: int = 0, + window_min_wl_m: int = 0 ) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return - var bytes := Protocol.encode_atlas_layer_request(body_id, up_to, window_center, window_n) + var bytes := Protocol.encode_atlas_layer_request( + body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m + ) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) diff --git a/client/scripts/protocol/atlas_map_protocol.gd b/client/scripts/protocol/atlas_map_protocol.gd index bbda6245a..c38818582 100644 --- a/client/scripts/protocol/atlas_map_protocol.gd +++ b/client/scripts/protocol/atlas_map_protocol.gd @@ -31,18 +31,32 @@ class_name AtlasMapProtocol ## [1, DISTRICT_WINDOW_MAX_N] itself and never trusts the wire value; the ## client-side default/cap constants (DISTRICT_WINDOW_DEFAULT_N/MAX_N) live on ## the regional-window viewer, not duplicated into the codec. +## +## `window_granularity`/`window_min_wl_m` (T-1150): the derivation-granularity +## axis (district=1/omitted vs. quarter=4) and the octave cutoff, in whole +## metres. Both OMITTED (not sent as 0) when at their default — this is +## struct/key plumbing only (T-1150 scope): no caller in this codebase +## requests quarter granularity yet (that's T-1153); this function just makes +## it possible to ask, byte-compatible with every existing caller that +## doesn't pass them. static func encode_atlas_layer_request( mp, body_id: String, up_to: String = "Topography", window_center: Variant = null, - window_n: int = 0 + window_n: int = 0, + window_granularity: int = 0, + window_min_wl_m: int = 0 ) -> PackedByteArray: var msg := {"body_id": body_id, "up_to": up_to} if window_center != null: var center: Vector2i = window_center msg["window_center"] = [center.x, center.y] msg["window_n"] = window_n + if window_granularity != 0: + msg["window_granularity"] = window_granularity + if window_min_wl_m != 0: + msg["window_min_wl_m"] = window_min_wl_m var result = mp.encode(msg) if result.status != null: push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index c9989a350..9a089ee9f 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -780,14 +780,19 @@ static func encode_request_bookmark_catalog() -> PackedByteArray: ## windowed district-resolution regional-map query — see ## atlas_map_protocol.gd's encode_atlas_layer_request doc for the wire shape. ## Omitted callers (every whole-body-layer call site predating T-1138) are -## byte-unchanged. +## byte-unchanged. window_granularity/window_min_wl_m (T-1150): same +## byte-compatibility contract, see atlas_map_protocol.gd. static func encode_atlas_layer_request( body_id: String, up_to: String = "Topography", window_center: Variant = null, - window_n: int = 0 + window_n: int = 0, + window_granularity: int = 0, + window_min_wl_m: int = 0 ) -> PackedByteArray: - return _amp().encode_atlas_layer_request(_mp(), body_id, up_to, window_center, window_n) + return _amp().encode_atlas_layer_request( + _mp(), body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m + ) ## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary diff --git a/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack index ccd64e073fa74c3d25900a25d03de6dfc630b36a..32c85b74259aec9b87a58294eeff444449c4dc80 100644 GIT binary patch delta 47 zcmaFP_>XZyhHBTc void: assert_that(decoded.value.get("window_n")).is_equal(32) +## T-1150: window_granularity/window_min_wl_m are OMITTED (not sent as 0) +## when at their default — a windowed request that doesn't pass them (every +## pre-T-1150 window caller) is byte-identical to pre-T-1150 wire traffic, +## same contract as window_center/window_n's own default-omission above. +func test_encode_atlas_layer_request_omits_granularity_and_min_wl_by_default() -> void: + var bytes := Protocol.encode_atlas_layer_request( + "GJ1c", "Topography", Vector2i(140, 260), 32 + ) + var decoded = Messagepack.decode(bytes) + assert_bool(decoded.value.has("window_granularity")).is_false() + assert_bool(decoded.value.has("window_min_wl_m")).is_false() + + +## T-1150: a quarter-granularity request with an octave cutoff carries both +## new fields verbatim, unclamped (the server owns +## resolve_window_granularity()/clamp_window_n() — never trusted from the +## wire, same posture as window_n). +func test_encode_atlas_layer_request_carries_granularity_and_min_wl() -> void: + var bytes := Protocol.encode_atlas_layer_request( + "GJ1c", "Topography", Vector2i(140, 260), 32, 4, 512 + ) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.value.get("window_granularity")).is_equal(4) + assert_that(decoded.value.get("window_min_wl_m")).is_equal(512) + + ## §2: district_window is a distinct payload (echoes center/n for the ## client's staleness guard) but the codec passthrough is the same shape as ## every sibling layer — raw.get(), no reshaping. Field types follow the @@ -224,6 +250,8 @@ func test_atlas_response_district_window_passthrough() -> void: var window := { "center": [140, 260], "n": 32, + "granularity": 4, # T-1150: quarter granularity, passed through same as every other field + "min_wl_m": 512, "morphology": PackedByteArray([8, 14, 0, 5]), "elev_q": PackedByteArray([40, 62, 5, 88]), "temp_dc": [120, 95, AtlasOverlayColors.REGION_TEMP_NONE_DC, 60], diff --git a/client/tests/test_atlas_window_cache.gd b/client/tests/test_atlas_window_cache.gd index 1471f7823..3fdc61d02 100644 --- a/client/tests/test_atlas_window_cache.gd +++ b/client/tests/test_atlas_window_cache.gd @@ -119,3 +119,64 @@ func test_clear_empties_the_cache() -> void: cache.clear() assert_int(cache.size()).is_equal(0) assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).is_false() + + +# ============================================================================= +# granularity / min_wl_m (T-1150, zoom ladder design doc §3 aliasing risk) +# ============================================================================= + + +## **MANDATORY aliasing regression (client half, T-1150):** a granularity-4 +## (quarter) key and a granularity-1 (district) key at the IDENTICAL +## (body_id, center, n) must be DISTINCT cache keys — this is what prevents a +## quarter-spacing request from silently reading (or overwriting) a +## district-spacing window's cache entry, and vice versa. +func test_make_key_distinguishes_granularity_at_identical_body_center_n() -> void: + var k_district := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY + ) + var k_quarter := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 4) + assert_str(k_district).is_not_equal(k_quarter) + + +## Same aliasing risk, the other new axis: two requests identical except for +## `min_wl_m` (the octave cutoff) must not collide either — different cutoffs +## are different derived payloads (T-1149/T-1150). +func test_make_key_distinguishes_min_wl_m_at_identical_body_center_n_granularity() -> void: + var k_uncut := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0 + ) + var k_cut := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 512 + ) + assert_str(k_uncut).is_not_equal(k_cut) + + +## Omitting granularity/min_wl_m (every pre-T-1150 call site) must produce the +## SAME key as passing the explicit district/no-cutoff defaults — byte/string +## compatibility for existing callers, not just "doesn't crash". +func test_omitted_granularity_and_min_wl_m_match_explicit_district_defaults() -> void: + var k_omitted := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32) + var k_explicit := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0 + ) + assert_str(k_omitted).is_equal(k_explicit) + + +## End-to-end through put()/get_window()/has() (not just make_key() in +## isolation): a quarter-granularity window and a district-granularity window +## at the identical (body, center, n) must both be independently retrievable, +## neither one clobbering or masking the other. +func test_district_and_quarter_windows_coexist_at_identical_body_center_n() -> void: + var cache := AtlasWindowCache.new() + var district_window := {"granularity": AtlasWindowCache.DISTRICT_GRANULARITY, "id": "district"} + var quarter_window := {"granularity": 4, "id": "quarter"} + + cache.put("GJ1c", Vector2i(10, 20), 32, district_window, AtlasWindowCache.DISTRICT_GRANULARITY) + cache.put("GJ1c", Vector2i(10, 20), 32, quarter_window, 4) + + assert_int(cache.size()).is_equal(2) + assert_that( + cache.get_window("GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY) + ).is_equal(district_window) + assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32, 4)).is_equal(quarter_window) diff --git a/client/ui/implant/apps/atlas/atlas_window_cache.gd b/client/ui/implant/apps/atlas/atlas_window_cache.gd index 0d36ec498..dfdec3785 100644 --- a/client/ui/implant/apps/atlas/atlas_window_cache.gd +++ b/client/ui/implant/apps/atlas/atlas_window_cache.gd @@ -1,13 +1,22 @@ extends RefCounted ## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226 -## T-1124 amendment §4 "Client cache policy"). +## T-1124 amendment §4 "Client cache policy"; extended T-1150 for the +## granularity/min_wl axes). ## -## Keyed on (body_id, center, n) — D-227's determinism guarantee (same seed + -## body + position -> same derived output, always) means a previously-fetched -## window is valid FOREVER for that body+seed. This is an LRU-evict-only -## cache: no freshness check, no TTL, no invalidation path at all. The only -## reason an entry ever leaves is capacity pressure. +## Keyed on (body_id, center, n, granularity, min_wl_m) — D-227's determinism +## guarantee (same seed + body + position + derivation params -> same derived +## output, always) means a previously-fetched window is valid FOREVER for +## that body+seed. This is an LRU-evict-only cache: no freshness check, no +## TTL, no invalidation path at all. The only reason an entry ever leaves is +## capacity pressure. +## +## granularity/min_wl_m default to DISTRICT_GRANULARITY/0 (district spacing, +## no octave cutoff) — every pre-T-1150 caller that doesn't pass them keeps +## its existing key shape and cache behavior unchanged. This is the client +## half of the mandatory aliasing fix (T-1150 design doc §3): a +## quarter-granularity request and a district-granularity request at the +## identical (body, center, n) MUST NOT collide on the same cache slot. ## ## Godot's Dictionary preserves insertion order, so "move to the end on ## touch, evict from the front on overflow" is the whole LRU implementation — @@ -20,6 +29,10 @@ extends RefCounted const DEFAULT_MAX_ENTRIES: int = 24 +## Mirrors the server's WINDOW_GRANULARITY_DISTRICT (layer_proxy.rs) — the +## default granularity every pre-T-1150 caller implicitly requests. +const DISTRICT_GRANULARITY: int = 1 + var _max_entries: int = DEFAULT_MAX_ENTRIES var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary @@ -28,19 +41,34 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void: _max_entries = maxi(1, max_entries) -## Build the cache key from the three fields D-227 makes sufficient: -## body_id (which world+body), center (a [row, col] pair or Vector2i), and n -## (window side length). String-keyed rather than a nested Dictionary/Array -## key — Godot Dictionary keys compare by value for primitives but a -## consistent stringification sidesteps any Vector2i-vs-Array identity -## mismatch between what a caller happens to hand in. -static func make_key(body_id: String, center: Vector2i, n: int) -> String: - return "%s:%d,%d:%d" % [body_id, center.x, center.y, n] +## Build the cache key from the five fields D-227 + T-1150 make sufficient: +## body_id (which world+body), center (a [row, col] pair or Vector2i), n +## (window extent in districts), granularity (district=1 / quarter=4), and +## min_wl_m (the octave cutoff, 0 = none). String-keyed rather than a nested +## Dictionary/Array key — Godot Dictionary keys compare by value for +## primitives but a consistent stringification sidesteps any +## Vector2i-vs-Array identity mismatch between what a caller happens to hand +## in. +static func make_key( + body_id: String, + center: Vector2i, + n: int, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> String: + return "%s:%d,%d:%d:%d:%d" % [body_id, center.x, center.y, n, granularity, min_wl_m] -## True if a window is already cached for this exact (body, center, n). -func has(body_id: String, center: Vector2i, n: int) -> bool: - return _entries.has(make_key(body_id, center, n)) +## True if a window is already cached for this exact (body, center, n, +## granularity, min_wl_m). +func has( + body_id: String, + center: Vector2i, + n: int, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> bool: + return _entries.has(make_key(body_id, center, n, granularity, min_wl_m)) ## Fetch a cached window, touching it (move-to-most-recently-used). Returns @@ -48,8 +76,14 @@ func has(body_id: String, center: Vector2i, n: int) -> bool: ## response, which is a different concept (§1: an as-yet-underived window is ## carried as `district_window: None` inside a `Ready` AtlasLayerResponse, ## not a cache state). -func get_window(body_id: String, center: Vector2i, n: int) -> Variant: - var key := make_key(body_id, center, n) +func get_window( + body_id: String, + center: Vector2i, + n: int, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> Variant: + var key := make_key(body_id, center, n, granularity, min_wl_m) if not _entries.has(key): return null var value: Variant = _entries[key] @@ -61,8 +95,15 @@ func get_window(body_id: String, center: Vector2i, n: int) -> Variant: ## Store a window, evicting the least-recently-used entry(ies) if over ## capacity. Overwriting an existing key also counts as a touch. -func put(body_id: String, center: Vector2i, n: int, window: Dictionary) -> void: - var key := make_key(body_id, center, n) +func put( + body_id: String, + center: Vector2i, + n: int, + window: Dictionary, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> void: + var key := make_key(body_id, center, n, granularity, min_wl_m) if _entries.has(key): _entries.erase(key) _entries[key] = window diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd index c37ef8002..28ea6ed88 100644 --- a/client/ui/implant/apps/atlas/atlas_window_request.gd +++ b/client/ui/implant/apps/atlas/atlas_window_request.gd @@ -41,11 +41,20 @@ const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5 const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES +## T-1150 struct/key plumbing: this viewer only ever REQUESTS district +## granularity today (requesting quarter is T-1153's job) — these constants +## exist so the cache key / staleness guard below are granularity-aware from +## day one, not bolted on later. +const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY +const DEFAULT_MIN_WL_M: int = 0 + var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) var _cache = null # AtlasWindowCache var _body_id: String = "" var _center: Vector2i = Vector2i.ZERO var _n: int = DISTRICT_WINDOW_DEFAULT_N +var _granularity: int = DEFAULT_GRANULARITY +var _min_wl_m: int = DEFAULT_MIN_WL_M var _pending: bool = false var _retries: int = 0 var _debounce_timer: Timer = null @@ -89,9 +98,11 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF _body_id = body_id _center = center _n = n + _granularity = DEFAULT_GRANULARITY + _min_wl_m = DEFAULT_MIN_WL_M _debounce_timer.stop() # a direct request supersedes any pending debounced one - var cached: Variant = _cache.get_window(body_id, center, n) + var cached: Variant = _cache.get_window(body_id, center, n, _granularity, _min_wl_m) if cached != null: _pending = false _retries = 0 @@ -100,7 +111,7 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF _pending = true _retries = 0 - SimBridge.request_atlas_layers(body_id, "Topography", center, n) + SimBridge.request_atlas_layers(body_id, "Topography", center, n, _granularity, _min_wl_m) ## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not @@ -112,6 +123,8 @@ func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WIND _body_id = body_id _center = center _n = n + _granularity = DEFAULT_GRANULARITY + _min_wl_m = DEFAULT_MIN_WL_M _debounce_timer.start() @@ -122,10 +135,11 @@ func _on_debounce_timeout() -> void: ## Handle an AtlasLayerResponse (routed by the owning viewer from its own ## SimBridge.atlas_layers_received subscription — this object has no signal ## connection of its own, matching atlas_generation_proxy.gd's on_response() -## shape). Ignores responses for a stale body/center/n (the player panned or -## navigated away while a request was in flight) — the echoed center/n IS the -## staleness guard (§2), compared here against what THIS object most recently -## asked for. +## shape). Ignores responses for a stale body/center/n/granularity/min_wl_m +## (the player panned or navigated away while a request was in flight, or a +## different rung's derive answers a request for a different rung, T-1150) — +## the echoed fields ARE the staleness guard (§2, extended T-1150), compared +## here against what THIS object most recently asked for. func on_response(response: Dictionary) -> void: if str(response.get("body_id", "")) != _body_id: return @@ -150,12 +164,19 @@ func on_response(response: Dictionary) -> void: var w: Dictionary = window var echoed_center := _vec_from_center(w.get("center", [0, 0])) var echoed_n := int(w.get("n", 0)) - if echoed_center != _center or echoed_n != _n: - return # stale — answers a window we've since panned away from (§2) + var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY)) + var echoed_min_wl_m := int(w.get("min_wl_m", 0)) + if ( + echoed_center != _center + or echoed_n != _n + or echoed_granularity != _granularity + or echoed_min_wl_m != _min_wl_m + ): + return # stale — answers a window we've since panned away from, or a different rung (§2/T-1150) _pending = false _retries = 0 - _cache.put(_body_id, _center, _n, w) + _cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m) window_ready.emit(w) @@ -164,7 +185,9 @@ func _schedule_retry() -> void: timer.timeout.connect( func() -> void: if _pending: - SimBridge.request_atlas_layers(_body_id, "Topography", _center, _n) + SimBridge.request_atlas_layers( + _body_id, "Topography", _center, _n, _granularity, _min_wl_m + ) ) diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 79b88fefe..ea26c32f8 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -203,10 +203,17 @@ pub enum GenWorkItem { /// reason `AnalyzeBody.body_params` is boxed. body_params: Box, /// Window centre + side length in districts. `n` is ALREADY clamped to - /// `[1, DISTRICT_WINDOW_MAX_N]` by the caller (`handle_atlas_request`) + /// `[1, DISTRICT_WINDOW_MAX_N]` AND the granularity-aware + /// `WIRE_CAP_CELLS` ceiling by the caller (`handle_atlas_request`) /// before this item is built — never trusted from the wire again here. center: DistrictPos, n: u32, + /// Derivation granularity (T-1150) — `WINDOW_GRANULARITY_DISTRICT` (1) + /// or `WINDOW_GRANULARITY_QUARTER` (4). Already resolved via + /// `resolve_window_granularity` by the caller. + granularity: u32, + /// Octave cutoff in whole metres (T-1149/T-1150), `0` = no cutoff. + min_wl_m: u32, }, } @@ -218,14 +225,23 @@ impl GenWorkItem { } } - /// Coalescing key for `DeriveWindow` items only — `(connection, body)`. + /// Coalescing key for `DeriveWindow` items only — `(connection, body, + /// granularity)` (T-1150, design doc §3 [SOFT] recommendation, extending + /// T-1137's `(connection, body)`). `granularity` is part of the key so an + /// in-flight district-spacing (granularity 1) pan-burst is never + /// superseded by an unrelated quarter-spacing (granularity 4) request for + /// the same connection+body, and vice versa — the two rungs are separate + /// in-flight derives, not competing updates to the same one. /// `None` for every other variant (they don't coalesce this way). - pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str)> { + pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, u32)> { if let GenWorkItem::DeriveWindow { - body_id, conn_id, .. + body_id, + conn_id, + granularity, + .. } = self { - Some((*conn_id, body_id)) + Some((*conn_id, body_id, *granularity)) } else { None } @@ -403,11 +419,15 @@ impl GenerationQueue { } /// Submit a `DeriveWindow` item with per-connection coalescing (D-226 - /// T-1124 amendment §1, "recommended"): if a `DeriveWindow` item for the - /// SAME `(connection, body)` is still sitting in the pending queue - /// (not yet dispatched to a Rayon worker), it is replaced in place by the - /// new one — a pan-burst that queues several window requests for the same - /// connection+body before the first is dispatched collapses to one derive. + /// T-1124 amendment §1, "recommended"; extended T-1150 to key on + /// granularity too): if a `DeriveWindow` item for the SAME `(connection, + /// body, granularity)` is still sitting in the pending queue (not yet + /// dispatched to a Rayon worker), it is replaced in place by the new one + /// — a pan-burst that queues several window requests for the same + /// connection+body+granularity before the first is dispatched collapses + /// to one derive. A district-spacing and quarter-spacing request for the + /// same connection+body do NOT coalesce with each other — they're + /// separate in-flight derives, not competing updates to the same rung. /// /// Deliberately does **not** attempt to cancel an item already dispatched /// to a Rayon worker (no cancellation channel exists, and the amendment @@ -419,12 +439,12 @@ impl GenerationQueue { /// `window_supersede_key()` returns `None`). pub fn submit_window(&self, item: GenWorkItem, priority: GenPriority) { if let Some(key) = item.window_supersede_key() { - let key = (key.0, key.1.to_string()); + let key = (key.0, key.1.to_string(), key.2); let mut pending = self.pending.lock().unwrap(); pending.retain(|q| { q.item .window_supersede_key() - .map(|k| (k.0, k.1.to_string()) != key) + .map(|k| (k.0, k.1.to_string(), k.2) != key) .unwrap_or(true) }); let pos = pending @@ -747,6 +767,8 @@ fn run_work_item( body_params, center, n, + granularity, + min_wl_m, } => match load_heightmap_png(heightmap_path, body_id, *sea_level) { Ok(hm) => { // Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the @@ -780,6 +802,8 @@ fn run_work_item( *center, *n, &climate, + *granularity, + *min_wl_m, ); GenCompletion::WindowDerived { body_id: body_id.clone(), @@ -1137,6 +1161,8 @@ mod tests { }), center, n: 4, + granularity: crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 5735a1301..5281ee990 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -39,6 +39,53 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3; /// caller clamps to `[1, DISTRICT_WINDOW_MAX_N]` before deriving. pub const DISTRICT_WINDOW_MAX_N: u32 = 64; +/// [`AtlasLayerRequest::window_granularity`] encoding (T-1150, zoom ladder +/// design doc §3/§5): the number of derived cells per district side. `0` on +/// the wire (the `#[serde(default)]` absent case) and `1` both mean district +/// spacing (2,048 m/cell, [`DISTRICT_WINDOW_MAX_N`]'s existing behavior, +/// byte-compatible with every pre-T-1150 caller). `4` means quarter spacing +/// (512 m/cell, D-243) — Option B from the design doc: full reclassification +/// at the finer spacing via `derive_at_metres`, not a coarser-cell +/// interpolation. No other values are legal; `resolve_window_granularity` +/// clamps unrecognized values down to district (never trust the wire, same +/// discipline as `window_n`). +pub const WINDOW_GRANULARITY_DISTRICT: u32 = 1; +pub const WINDOW_GRANULARITY_QUARTER: u32 = 4; + +/// Server-side wire-size ceiling (T-1150, design doc §3 "Cell-count cap"): +/// `window_n² × granularity² ≤ WIRE_CAP_CELLS`. At `WIRE_CAP_CELLS = 4,096`, +/// district `n=64` (the existing [`DISTRICT_WINDOW_MAX_N`] cap) sits exactly +/// at the ceiling (64² × 1² = 4,096), and quarter mode is clamped to +/// `n=16` districts across (16² × 4² = 4,096 — matching the design doc's +/// worked example, "Quarter, capped to same cell budget (n≈16 districts +/// across)") — this is the "extent shrinks as granularity refines, payload +/// stays ~constant" rule the design doc requires, enforced here (never +/// trusted from the wire) rather than merely asserted. +pub const WIRE_CAP_CELLS: u32 = 4_096; + +/// Resolve a wire-supplied `window_granularity` value to one of the two legal +/// granularities, clamping anything else down to district spacing — **never +/// trust the wire** (same posture as `window_n`/`normalize_window_center`). +fn resolve_window_granularity(raw: u32) -> u32 { + if raw == WINDOW_GRANULARITY_QUARTER { + WINDOW_GRANULARITY_QUARTER + } else { + WINDOW_GRANULARITY_DISTRICT + } +} + +/// Clamp `window_n` against BOTH the existing per-axis cap +/// ([`DISTRICT_WINDOW_MAX_N`]) and the granularity-aware wire-size ceiling +/// ([`WIRE_CAP_CELLS`]) — `window_n² × granularity² ≤ WIRE_CAP_CELLS` (T-1150 +/// design doc §3). Applied AFTER the per-axis clamp so a request that already +/// satisfies `DISTRICT_WINDOW_MAX_N` still shrinks further at granularity 4. +fn clamp_window_n(raw_n: u32, granularity: u32) -> u32 { + let n = raw_n.clamp(1, DISTRICT_WINDOW_MAX_N); + let g = granularity.max(1); + let cap_n = (WIRE_CAP_CELLS as f64).sqrt() / g as f64; + n.min(cap_n.floor().max(1.0) as u32) +} + /// A client request for a body's generation layers (D-225), extended with an /// optional district-resolution window query (D-226 T-1124 amendment §1, T-1137). /// @@ -61,6 +108,24 @@ pub struct AtlasLayerRequest { /// from the wire** (D-226 T-1124 amendment §4). #[serde(default)] pub window_n: u32, + /// Window derivation granularity (T-1150, zoom ladder design doc §3/§5). + /// `0` (absent, `#[serde(default)]`) or `1` = district spacing (2,048 m, + /// today's behavior, byte-compatible with every pre-T-1150 caller); `4` = + /// quarter spacing (512 m, D-243). See [`WINDOW_GRANULARITY_DISTRICT`] / + /// [`WINDOW_GRANULARITY_QUARTER`]. Resolved via + /// [`resolve_window_granularity`] — **never trusted from the wire**, + /// unrecognized values fall back to district. + #[serde(default)] + pub window_granularity: u32, + /// Octave cutoff for the invented-terrain scatter (T-1149's + /// `min_wavelength_m`), in whole metres. `0` (absent) = no cutoff = the + /// pre-T-1150 behavior. Threaded straight to `derive_at_metres` as + /// `min_wl as f64` — no client-side quantization band is enforced here + /// (the design doc's §5 quantized-band gap-fix is a client-request-shaping + /// concern; the server takes whatever whole-metre value it's given and + /// keys the cache on it verbatim, same posture as `window_n`). + #[serde(default)] + pub window_min_wl_m: u32, } /// Status of a layer response (D-225). @@ -268,23 +333,40 @@ pub fn build_region_grid( // --------------------------------------------------------------------------- /// The requested district window: an `n × n` grid of TRUE 2 km districts -/// centred on `center`, derived on-demand via `district_profile::derive_district` -/// (D-226 T-1124 amendment §2). **Echoes `center`/`n` back** — this is the -/// client's race-condition guard, not a convenience field: because -/// `derive_district` is pure and deterministic (D-227), the same `(center, n)` -/// query always yields the same payload, so the echoed tuple *is* the -/// cache/staleness key the client compares against its most recently requested -/// window (`body_id` disambiguation rides the enclosing `AtlasLayerResponse`, -/// not the echo — see the amendment). +/// (or, at `granularity = 4`, an effective `(4n) × (4n)` grid of 512 m +/// quarters covering the SAME world extent — see `granularity` doc below), +/// derived on-demand via `district_profile::derive_district`/`derive_at_metres` +/// (D-226 T-1124 amendment §2, T-1150). **Echoes `center`/`n`/`granularity` +/// back** — this is the client's race-condition guard, not a convenience +/// field: because the derivation is pure and deterministic (D-227), the same +/// `(center, n, granularity, min_wl_m)` query always yields the same payload, +/// so the echoed tuple *is* the cache/staleness key the client compares +/// against its most recently requested window (`body_id` disambiguation +/// rides the enclosing `AtlasLayerResponse`, not the echo — see the +/// amendment). /// -/// All six arrays are dense row-major `n × n` (`i = row * n + col`), matching -/// the `DistrictGridLayer`/`RegionGridLayer` indexing convention. Per-cell wire -/// cost is 7 bytes (1+1+2+1+1+1) before MessagePack framing overhead (D-226 -/// T-1124 amendment §4). +/// All six arrays are dense row-major (`i = row * side + col`, where `side` +/// is `n` at district granularity or `4n` at quarter granularity), matching +/// the `DistrictGridLayer`/`RegionGridLayer` indexing convention. Per-cell +/// wire cost is 7 bytes (1+1+2+1+1+1) before MessagePack framing overhead +/// (D-226 T-1124 amendment §4). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DistrictWindowLayer { pub center: DistrictPos, + /// Window extent in DISTRICTS — this does NOT change with granularity + /// (T-1150 design doc §2: "the window's `n` stays the DISTRICT extent"). + /// The derived cell grid's actual side length is `n * granularity`. pub n: u32, + /// Derivation granularity (T-1150): [`WINDOW_GRANULARITY_DISTRICT`] (1) + /// or [`WINDOW_GRANULARITY_QUARTER`] (4). Echoed so the client's cache + /// key and staleness guard can distinguish a district-spacing window from + /// a quarter-spacing window requested at the identical `(center, n)`. + pub granularity: u32, + /// The `min_wavelength_m` octave cutoff (T-1149) this window was derived + /// with, in whole metres (`0` = no cutoff). Echoed for the same reason as + /// `granularity` — two windows at identical `(center, n, granularity)` + /// but different cutoffs are NOT the same payload and must not alias. + pub min_wl_m: u32, /// `MorphologyZone` discriminant, the frozen 17-zone vocabulary (D-239 §6). pub morphology: Vec, /// 0-100, matches `DistrictGridLayer.elev_q` encoding. @@ -305,11 +387,16 @@ pub struct DistrictWindowLayer { pub glaciation: Vec, } -/// Key for the server-side window derive cache (T-1137): `(body_id, center, n)`. -/// D-227 purity means a cached window is valid forever for a given body+seed — -/// no staleness/TTL invalidation is needed, only a bound on unbounded growth -/// (see [`DistrictWindowCache`]). -pub type DistrictWindowKey = (String, DistrictPos, u32); +/// Key for the server-side window derive cache (T-1137, extended T-1150): +/// `(body_id, center, n, granularity, min_wl_m)`. D-227 purity means a cached +/// window is valid forever for a given body+seed — no staleness/TTL +/// invalidation is needed, only a bound on unbounded growth (see +/// [`DistrictWindowCache`]). `granularity`/`min_wl_m` MUST be part of the key +/// — the design doc's aliasing risk (§3): a granularity-4 request at the same +/// `(body, center, n)` as a granularity-1 request is a DIFFERENT payload and +/// must land in a different cache slot, never silently overwrite or be served +/// by the other. +pub type DistrictWindowKey = (String, DistrictPos, u32, u32, u32); /// Bounded LRU-ish cache of completed district-window derives (T-1137), a /// sibling to [`BodyWorldStateCache`] rather than a field on it: windows are @@ -375,16 +462,150 @@ impl DistrictWindowCache { } } -/// Build a [`DistrictWindowLayer`] by deriving every district in the -/// `n × n` window around `center` (T-1137). Mirrors +/// One derived cell's packed wire fields — the per-cell output of the window +/// loop body, shared between the serial and parallel builders (T-1151) so the +/// packing logic can never drift between them. +struct WindowCell { + morphology: u8, + elev_q: u8, + temp_dc: i16, + moisture_q: u8, + vegetation: u8, + glaciation: u8, +} + +/// Derive one window cell at `(row, col)` and pack its wire fields. Pure +/// (D-227) — the whole reason row-chunked `par_iter` (T-1151) is safe: every +/// cell is an independent function of its own world-metre position, nothing +/// shared mutably. +/// +/// `step_m` is the metre spacing between cells (T-1150): `DISTRICT_M` at +/// district granularity, `QUARTER_M` at quarter granularity — the caller +/// picks it, this function is granularity-agnostic (it only knows metres). +/// `half_cells` is HALF the cell-grid side (`side / 2`, already in the +/// caller's cell units, not districts), so `center` (a `DistrictPos`, always +/// district-scale) is converted to a world-metre origin once by the caller +/// and offset here in `step_m` units — this is what makes the quarter grid +/// cover the SAME world rect as the district grid at 4x the cell density +/// (design doc §2 Option B). +#[allow(clippy::too_many_arguments)] +fn derive_window_cell( + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + center_world_m: (f64, f64), + half_cells: i32, + step_m: f64, + min_wavelength_m: f64, + row: i32, + col: i32, +) -> WindowCell { + // Row 0 = northmost, matching aliveness_probe's render_window_panels + // (derive_at_metres maps negative wy to negative lat_frac = north). + let wx = center_world_m.0 + (col - half_cells) as f64 * step_m; + let wy = center_world_m.1 + (row - half_cells) as f64 * step_m; + let prof = crate::atlas::district_profile::derive_at_metres( + seed, + body_id, + params, + ta, + wx, + wy, + climate, + min_wavelength_m, + ); + WindowCell { + morphology: prof.morphology_zone as u8, + elev_q: prof.elev_q.clamp(0, 100) as u8, + temp_dc: match prof.temperature_c { + Some(t) => ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16, + None => REGION_TEMP_NONE_DC, + }, + moisture_q: prof.moisture_q.clamp(0, 100) as u8, + vegetation: prof.vegetation_class as u8, + glaciation: prof.glaciation_grade as u8, + } +} + +/// Scatter a computed row of [`WindowCell`]s into the six flat output arrays +/// at row-major offset `row * side`. +#[allow(clippy::too_many_arguments)] +fn scatter_row( + row_cells: &[WindowCell], + row: i32, + side: i32, + morphology: &mut [u8], + elev_q: &mut [u8], + temp_dc: &mut [i16], + moisture_q: &mut [u8], + vegetation: &mut [u8], + glaciation: &mut [u8], +) { + let base = (row * side) as usize; + for (col, cell) in row_cells.iter().enumerate() { + let i = base + col; + morphology[i] = cell.morphology; + elev_q[i] = cell.elev_q; + temp_dc[i] = cell.temp_dc; + moisture_q[i] = cell.moisture_q; + vegetation[i] = cell.vegetation; + glaciation[i] = cell.glaciation; + } +} + +/// Resolve `center` (a district-grid position) to its world-metre origin — +/// shared by both window builders so the district->metres convention can +/// never drift between them. Mirrors `derive_district`'s own quantization +/// (`district_profile.rs`) exactly: `dm = DISTRICT_M`, `(dx*dm, dy*dm)`. +fn center_to_world_m(center: DistrictPos) -> (f64, f64) { + let dm = DISTRICT_M as f64; + (center.0 as f64 * dm, center.1 as f64 * dm) +} + +/// Cell step size in metres for a given granularity (T-1150): district +/// spacing (2,048 m) or quarter spacing (512 m). Any other value resolves to +/// district spacing (mirrors [`resolve_window_granularity`]'s fallback). +fn step_m_for_granularity(granularity: u32) -> f64 { + if granularity == WINDOW_GRANULARITY_QUARTER { + crate::atlas::scale::QUARTER_M as f64 + } else { + DISTRICT_M as f64 + } +} + +/// Build a [`DistrictWindowLayer`] by deriving every cell in the window +/// around `center` (T-1137, extended T-1150). Mirrors /// `aliveness_probe::render_window_panels`'s derive loop exactly (the probe /// this design promotes to a served layer, D-226 T-1124 amendment §2) — same -/// row-major indexing, same `derive_district` call per cell. +/// row-major indexing, same per-cell derive call. /// -/// `n` MUST already be clamped to `[1, DISTRICT_WINDOW_MAX_N]` by the caller — -/// this function trusts it verbatim (the clamp is `handle_atlas_request`'s -/// job, applied once at the wire boundary, not re-checked on every internal -/// caller per the existing codebase convention of clamping at the edge). +/// `n` MUST already be clamped by the caller ([`clamp_window_n`], +/// `[1, DISTRICT_WINDOW_MAX_N]` AND the granularity-aware `WIRE_CAP_CELLS` +/// ceiling) — this function trusts it verbatim (the clamp is +/// `handle_atlas_request`'s job, applied once at the wire boundary, not +/// re-checked on every internal caller per the existing codebase convention +/// of clamping at the edge). +/// +/// `n` is always the window extent in DISTRICTS (design doc §2 Option B: "the +/// window's `n` stays the DISTRICT extent"). At `granularity = 1` the derived +/// cell grid is `n × n` districts; at `granularity = 4` it is `(4n) × (4n)` +/// quarters covering the SAME world rect — full reclassification at the finer +/// spacing (`derive_at_metres` with `min_wavelength_m` matching the rung), +/// never a coarser-cell interpolation. +/// +/// **Row-chunked `par_iter` (T-1151):** each cell is a pure function of its +/// own position (D-227), so rows can derive in parallel with no shared +/// mutable state. Chunking by ROW (not per-cell) amortizes Rayon's own +/// task-dispatch overhead against the ~1.2 µs/cell derive cost (design doc +/// §7: naive per-cell parallelization risks the dispatch overhead itself +/// costing more than the work) — one Rayon task per row means `side` tasks of +/// `side` cells each, not `side²` tasks of one cell each. [`build_district_window_layer_serial`] +/// is kept alongside this as the golden-comparison baseline (T-1151 +/// acceptance: bit-identical serial vs. parallel output, exact row-major +/// array ordering preserved either way). +#[allow(clippy::too_many_arguments)] pub fn build_district_window_layer( seed: SeedChain, body_id: &str, @@ -393,41 +614,143 @@ pub fn build_district_window_layer( center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, + granularity: u32, + min_wl_m: u32, ) -> DistrictWindowLayer { - let n_i = n as i32; - let half = n_i / 2; - let cells = (n * n) as usize; + use rayon::prelude::*; + + let side = (n * granularity.max(1)) as i32; + let half = side / 2; + let step_m = step_m_for_granularity(granularity); + let min_wavelength_m = min_wl_m as f64; + let center_world_m = center_to_world_m(center); + let cells = (side * side) as usize; let mut morphology = vec![0u8; cells]; let mut elev_q = vec![0u8; cells]; let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells]; let mut moisture_q = vec![0u8; cells]; let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; - for row in 0..n_i { - for col in 0..n_i { - // Row 0 = northmost, matching aliveness_probe's render_window_panels - // (derive_district maps negative wy to negative lat_frac = north). - let dp = (center.0 - half + col, center.1 - half + row); - let prof = crate::atlas::district_profile::derive_district( - seed, body_id, params, ta, dp, climate, - ); - let i = (row * n_i + col) as usize; - morphology[i] = prof.morphology_zone as u8; - elev_q[i] = prof.elev_q.clamp(0, 100) as u8; - temp_dc[i] = match prof.temperature_c { - Some(t) => { - ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16 - } - None => REGION_TEMP_NONE_DC, - }; - moisture_q[i] = prof.moisture_q.clamp(0, 100) as u8; - vegetation[i] = prof.vegetation_class as u8; - glaciation[i] = prof.glaciation_grade as u8; - } + + // One Rayon task per row: derive_window_cell(row, ..) for every col, then + // scatter that row's results into the flat arrays. Row order in the + // output collection is preserved by `par_iter` (it yields in index + // order), so the scatter below reproduces the exact row-major layout the + // serial loop produces. + let rows: Vec> = (0..side) + .into_par_iter() + .map(|row| { + (0..side) + .map(|col| { + derive_window_cell( + seed, + body_id, + params, + ta, + climate, + center_world_m, + half, + step_m, + min_wavelength_m, + row, + col, + ) + }) + .collect() + }) + .collect(); + + for (row, row_cells) in rows.into_iter().enumerate() { + scatter_row( + &row_cells, + row as i32, + side, + &mut morphology, + &mut elev_q, + &mut temp_dc, + &mut moisture_q, + &mut vegetation, + &mut glaciation, + ); + } + + DistrictWindowLayer { + center, + n, + granularity, + min_wl_m, + morphology, + elev_q, + temp_dc, + moisture_q, + vegetation, + glaciation, + } +} + +/// Serial twin of [`build_district_window_layer`] (T-1151) — the pre-parallel +/// row/col double loop, kept ONLY as the golden-comparison baseline for the +/// bit-identical serial-vs-parallel test. Not used by production callers. +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +fn build_district_window_layer_serial( + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + center: DistrictPos, + n: u32, + climate: &crate::atlas::district_profile::ClimateConstants, + granularity: u32, + min_wl_m: u32, +) -> DistrictWindowLayer { + let side = (n * granularity.max(1)) as i32; + let half = side / 2; + let step_m = step_m_for_granularity(granularity); + let min_wavelength_m = min_wl_m as f64; + let center_world_m = center_to_world_m(center); + let cells = (side * side) as usize; + let mut morphology = vec![0u8; cells]; + let mut elev_q = vec![0u8; cells]; + let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells]; + let mut moisture_q = vec![0u8; cells]; + let mut vegetation = vec![0u8; cells]; + let mut glaciation = vec![0u8; cells]; + for row in 0..side { + let row_cells: Vec = (0..side) + .map(|col| { + derive_window_cell( + seed, + body_id, + params, + ta, + climate, + center_world_m, + half, + step_m, + min_wavelength_m, + row, + col, + ) + }) + .collect(); + scatter_row( + &row_cells, + row, + side, + &mut morphology, + &mut elev_q, + &mut temp_dc, + &mut moisture_q, + &mut vegetation, + &mut glaciation, + ); } DistrictWindowLayer { center, n, + granularity, + min_wl_m, morphology, elev_q, temp_dc, @@ -842,8 +1165,10 @@ fn normalize_window_center(params: &BodyParams, center: DistrictPos) -> District /// processed the completion (the existing D-225 poll-and-recheck-cache /// pattern every other layer already uses, not a push). /// -/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` here — the ONE place -/// that clamp is applied; nothing downstream re-checks the wire value. +/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` AND the +/// granularity-aware `WIRE_CAP_CELLS` ceiling here — the ONE place that clamp +/// is applied; nothing downstream re-checks the wire value. `window_granularity` +/// is resolved via [`resolve_window_granularity`] at the same boundary (T-1150). #[allow(clippy::too_many_arguments)] fn serve_district_window( req: &AtlasLayerRequest, @@ -855,7 +1180,9 @@ fn serve_district_window( conn_id: ConnectionId, ) -> Option { let raw_center = req.window_center?; - let n = req.window_n.clamp(1, DISTRICT_WINDOW_MAX_N); + let granularity = resolve_window_granularity(req.window_granularity); + let n = clamp_window_n(req.window_n, granularity); + let min_wl_m = req.window_min_wl_m; // body_params is needed to normalize the centre BEFORE either cache key // exists (T-1142) — read it first, unconditionally (not gated on a cache @@ -892,7 +1219,11 @@ fn serve_district_window( ); } - let key: DistrictWindowKey = (req.body_id.clone(), center, n); + // T-1150: granularity + min_wl_m are part of the cache key — a + // granularity-4 request at the same (body, center, n) as a granularity-1 + // request is a DIFFERENT payload and must never alias onto the same slot + // (design doc §3's aliasing risk, the mandatory regression test below). + let key: DistrictWindowKey = (req.body_id.clone(), center, n, granularity, min_wl_m); if let Some(layer) = window_cache.get(&key) { return Some(layer.clone()); } @@ -923,6 +1254,8 @@ fn serve_district_window( body_params: Box::new(body_params), center, n, + granularity, + min_wl_m, }, GenPriority::Immediate, ); @@ -1298,11 +1631,22 @@ mod tests { let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); let n = 4u32; - let layer = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (10, -5), n, &climate); + let layer = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (10, -5), + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!(layer.center, (10, -5)); assert_eq!(layer.n, n); + assert_eq!(layer.granularity, WINDOW_GRANULARITY_DISTRICT); + assert_eq!(layer.min_wl_m, 0); let cells = (n * n) as usize; assert_eq!(layer.morphology.len(), cells); assert_eq!(layer.elev_q.len(), cells); @@ -1341,8 +1685,17 @@ mod tests { let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); - let layer = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (0, 0), 1, &climate); + let layer = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (0, 0), + 1, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!(layer.n, 1); assert_eq!(layer.morphology.len(), 1); assert_eq!(layer.elev_q.len(), 1); @@ -1366,16 +1719,85 @@ mod tests { let seed = SeedChain::root(7).derive(SeedDomain::Body, 3); let n = 8u32; - let first = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); - let second = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); + let first = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (3, -2), + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + let second = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (3, -2), + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!( first, second, "two full derive passes over the same (center, n) must be byte-identical (D-010/D-227)" ); } + /// T-1151 acceptance: the row-chunked `par_iter` window build + /// ([`build_district_window_layer`]) must be bit-identical to the + /// pre-parallel serial baseline ([`build_district_window_layer_serial`]) + /// — same inputs, same output, exact row-major array ordering preserved. + /// Run at a window size large enough (16×16 = 256 cells) to actually + /// exercise multiple Rayon-dispatched rows, not just n=1. + #[test] + fn build_district_window_layer_parallel_matches_serial() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(13).derive(SeedDomain::Body, 4); + + let n = 16u32; + let center = (5, -9); + let parallel = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + let serial = build_district_window_layer_serial( + seed, + "test_body", + ¶ms, + &ta, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + assert_eq!( + parallel, serial, + "row-chunked par_iter window build must be bit-identical to the serial baseline" + ); + // Row-major ordering check, explicit (not just struct equality): the + // parallel path collects one Vec per row via par_iter, + // which preserves index order (`par_iter().map(...).collect()` is + // order-preserving), but pin the ordering assumption directly too. + assert_eq!(parallel.morphology.len(), (n * n) as usize); + assert_eq!(parallel.center, center); + assert_eq!(parallel.n, n); + } + /// FULL-PATH determinism (PR #187 review — Tyre C3, binding, load-bearing /// for save-file lineage under D-227): the test above reuses ONE `ta` for /// both passes, which only proves `build_district_window_layer` (the @@ -1420,10 +1842,28 @@ mod tests { // Now the FULL path: pack a DistrictWindowLayer from each independent // TerrainAnalysis and confirm the complete served payload agrees. - let window_from_pass1 = - build_district_window_layer(seed, "test_body", ¶ms, &ta_pass1, center, n, &climate); - let window_from_pass2 = - build_district_window_layer(seed, "test_body", ¶ms, &ta_pass2, center, n, &climate); + let window_from_pass1 = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta_pass1, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + let window_from_pass2 = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta_pass2, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!( window_from_pass1, window_from_pass2, "two independent run_layer1 derivations from the same (seed, heightmap) \ @@ -1440,12 +1880,14 @@ mod tests { #[test] fn district_window_cache_insert_get_and_evict() { let mut cache = DistrictWindowCache::new(2); - let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4); - let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4); - let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4); + let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4, WINDOW_GRANULARITY_DISTRICT, 0); + let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4, WINDOW_GRANULARITY_DISTRICT, 0); + let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4, WINDOW_GRANULARITY_DISTRICT, 0); let mk = |center, n| DistrictWindowLayer { center, n, + granularity: WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, morphology: vec![0; (n * n) as usize], elev_q: vec![0; (n * n) as usize], temp_dc: vec![REGION_TEMP_NONE_DC; (n * n) as usize], @@ -1491,6 +1933,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((0, 0)), window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust + window_granularity: 0, + window_min_wl_m: 0, }; let resp = handle_atlas_request( @@ -1527,6 +1971,78 @@ mod tests { ); } + // ------------------------------------------------------------------- + // resolve_window_granularity / clamp_window_n (T-1150) + // ------------------------------------------------------------------- + + #[test] + fn resolve_window_granularity_maps_known_values() { + assert_eq!(resolve_window_granularity(0), WINDOW_GRANULARITY_DISTRICT); + assert_eq!( + resolve_window_granularity(WINDOW_GRANULARITY_DISTRICT), + WINDOW_GRANULARITY_DISTRICT + ); + assert_eq!( + resolve_window_granularity(WINDOW_GRANULARITY_QUARTER), + WINDOW_GRANULARITY_QUARTER + ); + } + + /// Never trust the wire: an unrecognized granularity value (garbage, or a + /// future rung not yet implemented) falls back to district, never panics + /// or propagates un-vetted. + #[test] + fn resolve_window_granularity_unknown_value_falls_back_to_district() { + for garbage in [2, 3, 5, 100, u32::MAX] { + assert_eq!( + resolve_window_granularity(garbage), + WINDOW_GRANULARITY_DISTRICT, + "unrecognized granularity {garbage} must fall back to district" + ); + } + } + + /// District granularity: the per-axis DISTRICT_WINDOW_MAX_N cap alone + /// governs (64² × 1² = 4,096 = WIRE_CAP_CELLS exactly, so the cap is + /// never tighter than DISTRICT_WINDOW_MAX_N at granularity 1). + #[test] + fn clamp_window_n_district_granularity_uses_per_axis_cap() { + assert_eq!( + clamp_window_n(DISTRICT_WINDOW_MAX_N * 10, WINDOW_GRANULARITY_DISTRICT), + DISTRICT_WINDOW_MAX_N + ); + assert_eq!(clamp_window_n(32, WINDOW_GRANULARITY_DISTRICT), 32); + assert_eq!(clamp_window_n(0, WINDOW_GRANULARITY_DISTRICT), 1); + } + + /// Quarter granularity: the wire-size ceiling bites BEFORE the per-axis + /// cap — a request for n=64 at granularity 4 would derive 256×256=65,536 + /// cells (16x over budget), so it must clamp down to n=16 + /// (16² × 4² = 4,096 = WIRE_CAP_CELLS exactly — matching the design + /// doc's §3 worked example, "Quarter, capped to same cell budget + /// (n≈16 districts across)"), never to the raw DISTRICT_WINDOW_MAX_N=64. + #[test] + fn clamp_window_n_quarter_granularity_uses_wire_cap_not_per_axis_cap() { + let clamped = clamp_window_n(DISTRICT_WINDOW_MAX_N, WINDOW_GRANULARITY_QUARTER); + assert_eq!( + clamped, 16, + "quarter granularity must clamp n to keep (n*granularity)^2 <= WIRE_CAP_CELLS" + ); + assert!( + clamped * clamped * WINDOW_GRANULARITY_QUARTER * WINDOW_GRANULARITY_QUARTER + <= WIRE_CAP_CELLS, + "clamped cell count must never exceed WIRE_CAP_CELLS" + ); + } + + /// A small requested `n` at quarter granularity is left unclamped when it + /// already fits the budget (the cap must not be a flat floor/ceiling + /// substitution — only trims when the request would actually overflow). + #[test] + fn clamp_window_n_quarter_granularity_leaves_small_n_unclamped() { + assert_eq!(clamp_window_n(8, WINDOW_GRANULARITY_QUARTER), 8); + } + // ------------------------------------------------------------------- // normalize_window_center (T-1142 — letterbox-click out-of-range bug) // ------------------------------------------------------------------- @@ -1653,6 +2169,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((12276, 3021)), window_n: 4, + window_granularity: 0, + window_min_wl_m: 0, }; let resp1 = handle_atlas_request( &insane_req, @@ -1678,7 +2196,10 @@ mod tests { for c in completions { if let GenCompletion::WindowDerived { body_id, layer } = c { if body_id == "SmallMoon" { - window_cache.insert((body_id, layer.center, layer.n), *layer); + window_cache.insert( + (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + *layer, + ); } } } @@ -1698,6 +2219,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((4, 383)), // the hand-computed canonical twin window_n: 4, + window_granularity: 0, + window_min_wl_m: 0, }; let resp2 = handle_atlas_request( &sane_twin_req, @@ -1750,6 +2273,154 @@ mod tests { ); } + /// **MANDATORY aliasing regression (T-1150, design doc §3's flagged + /// aliasing risk):** a granularity-4 (quarter) request and a + /// granularity-1 (district) request at the IDENTICAL `(body, center, n)` + /// must produce DISTINCT `DistrictWindowCache` entries and correct + /// per-granularity payloads — never silently alias onto the same slot + /// and serve one rung's data for the other's request. + #[test] + fn granularity_4_and_granularity_1_requests_produce_distinct_cache_entries() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = + resolver_and_params_reader_with_radius("AliasBody", 6371.0); + // 3 threads: BOTH DeriveWindow items (district + quarter) need to + // dispatch concurrently with the AnalyzeBody item the first request's + // whole-body cache miss also enqueues (handle_atlas_request always + // fires an AnalyzeBody alongside the window derive on a cold body) — + // 2 threads left one DeriveWindow stuck behind AnalyzeBody within the + // single drain_completions() call below. + let queue = GenerationQueue::with_threads(3); + + let center = Some((10, -5)); + let n = 4u32; + + let district_req = AtlasLayerRequest { + body_id: "AliasBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: center, + window_n: n, + window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_min_wl_m: 0, + }; + let quarter_req = AtlasLayerRequest { + body_id: "AliasBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: center, + window_n: n, + window_granularity: WINDOW_GRANULARITY_QUARTER, + window_min_wl_m: 0, + }; + + // Fire both requests — same (body, center, n), different granularity. + handle_atlas_request( + &district_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + handle_atlas_request( + &quarter_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + for c in completions { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "AliasBody" { + window_cache.insert( + (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + *layer, + ); + } + } + } + + assert_eq!( + window_cache.len(), + 2, + "district and quarter requests at the SAME (body, center, n) must occupy \ + TWO distinct cache entries, not alias onto one" + ); + + // Re-request both — each must now hit ITS OWN cached entry and return + // the CORRECT per-granularity payload (not the other rung's data). + let district_resp = handle_atlas_request( + &district_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + let quarter_resp = handle_atlas_request( + &quarter_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + + let district_layer = district_resp + .district_window + .expect("district request must hit its own cached entry"); + let quarter_layer = quarter_resp + .district_window + .expect("quarter request must hit its own cached entry"); + + assert_eq!(district_layer.granularity, WINDOW_GRANULARITY_DISTRICT); + assert_eq!(quarter_layer.granularity, WINDOW_GRANULARITY_QUARTER); + // n echoes the DISTRICT extent unchanged at both granularities + // (design doc §2: "the window's n stays the DISTRICT extent"). + assert_eq!(district_layer.n, n); + assert_eq!(quarter_layer.n, n); + // The derived CELL GRID differs: n×n at district, (4n)×(4n) at quarter. + assert_eq!(district_layer.morphology.len(), (n * n) as usize); + assert_eq!( + quarter_layer.morphology.len(), + (n * WINDOW_GRANULARITY_QUARTER * n * WINDOW_GRANULARITY_QUARTER) as usize + ); + // Correct per-granularity payload, not the other rung's data reused: + // the quarter grid must show finer per-cell VARIATION than a naive + // 4x-repeat of the district grid would (Option B — full + // reclassification at 512 m, not a coarser-cell interpolation). + let quarter_elev_range = { + let min = quarter_layer.elev_q.iter().min().copied().unwrap_or(0); + let max = quarter_layer.elev_q.iter().max().copied().unwrap_or(0); + max - min + }; + assert!( + quarter_elev_range > 0, + "quarter-granularity window must show real sub-district elevation \ + variation, not a blocky repeat of the district cells" + ); + } + /// The echoed `center` on `DistrictWindowLayer` is the NORMALIZED value, /// not the raw wire value — the client's D-227 staleness guard (D-226 /// T-1124 amendment §2) must see what was ACTUALLY derived, so it can @@ -1768,6 +2439,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((12276, 3021)), // raw, out-of-range window_n: 4, + window_granularity: 0, + window_min_wl_m: 0, }; handle_atlas_request( &insane_req, @@ -1846,6 +2519,8 @@ mod tests { let window = DistrictWindowLayer { center: (10, -5), n: 2, + granularity: WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, morphology: vec![0, 8, 14, 16], elev_q: vec![0, 45, 98, 60], temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], @@ -1902,6 +2577,14 @@ mod tests { assert_eq!(decoded.up_to, CascadeLayer::Topography); assert_eq!(decoded.window_center, None); assert_eq!(decoded.window_n, 0); + assert_eq!( + decoded.window_granularity, 0, + "T-1150: absent window_granularity decodes to 0 (district), byte-compatible" + ); + assert_eq!( + decoded.window_min_wl_m, 0, + "T-1150: absent window_min_wl_m decodes to 0 (no cutoff), byte-compatible" + ); } /// T-1119: `build_quarter_footprint_layer` returns `None` when no @@ -2517,6 +3200,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, } } diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index a24ea3a3d..1a49e946c 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -413,7 +413,10 @@ fn drain_generation_completions( // NEXT poll (the existing D-225 re-request loop) hits // `handle_atlas_request`'s window branch, which finds this // entry via `DistrictWindowCache::get` and serves it. - window_cache.insert((body_id, layer.center, layer.n), *layer); + window_cache.insert( + (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + *layer, + ); } } } @@ -904,6 +907,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }, )])); world.insert_resource(AtlasResponseBuffer::default()); diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 4ecd73c32..be787b510 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -1201,6 +1201,8 @@ mod inbound_tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }; let frame = rmp_serde::to_vec_named(&req).unwrap(); assert!( @@ -1246,6 +1248,8 @@ mod inbound_tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }) .unwrap(); let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap(); diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index d54d769ce..ff213b725 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -371,6 +371,8 @@ fn single_tick_drains_all_ready_inbound_frames() { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }; let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize"); write_framed(&mut stream, &payload).expect("write atlas frame"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 6eabe2667..c43192109 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -7,6 +7,7 @@ use settled_reach_server::atlas::layer_proxy::{ AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry, QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, SettlementEntry, SettlementLayer, SettlementSizeClass, REGION_TEMP_NONE_DC, + WINDOW_GRANULARITY_DISTRICT, }; use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState}; use settled_reach_server::atlas::road_graph::RoadNodeKind; @@ -725,6 +726,8 @@ fn generate_atlas_layer_response_fixtures() { let window = DistrictWindowLayer { center: (10, -5), n: 2, + granularity: WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland elev_q: vec![0, 45, 98, 60], temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], // 20.5°C, 15.0°C, airless sentinel, 8.0°C From 0159a63cc2d6be6a5778f4e991eea4bf60a11a6b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 00:47:53 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(simulation):=20PR=20#191=20review=20rou?= =?UTF-8?q?nd=20=E2=80=94=20n-clamp=20mirror,=20min=5Fwl=20band=20quantiza?= =?UTF-8?q?tion,=20coalescing=20coverage,=20fixture=20consumer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven Hoshe/Tyre findings addressed, none retracted: - n-clamp/echo/staleness triangle (Tyre C1): client _clamp_window_n_mirror (bit-for-bit twin of the server clamp, canonicalize_district_center precedent) applied before _n is stored/sent; server test pins the quarter n=32 -> echo 16 contract. - min_wl band quantization (Hoshe 1/Tyre C3): quantize_min_wl_m snaps to MIN_WL_BANDS_M {0, 32768, 16384, 8192, 4096} before cache key and echo (design doc §5's unbounded-key fix), reusing the one true OCTAVE_WAVELENGTHS_M array; docstrings now state the server-quantizes/ client-sends-raw split; same-band cache-sharing test. - coalescing granularity axis (Hoshe 2): two tests pin different- granularity requests as separate in-flight slots and same-granularity coalescing unchanged. - orphaned fixture (Hoshe 3): test_protocol.gd consumer decodes atlas_response_ready_with_window.msgpack through the real IPC path and asserts the new fields. - atlas_window_request coverage (Hoshe 4): new test file — stale-drop on granularity mismatch, old-server-shape defaults accepted, clamp mirror formula + wiring. First draft's quarter-via-request_now test would have passed for the wrong reason (request_now resets granularity by design until T-1153) — split into formula pin + reachable-path wiring proof. - granularity type seam (Tyre C2): field + resolver docstrings state finer-only integer multiples with resolve_window_granularity as the single widening point; matching contract note added to the D-226 T-1143-rulings amendment. cargo --lib 1807/1807; goldens bit-identical; gdlint clean. --- client/scripts/protocol/atlas_map_protocol.gd | 10 + client/tests/test_atlas_window_request.gd | 177 ++++++++++ client/tests/test_protocol.gd | 21 ++ .../apps/atlas/atlas_window_request.gd | 49 ++- governance/decisions/architecture.md | 2 +- server/src/atlas/detail_scatter.rs | 10 +- server/src/atlas/gen_queue.rs | 98 +++++- server/src/atlas/layer_proxy.rs | 317 +++++++++++++++++- 8 files changed, 670 insertions(+), 14 deletions(-) create mode 100644 client/tests/test_atlas_window_request.gd diff --git a/client/scripts/protocol/atlas_map_protocol.gd b/client/scripts/protocol/atlas_map_protocol.gd index c38818582..8935f8b49 100644 --- a/client/scripts/protocol/atlas_map_protocol.gd +++ b/client/scripts/protocol/atlas_map_protocol.gd @@ -39,6 +39,16 @@ class_name AtlasMapProtocol ## requests quarter granularity yet (that's T-1153); this function just makes ## it possible to ask, byte-compatible with every existing caller that ## doesn't pass them. +## +## **Quantization split (PR #191 review, Hoshe 1 / Tyre C3):** `window_min_wl_m` +## is sent HERE as a raw, unquantized value — this codec does NOT snap it to +## the design doc §5 fixed band set. The SERVER is the one place quantization +## happens (`serve_district_window` → `quantize_min_wl_m`, `layer_proxy.rs`): +## it snaps every request's value to the nearest band before touching the +## cache key or the echo, so a caller here is free to send a +## viewport-continuous estimate (e.g. `E/C` from the rung-selection rule) — +## don't pre-quantize client-side, it would just duplicate logic the server +## already owns and could drift out of sync with it. static func encode_atlas_layer_request( mp, body_id: String, diff --git a/client/tests/test_atlas_window_request.gd b/client/tests/test_atlas_window_request.gd new file mode 100644 index 000000000..942d243dd --- /dev/null +++ b/client/tests/test_atlas_window_request.gd @@ -0,0 +1,177 @@ +## T-1150 (PR #191 review, Hoshe 4): atlas_window_request.gd had NO test file +## at all before this — direct coverage of the granularity/min_wl_m staleness +## guard, the n-clamp mirror (Tyre C1), and the old-server-shape default +## disposition. Follows test_atlas_window_viewer.gd's own +## "AtlasWindowRequest — cache reuse" section conventions (same +## instantiation pattern: `AtlasWindowRequest.new(owner_stub)`, `add_child()` +## for the debounce Timer, hand-built response dicts) rather than +## re-inventing a shape. +class_name TestAtlasWindowRequest +extends GdUnitTestSuite + +# atlas_window_request.gd has no class_name (review #8 precedent throughout +# this cluster) — preloaded once here, not re-load()ed per test (gdlint +# duplicated-load). +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") + + +## Build a hand-authored DistrictWindowLayer dict, granularity-aware +## (T-1150) — mirrors test_atlas_window_viewer.gd's own _mock_window(), with +## granularity/min_wl_m added as optional params so callers can build both +## rungs' echo shapes with one helper. +static func _mock_window( + center: Vector2i, n: int = 2, granularity: int = 1, min_wl_m: int = 0 +) -> Dictionary: + return { + "center": [center.x, center.y], + "n": n, + "granularity": granularity, + "min_wl_m": min_wl_m, + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + + +static func _mock_response(body_id: String, window: Variant) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "district_window": window} + + +func _make_request() -> Variant: + var owner_stub := RefCounted.new() + var req = auto_free(AtlasWindowRequest.new(owner_stub)) + add_child(req) + return req + + +# ============================================================================= +# (a) granularity mismatch on the echo -> dropped as stale +# ============================================================================= + + +## The mandatory item-(a) case: request_now() asks at the default district +## granularity (1); a response echoing granularity=4 (quarter) for the SAME +## center/n must be dropped as stale, not accepted — a different rung's +## derive answering a request for a different rung is exactly as stale as a +## mismatched center (T-1150 extends §2's guard to this axis). +func test_on_response_with_mismatched_granularity_is_dropped_as_stale() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(2, 2), 2) + assert_bool(req.is_pending()).is_true() + + var quarter_window: Dictionary = _mock_window(Vector2i(2, 2), 2, 4, 0) + req.on_response(_mock_response("GJ380c", quarter_window)) + + assert_bool(req.is_pending()).override_failure_message( + "a granularity-mismatched response must be dropped as stale, leaving the district request still pending" + ).is_true() + + +# ============================================================================= +# (b) old-server-shape response (no granularity/min_wl_m keys) -> defaults +# ============================================================================= + + +## A response from a hypothetical pre-T-1150 server (or any response whose +## district_window dict simply omits the new keys) must decode granularity +## as district (1) and min_wl_m as 0 via the same defaulting on_response() +## already applies — and since request_now()'s own defaults are identical, +## the response is ACCEPTED, not treated as stale just because two keys are +## missing. +func test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(3, 3), 2) + assert_bool(req.is_pending()).is_true() + + # Old-shape window: no "granularity"/"min_wl_m" keys at all. + var old_shape_window := { + "center": [3, 3], + "n": 2, + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + req.on_response(_mock_response("GJ380c", old_shape_window)) + + assert_bool(req.is_pending()).override_failure_message( + ( + "an old-server-shape response (missing granularity/min_wl_m) must " + + "default to district/0 and be ACCEPTED, not dropped as stale" + ) + ).is_false() + + var received: Array = [] + req.window_ready.connect(func(w: Dictionary) -> void: received.append(w)) + # Re-request the same (body, center, n) — must now be a cache hit, proving + # on_response() actually stored the old-shape window under the + # district/0 key, not silently discarding it. + req.request_now("GJ380c", Vector2i(3, 3), 2) + assert_int(received.size()).is_equal(1) + assert_bool(req.is_pending()).is_false() + + +# ============================================================================= +# (c) n-clamp mirror (Tyre C1) — quarter n=32 stores clamped n=16 +# ============================================================================= + + +## **Item (c) as literally scoped by the ticket** ("the clamp-mirror from +## item 1"): `_clamp_window_n_mirror()` reproduces the server's +## `clamp_window_n(raw_n, granularity)` bit-for-bit, INCLUDING the quarter +## n=32 -> 16 case — pinned directly against the static helper, independent +## of the request/response plumbing (`request_now()` has no public +## "request quarter" entry point today; T-1150 is struct/key plumbing only, +## requesting quarter is T-1153's job — see the class-level docstring on +## `_clamp_window_n_mirror()` for why calling `request_now()` at district +## granularity can never itself exercise the quarter branch: it unconditionally +## resets `_granularity` to district BEFORE clamping, by design, since no +## caller can ask for quarter yet). +func test_clamp_window_n_mirror_matches_server_formula_at_quarter_n32() -> void: + assert_int(AtlasWindowRequest._clamp_window_n_mirror(32, 4)).is_equal(16) + # District granularity: the per-axis cap (64) governs, matching the + # server's clamp_window_n_district_granularity_uses_per_axis_cap test. + assert_int(AtlasWindowRequest._clamp_window_n_mirror(640, 1)).is_equal(64) + # Small n well under budget at quarter granularity stays unclamped, + # matching clamp_window_n_quarter_granularity_leaves_small_n_unclamped. + assert_int(AtlasWindowRequest._clamp_window_n_mirror(8, 4)).is_equal(8) + + +## **Item (c), the request/response half:** `request_now()` actually WIRES +## the mirror in (not just defines it) — a request for a district-legal but +## per-axis-oversized `n` (e.g. 640, mirroring the server's own +## `DISTRICT_WINDOW_MAX_N*10` oversized-request test) stores the CLAMPED +## `_n=64`, so a server response echoing the server's OWN clamped n=64 is +## ACCEPTED, not rejected as stale for "not matching" the raw 640 that was +## asked for. This is the exact n-clamp/echo/staleness triangle Tyre C1 +## flagged, exercised through the reachable (district) path today; the +## quarter-specific n=32->16 number is pinned by the formula test above since +## no public API can drive quarter through `request_now()` yet. +func test_oversized_n_request_stores_clamped_n_and_accepts_matching_echo() -> void: + var req = _make_request() + req.request_now("GJ380c", Vector2i(4, 4), 640) + + assert_int(req._n).override_failure_message( + ( + "request_now() must mirror the server's clamp_window_n(640, granularity=1) " + + "== 64 BEFORE storing _n, not store the raw requested 640" + ) + ).is_equal(64) + assert_bool(req.is_pending()).is_true() + + # The server's real response for this request echoes n=64 (its own + # clamp_window_n() result) — must be ACCEPTED, not stale. + var clamped_echo: Dictionary = _mock_window(Vector2i(4, 4), 64, 1, 0) + req.on_response(_mock_response("GJ380c", clamped_echo)) + + assert_bool(req.is_pending()).override_failure_message( + ( + "a response echoing the CLAMPED n=64 must be accepted, since _n was " + + "already clamped to 64 before the request fired" + ) + ).is_false() diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 5c3428437..c8d3e8d59 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -511,6 +511,27 @@ func test_decode_atlas_response_not_found() -> void: assert_that(resp.status).is_equal("NotFound") +## PR #191 review, Hoshe 3: `atlas_response_ready_with_window.msgpack` had NO +## consumer anywhere in client/tests — regenerated by the T-1150 `granularity`/ +## `min_wl_m` field additions but nothing decoded it through the real IPC path. +## This is that consumer, matching the sibling `test_decode_atlas_response_*` +## tests' style/fixture-dir convention above: full decode_atlas_layer_response() +## round trip (not a hand-built Dictionary like test_atlas_data_delivery.gd's +## passthrough tests), confirming `district_window.granularity`/`.min_wl_m` +## (T-1150's two new echo fields) survive the real client decode path. +func test_decode_atlas_response_ready_with_window() -> void: + var bytes := _load_fixture("atlas_response_ready_with_window") + var resp = Protocol.decode_atlas_layer_response(bytes) + assert_that(resp).is_not_null() + assert_that(resp.status).is_equal("Ready") + assert_that(resp.district_window).is_not_null() + var window: Dictionary = resp.district_window + assert_that(window.get("center")).is_equal([10, -5]) + assert_that(int(window.get("n"))).is_equal(2) + assert_that(int(window.get("granularity"))).is_equal(1) + assert_that(int(window.get("min_wl_m"))).is_equal(0) + + func test_snapshot_is_not_decoded_as_atlas_response() -> void: # Disambiguation: an ObserverSnapshot has no "status" key, so the atlas # decoder rejects it. receive_bytes relies on this to route correctly. diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd index 28ea6ed88..7f1c7b97d 100644 --- a/client/ui/implant/apps/atlas/atlas_window_request.gd +++ b/client/ui/implant/apps/atlas/atlas_window_request.gd @@ -48,6 +48,15 @@ const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY const DEFAULT_MIN_WL_M: int = 0 +## Mirrors server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N / +## WIRE_CAP_CELLS exactly (PR #191 review, Tyre C1). `_clamp_window_n_mirror()` +## below reproduces `clamp_window_n()` bit-for-bit — the load-bearing-mirror +## pattern `AtlasDescendGeometry.canonicalize_district_center()` already uses +## for the server's `normalize_window_center()`. Keep both numbers in sync +## with the server constants of the same name if either ever changes. +const SERVER_DISTRICT_WINDOW_MAX_N: int = 64 +const SERVER_WIRE_CAP_CELLS: int = 4_096 + var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) var _cache = null # AtlasWindowCache var _body_id: String = "" @@ -86,6 +95,38 @@ func reset() -> void: _debounce_timer.stop() +## Mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n(raw_n, +## granularity)` EXACTLY (PR #191 review, Tyre C1 — "the sharpest" finding): +## `serve_district_window` echoes the CLAMPED `n` back in +## `DistrictWindowLayer.n`, but `on_response()`'s staleness guard compares the +## echo against `_n`. Without this mirror, `_n` would hold the RAW requested +## value while the server echoes the CLAMPED one — the moment a caller +## requests quarter (granularity=4) at n=32, the server clamps to n=16 and +## echoes THAT, `on_response()` sees `echoed_n=16 != _n=32`, decides the +## response is stale, and the window silently never loads (no error, no log +## on this side — just an eternally-pending request). +## +## Clamping HERE, before `_n` is ever stored or sent, means `_n` already +## equals what the server will echo — no drift between the two sides, the +## SAME load-bearing-mirror pattern `AtlasDescendGeometry. +## canonicalize_district_center()` uses for the server's +## `normalize_window_center()` (see that function's docstring for the general +## rationale: canonicalizing before the request is sent means the client's +## held state already equals what the server will echo back). +## +## Formula, bit-for-bit: `n = raw_n.clamp(1, SERVER_DISTRICT_WINDOW_MAX_N)`, +## then `n = min(n, floor(sqrt(SERVER_WIRE_CAP_CELLS) / max(granularity, 1)))` +## — applied in that order (per-axis cap first, then the granularity-aware +## wire-size ceiling), matching `clamp_window_n`'s own comment ("Applied AFTER +## the per-axis clamp so a request that already satisfies +## DISTRICT_WINDOW_MAX_N still shrinks further at granularity 4"). +static func _clamp_window_n_mirror(raw_n: int, granularity: int) -> int: + var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N) + var g: int = maxi(granularity, 1) + var cap_n: int = int(floor(sqrt(float(SERVER_WIRE_CAP_CELLS)) / float(g))) + return mini(n, maxi(cap_n, 1)) + + ## Entry point + pan re-request: request the window centered on `center` ## (a DistrictPos-equivalent Vector2i) for `body_id`. Cache hit -> immediate ## synchronous window_ready emit, no network traffic at all. Cache miss -> @@ -97,12 +138,12 @@ func reset() -> void: func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void: _body_id = body_id _center = center - _n = n _granularity = DEFAULT_GRANULARITY _min_wl_m = DEFAULT_MIN_WL_M + _n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting _debounce_timer.stop() # a direct request supersedes any pending debounced one - var cached: Variant = _cache.get_window(body_id, center, n, _granularity, _min_wl_m) + var cached: Variant = _cache.get_window(body_id, center, _n, _granularity, _min_wl_m) if cached != null: _pending = false _retries = 0 @@ -111,7 +152,7 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF _pending = true _retries = 0 - SimBridge.request_atlas_layers(body_id, "Topography", center, n, _granularity, _min_wl_m) + SimBridge.request_atlas_layers(body_id, "Topography", center, _n, _granularity, _min_wl_m) ## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not @@ -122,9 +163,9 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void: _body_id = body_id _center = center - _n = n _granularity = DEFAULT_GRANULARITY _min_wl_m = DEFAULT_MIN_WL_M + _n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting _debounce_timer.start() diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 53fd6d2a5..902a10ad9 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1669,7 +1669,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser **Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it. - **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. + **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. - **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem. - **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`. - **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer. diff --git a/server/src/atlas/detail_scatter.rs b/server/src/atlas/detail_scatter.rs index 7f2e7d29f..86a3b19b2 100644 --- a/server/src/atlas/detail_scatter.rs +++ b/server/src/atlas/detail_scatter.rs @@ -22,7 +22,15 @@ use crate::seed::splitmix64; /// Mid-scale octave wavelengths in metres — the 2–40 km band. Coarsest first. /// Below the finest (~4 km) the district→voxel layers own the detail; above the /// coarsest (~33 km) the heightmap itself carries the shape. -const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0]; +/// +/// `pub(crate)`: also the quantization band set for `layer_proxy`'s +/// `window_min_wl_m` (T-1150, zoom ladder design doc §5 — "quantize +/// `min_wl_m` to a small fixed set of bands per rung, **matching the rung's +/// own octave bands**"). District/quarter windows both derive via +/// `terrain_detail`, so this IS "the rung's own octave bands" for both rungs +/// today — one array, no duplicated magic numbers that could drift out of +/// sync with the actual cutoff behavior. +pub(crate) const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0]; /// Voxel-tier octave wavelengths in metres — the ≈0.13–1 km **sub-district** band /// (all finer than the 2 km district planning unit) that the district-tier diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index ea26c32f8..780ae17d7 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -1144,8 +1144,26 @@ mod tests { // ------------------------------------------------------------------- /// Build a `DeriveWindow` work item pointing at a tiny test heightmap, - /// mirroring `analyze()`'s fixture shape. + /// mirroring `analyze()`'s fixture shape. `granularity` defaults to + /// district (matching every pre-T-1150 call site) via + /// `derive_window_at()` below — extended (PR #191 review, Hoshe 2) so + /// coalescing tests can exercise the granularity axis of + /// `window_supersede_key()` without a second near-duplicate helper. fn derive_window(body_id: &str, conn_id: ConnectionId, center: DistrictPos) -> GenWorkItem { + derive_window_at( + body_id, + conn_id, + center, + crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, + ) + } + + fn derive_window_at( + body_id: &str, + conn_id: ConnectionId, + center: DistrictPos, + granularity: u32, + ) -> GenWorkItem { GenWorkItem::DeriveWindow { body_id: body_id.to_string(), conn_id, @@ -1161,7 +1179,7 @@ mod tests { }), center, n: 4, - granularity: crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, + granularity, min_wl_m: 0, } } @@ -1282,6 +1300,82 @@ mod tests { ); } + /// **PR #191 review, Hoshe 2 — zero coverage before this test.** + /// `window_supersede_key()`'s doc claims district and quarter requests + /// for the SAME `(connection, body)` are separate in-flight slots (the + /// key is `(conn_id, body_id, granularity)`, not `(conn_id, body_id)`). + /// Two submissions for the same connection+body but DIFFERENT + /// granularity must NOT coalesce — both survive as independent pending + /// items. + #[test] + fn submit_window_does_not_coalesce_different_granularity() { + let q = GenerationQueue::with_threads(1); + // See `submit_window_coalesces_same_connection_and_body`'s comment on + // why the occupier must be `analyze()`, not `FillChunk`. + q.submit(analyze("Occupier3"), GenPriority::Low); + + let conn = ConnectionId(9); + q.submit_window( + derive_window_at( + "GranBody", + conn, + (0, 0), + crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, + ), + GenPriority::Immediate, + ); + q.submit_window( + derive_window_at( + "GranBody", + conn, + (0, 0), + crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER, + ), + GenPriority::Immediate, + ); + assert_eq!( + q.pending_count(), + 2, + "same (connection, body) but DIFFERENT granularity must NOT coalesce — \ + district and quarter are separate in-flight slots" + ); + } + + /// The coalescing-DOES-happen counterpart to the test above: two + /// submissions for the SAME `(connection, body, granularity)` still + /// collapse to one pending item — confirms the granularity axis didn't + /// accidentally loosen the existing same-key coalescing behavior. + #[test] + fn submit_window_coalesces_same_connection_body_and_granularity() { + let q = GenerationQueue::with_threads(1); + q.submit(analyze("Occupier4"), GenPriority::Low); + + let conn = ConnectionId(11); + q.submit_window( + derive_window_at( + "SameGranBody", + conn, + (0, 0), + crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER, + ), + GenPriority::Immediate, + ); + q.submit_window( + derive_window_at( + "SameGranBody", + conn, + (5, 5), + crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER, + ), + GenPriority::Immediate, + ); + assert_eq!( + q.pending_count(), + 1, + "same (connection, body, granularity) must still coalesce to one pending item" + ); + } + // ------------------------------------------------------------------- // TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1) // ------------------------------------------------------------------- diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 5281ee990..54c6d364d 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -66,6 +66,16 @@ pub const WIRE_CAP_CELLS: u32 = 4_096; /// Resolve a wire-supplied `window_granularity` value to one of the two legal /// granularities, clamping anything else down to district spacing — **never /// trust the wire** (same posture as `window_n`/`normalize_window_center`). +/// +/// **This is THE single widening point (Tyre C2, PR #191 review).** The +/// field only ever expresses finer-than-district integer multiples (see +/// [`AtlasLayerRequest::window_granularity`]'s doc for the full type-seam +/// contract); adding a future finer rung means adding its legal value here +/// and nowhere else. Do NOT add a value < 1 or attempt to encode +/// coarser-than-district rungs (region/orbital) through this function — +/// design doc §5/§9 R5 requires a signed/log-scale or enum redesign for that +/// direction, which this `u32` cannot express regardless of what this +/// function returns. fn resolve_window_granularity(raw: u32) -> u32 { if raw == WINDOW_GRANULARITY_QUARTER { WINDOW_GRANULARITY_QUARTER @@ -79,6 +89,16 @@ fn resolve_window_granularity(raw: u32) -> u32 { /// ([`WIRE_CAP_CELLS`]) — `window_n² × granularity² ≤ WIRE_CAP_CELLS` (T-1150 /// design doc §3). Applied AFTER the per-axis clamp so a request that already /// satisfies `DISTRICT_WINDOW_MAX_N` still shrinks further at granularity 4. +/// +/// **This clamp is echoed, not silently applied** — `serve_district_window` +/// puts the CLAMPED `n` into `DistrictWindowLayer.n`, so a client that +/// requests an oversized `n` gets back a smaller one. Any client-side +/// staleness guard comparing its own requested `n` against the echo MUST +/// mirror this exact function first (PR #191 review, Tyre C1) — see +/// `atlas_window_request.gd`'s `_clamp_window_n_mirror()`, which matches this +/// function bit-for-bit, the same load-bearing-mirror pattern +/// `canonicalize_district_center()` (`atlas_descend_geometry.gd`) already +/// uses for `normalize_window_center`. fn clamp_window_n(raw_n: u32, granularity: u32) -> u32 { let n = raw_n.clamp(1, DISTRICT_WINDOW_MAX_N); let g = granularity.max(1); @@ -86,6 +106,49 @@ fn clamp_window_n(raw_n: u32, granularity: u32) -> u32 { n.min(cap_n.floor().max(1.0) as u32) } +/// Quantized `window_min_wl_m` bands (T-1150, zoom ladder design doc §5): +/// `0` (no cutoff) plus every entry of +/// [`crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M`] — the SAME array +/// `terrain_detail`'s octave sum truncates against (both district and +/// quarter rungs derive via `terrain_detail`, so this is genuinely "the +/// rung's own octave bands", not a second independently-chosen scale). +/// Descending order except the leading `0.0` sentinel, matched by +/// `quantize_min_wl_m`'s scan below. +const MIN_WL_BANDS_M: [f64; 5] = [ + 0.0, + crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[0], + crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[1], + crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[2], + crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M[3], +]; + +/// Snap a wire-supplied `window_min_wl_m` to the nearest fixed band in +/// [`MIN_WL_BANDS_M`] (T-1150, design doc §5's gap-fix): "as specified, +/// `window_min_wl_m` is viewport-continuous while the cache key/echo tuple is +/// `(body, center, n, granularity)` — same key, different `min_wl`, would +/// silently collide. Fix: quantize `min_wl_m` to a small fixed set of bands +/// ... and add the quantized band to both the echo and the cache key." This +/// is that quantization, applied unconditionally to every request before it +/// touches either the cache key or the `DeriveWindow` work item — **never +/// the raw wire value past this point**, same discipline as `window_n`'s +/// clamp and `window_center`'s normalization. Nearest-band snap (ties round +/// to the coarser/lower band, i.e. `<=` on the running best distance) keeps +/// the mapping total and deterministic for any `u32` input, including values +/// far outside the octave range (e.g. `u32::MAX` snaps to the coarsest band). +fn quantize_min_wl_m(raw: u32) -> u32 { + let raw_f = raw as f64; + let mut best = MIN_WL_BANDS_M[0]; + let mut best_dist = (raw_f - best).abs(); + for &band in &MIN_WL_BANDS_M[1..] { + let dist = (raw_f - band).abs(); + if dist < best_dist { + best = band; + best_dist = dist; + } + } + best as u32 +} + /// A client request for a body's generation layers (D-225), extended with an /// optional district-resolution window query (D-226 T-1124 amendment §1, T-1137). /// @@ -115,15 +178,38 @@ pub struct AtlasLayerRequest { /// [`WINDOW_GRANULARITY_QUARTER`]. Resolved via /// [`resolve_window_granularity`] — **never trusted from the wire**, /// unrecognized values fall back to district. + /// + /// **Type seam (Tyre C2, PR #191 review):** this field expresses ONLY + /// finer-than-district integer multiples of the district spacing — `1` + /// and `4` are legal today, and each new finer rung (e.g. a future + /// block/tile value) is a deliberate widening of + /// [`resolve_window_granularity`]'s whitelist, the single point where + /// that widening happens. It CANNOT express coarser-than-district rungs + /// (region/orbital, granularity < 1) — reusing this field for those is + /// explicitly out of scope; design doc §5/§9 R5 requires a + /// signed/log-scale value or an explicit rung enum instead. Do not smuggle + /// a "granularity 0 means region" convention into this `u32` — that is + /// the redesign R5 already flags, not a value to add here. #[serde(default)] pub window_granularity: u32, /// Octave cutoff for the invented-terrain scatter (T-1149's /// `min_wavelength_m`), in whole metres. `0` (absent) = no cutoff = the - /// pre-T-1150 behavior. Threaded straight to `derive_at_metres` as - /// `min_wl as f64` — no client-side quantization band is enforced here - /// (the design doc's §5 quantized-band gap-fix is a client-request-shaping - /// concern; the server takes whatever whole-metre value it's given and - /// keys the cache on it verbatim, same posture as `window_n`). + /// pre-T-1150 behavior. + /// + /// **Quantization contract (Hoshe 1 / Tyre C3, PR #191 review; design doc + /// §5):** the wire value here is an UNQUANTIZED, unclamped raw passthrough + /// — client codecs may send any `u32`. The SERVER is the one place + /// quantization happens: `serve_district_window` snaps every request's + /// value to the nearest fixed band in + /// [`MIN_WL_BANDS_M`] via [`quantize_min_wl_m`] BEFORE it ever touches + /// the cache key or the `DeriveWindow` work item, and the QUANTIZED value + /// (not this raw field) is what gets echoed back on + /// `DistrictWindowLayer.min_wl_m` and used as the cache key component. + /// This closes the §5 gap: without quantization, two requests differing + /// only in a continuous-valued `min_wl_m` would silently miss each + /// other's cache entries (the unbounded-key-space problem §5 exists to + /// close) — the client is free to send a viewport-continuous estimate; + /// the server's quantization is what makes the key space bounded again. #[serde(default)] pub window_min_wl_m: u32, } @@ -1182,7 +1268,11 @@ fn serve_district_window( let raw_center = req.window_center?; let granularity = resolve_window_granularity(req.window_granularity); let n = clamp_window_n(req.window_n, granularity); - let min_wl_m = req.window_min_wl_m; + // T-1150 design doc §5: quantize BEFORE either the cache key or the + // DeriveWindow work item sees it — the raw wire value never reaches + // either (same discipline as window_n's clamp above and + // normalize_window_center's wrap/clamp below). + let min_wl_m = quantize_min_wl_m(req.window_min_wl_m); // body_params is needed to normalize the centre BEFORE either cache key // exists (T-1142) — read it first, unconditionally (not gated on a cache @@ -1971,6 +2061,67 @@ mod tests { ); } + /// **Contract-pinning test (PR #191 review, Tyre C1):** a quarter + /// (granularity=4) request for `n=32` echoes the WIRE-CAP-CLAMPED `n=16`, + /// not the requested 32 — `32² × 4² = 16,384` cells, 4x over + /// `WIRE_CAP_CELLS`. This is the exact scenario the review flagged as + /// silently breaking the client the moment T-1153 requests quarter at + /// n=32: the server echoes a DIFFERENT `n` than what was asked for, and + /// any client staleness guard comparing raw `_n` against the echo must + /// already know this will happen (see + /// `atlas_window_request.gd::_clamp_window_n_mirror()`, the client-side + /// fix landed alongside this test). + #[test] + fn quarter_n32_request_echoes_clamped_n16() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = resolver_and_params_reader("GJ1c"); + let queue = GenerationQueue::with_threads(1); + + let quarter_n32_req = AtlasLayerRequest { + body_id: "GJ1c".to_string(), + up_to: CascadeLayer::Topography, + window_center: Some((0, 0)), + window_n: 32, + window_granularity: WINDOW_GRANULARITY_QUARTER, + window_min_wl_m: 0, + }; + + let resp = handle_atlas_request( + &quarter_n32_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + assert!(resp.district_window.is_none(), "first request — cache miss"); + + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + let window_completion = completions.into_iter().find_map(|c| { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "GJ1c" { + return Some(layer); + } + } + None + }); + let layer = window_completion.expect("DeriveWindow must complete for GJ1c"); + assert_eq!( + layer.granularity, WINDOW_GRANULARITY_QUARTER, + "granularity must echo back as requested (4 is within budget on its own)" + ); + assert_eq!( + layer.n, 16, + "a quarter n=32 request must echo the wire-cap-clamped n=16, not the requested 32" + ); + } + // ------------------------------------------------------------------- // resolve_window_granularity / clamp_window_n (T-1150) // ------------------------------------------------------------------- @@ -2043,6 +2194,160 @@ mod tests { assert_eq!(clamp_window_n(8, WINDOW_GRANULARITY_QUARTER), 8); } + // ------------------------------------------------------------------- + // quantize_min_wl_m (T-1150, PR #191 review — Hoshe 1 / Tyre C3, design doc §5) + // ------------------------------------------------------------------- + + #[test] + fn quantize_min_wl_m_exact_band_values_are_stable() { + for &band in &MIN_WL_BANDS_M { + assert_eq!(quantize_min_wl_m(band as u32), band as u32); + } + } + + #[test] + fn quantize_min_wl_m_zero_stays_zero() { + assert_eq!(quantize_min_wl_m(0), 0); + } + + /// A value nearer to 0 than to the finest real octave band (4,096) snaps + /// to 0 (no cutoff) — the band set includes 0 as a real, selectable band, + /// not just a special-cased default. + #[test] + fn quantize_min_wl_m_small_value_snaps_to_zero_band() { + assert_eq!(quantize_min_wl_m(500), 0); + } + + /// A value between two real octave bands snaps to the NEAREST one, not + /// always up or always down. + #[test] + fn quantize_min_wl_m_mid_value_snaps_to_nearest_band() { + // Between 4,096 and 8,192: 5,000 is nearer 4,096 (dist 904 vs 3,192). + assert_eq!(quantize_min_wl_m(5_000), 4_096); + // 7,500 is nearer 8,192 (dist 692 vs 3,404). + assert_eq!(quantize_min_wl_m(7_500), 8_192); + } + + /// A value far above the coarsest band snaps to the coarsest band, never + /// panics or overflows — quantization must be a TOTAL function over all + /// u32 input (never trust the wire). + #[test] + fn quantize_min_wl_m_huge_value_snaps_to_coarsest_band() { + assert_eq!(quantize_min_wl_m(u32::MAX), 32_768); + assert_eq!(quantize_min_wl_m(1_000_000), 32_768); + } + + /// The mandatory §5 aliasing-closing test: two requests differing only in + /// an UNQUANTIZED `min_wl_m` that both fall in the SAME band must share + /// ONE cache entry, not two — this is the exact gap §5 flags ("same key, + /// different min_wl, would silently collide" becomes "same key, same + /// quantized min_wl, correctly coalesce"). + #[test] + fn two_requests_in_same_min_wl_band_share_one_cache_entry() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = + resolver_and_params_reader_with_radius("BandBody", 6371.0); + let queue = GenerationQueue::with_threads(2); + + // Both values are nearer 4,096 than any other band (4,000 and 4,300 + // both round to 4,096 — see the mid-value test above for the + // distance math), so they must land in the SAME quantized band. + let req_a = AtlasLayerRequest { + body_id: "BandBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: Some((10, -5)), + window_n: 4, + window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_min_wl_m: 4_000, + }; + let req_b = AtlasLayerRequest { + body_id: "BandBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: Some((10, -5)), + window_n: 4, + window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_min_wl_m: 4_300, + }; + + handle_atlas_request( + &req_a, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + handle_atlas_request( + &req_b, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + for c in completions { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "BandBody" { + window_cache.insert( + (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + *layer, + ); + } + } + } + + assert_eq!( + window_cache.len(), + 1, + "two requests in the SAME quantized min_wl_m band at identical \ + (body, center, n, granularity) must share ONE cache entry, not two" + ); + + // Re-request both — each must hit the SAME cached entry and echo the + // QUANTIZED band (4,096), not either raw wire value. + let resp_a = handle_atlas_request( + &req_a, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + let resp_b = handle_atlas_request( + &req_b, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + let layer_a = resp_a.district_window.expect("req_a must hit the cache"); + let layer_b = resp_b.district_window.expect("req_b must hit the cache"); + assert_eq!(layer_a.min_wl_m, 4_096, "echo must be the QUANTIZED band"); + assert_eq!(layer_b.min_wl_m, 4_096, "echo must be the QUANTIZED band"); + assert_eq!(layer_a, layer_b, "both requests must resolve to the identical cached layer"); + } + // ------------------------------------------------------------------- // normalize_window_center (T-1142 — letterbox-click out-of-range bug) // ------------------------------------------------------------------- From 1ce4085901a71841684e1450bd6a35a241d315e8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 00:51:22 +0200 Subject: [PATCH 4/4] style(simulation): cargo fmt on the fix round + governance amendment typo (Hoshe nit) --- governance/decisions/architecture.md | 2 +- server/src/atlas/detail_scatter.rs | 5 ++++- server/src/atlas/district_profile.rs | 3 +-- server/src/atlas/layer_proxy.rs | 33 +++++++++++++++++++++++----- server/src/atlas/plugin.rs | 8 ++++++- server/tests/zoom_ladder_bench.rs | 30 ++++--------------------- 6 files changed, 45 insertions(+), 36 deletions(-) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 902a10ad9..2f65849a6 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1669,7 +1669,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser **Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it. - **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. + **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. - **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem. - **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`. - **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer. diff --git a/server/src/atlas/detail_scatter.rs b/server/src/atlas/detail_scatter.rs index 86a3b19b2..6cbdaa570 100644 --- a/server/src/atlas/detail_scatter.rs +++ b/server/src/atlas/detail_scatter.rs @@ -329,7 +329,10 @@ mod tests { // (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); + assert_eq!( + terrain_detail(5, 1_000.0, 2_000.0, 0.6, 0.5, 100_000.0), + 0.0 + ); } #[test] diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 36dce66eb..fb1892ada 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -2044,8 +2044,7 @@ mod tests { 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 uncut = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0); let cut = derive_at_metres( test_seed(), "test_body", diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 54c6d364d..8b48c8b84 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -606,7 +606,9 @@ fn derive_window_cell( morphology: prof.morphology_zone as u8, elev_q: prof.elev_q.clamp(0, 100) as u8, temp_dc: match prof.temperature_c { - Some(t) => ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16, + Some(t) => { + ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16 + } None => REGION_TEMP_NONE_DC, }, moisture_q: prof.moisture_q.clamp(0, 100) as u8, @@ -2301,7 +2303,13 @@ mod tests { if let GenCompletion::WindowDerived { body_id, layer } = c { if body_id == "BandBody" { window_cache.insert( - (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + ( + body_id, + layer.center, + layer.n, + layer.granularity, + layer.min_wl_m, + ), *layer, ); } @@ -2345,7 +2353,10 @@ mod tests { let layer_b = resp_b.district_window.expect("req_b must hit the cache"); assert_eq!(layer_a.min_wl_m, 4_096, "echo must be the QUANTIZED band"); assert_eq!(layer_b.min_wl_m, 4_096, "echo must be the QUANTIZED band"); - assert_eq!(layer_a, layer_b, "both requests must resolve to the identical cached layer"); + assert_eq!( + layer_a, layer_b, + "both requests must resolve to the identical cached layer" + ); } // ------------------------------------------------------------------- @@ -2502,7 +2513,13 @@ mod tests { if let GenCompletion::WindowDerived { body_id, layer } = c { if body_id == "SmallMoon" { window_cache.insert( - (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + ( + body_id, + layer.center, + layer.n, + layer.granularity, + layer.min_wl_m, + ), *layer, ); } @@ -2650,7 +2667,13 @@ mod tests { if let GenCompletion::WindowDerived { body_id, layer } = c { if body_id == "AliasBody" { window_cache.insert( - (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + ( + body_id, + layer.center, + layer.n, + layer.granularity, + layer.min_wl_m, + ), *layer, ); } diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 1a49e946c..eca7a6ff7 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -414,7 +414,13 @@ fn drain_generation_completions( // `handle_atlas_request`'s window branch, which finds this // entry via `DistrictWindowCache::get` and serves it. window_cache.insert( - (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + ( + body_id, + layer.center, + layer.n, + layer.granularity, + layer.min_wl_m, + ), *layer, ); } diff --git a/server/tests/zoom_ladder_bench.rs b/server/tests/zoom_ladder_bench.rs index d2db9e3d7..d917774e9 100644 --- a/server/tests/zoom_ladder_bench.rs +++ b/server/tests/zoom_ladder_bench.rs @@ -80,16 +80,8 @@ fn time_derive_sweep( 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, - ); + 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); } @@ -136,14 +128,7 @@ fn bench_derive_at_metres_district_and_quarter_spacing() { // 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, + 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)", @@ -155,14 +140,7 @@ fn bench_derive_at_metres_district_and_quarter_spacing() { // 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, + 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)",