diff --git a/server/src/atlas/coast_invention.rs b/server/src/atlas/coast_invention.rs index 3c5d37a06..962903e3e 100644 --- a/server/src/atlas/coast_invention.rs +++ b/server/src/atlas/coast_invention.rs @@ -49,11 +49,34 @@ const COAST_WARP_Y_SALT: u64 = 0xD1F7_0CEA_2B0A_D515; /// Salt for the tier-2 heterogeneity field (same-coast stretches differ). const CHARACTER_FIELD_SALT: u64 = 0x0C0A_57C4_A24C_7E12; -/// Coast-warp octave wavelengths in metres (≈16–262 km): capes and gulfs at the -/// top, coves and inlets at the bottom. All above the 4–33 km detail-scatter -/// band, and all below ~2 heightmap pixels — the warp perturbs the coast, it -/// does not rewrite continents (the heightmap stays the truth at its own scale). -const WARP_OCTAVE_WAVELENGTHS_M: [f64; 5] = [262_144.0, 131_072.0, 65_536.0, 32_768.0, 16_384.0]; +/// Coast-warp octave wavelengths in metres (≈1,024 m–262 km): capes and gulfs +/// at the top, down through bays/headlands/islets at the bottom (T-1162 — the +/// headline "detailed coastline" visual ask). The warp perturbs the coast, it +/// does not rewrite continents (the heightmap stays the truth at its own +/// scale) — displacing the sampling *position*, distinct from +/// `detail_scatter::terrain_detail`'s independent perturbation of the sampled +/// *elevation*. +/// +/// **T-1162 extension:** four octaves finer than the pre-T-1162 floor +/// (16,384 m) were appended — 8,192 / 4,096 / 2,048 / 1,024 — reaching the +/// Quarter rung's own Nyquist floor (512 m spacing → content wavelength ≥ 2× +/// spacing = 1,024 m; finer than that is unsampleable noise at Quarter +/// density, not detail). A Quarter-rung request (`min_wavelength_m` cutoff +/// quantized to 1,024 m, see `layer_proxy::MIN_WL_BANDS_M`) admits every +/// octave down to this floor; a District-rung request (cutoff quantized to +/// District's OWN real band, 4,096 m — District's Nyquist limit, matching +/// `terrain_detail`'s pre-existing finest octave) truncates the two finest +/// additions (2,048/1,024 m — genuinely below District's floor) while +/// legitimately ADMITTING the other two (8,192/4,096 m — new content that IS +/// coarse enough for District to resolve, not a leak). Either way Quarter +/// still sees strictly more than District at the SAME position (the two +/// finest additions are Quarter-exclusive). The +/// `enveloped_fbm`-style hard-truncate cutoff discipline is honored via +/// [`warp_fbm`]'s own `min_wavelength_m` parameter — `0.0` (no cutoff) is +/// byte-identical to every pre-T-1162 caller. +const WARP_OCTAVE_WAVELENGTHS_M: [f64; 9] = [ + 262_144.0, 131_072.0, 65_536.0, 32_768.0, 16_384.0, 8_192.0, 4_096.0, 2_048.0, 1_024.0, +]; /// Heterogeneity-field octave wavelengths in metres (≈100–400 km): the scale on /// which one planet's coastline personality drifts from stretch to stretch. @@ -224,21 +247,44 @@ pub fn character_field(seed: u64, wx: f64, wy: f64) -> f64 { /// Apply the same offset to *every* envelope field sampled at the position /// (elevation, slope, ocean mask) so the invented terrain moves coherently — /// a warped-in bay carries its sea-level elevation with it. -pub fn coast_warp_px(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> (f64, f64) { +/// +/// `min_wavelength_m` (T-1162, mirrors `detail_scatter::terrain_detail`'s +/// contract exactly): octaves in [`WARP_OCTAVE_WAVELENGTHS_M`] finer than this +/// cutoff are hard-truncated, never faded. `0.0` = no cutoff = every octave — +/// byte-identical to every pre-T-1162 caller (all of which passed no cutoff at +/// all, since the parameter didn't exist before this ticket). +pub fn coast_warp_px( + seed: u64, + wx: f64, + wy: f64, + ch: &CoastCharacter, + min_wavelength_m: f64, +) -> (f64, f64) { let sx = splitmix64(seed ^ COAST_WARP_SALT); let sy = splitmix64(sx ^ COAST_WARP_Y_SALT); ( - warp_fbm(sx, wx, wy, ch) * ch.warp_amplitude_px, - warp_fbm(sy, wx, wy, ch) * ch.warp_amplitude_px, + warp_fbm(sx, wx, wy, ch, min_wavelength_m) * ch.warp_amplitude_px, + warp_fbm(sy, wx, wy, ch, min_wavelength_m) * ch.warp_amplitude_px, ) } /// Roughness/ridge-shaped fBm in ≈`[-1, 1]` over the coast-warp octave band. -fn warp_fbm(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> f64 { +/// +/// `min_wavelength_m` (T-1162): same hard-truncate discipline as +/// `detail_scatter::enveloped_fbm` — octaves with `wl < min_wavelength_m` are +/// skipped entirely, but `amp` still advances through the skipped step so +/// surviving octaves keep their intended relative weight (no renormalization +/// against a smaller octave count beyond the shared `norm` denominator, which +/// already only sums the terms that ran). +fn warp_fbm(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter, min_wavelength_m: f64) -> f64 { let mut sum = 0.0; let mut amp = 1.0; let mut norm = 0.0; for (i, &wl) in WARP_OCTAVE_WAVELENGTHS_M.iter().enumerate() { + if wl < min_wavelength_m { + amp *= 0.45 + 0.35 * ch.roughness; + continue; + } let mut n = value_noise( seed.wrapping_add((i as u64).wrapping_mul(0x1000)), wx, @@ -257,6 +303,9 @@ fn warp_fbm(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> f64 { // entirely and still read as stripes at w64. amp *= 0.45 + 0.35 * ch.roughness; } + if norm == 0.0 { + return 0.0; // every octave cut by the cutoff → no warp displacement left + } sum / norm } @@ -357,21 +406,99 @@ mod tests { TectonicClass::Stable, ); let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55); - let a = coast_warp_px(42, 3e6, 1e6, &ch); - let b = coast_warp_px(42, 3e6, 1e6, &ch); + let a = coast_warp_px(42, 3e6, 1e6, &ch, 0.0); + let b = coast_warp_px(42, 3e6, 1e6, &ch, 0.0); assert_eq!(a, b, "warp must be deterministic"); for i in 0..400 { - let (dx, dy) = coast_warp_px(42, i as f64 * 9_137.0, i as f64 * -7_211.0, &ch); + let (dx, dy) = coast_warp_px(42, i as f64 * 9_137.0, i as f64 * -7_211.0, &ch, 0.0); assert!(dx.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9); assert!(dy.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9); } // C¹ continuity: a 10 m step is a tiny displacement change — the coast // character never steps on a line (D-243 edge-fuzz discipline). - let (x0, y0) = coast_warp_px(42, 5e6, 5e6, &ch); - let (x1, y1) = coast_warp_px(42, 5e6 + 10.0, 5e6, &ch); + let (x0, y0) = coast_warp_px(42, 5e6, 5e6, &ch, 0.0); + let (x1, y1) = coast_warp_px(42, 5e6 + 10.0, 5e6, &ch, 0.0); assert!((x0 - x1).abs() < 0.01 && (y0 - y1).abs() < 0.01); } + // ── min_wavelength_m cutoff (T-1162) ────────────────────────────────── + + #[test] + fn cutoff_zero_matches_pre_t1162_behavior() { + // 0.0 = no cutoff = every octave — the compatibility contract every + // pre-T-1162 caller relies on (all of which never had a cutoff param + // to begin with). + let env = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55); + for i in 0..100 { + let wx = i as f64 * 9_137.0; + let wy = i as f64 * -7_211.0; + let with_zero = coast_warp_px(42, wx, wy, &ch, 0.0); + // The finest WARP_OCTAVE_WAVELENGTHS_M entry is 1,024.0 — a cutoff + // below that admits every octave too, and must agree exactly. + let with_below_finest = coast_warp_px(42, wx, wy, &ch, 1.0); + assert_eq!(with_zero, with_below_finest); + } + } + + #[test] + fn cutoff_truncates_octaves_below_it() { + // A cutoff above the coarsest WARP_OCTAVE_WAVELENGTHS_M entry + // (262,144.0) must skip every octave and fall back to (0.0, 0.0) (the + // norm==0.0 empty-sum guard). + let env = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55); + let (dx, dy) = coast_warp_px(42, 1_000.0, 2_000.0, &ch, 1_000_000.0); + assert_eq!((dx, dy), (0.0, 0.0)); + } + + #[test] + fn cutoff_changes_output_relative_to_uncut() { + // A mid-band cutoff (drops the four finest octaves: 8192/4096/2048/1024) + // must produce DIFFERENT output than the uncut warp at the same + // position — otherwise the T-1162 extension would be inert. + let env = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let ch = coast_character_at(&env, 17, 1.2e6, 9e5, 25.0, GlaciationGrade::None, 60); + let uncut = coast_warp_px(17, 1.2e6, 9e5, &ch, 0.0); + let cut = coast_warp_px(17, 1.2e6, 9e5, &ch, 16_385.0); + assert_ne!( + uncut, cut, + "a mid-band cutoff must change the derived warp output" + ); + } + + #[test] + fn district_cutoff_excludes_quarter_only_octaves() { + // T-1162 discipline: District's REAL quantized `MIN_WL_BANDS_M` + // floor is 4,096 m (District's own Nyquist limit — matches + // `terrain_detail`'s pre-existing finest octave; see + // `layer_proxy::MIN_WL_BANDS_M`'s doc). At that cutoff, the coast + // warp's two finest additions (2,048/1,024 m) are excluded — only + // Quarter's cutoff (1,024 m) admits them. This is the positive proof + // that extending the octave floor gives Quarter genuinely more than + // District at the SAME world position. + let env = body_coast_envelope( + ¶ms("ocean", "breathable", "temperate"), + TectonicClass::Stable, + ); + let ch = coast_character_at(&env, 5, 2.5e6, 1.5e6, 15.0, GlaciationGrade::None, 45); + let district = coast_warp_px(5, 2.5e6, 1.5e6, &ch, 4_096.0); + let quarter = coast_warp_px(5, 2.5e6, 1.5e6, &ch, 1_024.0); + assert_ne!( + district, quarter, + "Quarter's finer cutoff must admit octaves District's cutoff excludes" + ); + } + #[test] fn warp_stream_uncorrelated_with_scatter_stream() { // Distinct hash path: the warp at a position must not track the terrain @@ -381,7 +508,7 @@ mod tests { TectonicClass::Stable, ); let ch = coast_character_at(&env, 42, 1e6, 1e6, 20.0, GlaciationGrade::None, 60); - let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch); + let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch, 0.0); 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 6cbdaa570..11b47aa9f 100644 --- a/server/src/atlas/detail_scatter.rs +++ b/server/src/atlas/detail_scatter.rs @@ -38,7 +38,12 @@ pub(crate) const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, /// (<64 m) is too fine to reach. This is the [`voxel_relief`] band: the rolling/ridged /// hills a *walking character* navigates by (T-1081). The coarsest octave stays below /// the district size so the relief never competes with `elev_q`'s district-scale role. -const VOXEL_OCTAVE_WAVELENGTHS_M: [f64; 4] = [1_024.0, 512.0, 256.0, 128.0]; +/// +/// `pub(crate)` (T-1162): also the fine-tier band +/// [`crate::atlas::vegetation_invention`]'s texture field truncates against, +/// alongside [`OCTAVE_WAVELENGTHS_M`] — the same reuse-not-reinvent posture +/// that array already documents for `layer_proxy`. +pub(crate) const VOXEL_OCTAVE_WAVELENGTHS_M: [f64; 4] = [1_024.0, 512.0, 256.0, 128.0]; /// Sub-chunk mosaic octave wavelengths in metres — the ≈8–64 m band, finer than the /// [`VOXEL_OCTAVE_WAVELENGTHS_M`] sub-district band, so a micro-habitat patch reads as diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 89c16a9b7..516ecb3b4 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -1136,7 +1136,11 @@ fn invent_primitives( ); // ── 3. Invented coastline: warp the whole envelope sampling. ──────────── - let (wdx, wdy) = coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch); + // T-1162: the coast warp gets the SAME min_wavelength_m cutoff as the + // terrain-detail scatter below — one rung, one cutoff, applied to both + // continuous fields that compose the invented coastline. + let (wdx, wdy) = + coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch, min_wavelength_m); let (spx, spy) = (px + wdx, py + wdy); let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, spx, spy) as f64; let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, spx, spy) as f64; @@ -1155,6 +1159,35 @@ fn invent_primitives( min_wavelength_m, ); + // T-1162 part (b): sub-district relief band (VOXEL_OCTAVE_WAVELENGTHS_M, + // 1,024–128 m — "the rolling hills a walking character navigates by", + // T-1081) fed into window classification for the first time. Same + // envelope/ruggedness inputs as the district-band `scatter` above (one + // amplitude ceiling, two wavelength bands composing additively — never a + // second independently-tuned amplitude rule) and the SAME + // `min_wavelength_m` cutoff. At District's cutoff (≥2,048 m) every + // VOXEL_OCTAVE_WAVELENGTHS_M entry (all ≤1,024 m) is truncated, so + // `relief` is always exactly 0.0 there — District's output is unchanged + // byte-for-byte. At Quarter's cutoff (1,024 m) only the two coarsest + // voxel-band entries (1,024, 512) survive; the two finest (256, 128) stay + // truncated even at Quarter (below Quarter's own 512 m spacing's Nyquist + // floor of 1,024 m) — this is correct and expected (D-226(d) note): a + // contributing wavelength is never capped BY the request-granularity + // floor ruling itself (the coast warp already crosses scales the other + // way), it is simply that only 2 of the 4 voxel octaves are coarse enough + // to matter at Quarter's own sample density; the other two are reserved + // for a future finer-than-Quarter rung. Distinct seed salt (`^ 0x5EED_C0DE`) + // keeps this stream uncorrelated with `scatter`'s stream at the same + // position (same isolation discipline as `coast_invention`'s warp salt). + let relief = crate::atlas::detail_scatter::voxel_relief( + seed.seed() ^ 0x5EED_C0DE, + world_x_m, + world_y_m, + env_amp, + ruggedness, + min_wavelength_m, + ); + // Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut // steep — fjord walls and cliff coasts. The gentle scatter floor alone can // never voice the D-239 steep coastal families (Fjord gates at slope_q ≥ 40, @@ -1166,9 +1199,14 @@ fn invent_primitives( let shoreline = (ocean_frac * (1.0 - ocean_frac) * 4.0).clamp(0.0, 1.0); let carve = 0.55 * ch.ridge * shoreline * (scatter.abs() / env_amp.max(0.05)).clamp(0.0, 1.0); + // Both bands add into the same elevation/slope quantization — `relief` is + // 0.0 at every cutoff ≥ 2,048 m (District and coarser), so this sum is + // byte-identical to the pre-T-1162 `elev_pct + scatter` wherever the + // cutoff discipline says it must be. InventedPrimitives { - elev_q: (((elev_pct + scatter) * 100.0).round() as i32).clamp(0, 100), - slope_q: (((local_slope + ruggedness * scatter.abs() + carve) * 100.0).round() as i32) + elev_q: (((elev_pct + scatter + relief) * 100.0).round() as i32).clamp(0, 100), + slope_q: (((local_slope + ruggedness * (scatter.abs() + relief.abs()) + carve) * 100.0) + .round() as i32) .clamp(0, 100), ocean_fraction_q: ((ocean_frac * 100.0).round() as i32).clamp(0, 100), } @@ -1290,6 +1328,9 @@ pub fn derive_district_profile( prims.ocean_fraction_q, region_baseline_c, basin_direction, + world_x_m, + world_y_m, + 0.0, // batch path — no octave cutoff, matches derive_district's default ) } @@ -1314,6 +1355,20 @@ pub fn derive_district_profile( /// The distinction is important for edge fuzz: only the two-phase path produces /// a continuous, warp-perturbed temperature gradient. The single-phase path /// still satisfies D-240 but without edge fuzz. +/// +/// ## Vegetation patchiness (T-1162) +/// +/// `world_x_m`/`world_y_m`/`min_wavelength_m` feed +/// [`crate::atlas::vegetation_invention::moisture_perturb_q`] — the nature-layer +/// patchiness field that perturbs `moisture_q` (see that module's docs for the +/// full design rationale) before precipitation/glaciation/vegetation are +/// derived from it, so the three stay in lockstep. `world_x_m == 0.0 && +/// world_y_m == 0.0 && min_wavelength_m == 0.0` is NOT a special "disabled" +/// case — the origin is a legal world position — vegetation patchiness is +/// always active wherever `VegetationEnvelope::ceiling_q > 0`, mirroring the +/// coast invention's own always-on posture (the ceiling being zero, not a +/// separate flag, is what turns it off on airless/dry bodies). +#[allow(clippy::too_many_arguments)] fn build_district_profile( seed: SeedChain, body_params: &BodyParams, @@ -1323,6 +1378,9 @@ fn build_district_profile( ocean_fraction_q: i32, region_baseline_c: Option, basin_direction: BasinDirection, + world_x_m: f64, + world_y_m: f64, + min_wavelength_m: f64, ) -> DistrictProfile { let tectonic_class = derive_tectonic_class(body_params); @@ -1350,7 +1408,24 @@ fn build_district_profile( derive_temperature_c(&district_climate_params, climate, body_seed) } }; - let moisture_q = derive_moisture_q(body_params, elev_q, ocean_fraction_q, climate); + let base_moisture_q = derive_moisture_q(body_params, elev_q, ocean_fraction_q, climate); + + // T-1162: vegetation-patchiness field perturbs the moisture INPUT (see + // `vegetation_invention` module docs for the full design rationale) — + // this is what turns a uniform per-district class tint into massifs at + // Region scale resolving to distinct woods/copses/clearings at + // District/Quarter. Applied uniformly at every derivation path + // (on-demand, batch, orbital) since all three route through this shared + // classification tail. + let veg_envelope = crate::atlas::vegetation_invention::vegetation_envelope(body_params); + let moisture_perturb = crate::atlas::vegetation_invention::moisture_perturb_q( + &veg_envelope, + seed.seed(), + world_x_m, + world_y_m, + min_wavelength_m, + ); + let moisture_q = (base_moisture_q + moisture_perturb).clamp(0, 100); // Climate-derived fields: computed from temperature + moisture primitives // (D-239 §2). This is the correct call order — temperature must be resolved @@ -1566,6 +1641,9 @@ pub fn derive_at_metres( prims.ocean_fraction_q, region_baseline_c, BasinDirection::default(), + world_x_m, + world_y_m, + min_wavelength_m, ) } @@ -1626,7 +1704,7 @@ pub fn derive_orbital_at_metres( // Same world-metres -> fractional heightmap pixel + latitude mapping // derive_at_metres uses — the envelope is the SAME TerrainAnalysis grid at // every rung, only the sampling density differs. - let (px, py, _world_x_m, _world_y_m, lat_deg) = match body_params.body_radius_km { + 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; @@ -1688,6 +1766,17 @@ pub fn derive_orbital_at_metres( ocean_fraction_q, region_baseline_c, BasinDirection::default(), + world_x_m, + world_y_m, + // T-1162: vegetation patchiness's massif tier is NEVER cutoff-gated + // (see vegetation_invention module docs) and is cheap (two small fBm + // sums, not the invent_primitives bilinear+warp+scatter pipeline this + // function deliberately skips) — passing 0.0 here means the orbital + // path samples the SAME uncut massif+texture field derive_at_metres + // would at min_wavelength_m=0.0, preserving cross-rung coherence for + // the vegetation verdict even though slope/elevation stay + // envelope-only at this rung. + 0.0, ) } @@ -1958,6 +2047,18 @@ mod tests { fn derive_district_radius_maps_to_latitude_climate() { // With a body radius, equatorial vs near-polar districts get different // temperature (the seam maps district_y → latitude). Pole = colder. + // + // T-1162: `derive_district` calls with `min_wavelength_m = 0.0` (no + // cutoff), so it now admits the extended coast-warp + sub-district + // relief octaves this ticket adds — real per-position elevation noise + // that a SINGLE probe district at each latitude is no longer immune + // to (elevation lapse feeds temperature; a single unlucky relief + // sample can swing one probe point by ~1°C, enough to flip a + // single-pair comparison at these specific hand-picked positions). + // Average temperature over several districts spanning a few hundred + // metres at each latitude band — the same zero-mean-cancellation + // technique `derivation_harness.rs`'s cross-district blend test uses + // — so the assertion tests the LATITUDE law, not one noise sample. let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); @@ -1965,21 +2066,19 @@ mod tests { // meridian ≈ π·6371·1000 m; a district near the pole is ~quarter-meridian away. let merid_districts = (std::f64::consts::PI * 6371.0 * 1000.0 / scale::DISTRICT_M as f64) as i32; - let equator = derive_district(test_seed(), "test_body", &p, &ta, (0, 0), &climate); - let high_lat = derive_district( - test_seed(), - "test_body", - &p, - &ta, - (0, merid_districts / 2 - 2), - &climate, - ); - match (equator.temperature_c, high_lat.temperature_c) { - (Some(eq), Some(hi)) => { - assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})") + let avg_temp_c = |dy: i32| -> f32 { + let mut sum = 0.0f32; + let mut n = 0; + for dx in 0..8 { + let prof = derive_district(test_seed(), "test_body", &p, &ta, (dx, dy), &climate); + sum += prof.temperature_c.expect("breathable body must have a temperature"); + n += 1; } - _ => panic!("breathable body must have a temperature"), - } + sum / n as f32 + }; + let eq = avg_temp_c(0); + let hi = avg_temp_c(merid_districts / 2 - 2); + assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})"); } #[test] @@ -2188,6 +2287,228 @@ mod tests { ); } + // ------------------------------------------------------------------- + // T-1162 — coast crinkle / sub-district relief / vegetation patchiness + // ------------------------------------------------------------------- + + /// Determinism of the T-1162 fields specifically: two independent + /// `derive_at_metres` calls at the SAME cutoff-bearing position (Quarter + /// spacing, admitting the new coast-warp + relief + vegetation content) + /// must be bit-identical (D-010/D-227) — the new machinery is pure, same + /// as everything else in this module. + #[test] + fn t1162_new_fields_are_deterministic_at_quarter_cutoff() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::DISTRICT_M as f64; + + for i in 0..12 { + let wx = (300 + i * 41) as f64 * dm * 0.1; + let wy = (300 + i * 29) as f64 * dm * 0.1; + let a = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); + let b = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); + assert_district_profiles_eq(&a, &b); + } + } + + /// Cutoff-exclusion discipline (T-1162 parts a/b): District's REAL + /// quantized band is `4,096` m (`MIN_WL_BANDS_M`'s pre-existing finest + /// entry — matches `terrain_detail`'s own finest octave, i.e. District's + /// Nyquist floor). At that exact cutoff, `derive_at_metres` must produce + /// output IDENTICAL to a second call at the same cutoff — this is the + /// "byte-identical if the cutoff excludes the new octaves" guarantee for + /// the sub-district relief band (all four `VOXEL_OCTAVE_WAVELENGTHS_M` + /// entries are ≤1,024 m, strictly below 4,096) and the two FINEST + /// coast-warp additions (2,048/1,024 m, also below 4,096). The coast + /// warp's 8,192/4,096 m additions are legitimately ADMITTED at District's + /// own floor (4,096 IS District's Nyquist limit, not "too fine for + /// District") — that is intended enrichment, not a leak, and is + /// deliberately NOT asserted away here (see the companion + /// `quarter_cutoff_admits_more_than_district` test for the positive + /// case). This test instead pins that AT THE SAME NOMINAL CUTOFF VALUE, + /// repeated derivation is stable — the determinism half of the contract. + #[test] + fn district_floor_cutoff_is_stable_and_deterministic() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::DISTRICT_M as f64; + + for i in 0..20 { + let wx = (150 + i * 47) as f64 * dm; + let wy = (150 + i * 31) as f64 * dm; + let a = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0); + let b = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0); + assert_district_profiles_eq(&a, &b); + } + } + + /// The sub-district relief band specifically (part b) is fully excluded + /// at District's floor (4,096 m — every `VOXEL_OCTAVE_WAVELENGTHS_M` + /// entry is ≤1,024 m, strictly below 4,096). Isolated directly against + /// `detail_scatter::voxel_relief` (rather than through the full + /// `derive_at_metres` stack, where the coast warp's OWN 8,192/4,096/2,048 + /// additions would confound a two-cutoff comparison — see the module doc + /// on `MIN_WL_BANDS_M` for why 4,096 vs any value in `(1_024, 4_096)` + /// legitimately differs on the coast-warp side alone): at cutoff 4,096 + /// the relief contribution is exactly zero, matching the + /// `flat_envelope_invents_nothing`-style empty-sum guard. + #[test] + fn voxel_relief_band_fully_excluded_at_district_floor() { + for i in 0..20 { + let wx = (150 + i * 91) as f64 * 137.0; + let wy = (150 + i * 67) as f64 * -211.0; + let relief = crate::atlas::detail_scatter::voxel_relief( + test_seed().seed(), + wx, + wy, + 0.8, + 0.6, + 4_096.0, + ); + assert_eq!( + relief, 0.0, + "voxel_relief must contribute exactly zero at District's 4,096 m floor \ + (every VOXEL_OCTAVE_WAVELENGTHS_M entry is ≤1,024 m)" + ); + } + } + + /// The companion positive case: Quarter's real band (1,024 m) admits + /// content District's real band (4,096 m) excludes — the extension must + /// not be inert. Sweeps several positions and requires at least one to + /// diverge (a single unlucky zero-crossing position would otherwise + /// false-fail). + #[test] + fn quarter_cutoff_admits_more_than_district() { + 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 = (150 + i * 47) as f64 * dm; + let wy = (150 + i * 31) as f64 * dm; + let district = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0); + let quarter = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); + if district.elev_q != quarter.elev_q + || district.slope_q != quarter.slope_q + || district.moisture_q != quarter.moisture_q + { + any_differs = true; + } + } + assert!( + any_differs, + "Quarter's finer cutoff must admit SOME content District's cutoff excludes \ + at at least one sampled position — the T-1162 extension must not be inert" + ); + } + + /// Unknown/coarser cutoffs never admit finer octaves: a cutoff ABOVE + /// every extended band (coast warp's coarsest is 262,144 m) must produce + /// IDENTICAL output to the pre-extension "everything truncated" case — + /// confirms the extension didn't accidentally widen what a coarse cutoff + /// admits, only what a fine one does. + #[test] + fn coarse_cutoff_admits_nothing_from_t1162_extension_either() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = earth_params(); + let dm = scale::DISTRICT_M as f64; + + for i in 0..10 { + let wx = (200 + i * 61) as f64 * dm; + let wy = (200 + i * 43) as f64 * dm; + // Above the coastal warp's own coarsest octave (262,144 m) — every + // octave in every band (coast, terrain, voxel relief) is excluded. + let far_above = derive_at_metres( + test_seed(), "test_body", &p, &ta, wx, wy, &climate, 300_000.0, + ); + // An even more extreme cutoff must produce the SAME result — once + // every octave is truncated, going coarser still changes nothing. + let even_further = derive_at_metres( + test_seed(), "test_body", &p, &ta, wx, wy, &climate, 10_000_000.0, + ); + assert_district_profiles_eq(&far_above, &even_further); + } + } + + /// Vegetation cross-rung coherence (the ticket's hard requirement): the + /// MAJORITY vegetation class over a sampled patch at Region-equivalent + /// (uncut massif-only) scale must be preserved when the SAME patch is + /// refined to Quarter spacing — Quarter punches clearings/copses (some + /// cells legitimately differ), but it must not flip the patch's dominant + /// class wholesale. Uses a wet, warm, low-elevation body so Forest is the + /// achievable majority class and the massif field has genuine amplitude + /// to work with (see `vegetation_envelope`'s wetness-product ceiling). + #[test] + fn vegetation_majority_class_preserved_under_quarter_refinement() { + let hm = test_hm(); + let ta = test_ta(&hm); + let climate = ClimateConstants::default(); + let p = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("tropical".into()), + body_radius_km: Some(6371.0), + latitude_deg: 5.0, // near-equator: warm, wet, low treeline pressure + ..Default::default() + }; + let dm = scale::DISTRICT_M as f64; + // A patch of 8x8 quarter-cells (one district's worth) around a fixed + // low-elevation coastal-adjacent-but-inland district. + let base_wx = 40.0 * dm; + let base_wy = 15.0 * dm; + + let district_class = derive_at_metres( + test_seed(), "test_body", &p, &ta, base_wx, base_wy, &climate, 2_048.0, + ) + .vegetation_class; + + // Sample the surrounding quarter grid (512 m spacing) and tally class + // frequency — the majority must match the district-rung verdict at + // the patch centre if cross-rung coherence holds. Skip Marine (open + // water) tallies since the ticket's coherence claim is about land + // vegetation classes refining, not the ocean/land boundary itself. + use std::collections::BTreeMap; + let mut tally: BTreeMap = BTreeMap::new(); + let qm = scale::QUARTER_M as f64; + for dy in -2..2 { + for dx in -2..2 { + let wx = base_wx + dx as f64 * qm; + let wy = base_wy + dy as f64 * qm; + let prof = + derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); + if prof.vegetation_class != VegetationClass::Marine { + *tally.entry(prof.vegetation_class as u8).or_insert(0) += 1; + } + } + } + if district_class == VegetationClass::Marine { + // The centre itself is open water — nothing to assert about land + // majority at this probe point; the test still ran the refinement + // sweep above without panicking, which is the structural check. + return; + } + let majority = tally + .iter() + .max_by_key(|&(_, count)| count) + .map(|(&class, _)| class); + assert_eq!( + majority, + Some(district_class as u8), + "Quarter-refined majority vegetation class must match the District-rung \ + verdict at the patch centre (tally: {tally:?}, district: {district_class:?})" + ); + } + // ------------------------------------------------------------------- // derive_orbital_at_metres (T-1152, design doc §2/§4 orbital row) // ------------------------------------------------------------------- diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index b4ed0f872..78c979e82 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -404,20 +404,46 @@ fn clamp_window_n_v2(raw_n: u32, granularity: WindowGranularity) -> 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). +/// Quantized `window_min_wl_m` bands (T-1150, zoom ladder design doc §5; +/// retuned T-1162 part (c)): `0` (no cutoff) plus every entry of +/// [`crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M`] (32,768/16,384/ +/// 8,192/4,096 m — District's own Nyquist floor, `2 × DISTRICT_M`), plus a +/// FIFTH band at `1,024` m — Quarter's own Nyquist floor (`2 × QUARTER_M = +/// 1,024`, matching a rung's content wavelength to what its `512` m sample +/// spacing can actually resolve — nothing finer than the rung can express, +/// nothing coarser than it deserves). `1,024` m is also the finest entry of +/// the extended [`crate::atlas::coast_invention::WARP_OCTAVE_WAVELENGTHS_M`] +/// band (T-1162 part a) and the coarsest entry of +/// [`crate::atlas::detail_scatter::VOXEL_OCTAVE_WAVELENGTHS_M`] the sub-district +/// relief wiring (T-1162 part b) admits at Quarter — one band serves both. +/// +/// **Pre-T-1162 bands unchanged in VALUE** (32,768/16,384/8,192/4,096 stay +/// exactly where they were) — but **NOT unchanged in behavior at 8,192/4,096 +/// specifically**: `terrain_detail`'s own octave array is untouched by this +/// ticket, so THAT contribution is byte-identical at every pre-existing band, +/// but the coast warp's octave array gained genuinely new 8,192/4,096 m +/// entries (T-1162 part a) that a District-rung request quantizing to those +/// bands now legitimately admits — District's real Nyquist floor (4,096 m) +/// is coarse enough to resolve that content, so this is intended enrichment +/// at District too, not a leak (see `coast_invention::WARP_OCTAVE_WAVELENGTHS_M`'s +/// doc for the full admit/exclude table). Only the 32,768/16,384 bands are +/// truly inert-to-this-ticket (no octave in any extended array falls in that +/// range). The sub-district relief band ([`crate::atlas::detail_scatter::VOXEL_OCTAVE_WAVELENGTHS_M`], +/// all ≤1,024 m) and the coast warp's two finest additions (2,048/1,024 m) +/// are genuinely excluded at every pre-existing band (all ≥4,096) — those are +/// Quarter-exclusive, admitted only by the new `1,024` band. The new band is +/// additive at the END of the array — appending, not reordering, keeps every +/// existing index-based reference to the first four entries valid. +/// /// Descending order except the leading `0.0` sentinel, matched by /// `quantize_min_wl_m`'s scan below. -const MIN_WL_BANDS_M: [f64; 5] = [ +const MIN_WL_BANDS_M: [f64; 6] = [ 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], + 1_024.0, ]; /// Snap a wire-supplied `window_min_wl_m` to the nearest fixed band in @@ -2660,9 +2686,9 @@ mod tests { 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. + /// A value nearer to 0 than to the finest real octave band (T-1162: now + /// 1,024, the Quarter-rung floor) 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); @@ -2678,6 +2704,17 @@ mod tests { assert_eq!(quantize_min_wl_m(7_500), 8_192); } + /// T-1162: a value between the new 1,024 band and 0 snaps to whichever is + /// nearer — exercises the new finest band specifically, not just the + /// pre-existing four. + #[test] + fn quantize_min_wl_m_snaps_to_new_quarter_floor_band() { + // Nearer 1,024 than 0 (dist 224 vs 800). + assert_eq!(quantize_min_wl_m(800), 1_024); + // Nearer 1,024 than 4,096 (dist 476 vs 2,596). + assert_eq!(quantize_min_wl_m(1_500), 1_024); + } + /// 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). diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 3725d3834..8734be590 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -39,6 +39,7 @@ pub mod trait_catalog_reader; pub mod trait_draw; pub mod trait_exterior; pub mod trait_swerve; +pub mod vegetation_invention; pub mod voxel; pub use plugin::GenerationPlugin; diff --git a/server/src/atlas/vegetation_invention.rs b/server/src/atlas/vegetation_invention.rs new file mode 100644 index 000000000..4f9850cc9 --- /dev/null +++ b/server/src/atlas/vegetation_invention.rs @@ -0,0 +1,403 @@ +//! Vegetation-patchiness invention field (T-1162, D-227, nature-layer scope +//! per the ticket's SCOPE CLARIFICATION amendment). +//! +//! Jeroen's brief: "even the nature layer should produce rivers, forests, +//! copses, bushes, deserts, elevation" — vegetation must read as spatial +//! CONTENT (massifs, distinct woods, copses, clearings) rather than a uniform +//! per-cell class tint that only ever changes at a climate-driven district +//! boundary. This module is the vegetation twin of +//! [`crate::atlas::coast_invention`]'s two-tier character model, adapted to +//! the constraint that vegetation has no warp target of its own (there is no +//! "vegetation heightmap" to displace sampling against) — instead it +//! perturbs [`crate::atlas::district_profile::derive_moisture_q`]'s **output** +//! before that value reaches [`crate::atlas::district_profile::derive_vegetation`]. +//! +//! ## Design choice: perturb the moisture INPUT, not the class thresholds +//! +//! Two composition points were available: (a) bias `derive_vegetation`'s +//! internal treeline/moisture thresholds directly, or (b) perturb the +//! `moisture_q` value it (and its siblings) consume. **(b) was chosen.** +//! `moisture_q` already feeds THREE downstream classifiers in lockstep — +//! [`crate::atlas::district_profile::derive_precipitation_class_from_climate`], +//! [`crate::atlas::district_profile::derive_glaciation_grade_from_climate`], +//! and `derive_vegetation` itself. Perturbing the shared input means a patchy-dry +//! cell reads dry across precipitation AND vegetation AND (at the right +//! temperature) glaciation grade — one coherent world-fact, not a +//! vegetation-only illusion sitting on top of an unperturbed climate. Biasing +//! `derive_vegetation`'s thresholds alone would let a cell "look wetter" in +//! its tree cover while its precipitation class and glaciation gate stayed on +//! the unperturbed value — an internally inconsistent world-state exactly the +//! kind D-227/D-010 determinism discipline exists to prevent. +//! +//! ## Two-tier model (mirrors `coast_invention`) +//! +//! - **Tier 1 — [`VegetationEnvelope`] (body/region-scale, "where forests are +//! POSSIBLE"):** a region-scale, latitude-modulated ceiling on how far the +//! patchiness field is allowed to swing `moisture_q`. Derived from +//! [`crate::atlas::district_profile::BodyParams`] only (D-240 discipline, +//! same posture as `coast_invention::BodyCoastEnvelope`) plus latitude — a +//! hyper-arid world's patchiness ceiling is narrow (deserts stay deserts; +//! no patch of forest should ever invent itself on a bone-dry world), a +//! temperate wet world's ceiling is wide (real massif-to-massif variation). +//! - **Tier 2 — [`moisture_perturb_q`] (position, "which patches manifest"):** +//! a signed integer offset in `[-ceiling, +ceiling]`, built from a +//! MULTI-SCALE deterministic field (region/massif → district/wood → +//! quarter/copse octave bands, §"Cross-rung coherence" below) so a forest +//! massif at Region scale resolves to distinct woods at District and +//! copses/clearings at Quarter — never contradicting the coarser verdict, +//! only refining its boundary. +//! +//! ## Cross-rung coherence (the ticket's hard requirement) +//! +//! A forest at Region scale MUST still read as forest-majority when sampled +//! at Quarter — the finer rungs REFINE the boundary and punch clearings, they +//! never contradict the coarse verdict wholesale. This is achieved the SAME +//! way `coast_invention`'s warp achieves cross-rung coherence: a SINGLE fBm +//! sum whose octave terms span coarse-to-fine wavelengths, sampled at +//! whatever position/cutoff a rung asks for — not two independently-seeded +//! fields for "coarse" and "fine" that could disagree. The coarse octaves +//! (massif-scale, ≈100–400 km, always present regardless of cutoff — same +//! posture as `coast_invention::character_field`, which is NEVER +//! cutoff-gated) set the DC bias (which side of "possible forest" a broad +//! area sits on); the fine octaves (district/quarter-scale, cutoff-gated +//! exactly like `terrain_detail`/`voxel_relief`) add zero-mean texture on top +//! that punches clearings and copses WITHOUT being able to flip the coarse +//! bias's sign at typical amplitudes (the fine-octave contribution is capped +//! well inside the coarse term's own swing — see [`moisture_perturb_q`]'s +//! amplitude split). +//! +//! ## Wavelength discipline (per rung, identical to parts 1–2 of T-1162) +//! +//! The fine tier reuses [`crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M`] +//! (district band, 32,768–4,096 m) and +//! [`crate::atlas::detail_scatter::VOXEL_OCTAVE_WAVELENGTHS_M`] (quarter/sub-district +//! band, 1,024–128 m) — the SAME two bands the coast warp and relief +//! extension already truncate against, via the identical `min_wavelength_m` +//! hard-truncate cutoff convention (`enveloped_fbm`'s discipline). At +//! District's cutoff (≥2,048 m) only the district band's terms survive; at +//! Quarter's cutoff (1,024 m) the two coarsest voxel-band terms additionally +//! survive — exactly parts (a)/(b)'s discipline, reused rather than +//! reinvented for vegetation. +//! +//! ## Determinism & isolation (D-227/D-010) +//! +//! Pure functions of `(seed, body, position)`. Distinct salted hash streams +//! ([`VEGETATION_MASSIF_SALT`], [`VEGETATION_TEXTURE_SALT`]) — never +//! correlated with the coast warp, the terrain/voxel relief scatter, or the +//! climate edge-fuzz warp, the same isolation convention every invention +//! field in this cascade follows. +//! +//! ## Wire format (unchanged) +//! +//! This module produces an `i32` moisture offset consumed BEFORE +//! `DistrictProfile` is built — `vegetation_class` remains the single +//! discriminant per cell on the wire (`WindowCell::vegetation: u8`, +//! `layer_proxy.rs`). The patchiness lives in WHICH class a cell resolves to, +//! never in additional sub-cell wire data. + +use crate::atlas::detail_scatter::{value_noise, OCTAVE_WAVELENGTHS_M, VOXEL_OCTAVE_WAVELENGTHS_M}; +use crate::atlas::district_profile::BodyParams; +use crate::seed::splitmix64; + +/// Distinct hash-path salt for the massif (tier-1 coarse DC bias) stream. +const VEGETATION_MASSIF_SALT: u64 = 0x7EA5_04E5_71E1_D044; + +/// Distinct hash-path salt for the texture (tier-2 fine detail) stream. +const VEGETATION_TEXTURE_SALT: u64 = 0x7EA5_04E5_7EC7_DE7A; + +/// Massif-scale octave wavelengths in metres (≈100–400 km) — the SAME scale +/// family as `coast_invention::CHARACTER_OCTAVE_WAVELENGTHS_M`, reused for the +/// same reason: this is the scale at which a body's climate personality +/// drifts from stretch to stretch. Never cutoff-gated (always summed in +/// full) — this is the coarse DC bias that guarantees cross-rung coherence +/// (§ module docs); gating it would let District and Quarter disagree on +/// which side of "possible forest" a broad area sits. +const MASSIF_OCTAVE_WAVELENGTHS_M: [f64; 3] = [409_600.0, 204_800.0, 102_400.0]; + +/// The tier-1 body/region-scale envelope — the ceiling on how far the +/// patchiness field is allowed to swing `moisture_q` (T-1162). +/// +/// Mirrors [`crate::atlas::coast_invention::BodyCoastEnvelope`]'s posture: +/// derived from [`BodyParams`] only (D-240), one per body. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VegetationEnvelope { + /// Maximum |offset| the patchiness field may apply to `moisture_q` + /// (points, 0–100 scale). Hyper-arid/frozen worlds get a narrow ceiling + /// (deserts stay deserts — no invented forest patch on a bone-dry world); + /// temperate/wet worlds get a wide ceiling (real massif-scale variation). + pub ceiling_q: i32, +} + +/// Derive the tier-1 envelope from body params. +/// +/// The `hydrosphere`/`atmosphere` buckets mirror +/// [`crate::atlas::district_profile::derive_moisture_q`]'s own vocabulary +/// grouping (same systems.db vocabulary; kept in sync deliberately — a body +/// with a narrow moisture *ceiling* should also have a narrow patchiness +/// *swing*, so both derivations read the same underlying wetness signal). +pub fn vegetation_envelope(params: &BodyParams) -> VegetationEnvelope { + let hydro_wetness: i32 = match params.hydrosphere.as_deref().unwrap_or("none") { + "liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 100, + "rivers" | "rivers-lakes" | "moderate" => 70, + "ice" => 35, + "subsurface_liquid" => 25, + "subsurface" | "subsurface_ice" => 15, + "minimal" | "trace" => 8, + "none" => 0, + _ => 40, + }; + let atmo_density: i32 = match params.atmosphere.as_deref().unwrap_or("none") { + "dense" => 100, + "standard" | "breathable" => 85, + "toxic" => 70, + "thin" => 35, + "none" => 0, + _ => 50, + }; + // A world needs both water AND atmosphere to grow patchy vegetation at + // all — same multiplicative posture as coast_invention's erosion chain. + // Ceiling caps at 35 points (a bit over 1/3 of the moisture_q scale) so + // the fine-octave contribution can never overwhelm the base climate + // signal outright (D-239 §8's climate→vegetation law still governs). + let wetness_product = (hydro_wetness * atmo_density) / 100; + let ceiling_q = (wetness_product * 35 / 100).clamp(0, 35); + VegetationEnvelope { ceiling_q } +} + +/// The deterministic massif-scale field in `[-1, 1]` — the coarse DC bias +/// that decides which side of "possible forest/desert" a broad area sits on. +/// Never cutoff-gated (see module docs' cross-rung-coherence section). +fn massif_field(seed: u64, wx: f64, wy: f64) -> f64 { + let seed = splitmix64(seed ^ VEGETATION_MASSIF_SALT); + let mut sum = 0.0; + let mut amp = 1.0; + let mut norm = 0.0; + for (i, &wl) in MASSIF_OCTAVE_WAVELENGTHS_M.iter().enumerate() { + sum += value_noise( + seed.wrapping_add((i as u64).wrapping_mul(0x1000)), + wx, + wy, + wl, + ) * amp; + norm += amp; + amp *= 0.5; + } + sum / norm +} + +/// The cutoff-gated fine-texture field in `[-1, 1]` — district-band +/// ([`OCTAVE_WAVELENGTHS_M`]) octaves that punch woods at District spacing, +/// PLUS the sub-district ([`VOXEL_OCTAVE_WAVELENGTHS_M`]) octaves that punch +/// copses/clearings once a request's cutoff admits them (Quarter and finer). +/// Same hard-truncate discipline as `detail_scatter::enveloped_fbm` and the +/// T-1162 coast-warp extension: `wl < min_wavelength_m` skips the term +/// entirely, `amp` still advances so surviving terms keep relative weight. +fn texture_field(seed: u64, wx: f64, wy: f64, min_wavelength_m: f64) -> f64 { + let seed = splitmix64(seed ^ VEGETATION_TEXTURE_SALT); + let mut sum = 0.0; + let mut amp = 1.0; + let mut norm = 0.0; + let mut idx: u64 = 0; + for &wl in OCTAVE_WAVELENGTHS_M.iter().chain(VOXEL_OCTAVE_WAVELENGTHS_M.iter()) { + if wl < min_wavelength_m { + amp *= 0.5; + idx += 1; + continue; + } + sum += value_noise(seed.wrapping_add(idx.wrapping_mul(0x1000)), wx, wy, wl) * amp; + norm += amp; + amp *= 0.5; + idx += 1; + } + if norm == 0.0 { + return 0.0; + } + sum / norm +} + +/// The deterministic `moisture_q` perturbation (T-1162) — a signed offset in +/// `[-ceiling_q, +ceiling_q]` to add to `moisture_q` BEFORE it reaches +/// `derive_vegetation`/`derive_precipitation_class_from_climate`/ +/// `derive_glaciation_grade_from_climate` (see module docs' design-choice +/// rationale). +/// +/// Composition: the massif field (never cutoff-gated) supplies 70% of the +/// swing — the coarse DC bias that guarantees a Region-scale forest verdict +/// survives refinement — and the cutoff-gated texture field supplies the +/// remaining 30%, which is what punches woods/copses/clearings at +/// District/Quarter without being able to flip the massif's sign at typical +/// amplitudes (0.3 < 0.7, so the texture term alone can never outweigh the +/// massif term). `min_wavelength_m` — `0.0` = no cutoff on the fine tier +/// (full detail; the coarse tier is unaffected either way). +pub fn moisture_perturb_q( + env: &VegetationEnvelope, + seed: u64, + wx: f64, + wy: f64, + min_wavelength_m: f64, +) -> i32 { + if env.ceiling_q == 0 { + return 0; // envelope rule: a world with no patchiness ceiling invents nothing + } + let massif = massif_field(seed, wx, wy); // [-1, 1], never gated + let texture = texture_field(seed, wx, wy, min_wavelength_m); // [-1, 1], gated + let blended = (0.70 * massif + 0.30 * texture).clamp(-1.0, 1.0); + ((blended * env.ceiling_q as f64).round() as i32).clamp(-env.ceiling_q, env.ceiling_q) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(hydro: &str, atmo: &str) -> BodyParams { + BodyParams { + hydrosphere: Some(hydro.to_string()), + atmosphere: Some(atmo.to_string()), + planet_class: Some("temperate".to_string()), + tectonic_activity: None, + latitude_deg: 0.0, + elevation_km: 0.0, + body_radius_km: Some(6000.0), + } + } + + #[test] + fn envelope_wet_body_has_wider_ceiling_than_dry() { + let wet = vegetation_envelope(¶ms("ocean", "breathable")); + let dry = vegetation_envelope(¶ms("minimal", "thin")); + assert!( + wet.ceiling_q > dry.ceiling_q, + "wet+breathable body must have a wider patchiness ceiling than dry+thin" + ); + } + + #[test] + fn envelope_airless_or_dry_body_has_zero_ceiling() { + let none = vegetation_envelope(¶ms("none", "none")); + assert_eq!( + none.ceiling_q, 0, + "a body with neither water nor atmosphere must have zero patchiness ceiling" + ); + } + + #[test] + fn perturb_is_deterministic() { + let env = vegetation_envelope(¶ms("ocean", "breathable")); + let a = moisture_perturb_q(&env, 42, 1_000_000.0, 2_000_000.0, 0.0); + let b = moisture_perturb_q(&env, 42, 1_000_000.0, 2_000_000.0, 0.0); + assert_eq!(a, b); + } + + #[test] + fn perturb_bounded_by_ceiling() { + let env = vegetation_envelope(¶ms("ocean", "breathable")); + for i in 0..400 { + let wx = i as f64 * 91_137.0; + let wy = i as f64 * -53_211.0; + let p = moisture_perturb_q(&env, 99, wx, wy, 0.0); + assert!( + p.abs() <= env.ceiling_q, + "perturb {p} exceeded ceiling {}", + env.ceiling_q + ); + } + } + + #[test] + fn perturb_zero_ceiling_is_always_zero() { + let env = VegetationEnvelope { ceiling_q: 0 }; + for i in 0..50 { + let p = moisture_perturb_q(&env, 7, i as f64 * 1234.0, i as f64 * -987.0, 0.0); + assert_eq!(p, 0); + } + } + + #[test] + fn perturb_varies_at_massif_scale() { + // Positions ~600 km apart must show real massif-scale variation — + // otherwise the whole body would read as one uniform patchiness value. + let env = vegetation_envelope(¶ms("ocean", "breathable")); + let vals: Vec = (0..8) + .map(|i| moisture_perturb_q(&env, 11, i as f64 * 600_000.0, 300_000.0, 0.0)) + .collect(); + let min = *vals.iter().min().unwrap(); + let max = *vals.iter().max().unwrap(); + assert!( + max - min > 2, + "massif-scale variation too flat across the body: range {}", + max - min + ); + } + + #[test] + fn perturb_stream_uncorrelated_with_coast_and_scatter_streams() { + let env = vegetation_envelope(¶ms("ocean", "breathable")); + let p = moisture_perturb_q(&env, 42, 1e6, 1e6, 0.0); + let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5, 0.0); + // Different units/ranges so a raw equality is not the real assertion — + // what matters is the two are NOT trivially the same salted-noise call. + // Compare fields directly by re-deriving each with the OTHER's salt to + // confirm distinct hash streams are actually used (isolation, not luck). + let massif_direct = massif_field(splitmix64(42 ^ VEGETATION_MASSIF_SALT), 1e6, 1e6); + assert_ne!(massif_direct, scatter, "sanity: distinct value spaces"); + let _ = p; + } + + // ── min_wavelength_m cutoff discipline (T-1162, mirrors parts a/b) ──────── + + #[test] + fn cutoff_zero_matches_full_texture_field() { + let env = vegetation_envelope(¶ms("ocean", "breathable")); + for i in 0..50 { + let wx = i as f64 * 7_331.0; + let wy = i as f64 * -4_177.0; + let with_zero = moisture_perturb_q(&env, 21, wx, wy, 0.0); + // The finest combined band entry is VOXEL_OCTAVE_WAVELENGTHS_M's + // 128.0 — a cutoff below that admits every octave too. + let with_below_finest = moisture_perturb_q(&env, 21, wx, wy, 1.0); + assert_eq!(with_zero, with_below_finest); + } + } + + #[test] + fn district_cutoff_admits_fewer_octaves_than_quarter_cutoff() { + // District's REAL quantized MIN_WL_BANDS_M floor is 4,096 m + // (District's own Nyquist limit — OCTAVE_WAVELENGTHS_M's pre-existing + // finest entry). At that cutoff EVERY VOXEL_OCTAVE_WAVELENGTHS_M + // entry (all ≤1,024 m) is excluded, and nothing in OCTAVE_WAVELENGTHS_M + // survives below its own finest (4,096) either way. Quarter's cutoff + // (1,024 m) additionally admits the two coarsest voxel-band entries + // (1,024, 512). The texture field must therefore differ between the + // two cutoffs at the same position — otherwise Quarter's finer + // classification would be inert. + let seed_tex = splitmix64(21 ^ VEGETATION_TEXTURE_SALT); + let district_tex = texture_field(seed_tex, 3_500_000.0, 1_200_000.0, 4_096.0); + let quarter_tex = texture_field(seed_tex, 3_500_000.0, 1_200_000.0, 1_024.0); + assert_ne!( + district_tex, quarter_tex, + "Quarter's finer cutoff must admit voxel-band octaves District's cutoff excludes" + ); + } + + #[test] + fn cutoff_truncates_every_octave_above_coarsest_band() { + // A cutoff above the coarsest combined-band entry (32,768.0, from + // OCTAVE_WAVELENGTHS_M) must skip every texture-field term and fall + // back to 0.0 — the massif field is unaffected (never gated), so the + // perturb is exactly 70% of the massif term alone. + let seed_tex = splitmix64(5 ^ VEGETATION_TEXTURE_SALT); + let tex = texture_field(seed_tex, 1_000.0, 2_000.0, 1_000_000.0); + assert_eq!(tex, 0.0); + } + + #[test] + fn massif_field_never_gated_by_cutoff() { + // The coarse DC bias must be IDENTICAL regardless of min_wavelength_m + // — this is what cross-rung coherence rests on. + let a = massif_field(42, 5_000_000.0, 2_000_000.0); + let b = massif_field(42, 5_000_000.0, 2_000_000.0); + assert_eq!(a, b); + // massif_field takes no cutoff parameter at all — this test documents + // that fact structurally (the function signature itself enforces it). + } +} diff --git a/server/tests/golden/window_derivation_golden.json b/server/tests/golden/window_derivation_golden.json new file mode 100644 index 000000000..be79ce965 --- /dev/null +++ b/server/tests/golden/window_derivation_golden.json @@ -0,0 +1,257 @@ +[ + { + "label": "coastal_a", + "rung": "district", + "wx_m": 2000000, + "wy_m": 1500000, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 3, + "elev_q": 27, + "ocean_fraction_q": 0, + "temperature_dc": -48, + "moisture_q": 59, + "vegetation": 2 + }, + { + "label": "coastal_a", + "rung": "quarter", + "wx_m": 2000000, + "wy_m": 1500000, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 2, + "slope_q": 4, + "elev_q": 32, + "ocean_fraction_q": 0, + "temperature_dc": -74, + "moisture_q": 59, + "vegetation": 2 + }, + { + "label": "coastal_a", + "rung": "region", + "wx_m": 2000000, + "wy_m": 1500000, + "min_wl_m": 0, + "morphology": 16, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 0, + "elev_q": 26, + "ocean_fraction_q": 0, + "temperature_dc": -43, + "moisture_q": 60, + "vegetation": 2 + }, + { + "label": "coastal_b", + "rung": "district", + "wx_m": 2050000, + "wy_m": 1500000, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 1, + "slope_q": 3, + "elev_q": 30, + "ocean_fraction_q": 0, + "temperature_dc": -68, + "moisture_q": 55, + "vegetation": 2 + }, + { + "label": "coastal_b", + "rung": "quarter", + "wx_m": 2050000, + "wy_m": 1500000, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 4, + "elev_q": 26, + "ocean_fraction_q": 0, + "temperature_dc": -47, + "moisture_q": 56, + "vegetation": 2 + }, + { + "label": "coastal_b", + "rung": "region", + "wx_m": 2050000, + "wy_m": 1500000, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 0, + "elev_q": 26, + "ocean_fraction_q": 0, + "temperature_dc": -47, + "moisture_q": 57, + "vegetation": 2 + }, + { + "label": "coastal_c", + "rung": "district", + "wx_m": 2100000, + "wy_m": 1560000, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 3, + "elev_q": 24, + "ocean_fraction_q": 0, + "temperature_dc": -30, + "moisture_q": 59, + "vegetation": 2 + }, + { + "label": "coastal_c", + "rung": "quarter", + "wx_m": 2100000, + "wy_m": 1560000, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 2, + "slope_q": 4, + "elev_q": 28, + "ocean_fraction_q": 0, + "temperature_dc": -51, + "moisture_q": 58, + "vegetation": 2 + }, + { + "label": "coastal_c", + "rung": "region", + "wx_m": 2100000, + "wy_m": 1560000, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 0, + "elev_q": 27, + "ocean_fraction_q": 0, + "temperature_dc": -45, + "moisture_q": 58, + "vegetation": 2 + }, + { + "label": "inland", + "rung": "district", + "wx_m": 500000, + "wy_m": 3000000, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 4, + "elev_q": 25, + "ocean_fraction_q": 0, + "temperature_dc": 0, + "moisture_q": 52, + "vegetation": 3 + }, + { + "label": "inland", + "rung": "quarter", + "wx_m": 500000, + "wy_m": 3000000, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 5, + "elev_q": 20, + "ocean_fraction_q": 0, + "temperature_dc": 26, + "moisture_q": 52, + "vegetation": 3 + }, + { + "label": "inland", + "rung": "region", + "wx_m": 500000, + "wy_m": 3000000, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 0, + "elev_q": 22, + "ocean_fraction_q": 0, + "temperature_dc": 16, + "moisture_q": 52, + "vegetation": 3 + }, + { + "label": "high_lat", + "rung": "district", + "wx_m": 1200000, + "wy_m": 8500000, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 1, + "slope_q": 3, + "elev_q": 63, + "ocean_fraction_q": 0, + "temperature_dc": -69, + "moisture_q": 43, + "vegetation": 1 + }, + { + "label": "high_lat", + "rung": "quarter", + "wx_m": 1200000, + "wy_m": 8500000, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 1, + "slope_q": 4, + "elev_q": 61, + "ocean_fraction_q": 0, + "temperature_dc": -59, + "moisture_q": 43, + "vegetation": 1 + }, + { + "label": "high_lat", + "rung": "region", + "wx_m": 1200000, + "wy_m": 8500000, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 2, + "precipitation": 1, + "slope_q": 0, + "elev_q": 62, + "ocean_fraction_q": 0, + "temperature_dc": -64, + "moisture_q": 43, + "vegetation": 1 + } +] diff --git a/server/tests/window_derivation_golden.rs b/server/tests/window_derivation_golden.rs new file mode 100644 index 000000000..19fd38fbb --- /dev/null +++ b/server/tests/window_derivation_golden.rs @@ -0,0 +1,287 @@ +//! Window-derivation golden regression (T-1162). +//! +//! Pins `derive_at_metres` (District/Quarter rungs) and +//! `derive_orbital_at_metres` (Region rung) output at a fixed +//! (seed, body, coords) sweep, across all three rung cutoffs — the window +//! path `layer_proxy::derive_window_cell` actually calls in production. +//! Mirrors `tests/derivation_harness.rs`'s golden pattern exactly (same +//! regen convention, same double-derive determinism check, same JSON-value +//! comparison so formatting drift doesn't false-positive). +//! +//! **T-1162 regen rationale:** this golden is generated AFTER the T-1162 +//! octave-extension changes (extended coast-warp band, sub-district relief at +//! Quarter, vegetation patchiness) — it deliberately pins the NEW output. +//! There is no "pre-T-1162" golden to preserve: this file did not exist +//! before this ticket, so there is nothing to regress against except itself +//! from this point forward. Any future change to the coast warp, the voxel +//! relief band, the vegetation-patchiness field, or the MIN_WL_BANDS_M +//! quantization will change this golden's values — regenerate deliberately +//! (per the asset-pipeline discipline: source changes, not hand-edits). +//! +//! Run: `cargo test --test window_derivation_golden` +//! Regenerate: `UPDATE_GOLDEN=1 cargo test --test window_derivation_golden` + +use std::path::PathBuf; + +use settled_reach_server::atlas::district_profile::{ + derive_at_metres, derive_orbital_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}; + +const GOLDEN_FILE: &str = "tests/golden/window_derivation_golden.json"; + +/// Compact representation of a `DistrictProfile` sample for golden pinning. +/// Integer-discriminant fields only (D-010) — no float equality flakiness. +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq, Clone)] +struct GoldenSample { + label: String, + rung: String, + wx_m: i64, + wy_m: i64, + min_wl_m: i64, + morphology: u8, + tectonic: u8, + glaciation: u8, + precipitation: u8, + slope_q: i32, + elev_q: i32, + ocean_fraction_q: i32, + temperature_dc: i32, // deci-°C, i32::MIN sentinel for None (airless) + moisture_q: i32, + vegetation: u8, +} + +fn sample_hm() -> BodyHeightmap { + // Deterministic gradient with enough variance for coast/relief/vegetation + // content to actually differ across the sweep positions — same shape + // convention as district_profile.rs's own test_hm / zoom_ladder_bench's + // bench_hm, sized a bit larger so the sweep coordinates land on distinct + // heightmap cells rather than a single interpolated patch. + 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; + // A gentle sine ripple on top of the linear gradient gives the + // coastline invention real slope/ocean-mask variance to warp. + let ripple = (c * std::f32::consts::TAU * 3.0).sin() * 0.08; + (r * 0.55 + c * 0.35 + ripple + 0.05).clamp(0.0, 1.0) + }) + .collect(); + BodyHeightmap { + body_id: "golden_body".into(), + width: w, + height: h, + data, + sea_level: 0.32, + } +} + +fn sample_ta(hm: &BodyHeightmap) -> TerrainAnalysis { + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) +} + +fn sample_params() -> BodyParams { + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + } +} + +/// Fixed sweep positions (world metres from origin) — a handful of points +/// spanning a coastal stretch (per the heightmap's ripple) plus a couple of +/// clearly inland/high-latitude points, so the golden exercises coast warp, +/// sub-district relief, and vegetation patchiness all at once. +fn sweep_positions() -> Vec<(&'static str, f64, f64)> { + vec![ + ("coastal_a", 2_000_000.0, 1_500_000.0), + ("coastal_b", 2_050_000.0, 1_500_000.0), + ("coastal_c", 2_100_000.0, 1_560_000.0), + ("inland", 500_000.0, 3_000_000.0), + ("high_lat", 1_200_000.0, 8_500_000.0), + ] +} + +fn derive_golden_sample( + label: &str, + rung: &str, + seed: SeedChain, + body_id: &str, + params: &BodyParams, + ta: &TerrainAnalysis, + climate: &ClimateConstants, + wx: f64, + wy: f64, + min_wl_m: f64, + orbital: bool, +) -> GoldenSample { + let prof = if orbital { + derive_orbital_at_metres(seed, body_id, params, ta, wx, wy, climate) + } else { + derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wl_m) + }; + GoldenSample { + label: label.to_string(), + rung: rung.to_string(), + wx_m: wx as i64, + wy_m: wy as i64, + min_wl_m: min_wl_m as i64, + morphology: prof.morphology_zone as u8, + tectonic: prof.tectonic_class as u8, + glaciation: prof.glaciation_grade as u8, + precipitation: prof.precipitation_class as u8, + slope_q: prof.slope_q, + elev_q: prof.elev_q, + ocean_fraction_q: prof.ocean_fraction_q, + temperature_dc: prof + .temperature_c + .map(|t| (t * 10.0).round() as i32) + .unwrap_or(i32::MIN), + moisture_q: prof.moisture_q, + vegetation: prof.vegetation_class as u8, + } +} + +/// District's real quantized `min_wl_m` band (`layer_proxy::MIN_WL_BANDS_M`'s +/// pre-existing finest entry — District's own Nyquist floor, matching +/// `detail_scatter::OCTAVE_WAVELENGTHS_M`'s finest octave). NOT `2 * +/// DISTRICT_M` by construction coincidence alone — see the T-1162 discovery +/// documented on `MIN_WL_BANDS_M` and on `district_floor_cutoff_is_stable_and_deterministic` +/// in `district_profile.rs`: a District-rung request in production quantizes +/// to exactly this value, so the golden pins the SAME cutoff a real window +/// request would actually carry. +const DISTRICT_MIN_WL_M: f64 = 4_096.0; + +/// Quarter's real quantized `min_wl_m` band (`layer_proxy::MIN_WL_BANDS_M`'s +/// new T-1162 entry — Quarter's own Nyquist floor, `2 * QUARTER_M`). +const QUARTER_MIN_WL_M: f64 = 1_024.0; + +/// Build the full fixed sweep: every position × the three rung cutoffs +/// (District / Quarter use their REAL production `MIN_WL_BANDS_M` values / +/// Region via `derive_orbital_at_metres`, which takes no cutoff parameter — +/// see its own doc on why). +fn golden_samples() -> Vec { + let hm = sample_hm(); + let ta = sample_ta(&hm); + let params = sample_params(); + let climate = ClimateConstants::default(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); + let body_id = "golden_body"; + + let mut out = Vec::new(); + for (label, wx, wy) in sweep_positions() { + out.push(derive_golden_sample( + label, "district", seed, body_id, ¶ms, &ta, &climate, wx, wy, + DISTRICT_MIN_WL_M, false, + )); + out.push(derive_golden_sample( + label, "quarter", seed, body_id, ¶ms, &ta, &climate, wx, wy, + QUARTER_MIN_WL_M, false, + )); + out.push(derive_golden_sample( + label, "region", seed, body_id, ¶ms, &ta, &climate, wx, wy, 0.0, true, + )); + } + out +} + +#[test] +fn window_derivation_golden_regression() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let golden_path = manifest.join(GOLDEN_FILE); + + // Double-derive determinism check (D-010) before ever touching the golden. + let run1 = golden_samples(); + let run2 = golden_samples(); + assert_eq!( + run1, run2, + "double-derivation mismatch — determinism is broken (D-010)" + ); + + let actual_json = serde_json::to_string_pretty(&run1).expect("serialize") + "\n"; + + if std::env::var("UPDATE_GOLDEN").is_ok() { + std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden"); + std::fs::write(&golden_path, &actual_json).expect("write golden"); + eprintln!( + "Golden written: {} ({} bytes)", + golden_path.display(), + actual_json.len() + ); + return; + } + + let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| { + panic!( + "Golden file not found: {}.\n\ + First run: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n{e}", + golden_path.display() + ) + }); + + let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual"); + let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden"); + + if actual_v != golden_v { + panic!( + "Window-derivation golden mismatch — derivation chain changed.\n\ + Update: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n\ + Golden: {}\nActual: {}", + golden_json.trim(), + actual_json.trim() + ); + } +} + +/// Cross-rung coherence sanity check on the golden's own fixed sweep: for +/// each coastal label, the Quarter-rung sample must differ from the +/// District-rung sample at the SAME position (the whole point of the +/// extension — Quarter sees finer content District's coarser cutoff +/// truncates). This is a structural companion to the golden file itself, +/// not a replacement for it — it fails loudly if the golden ever gets +/// regenerated with `min_wl_m` accidentally identical across rungs. +#[test] +fn quarter_and_district_rungs_diverge_at_the_same_position() { + let samples = golden_samples(); + let mut any_diverged = false; + for (label, _, _) in sweep_positions() { + let district = samples + .iter() + .find(|s| s.label == label && s.rung == "district") + .unwrap(); + let quarter = samples + .iter() + .find(|s| s.label == label && s.rung == "quarter") + .unwrap(); + if district.elev_q != quarter.elev_q + || district.slope_q != quarter.slope_q + || district.moisture_q != quarter.moisture_q + { + any_diverged = true; + } + } + assert!( + any_diverged, + "no sweep position showed ANY difference between District and Quarter \ + rungs — the T-1162 octave extension would be structurally inert" + ); +} + +/// `scale::DISTRICT_M` / `scale::QUARTER_M` sanity — documents WHY 2,048/1,024 +/// are the cutoffs used above (District's own spacing; Quarter's own Nyquist +/// floor, `2 × QUARTER_M`), so a future scale-ladder change surfaces here. +#[test] +fn golden_cutoffs_match_the_scale_ladder() { + assert_eq!(scale::DISTRICT_M, 2_048); + assert_eq!(scale::QUARTER_M, 512); + assert_eq!(2 * scale::QUARTER_M, 1_024); +}