From 4320df9b60a303c5b142ebfb83cc50fd3ac32dfb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Jul 2026 13:36:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20T-1170=20A2/A3=20+=20T-1168?= =?UTF-8?q?=20A5=20=E2=80=94=20course=20inventor,=20coast=20termination,?= =?UTF-8?q?=20riparian=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2 (river_course.rs): Stage A rung-independent valley-seeking control path (chord/8 stations, k=5 bilinear-scored candidates + continuity penalty); Stage B rung-indexed perpendicular warp on GLOBAL arc-length (window-independence, Ruling 1e), band chord/2 down to min_wavelength_m hard-truncate, sine taper to zero at anchors, amplitude min(8% chord, half-cell) slope/class-scaled. SeedDomain::RiverCourse=17, distinct salt. Wire: RiverCourse{edge_id,class,points,terminus} on DistrictWindowLayer.courses (serde-default); bbox-culled, window- cropped +1 station. TerrainAnalysisCache retains Layer1Output (the gen_queue:626 discard, Ruling 4b). A3: mouth termination walks stations sampling the window's OWN rung-consistent morphology verdict, 6-iteration bisect; land-at-anchor probes one segment then None; EdgeDrain never probes. A5: near_perennial_water point-to-segment predicate (D-239 §8 governed bands 1-3m class-scaled) threaded through both batch and window paths; Region always false; never touches moisture_q. Discipline closed: dormant zoom-ladder bench run + numbers recorded in the design doc (District 3.009/1.785, Quarter 1.823 us/cell, Region window 0.617ms); course-cost bench CAUGHT a real +12-36% per-cell riparian scan regression -> precomputed bbox O(1) reject (60ns->2.2ns/call), final delta +3.4-6.9% at budget; three determinism tests (overlapping-window byte-identity, cross-rung amplitude bound, warp-stream cross-correlation r<0.3); goldens: window sweep gained a verified course-bearing position (pure append), new river_course golden at both rungs, believability verified unchanged. Revert-verification discovered the pole-row branch is structurally unreachable (flow_direction bounds-check) — the real edge-drain path is k<0 flat-plateau; test fixture rewritten to exercise reality. scale.rs stale comment fixed. Full cargo test green. Tickets: T-1170, T-1168 Co-Authored-By: Claude Fable 5 --- docs/architecture/atlas-zoom-ladder-t1143.md | 14 + server/src/atlas/cascade.rs | 6 + server/src/atlas/district_profile.rs | 189 +- server/src/atlas/drainage.rs | 80 +- server/src/atlas/gen_queue.rs | 96 +- server/src/atlas/layer_proxy.rs | 750 +++++++- server/src/atlas/mod.rs | 1 + server/src/atlas/river_course.rs | 1199 +++++++++++++ server/src/atlas/scale.rs | 6 +- server/src/seed.rs | 10 + server/tests/derivation_harness.rs | 1 + server/tests/gen_fixtures.rs | 2 + server/tests/golden/river_course_golden.json | 1578 +++++++++++++++++ .../golden/window_derivation_golden.json | 153 ++ server/tests/window_derivation_golden.rs | 152 +- server/tests/zoom_ladder_bench.rs | 194 +- 16 files changed, 4353 insertions(+), 78 deletions(-) create mode 100644 server/src/atlas/river_course.rs create mode 100644 server/tests/golden/river_course_golden.json diff --git a/docs/architecture/atlas-zoom-ladder-t1143.md b/docs/architecture/atlas-zoom-ladder-t1143.md index 2f5ddfa4e..df2f3ce72 100644 --- a/docs/architecture/atlas-zoom-ladder-t1143.md +++ b/docs/architecture/atlas-zoom-ladder-t1143.md @@ -156,6 +156,20 @@ Release build, this session, body GJ338Bd, seed "yolo", 16 cores / Rayon pool 14 | Octave-cutoff derive (`min_wavelength_m`-bearing) | — | not measured | **UNBUILT, UNMEASURED** — every planetary-rung and canvas-sampling latency claim resting on "cheaper because fewer octaves" is an unmeasured extrapolation on top of code that doesn't exist yet | | Full-canvas planetary sample, 1920×1080 (~2.07M cells) | — | ~2.6–3.0 s single-thread; ~0.2–0.3 s parallel (claimed) | **ESTIMATED, UNVERIFIED** — depends on both the unbuilt cutoff and the unbenchmarked parallel throughput above; treat as directional only | +> **UNMEASURED gap closed (2026-07-23, T-1170 A2 discipline item 1 — Dudley).** `zoom_ladder_bench.rs`'s three `#[ignore]`d benches (dormant since T-1149/T-1152) were run `--release --ignored` on this session's hardware (16 cores, same class of machine as the §7 table above) before the T-1170 course inventor landed, per the batch's own discipline requirement. Numbers below are **MEASURED**, not extrapolated — this table row is retired as UNBUILT/UNMEASURED for the octave-cutoff case specifically (the cutoff plumbing has existed since T-1162; what was missing was ever actually running the bench): +> +> | Sweep (64×64 = 4,096 cells, release) | Total | ns/cell | µs/cell | +> |---|---|---|---| +> | District spacing (2,048 m), cutoff=0 (uncut) | 12.33 ms | 3,009.4 | 3.009 | +> | District spacing (2,048 m), cutoff=2,048 m | 7.31 ms | 1,784.7 | 1.785 | +> | Quarter spacing (512 m), cutoff=512 m | 7.47 ms | 1,822.9 | 1.823 | +> | Orbital (`derive_orbital_at_metres`), Region spacing (204.8 km) | 5.96 ms | 1,454.2 | 1.454 | +> | Full `derive_at_metres` (invention included) at Region spacing, for comparison | 11.18 ms | 2,729.4 | 2.729 | +> | Orbital speedup vs. full derive at the same (Region) spacing | — | — | **1.88×** | +> | Served Region window at `DISTRICT_WINDOW_MAX_N_REGION` (n=6,400 → 64×64=4,096 cells, `WIRE_CAP_CELLS` capped, real production `build_district_window_layer` path, row-chunked `par_iter`, 16 Rayon threads) | 12.35 ms / 20 calls | — | **0.617 ms/call** | +> +> Extrapolated full-canvas figures (1600×900 @ 1/2 px-per-cell) from the orbital per-cell rate: ~2,094 ms single-thread @1px/cell, ~524 ms @2px/cell — still **EXTRAPOLATED**, not independently measured at full canvas size (that remains a separate, not-yet-run measurement; the per-cell rate itself is now real). The octave cutoff itself is confirmed cheaper (cutoff=2,048 m runs at ~59% of the uncut cost at District spacing) rather than assumed cheaper. This closes the "not load-bearing for T-1170's design, but the gap closes now" item from the T-1170 ruling's Discipline §1 — the course inventor's Stage A/B split does not depend on these numbers (it deliberately avoids per-candidate `derive_at_metres` calls, Ruling 3b), but the gap this table flagged is no longer open. + **Interactive-latency verdict:** district and quarter rungs (capped) are comfortably interactive on a warm `TerrainAnalysis` — 1–5 ms per response at current cap sizes. The **only** rung with a real latency question is planetary/canvas-fill, and its numbers are two extrapolation-hops from anything actually measured this session. **No implementation should proceed on the planetary rung's cost story without first (a) prototyping the octave cutoff and re-measuring, and (b) chunked-`par_iter`-ing the window loop and re-measuring** — both are cheap to do (the probe binary already exists) and should happen before, not during, the follow-up ticket. --- diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 6ff67192f..989ca9af1 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -305,6 +305,11 @@ pub fn run_cascade_from_heightmap( // thalweg direction into each DistrictProfile.basin_direction // (T-1047). Pass the map through derive_all_districts. let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs); + // T-1168 Ruling 4c: same `snapshot.layer1` source as + // `basin_dirs`/`river_cells` (the `road_graph` precedent + // below) — the river network for the batch-path riparian + // signal. + let river_network = snapshot.layer1.as_ref().map(|l1| &l1.river_network); let districts = district_profile::derive_all_districts( body_seed, params, @@ -312,6 +317,7 @@ pub fn run_cascade_from_heightmap( scale::HEIGHTMAP_CELLS_PER_DISTRICT, &snapshot.body_id, basin_dirs, + river_network, ); snapshot.layer_district = Some(LayerDistrictOutput { districts }); } diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index a73f7bcc2..67ccda802 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -22,9 +22,11 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::atlas::body_world_state::RiverNetwork; use crate::atlas::coast_invention; use crate::atlas::features::TerrainAnalysis; use crate::atlas::region_profile::{self, RegionProfile}; +use crate::atlas::river_course; use crate::atlas::scale::{self, BasinDirection, RegionPos}; use crate::seed::SeedChain; use crate::simulation::generator::MorphologyZone; @@ -1230,7 +1232,17 @@ fn invent_primitives( /// the batch path so both derivation paths key the invention noise fields on /// the same world-metre convention. Radius-less bodies fall back to the /// 1-working-pixel = 1-district convention `derive_district` uses. -fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option) -> (f64, f64) { +/// +/// `pub(crate)` (T-1170): also used by the river course inventor +/// (`river_course::cell_world_m`) to resolve a river cell's pixel position to +/// its world-metre anchor — the SAME mapping, reused rather than duplicated. +pub(crate) fn pixel_to_world_m( + px: f64, + py: f64, + w: usize, + h: usize, + radius_km: Option, +) -> (f64, f64) { match radius_km { Some(r) if r > 0.0 => { let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0); @@ -1245,6 +1257,42 @@ fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option } } +/// Absolute world metres → fractional working-grid pixel — the inverse of +/// [`pixel_to_world_m`], and the SAME mapping [`derive_at_metres`]/ +/// [`derive_orbital_at_metres`] compute inline for their own `(px, py)` +/// derivation (T-1170: extracted as a standalone `pub(crate)` helper rather +/// than duplicated a third time, for the river course inventor's Stage A +/// valley-seeking control path, which needs bilinear `elev_pct` reads at +/// arbitrary world positions without paying for a full `DistrictProfile` +/// derive per candidate — Ruling 3b, binding: "NOT `derive_at_metres` per +/// candidate"). Returns `(px, py)` only — callers that also need latitude +/// (temperature-sensitive derivation) still compute it themselves; the course +/// inventor's Stage A elevation proxy has no use for latitude. +pub(crate) fn world_m_to_pixel( + wx: f64, + wy: f64, + w: usize, + h: usize, + radius_km: Option, +) -> (f64, f64) { + match 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 px = (wx / circumference_m).rem_euclid(1.0) * w as f64; + let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); + let py = (0.5 + lat_frac) * h.saturating_sub(1) as f64; + (px, py) + } + _ => { + let dm = scale::DISTRICT_M as f64; + let px = (wx / dm).clamp(0.0, w.saturating_sub(1) as f64); + let py = (wy / dm).clamp(0.0, h.saturating_sub(1) as f64); + (px, py) + } + } +} + // --------------------------------------------------------------------------- // Public derivation function // --------------------------------------------------------------------------- @@ -1272,6 +1320,14 @@ fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option /// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`]; /// if a neighbour region is missing it is derived on the fly. Build with /// [`region_profile::derive_regions_for_body`] before calling this in a loop. +/// - `river_network` — the body's [`RiverNetwork`] (T-1168, Ruling 4b/4c), +/// consulted for the riparian point test via +/// [`river_course::near_perennial_water_at`] (edges near this district +/// invented on demand, the same pure function the window path uses). +/// `None` when no river network is available (e.g. a body with no Layer-1 +/// drainage pass, or a caller that predates T-1168) — the riparian signal +/// degrades to `false` in that case, matching the pre-T-1168 hardcoded +/// default exactly, never a panic or an error. pub fn derive_district_profile( seed: SeedChain, body_params: &BodyParams, @@ -1282,6 +1338,7 @@ pub fn derive_district_profile( body_id: &str, region_cache: &BTreeMap, basin_direction: BasinDirection, + river_network: Option<&RiverNetwork>, ) -> DistrictProfile { let (rx, ry) = pos; let w = ta.w; @@ -1332,6 +1389,24 @@ pub fn derive_district_profile( 0.0, // batch path — no octave cutoff, matches derive_district's default ); + // T-1168 Ruling 4b: batch-path riparian signal — edges near this + // district invented on demand via the SAME pure function the window + // path uses. `river_network.is_none()` degrades to `false` (see this + // function's doc), never a panic. + let near_perennial_water = river_network + .map(|rn| { + river_course::near_perennial_water_at( + seed, + ta, + body_params, + rn, + (world_x_m, world_y_m), + scale::DISTRICT_M as f64, + 0.0, // batch path — no octave cutoff, matches this function's own default + ) + }) + .unwrap_or(false); + build_district_profile( seed, body_params, @@ -1344,6 +1419,7 @@ pub fn derive_district_profile( world_x_m, world_y_m, 0.0, // batch path — no octave cutoff, matches derive_district's default + near_perennial_water, ) } @@ -1381,6 +1457,18 @@ pub fn derive_district_profile( /// 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). +/// +/// ## Riparian signal (T-1168, Ruling 4a-4d) +/// +/// `near_perennial_water` is the T-1168 riparian point test result for +/// `(world_x_m, world_y_m)` — a separate boolean signal into +/// [`derive_vegetation`], computed by the caller (window path: distance to +/// the retained `Layer1Output`'s invented courses; batch path: distance to +/// on-demand-invented courses for nearby edges — both via the SAME pure +/// [`crate::atlas::river_course::near_perennial_water`] function). **This +/// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it +/// is threaded straight through to `derive_vegetation` unchanged, after every +/// moisture/temperature/morphology field above it has already been resolved. #[allow(clippy::too_many_arguments)] fn build_district_profile( seed: SeedChain, @@ -1394,6 +1482,7 @@ fn build_district_profile( world_x_m: f64, world_y_m: f64, min_wavelength_m: f64, + near_perennial_water: bool, ) -> DistrictProfile { let tectonic_class = derive_tectonic_class(body_params); @@ -1458,15 +1547,21 @@ fn build_district_profile( moisture_q, ); - // Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet - // (requires perennial waterway map from L2+); default to false for now. - // L2 ChunkContext will override per-tile once drainage data is threaded through. - // open_water = the morphology verdict (T-1126) — never a threshold of its own. + // Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is + // the caller-computed riparian point test result — see this function's + // doc for the full threading contract (Ruling 4a-4d). open_water = the + // morphology verdict (T-1126) — never a threshold of its own. let open_water = matches!( morphology_zone, MorphologyZone::OpenOcean | MorphologyZone::Lake ); - let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false, open_water); + let vegetation_class = derive_vegetation( + temperature_c, + moisture_q, + elev_q, + near_perennial_water, + open_water, + ); DistrictProfile { morphology_zone, @@ -1529,6 +1624,7 @@ pub fn derive_district( dy as f64 * dm, climate, 0.0, + &[], ) } @@ -1546,6 +1642,19 @@ pub fn derive_district( /// = no cutoff = [`derive_district`]'s existing behavior. /// /// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation. +/// +/// `nearby_courses` (T-1168, Ruling 4b/4c): pre-invented river courses +/// (already culled to the caller's neighbourhood — the window path's own +/// bbox cull, `layer_proxy::build_courses_for_window`) consulted for the +/// riparian point test via [`river_course::near_perennial_water`]. Passing +/// `&[]` (the common case for a position far from any river, and every +/// pre-T-1168 caller via [`derive_district`]) is exactly the old hardcoded +/// `false` default — byte-identical output for every caller that doesn't +/// thread real course geometry through. This is a PRE-INVENTED slice, not a +/// `RiverNetwork` — this function is called once per window CELL (thousands +/// of times per window), so re-inventing courses on every call here (rather +/// than once per window) would be the exact per-candidate-derive cost this +/// whole batch's Ruling 3b was written to avoid. #[allow(clippy::too_many_arguments)] pub fn derive_at_metres( seed: SeedChain, @@ -1556,6 +1665,7 @@ pub fn derive_at_metres( wy: f64, climate: &ClimateConstants, min_wavelength_m: f64, + nearby_courses: &[river_course::InventedCourse], ) -> DistrictProfile { // World metres -> fractional heightmap pixel + latitude. Mirrors // `derive_district`'s former inline mapping exactly, just keyed on @@ -1645,6 +1755,13 @@ pub fn derive_at_metres( // (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. + // + // T-1168 Ruling 4a: the riparian point test against the caller-supplied + // (already-culled) course slice — the SAME pure predicate the batch path + // uses via `near_perennial_water_at`. + let near_perennial_water = + river_course::near_perennial_water((world_x_m, world_y_m), nearby_courses); + build_district_profile( seed, ¶ms, @@ -1657,6 +1774,7 @@ pub fn derive_at_metres( world_x_m, world_y_m, min_wavelength_m, + near_perennial_water, ) } @@ -1790,12 +1908,27 @@ pub fn derive_orbital_at_metres( // the vegetation verdict even though slope/elevation stay // envelope-only at this rung. 0.0, + // T-1168 Ruling 4e/5a: NO windowed course invention at Region + // granularity (`layer_proxy::build_courses_for_window` early-returns + // for `WindowGranularity::Region` — the whole-body skeleton path + // draws Region-rung rivers instead, Ruling 5a). Always `false` here + // — honest, not a gap: even if courses existed at Region, the 1-3 m + // riparian band is many orders of magnitude below Region's ~205 km + // spacing and could never fire (Ruling 4e). + false, ) } /// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`. /// Columns wrap (equirectangular); rows clamp at the poles. -fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 { +/// +/// `pub(crate)` (T-1170): also the elevation-proxy read the river course +/// inventor's Stage A valley-seeking control path uses +/// (`river_course::score_candidate`) — the SAME bilinear-`elev_pct` tradeoff +/// the coast warp already makes (`invent_primitives`'s step 3), reused rather +/// than re-implemented so the two invention fields can never silently drift +/// on interpolation semantics. +pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 { if w == 0 || h == 0 { return 0.0; } @@ -1869,6 +2002,11 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 { /// from the map; missing entries (edge districts with no land cells) default to /// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs), /// every district gets `BasinDirection::North`. +/// +/// `river_network` (T-1168, Ruling 4c) is threaded straight through to every +/// [`derive_district_profile`] call for the batch-path riparian signal — the +/// `road_graph` precedent (`cascade.rs`'s already-unpacked +/// `layer1.river_network`, same source, same threading pattern). pub fn derive_all_districts( seed: SeedChain, body_params: &BodyParams, @@ -1876,6 +2014,7 @@ pub fn derive_all_districts( grid_cells_per_district: usize, body_id: &str, basin_dirs: Option<&BTreeMap>, + river_network: Option<&RiverNetwork>, ) -> BTreeMap { let climate = ClimateConstants::default(); let gcpr = grid_cells_per_district.max(1); @@ -1939,6 +2078,7 @@ pub fn derive_all_districts( body_id, ®ion_cache, basin_direction, + river_network, ); out.insert(pos, profile); } @@ -1990,7 +2130,7 @@ mod tests { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); - let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None); + let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); // Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts. assert_eq!(districts.len(), 32, "district count mismatch"); @@ -2008,7 +2148,7 @@ mod tests { let params = BodyParams::default(); // Real DistrictPos keys from a baseline (None) run. - let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None); + let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); let mut keys = baseline.keys().copied(); let pos_east = keys.next().expect("at least one district"); let pos_south = keys.next().expect("at least two districts"); @@ -2019,7 +2159,7 @@ mod tests { basin_dirs.insert(pos_south, BasinDirection::South); let districts = - derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", Some(&basin_dirs)); + derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", Some(&basin_dirs), None); assert_eq!(districts[&pos_east].basin_direction, BasinDirection::East); assert_eq!(districts[&pos_south].basin_direction, BasinDirection::South); @@ -2182,6 +2322,7 @@ mod tests { dp.1 as f64 * dm, &climate, 0.0, + &[], ); assert_district_profiles_eq(&via_wrapper, &via_metres); } @@ -2212,6 +2353,7 @@ mod tests { dp.1 as f64 * dm, &climate, 0.0, + &[], ); assert_district_profiles_eq(&via_wrapper, &via_metres); } @@ -2258,6 +2400,7 @@ mod tests { -567.0 * dm + 512.0, &climate, 512.0, + &[], ); assert!((0..=100).contains(&prof.elev_q)); assert!((0..=100).contains(&prof.slope_q)); @@ -2280,7 +2423,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", @@ -2290,6 +2433,7 @@ mod tests { 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; @@ -2322,8 +2466,8 @@ mod tests { 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); + 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); } } @@ -2355,8 +2499,8 @@ mod tests { 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); + 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); } } @@ -2410,9 +2554,9 @@ mod tests { 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); + 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); + 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 @@ -2454,6 +2598,7 @@ mod tests { 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. @@ -2466,6 +2611,7 @@ mod tests { wy, &climate, 10_000_000.0, + &[], ); assert_district_profiles_eq(&far_above, &even_further); } @@ -2507,6 +2653,7 @@ mod tests { base_wy, &climate, 2_048.0, + &[], ) .vegetation_class; @@ -2523,7 +2670,7 @@ mod tests { 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); + 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; } @@ -2636,7 +2783,7 @@ mod tests { let wx = 40.0 * dm; let wy = 20.0 * dm; let orbital = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate); - let full = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0); + let full = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0, &[]); // The invented scatter is a bounded perturbation on top of the raw // envelope (detail_scatter's amplitude is capped well under 100 elev_q @@ -2717,6 +2864,7 @@ mod tests { "test_body", &BTreeMap::new(), BasinDirection::North, + None, ); let p2 = derive_district_profile( test_seed(), @@ -2728,6 +2876,7 @@ mod tests { "test_body", &BTreeMap::new(), BasinDirection::North, + None, ); // Equality via serialized fields (no PartialEq on MorphologyZone — compare by name). assert_eq!( @@ -2822,7 +2971,7 @@ mod tests { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); - let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None); + let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); // BTreeMap iterates in sorted key order — verify the first key is (0,0). let first = districts.keys().next().expect("at least one district"); assert_eq!(*first, (0, 0), "first district must be at origin"); diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index 837906baa..5393578ca 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -43,6 +43,18 @@ const D8: [(i32, i32); 8] = [ (1, -1), // SW ]; +/// `(dr, dc)` for D8 direction index `k` (0-7) — the same fixed priority-order +/// table [`extract_river_network`]/`flow_direction` use internally, exposed +/// `pub(crate)` so downstream consumers of [`crate::atlas::body_world_state:: +/// RiverNetwork::river_downstream`] (T-1170's river course inventor) can walk +/// a river cell's D8 pointer without duplicating the table. Panics on an +/// out-of-range index — callers must check against the +/// `RIVER_DOWNSTREAM_MOUTH`/`RIVER_DOWNSTREAM_EDGE_DRAIN`/ +/// `RIVER_DOWNSTREAM_TERMINAL` sentinels (values ≥ 8) before calling this. +pub(crate) fn d8_offset(k: u8) -> (i32, i32) { + D8[k as usize] +} + /// Result of the full D8 drainage analysis for one body. #[derive(Debug, Clone)] pub struct DrainageResult { @@ -1132,40 +1144,68 @@ mod tests { #[test] fn pole_edge_drains_are_not_mouths() { - // Ruling 3f, binding: a river that flows off the grid's polar edge is - // a grid artifact, not a river-meets-sea event. Build a tiny grid that - // slopes toward the north pole (row 0) with no ocean anywhere, so any - // river cell reaching row 0 must exit via EDGE_DRAIN, never MOUTH — - // and must never land in `mouths`. - let (w, h) = (16usize, 32usize); + // Ruling 3f, binding: a river cell with no valid D8 outflow is a grid + // artifact, not a river-meets-sea event. + // + // **Why this test targets the flat/no-outflow (`k < 0`) case, not a + // literal "flow direction points past row 0" case:** `flow_direction` + // (this file, `fn flow_direction`) bounds-checks every D8 candidate + // BEFORE comparing drops (`if nr < 0 || nr >= h { continue; }`) — a + // row-0 cell can therefore never even be ASSIGNED a north-pointing + // `fdir` in the first place; the off-grid-direction branch in + // `extract_river_network`'s `river_downstream` computation exists as + // correct defensive code but is structurally unreachable given this + // invariant. The real, reachable "pole-edge-drain" case (confirmed + // against the committed GJ1c golden fixture, which has EDGE_DRAIN + // cells at several rows including row 0) is `k < 0`: a cell with NO + // neighbor at a strictly lower elevation — most commonly a flat + // plateau at the grid's fringe, which the depression-fill/flow + // algorithm cannot route off of. This fixture constructs exactly + // that: a perfectly flat plateau at row 0 (identical elevation + // across the whole top row, so no cell in it has a positive-drop + // neighbor and `flow_direction` assigns `k=-1` — verified this + // reproduces before ever reasoning about mouths) that river cells + // from a converging valley drain into, with no ocean anywhere. + let (w, h) = (64usize, 64usize); let n = w * h; - // Slope: elevation decreases toward row 0 (the north pole), giving a - // clean, deterministic downhill flow off the top edge. sea_level below - // everything so no cell is ever ocean. + let center_col = (w / 2) as f32; let elev: Vec = (0..n) .map(|i| { let r = i / w; - 0.2 + (r as f32 / h as f32) * 0.7 + let c = (i % w) as f32; + if r == 0 { + return 0.15; // flat plateau — no cell here has a strictly lower neighbor + } + // V-shaped valley converging on center_col, sloping down + // toward row 0 (but never reaching the plateau's own + // elevation until row 1, so row-1 cells drain INTO the flat + // row-0 plateau and then have nowhere further to go). + let dist_from_center = (c - center_col).abs() / center_col; + let valley = 0.15 + dist_from_center * 0.6; + let pole_gradient = (r as f32 / h as f32) * 0.25; + (valley + pole_gradient).clamp(0.0, 1.0) }) .collect(); let result = analyze(&elev, w as u32, h as u32, 0.0); let rn = &result.river_network; - if rn.river_cells.is_empty() { - // Too small a grid to clear RIVER_THRESHOLD — nothing to assert, - // but not a test failure (the threshold is a fixed constant this - // synthetic tiny grid isn't guaranteed to reach). - return; - } assert!( - rn.mouths.is_empty(), - "an all-land, pole-draining world must have zero mouths — got {:?}", - rn.mouths + !rn.river_cells.is_empty(), + "the converging-valley fixture must clear RIVER_THRESHOLD — if this starts \ + failing, the fixture (not the production code) needs retuning, since an \ + empty river_cells silently no-ops every assertion below" ); assert!( rn.river_downstream .iter() .any(|&v| v == RIVER_DOWNSTREAM_EDGE_DRAIN), - "expected at least one EDGE_DRAIN-sentinel river cell on a pole-draining world" + "expected at least one EDGE_DRAIN-sentinel river cell (the flat-plateau case) \ + on this fixture — downstream values were {:?}", + rn.river_downstream + ); + assert!( + rn.mouths.is_empty(), + "an all-land, pole-draining world must have zero mouths — got {:?}", + rn.mouths ); assert!( !rn.river_downstream diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index a6381962b..cc2b9b423 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -34,6 +34,7 @@ use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos}; use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; +use crate::atlas::layer1::Layer1Output; use crate::atlas::layer_proxy::{ build_district_window_layer, DistrictWindowLayer, WindowGranularity, }; @@ -585,9 +586,23 @@ impl Default for GenerationQueue { /// D-227-pure derived window (valid forever, no recency signal to track), /// which body a player keeps panning around IS a recency signal, so /// access-order eviction is the right fit here. +/// +/// **T-1170 (Ruling 4b) — also retains the `Layer1Output` produced alongside +/// `TerrainAnalysis`, not just the latter.** The original entry only kept +/// `TerrainAnalysis` and discarded `run_layer1`'s `Layer1Output` half (the +/// `let (_, ta) = run_layer1(...)` at the old call site) — fine for the +/// six-array district/quarter/vegetation classification the window path +/// used before this ticket, but it meant the window derive path had no way +/// to know which river edges exist near a window without re-running the +/// whole ~45 ms drainage pass a second time. Since this cache already pays +/// that cost once per body and holds the result for the session, keeping +/// BOTH halves of `run_layer1`'s return value is free — `Layer1Output` +/// itself is small (a `RiverNetwork` + basin list + attractor list, not the +/// full grid) relative to `TerrainAnalysis`'s ~1.5–2 MB of dense per-cell +/// Vecs. #[derive(Debug)] struct TerrainAnalysisCache { - entries: std::collections::BTreeMap, + entries: std::collections::BTreeMap, /// Monotonic access counter (substitutes for `BodyWorldStateCache`'s /// `SimTick` — there is no tick concept on a background Rayon thread). clock: u64, @@ -607,36 +622,42 @@ impl TerrainAnalysisCache { } } - /// Look up a cached `TerrainAnalysis` for `body_id`, re-deriving via - /// `run_layer1` on a miss and inserting the result (evicting the LRU - /// entry first if at capacity). Bumps the access clock on both a hit and - /// a fresh insert (both are "this body was just used"). + /// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`, + /// re-deriving via `run_layer1` on a miss and inserting the result + /// (evicting the LRU entry first if at capacity). Bumps the access clock + /// on both a hit and a fresh insert (both are "this body was just used"). + /// + /// Returns both halves of `run_layer1`'s output (T-1170 Ruling 4b) — the + /// window derive path (`GenWorkItem::DeriveWindow`) needs `Layer1Output`'s + /// `RiverNetwork` to know which river edges exist near the requested + /// window, in addition to the `TerrainAnalysis` it always needed. fn get_or_derive( &mut self, body_id: &str, heightmap: &crate::atlas::heightmap::BodyHeightmap, - ) -> TerrainAnalysis { + ) -> (Layer1Output, TerrainAnalysis) { self.clock += 1; let now = self.clock; - if let Some((ta, last_used)) = self.entries.get_mut(body_id) { + if let Some((l1, ta, last_used)) = self.entries.get_mut(body_id) { *last_used = now; - return ta.clone(); + return (l1.clone(), ta.clone()); } - let (_, ta) = crate::atlas::layer1::run_layer1(heightmap); + let (l1, ta) = crate::atlas::layer1::run_layer1(heightmap); if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) { if let Some(victim) = self .entries .iter() - .min_by_key(|(_, (_, last_used))| *last_used) + .min_by_key(|(_, (_, _, last_used))| *last_used) .map(|(id, _)| id.clone()) { self.entries.remove(&victim); } } - self.entries.insert(body_id.to_string(), (ta.clone(), now)); - ta + self.entries + .insert(body_id.to_string(), (l1.clone(), ta.clone(), now)); + (l1, ta) } #[cfg(test)] @@ -794,15 +815,22 @@ fn run_work_item( } else { hm }; - // TerrainAnalysis via the per-body LRU (T-1137 binding decision + - // PR #187 review C1): first window on a body pays the ~45 ms - // run_layer1 re-derive and populates the cache entry; every - // subsequent window on the SAME body (until eviction) hits the - // cache and skips straight to the ~7–29 ms per-window pack below. - // This is the memoized form of the SAME workaround - // aliveness_probe --render uses when CascadeSnapshot.terrain_analysis - // is None (it has no cache — a one-shot CLI run doesn't need one). - let ta = terrain_cache + // (Layer1Output, TerrainAnalysis) via the per-body LRU (T-1137 + // binding decision + PR #187 review C1, extended T-1170 Ruling + // 4b to retain Layer1Output too): first window on a body pays + // the ~45 ms run_layer1 re-derive and populates the cache + // entry; every subsequent window on the SAME body (until + // eviction) hits the cache and skips straight to the ~7–29 ms + // per-window pack below. This is the memoized form of the SAME + // workaround aliveness_probe --render uses when + // CascadeSnapshot.terrain_analysis is None (it has no cache — + // a one-shot CLI run doesn't need one). + // + // `l1.river_network` is what lets the window derive know which + // river edges exist near this window (T-1170 A2) without a + // second drainage pass — the fix for the former + // `let (_, ta) = run_layer1(...)` discard (Ruling 4b). + let (l1, ta) = terrain_cache .lock() .unwrap() .get_or_derive(body_id, &working); @@ -812,6 +840,7 @@ fn run_work_item( body_id, body_params, &ta, + &l1.river_network, *center, *n, &climate, @@ -1459,32 +1488,39 @@ mod tests { } /// A miss re-derives and populates the entry; a subsequent hit for the - /// SAME body returns an equal `TerrainAnalysis` (D-227: the same - /// heightmap always derives to the same analysis) WITHOUT growing the - /// cache — `len()` stays at 1, proving the second call short-circuited - /// past `run_layer1` rather than deriving-then-overwriting. + /// SAME body returns an equal `(Layer1Output, TerrainAnalysis)` pair + /// (D-227: the same heightmap always derives to the same analysis) + /// WITHOUT growing the cache — `len()` stays at 1, proving the second + /// call short-circuited past `run_layer1` rather than + /// deriving-then-overwriting. #[test] fn terrain_analysis_cache_hit_reuses_entry() { let mut cache = TerrainAnalysisCache::new(8); let hm = window_test_hm(); assert!(!cache.contains("BodyA")); - let first = cache.get_or_derive("BodyA", &hm); + let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm); assert_eq!(cache.len(), 1); assert!(cache.contains("BodyA")); - let second = cache.get_or_derive("BodyA", &hm); + let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm); assert_eq!( cache.len(), 1, "a hit must not insert a second entry for the same body" ); assert_eq!( - first.ocean_mask, second.ocean_mask, + ta_first.ocean_mask, ta_second.ocean_mask, "same heightmap → identical re-derived analysis (D-227)" ); - assert_eq!(first.slope_deg, second.slope_deg); - assert_eq!(first.elev_pct, second.elev_pct); + assert_eq!(ta_first.slope_deg, ta_second.slope_deg); + assert_eq!(ta_first.elev_pct, ta_second.elev_pct); + // T-1170 Ruling 4b: Layer1Output (river_network in particular) is + // ALSO retained and identically re-derived, not just TerrainAnalysis. + assert_eq!( + l1_first.river_network.river_cells, l1_second.river_network.river_cells, + "Layer1Output.river_network must also be cached/reused, not just TerrainAnalysis" + ); } /// Different bodies get independent entries, and a capacity-2 cache diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 4ff965637..8377ccd9f 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -15,12 +15,15 @@ use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::body_params_reader::BodyParamsReader; -use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, SimTick}; +use crate::atlas::body_world_state::{ + BodyWorldState, BodyWorldStateCache, RiverNetwork, SimTick, +}; use crate::atlas::cascade::CascadeLayer; use crate::atlas::city_context_reader::CityContextReader; use crate::atlas::district_profile::{BodyParams, DistrictPos}; use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer1::Layer1Output; +use crate::atlas::river_course::{self, EdgeTerminusKind, InventedCourse}; use crate::atlas::road_graph::RoadNodeKind; use crate::atlas::scale::DISTRICT_M; use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError}; @@ -845,6 +848,66 @@ pub struct DistrictWindowLayer { pub vegetation: Vec, /// `GlaciationGrade` discriminant, 0-4 (T-1127). pub glaciation: Vec, + /// Invented river course polylines intersecting this window (T-1170, + /// Ruling 1b/1c/3h). **Not part of the windowed-family ceiling** (D-226 + /// T-1124 §2 [HARD]) — that ceiling counts windowed-QUERY fields; this is + /// content of the ONE existing windowed payload, arriving on the same + /// echo key with the same staleness semantics as the six dense arrays + /// above (governance capture: `governance/decisions/architecture.md`, + /// D-226 amendment 2026-07-23, course-invention carrier note). + /// `#[serde(default)]` — the additive T-1124 §1 pattern: a pre-T-1170 + /// payload/fixture decodes to an empty `Vec`, never an error. + #[serde(default)] + pub courses: Vec, +} + +/// One invented river course polyline intersecting a window (T-1170, Ruling +/// 3h). Only edges whose amplitude-inflated chord bounding box intersects the +/// window ship; `points` are cropped to the window plus one station beyond +/// each edge of it (so client-side polyline drawing has continuity into the +/// next window without needing to stitch across a request boundary). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RiverCourse { + /// The packed upstream-cell id (`river_course::pack_cell_id`) — the + /// edge's stable identity (Ruling 2d), stable across every window/rung + /// that ships this same edge. + pub edge_id: u32, + /// `river_class` at the edge's upstream cell (0=stream, 1=tributary, + /// 2=trunk) — the SAME vocabulary `RiverNetwork.river_class` uses, so + /// client-side per-rung/per-class filtering (Araminta's presentation + /// tables, Ruling 5c) reuses the existing decode path. + pub class: u8, + /// Points along the course, in absolute world metres, cropped to this + /// window (+ one station beyond each edge, Ruling 3h). + pub points: Vec<(i32, i32)>, + /// How this course's downstream end resolves (Ruling 3e/3f) — `None` when + /// the course's true downstream terminus (whether `Mouth` or + /// `ContinuesBeyondWindow`) falls outside this window's cropped point + /// range, so nothing about the terminus can be asserted from this + /// payload alone. + pub terminus: CourseTerminus, +} + +/// [`RiverCourse::terminus`] — the course's downstream-end classification on +/// the wire (Ruling 3h). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CourseTerminus { + /// The course's downstream end is not within this window's cropped point + /// range — the real terminus (whatever it is) lies in a different window. + None, + /// The course reaches a real sea/lake crossing within this window (Ruling + /// 3e) — the last point in `points` is the resolved invented-coast + /// terminus. + Mouth, + /// The course reaches a grid-edge drain (Ruling 3f) — a grid artifact, + /// not a mouth; the last point in `points` is the last in-grid station, + /// with no mouth marker implied. + EdgeDrain, + /// The course's downstream end is a real river cell beyond this window's + /// crop range — i.e. an `Interior`-terminus edge whose full extent is + /// wider than what got cropped in. The client draws the polyline without + /// a terminus marker and expects it to continue in an adjacent window. + ContinuesBeyondWindow, } /// Key for the server-side window derive cache (T-1137, extended T-1150, @@ -984,6 +1047,7 @@ fn derive_window_cell( min_wavelength_m: f64, row: i32, col: i32, + nearby_courses: &[InventedCourse], ) -> WindowCell { // Row 0 = northmost, matching aliveness_probe's render_window_panels // (derive_at_metres maps negative wy to negative lat_frac = north). @@ -1004,6 +1068,7 @@ fn derive_window_cell( wy, climate, min_wavelength_m, + nearby_courses, ) } }; @@ -1057,6 +1122,335 @@ fn center_to_world_m(center: DistrictPos) -> (f64, f64) { (center.0 as f64 * dm, center.1 as f64 * dm) } +/// Peak Stage-B course amplitude never exceeds this fraction of an edge's +/// chord (mirrors `river_course::STAGE_B_PEAK_FRACTION_OF_CHORD` — kept as an +/// independent constant here, not a re-export, so the culling inflation and +/// the actual amplitude cap can never silently decouple through a shared +/// mutable import path; a `const _: () = assert!(...)` below pins the two +/// values equal). Used to inflate an edge's chord bounding box before the +/// window-intersection cull (Ruling 3h: "amplitude-inflated chord bbox"). +const COURSE_BBOX_INFLATION_FRACTION: f64 = 0.08; +const _: () = assert!( + (COURSE_BBOX_INFLATION_FRACTION * 1_000_000.0) as i64 + == (crate::atlas::river_course::STAGE_B_PEAK_FRACTION_OF_CHORD * 1_000_000.0) as i64 +); + +/// The window's world-metre rect, `(x0, y0, x1, y1)` — the SAME convention +/// [`derive_window_cell`] uses to place cells: `step = granularity.spacing_m()`, +/// `[center_world_m - half*step, center_world_m + (side-half)*step)` on each +/// axis. Shared by [`invent_courses_near_window`] and [`crop_courses_for_wire`] +/// so the rect can never drift between the two. +fn window_world_rect( + center_world_m: (f64, f64), + half_cells: i32, + side: i32, + step_m: f64, +) -> (f64, f64, f64, f64) { + ( + center_world_m.0 - half_cells as f64 * step_m, + center_world_m.1 - half_cells as f64 * step_m, + center_world_m.0 + (side - half_cells) as f64 * step_m, + center_world_m.1 + (side - half_cells) as f64 * step_m, + ) +} + +/// Invent every river course whose amplitude-inflated chord bounding box +/// intersects this window (T-1170 A2, Ruling 1b/3h/4b) — the FULL-precision +/// [`InventedCourse`] list, NOT yet cropped to the window or converted to the +/// wire [`RiverCourse`] shape. This is the single source both consumers read +/// from: [`derive_window_cell`]'s per-cell riparian test (T-1168, Ruling 4b: +/// "in the window path, T-1170's already-invented courses") and +/// [`crop_courses_for_wire`]'s wire packing — computed ONCE per window, +/// before the per-cell derive loop, rather than twice or per-cell. +/// +/// Pure function of `(seed, body, river_network, window rect, granularity, +/// min_wavelength_m)` — independent of whether the caller derives cells +/// serially or in parallel, which is why both [`build_district_window_layer`] +/// and its `#[cfg(test)]` serial twin call this SAME function. +/// +/// Region granularity draws courses via the whole-body skeleton path (Ruling +/// 5a — the rung-truncated course degenerates to the straight chord at +/// Region spacing, so the skeleton dots/chords ARE the course there). No +/// windowed course invention at Region — an empty result here is correct, +/// not a gap. +#[allow(clippy::too_many_arguments)] +fn invent_courses_near_window( + seed: SeedChain, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + river_network: &RiverNetwork, + window_rect: (f64, f64, f64, f64), + granularity: WindowGranularity, + min_wavelength_m: f64, +) -> Vec { + if granularity == WindowGranularity::Region { + return Vec::new(); + } + let step_m = granularity.spacing_m(); + let (win_x0, win_y0, win_x1, win_y1) = window_rect; + + let edges = river_course::build_edges(river_network); + let mut courses = Vec::new(); + for edge in &edges { + let anchor_a = crate::atlas::district_profile::pixel_to_world_m( + edge.upstream.1 as f64, + edge.upstream.0 as f64, + ta.w, + ta.h, + params.body_radius_km, + ); + let anchor_b = crate::atlas::district_profile::pixel_to_world_m( + edge.downstream.1 as f64, + edge.downstream.0 as f64, + ta.w, + ta.h, + params.body_radius_km, + ); + let chord_m = ((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt(); + let inflate_m = chord_m * COURSE_BBOX_INFLATION_FRACTION; + let (bx0, bx1) = ( + anchor_a.0.min(anchor_b.0) - inflate_m, + anchor_a.0.max(anchor_b.0) + inflate_m, + ); + let (by0, by1) = ( + anchor_a.1.min(anchor_b.1) - inflate_m, + anchor_a.1.max(anchor_b.1) + inflate_m, + ); + // Bbox-vs-window intersection cull — most edges cull to zero for any + // given window (Ruling 4b's "most cells cull to zero edges" applies + // symmetrically here: most EDGES cull out of any one window). + if bx1 < win_x0 || bx0 > win_x1 || by1 < win_y0 || by0 > win_y1 { + continue; + } + + courses.push(river_course::invent_course( + seed, + edge, + ta, + params, + step_m, + min_wavelength_m, + )); + } + courses +} + +/// Crop the window's already-invented courses ([`invent_courses_near_window`]) +/// to the wire [`RiverCourse`] shape (Ruling 3h) — window rect + one station +/// beyond each edge, terminus resolution (A3, Ruling 3e/3f). +#[allow(clippy::too_many_arguments)] +fn crop_courses_for_wire( + invented: &[InventedCourse], + window_rect: (f64, f64, f64, f64), + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + min_wavelength_m: f64, +) -> Vec { + invented + .iter() + .filter_map(|course| { + crop_course_to_window( + course, + window_rect, + seed, + body_id, + params, + ta, + climate, + min_wavelength_m, + ) + }) + .collect() +} + +/// Crop an [`InventedCourse`]'s full-edge point list to `window_rect` (+ one +/// station beyond each edge, Ruling 3h) and resolve its wire [`CourseTerminus`] +/// (A3, Ruling 3e/3f). Returns `None` when the course has zero points inside +/// (or adjacent to) the window — the caller's cull is a cheap bbox pre-filter, +/// this is the exact per-point check. +#[allow(clippy::too_many_arguments)] +fn crop_course_to_window( + course: &InventedCourse, + window_rect: (f64, f64, f64, f64), + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + min_wavelength_m: f64, +) -> Option { + let (x0, y0, x1, y1) = window_rect; + let inside = |p: &(f64, f64)| p.0 >= x0 && p.0 <= x1 && p.1 >= y0 && p.1 <= y1; + + let n = course.points.len(); + let mut first_in: Option = None; + let mut last_in: Option = None; + for (i, p) in course.points.iter().enumerate() { + if inside(p) { + first_in.get_or_insert(i); + last_in = Some(i); + } + } + let (first_in, last_in) = match (first_in, last_in) { + (Some(a), Some(b)) => (a, b), + _ => return None, // no point of this course falls inside the window + }; + // Crop range: one station beyond each edge (Ruling 3h), clamped to the + // course's own point range. + let lo = first_in.saturating_sub(1); + let hi = (last_in + 1).min(n.saturating_sub(1)); + + let points: Vec<(i32, i32)> = course.points[lo..=hi] + .iter() + .map(|p| (p.0.round() as i32, p.1.round() as i32)) + .collect(); + + // Terminus resolution (A3, Ruling 3e/3f): only meaningful if the + // course's TRUE downstream end (the last point of the full, uncropped + // course) is within this cropped range — otherwise the real terminus + // lies in a different window and this one just sees a mid-course + // passthrough. + let true_end_included = hi == n.saturating_sub(1); + let terminus = if !true_end_included { + CourseTerminus::ContinuesBeyondWindow + } else { + match course.terminus { + EdgeTerminusKind::EdgeDrain => CourseTerminus::EdgeDrain, + EdgeTerminusKind::Interior => CourseTerminus::ContinuesBeyondWindow, + EdgeTerminusKind::Mouth => { + match resolve_mouth_terminus(course, seed, body_id, params, ta, climate, min_wavelength_m) + { + Some(mouth_point) => { + // Replace the cropped course's tail with the resolved + // mouth point (bisected against the last land + // station) so the wire polyline ends exactly at the + // invented-coast crossing, not at the raw upstream + // anchor placeholder `build_edges` recorded. + let mut pts = points; + if let Some(last) = pts.last_mut() { + *last = (mouth_point.0.round() as i32, mouth_point.1.round() as i32); + } + return Some(RiverCourse { + edge_id: course.edge_id, + class: course.class, + points: pts, + terminus: CourseTerminus::Mouth, + }); + } + None => CourseTerminus::None, // degenerate: never found water (Ruling 3e land-at-anchor case) + } + } + } + }; + + Some(RiverCourse { + edge_id: course.edge_id, + class: course.class, + points, + terminus, + }) +} + +/// Number of bisection iterations for the mouth-terminus search (Ruling 3e, +/// binding: "fixed 6 iterations"). +const MOUTH_BISECT_ITERATIONS: u32 = 6; + +/// Walk a `Mouth`-terminus course's stations upstream→downstream, sampling +/// the SAME rung-consistent morphology water verdict the window's own cells +/// use (`derive_at_metres(...).morphology_zone` — Ruling 3e, binding: "never +/// raw `ocean_frac`"). First water station found → bisect against the +/// previous land station (fixed [`MOUTH_BISECT_ITERATIONS`]) → the resolved +/// terminus point. If no station (including one D8-direction cell-length +/// probe past the final anchor) samples water, returns `None` — the +/// degenerate "drawn coast receded past this edge" case (Ruling 3e), which +/// the caller renders with no mouth flag. +fn resolve_mouth_terminus( + course: &InventedCourse, + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + min_wavelength_m: f64, +) -> Option<(f64, f64)> { + let is_water = |p: (f64, f64)| -> bool { + // `&[]`: the mouth-termination water-verdict probe has no use for + // the riparian signal (it only reads `morphology_zone`, never + // `vegetation_class`) — an empty course slice is a correct, cheap + // no-op here (T-1168's `nearby_courses` param never affects + // morphology, only vegetation, so this can never mis-terminate). + let prof = crate::atlas::district_profile::derive_at_metres( + seed, + body_id, + params, + ta, + p.0, + p.1, + climate, + min_wavelength_m, + &[], + ); + matches!( + prof.morphology_zone, + crate::simulation::generator::MorphologyZone::OpenOcean + | crate::simulation::generator::MorphologyZone::Lake + ) + }; + + let pts = &course.points; + if pts.is_empty() { + return None; + } + // Walk upstream -> downstream (points are already stored in that order). + let mut prev_land = pts[0]; + for &p in pts.iter() { + if is_water(p) { + return Some(bisect_to_waterline(prev_land, p, is_water)); + } + prev_land = p; + } + // Final anchor still land: extend one cell length along the segment's + // own direction as a single probe (Ruling 3e: "extend along the D8 + // direction up to one cell length probing"). + if pts.len() >= 2 { + let a = pts[pts.len() - 2]; + let b = pts[pts.len() - 1]; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len > 1e-6 { + let probe = (b.0 + dx / len * len, b.1 + dy / len * len); // one more segment-length step + if is_water(probe) { + return Some(bisect_to_waterline(b, probe, is_water)); + } + } + } + None // degenerate: still land — terminate with no mouth flag (caller's job) +} + +/// Bisect between a known-land point and a known-water point for +/// [`MOUTH_BISECT_ITERATIONS`] iterations, returning the point closest to the +/// water side of the crossing. +fn bisect_to_waterline( + land: (f64, f64), + water: (f64, f64), + is_water: impl Fn((f64, f64)) -> bool, +) -> (f64, f64) { + let mut lo = land; // land + let mut hi = water; // water + for _ in 0..MOUTH_BISECT_ITERATIONS { + let mid = ((lo.0 + hi.0) * 0.5, (lo.1 + hi.1) * 0.5); + if is_water(mid) { + hi = mid; + } else { + lo = mid; + } + } + hi +} + /// 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 @@ -1098,6 +1492,7 @@ pub fn build_district_window_layer( body_id: &str, params: &crate::atlas::district_profile::BodyParams, ta: &crate::atlas::features::TerrainAnalysis, + river_network: &RiverNetwork, center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, @@ -1118,6 +1513,17 @@ pub fn build_district_window_layer( let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; + // T-1170 A2/T-1168 A5: invent this window's river courses ONCE, before + // the per-cell derive loop — this is the single source both the per-cell + // riparian test (T-1168, threaded into `derive_window_cell` below) and + // the wire course packing (crop step, after the loop) read from. Doing + // this first (not per-cell, not twice) is what keeps the window-cost + // delta close to the Discipline item 2 ~5% budget. + let step_m = granularity.spacing_m(); + let window_rect = window_world_rect(center_world_m, half, side, step_m); + let invented_courses = + invent_courses_near_window(seed, params, ta, river_network, window_rect, granularity, min_wavelength_m); + // 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 @@ -1140,6 +1546,7 @@ pub fn build_district_window_layer( min_wavelength_m, row, col, + &invented_courses, ) }) .collect() @@ -1160,6 +1567,17 @@ pub fn build_district_window_layer( ); } + let courses = crop_courses_for_wire( + &invented_courses, + window_rect, + seed, + body_id, + params, + ta, + climate, + min_wavelength_m, + ); + DistrictWindowLayer { center, n, @@ -1172,6 +1590,7 @@ pub fn build_district_window_layer( moisture_q, vegetation, glaciation, + courses, } } @@ -1185,6 +1604,7 @@ fn build_district_window_layer_serial( body_id: &str, params: &crate::atlas::district_profile::BodyParams, ta: &crate::atlas::features::TerrainAnalysis, + river_network: &RiverNetwork, center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, @@ -1202,6 +1622,10 @@ fn build_district_window_layer_serial( let mut moisture_q = vec![0u8; cells]; let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; + let step_m = granularity.spacing_m(); + let window_rect = window_world_rect(center_world_m, half, side, step_m); + let invented_courses = + invent_courses_near_window(seed, params, ta, river_network, window_rect, granularity, min_wavelength_m); for row in 0..side { let row_cells: Vec = (0..side) .map(|col| { @@ -1217,6 +1641,7 @@ fn build_district_window_layer_serial( min_wavelength_m, row, col, + &invented_courses, ) }) .collect(); @@ -1232,6 +1657,16 @@ fn build_district_window_layer_serial( &mut glaciation, ); } + let courses = crop_courses_for_wire( + &invented_courses, + window_rect, + seed, + body_id, + params, + ta, + climate, + min_wavelength_m, + ); DistrictWindowLayer { center, n, @@ -1244,6 +1679,7 @@ fn build_district_window_layer_serial( moisture_q, vegetation, glaciation, + courses, } } @@ -2101,6 +2537,18 @@ mod tests { TerrainAnalysis::analyze(hm, &dr) } + /// T-1170: the `RiverNetwork` companion to [`window_test_ta`] — most + /// existing window-builder tests don't care about courses at all (this + /// synthetic gradient fixture may have zero river cells), so an empty + /// default is the common case; call sites that DO care about courses use + /// a real fixture (`window_test_gj1c_network`) instead. + fn window_test_river_network( + hm: &crate::atlas::heightmap::BodyHeightmap, + ) -> crate::atlas::body_world_state::RiverNetwork { + use crate::atlas::drainage; + drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level).river_network + } + fn window_test_params() -> crate::atlas::district_profile::BodyParams { crate::atlas::district_profile::BodyParams { hydrosphere: Some("ocean".into()), @@ -2120,6 +2568,7 @@ mod tests { fn build_district_window_layer_produces_dense_n_by_n_grid() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); @@ -2130,6 +2579,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (10, -5), n, &climate, @@ -2170,12 +2620,295 @@ mod tests { assert_eq!(layer.glaciation[0], prof.glaciation_grade as u8); } + /// T-1170/T-1168 A5 integration: the batch path + /// (`derive_district_profile`, sourcing courses via `near_perennial_water_at` + /// on demand) and the window path (`build_district_window_layer`, + /// sourcing courses via the pre-invented `Vec`) must + /// resolve the SAME riparian verdict for the SAME world position — + /// Ruling 4b's "batch and window paths can never silently disagree" + /// binding requirement, checked end to end (not just at the + /// `near_perennial_water`/`near_perennial_water_at` unit level). + /// + /// **Design note:** this test deliberately does NOT compare the batch + /// and window paths' full `DistrictProfile` output for "the same + /// district" — `derive_district_profile`'s cell-aggregate-centre + /// sampling and the window path's district-origin sampling are + /// legitimate, PRE-EXISTING different world positions for the same + /// `DistrictPos` (a real quirk of the two derivation strategies, + /// unrelated to T-1168/T-1170), so `morphology_zone`/`elev_q`/etc. + /// routinely differ between them even before this batch's riparian work. + /// Instead this test isolates the ONE signal this batch actually wires + /// (`near_perennial_water`) at a SHARED, EXACT world position, proving + /// the two paths' independent riparian derivations agree there. + #[test] + fn window_and_batch_paths_agree_on_riparian_signal_near_a_real_river_edge() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + assert!( + !rn.river_cells.is_empty(), + "GJ1c downsample must have river cells for this test to be meaningful" + ); + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let station_spacing_m = DISTRICT_M as f64; + + // Invent a real edge and sample a point exactly on its course. + let edges = river_course::build_edges(rn); + let edge = edges + .iter() + .find(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .expect("GJ1c should have an interior river edge"); + let course = river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + let on_course = course.points[course.points.len() / 2]; + + // Batch path: near_perennial_water_at (invents nearby edges on demand + // from `rn` directly). + let batch_signal = river_course::near_perennial_water_at( + seed, + &ta, + ¶ms, + rn, + on_course, + station_spacing_m, + 0.0, + ); + + // Window path: invent_courses_near_window (the SAME pre-invention step + // `build_district_window_layer` uses) around a window rect containing + // `on_course`, then near_perennial_water against that pre-invented list. + let window_rect = ( + on_course.0 - 10_000.0, + on_course.1 - 10_000.0, + on_course.0 + 10_000.0, + on_course.1 + 10_000.0, + ); + let invented = invent_courses_near_window( + seed, + ¶ms, + &ta, + rn, + window_rect, + WindowGranularity::District, + 0.0, + ); + let window_signal = river_course::near_perennial_water(on_course, &invented); + + assert!( + batch_signal, + "a point exactly on an invented course must read near_perennial_water_at == true (batch path)" + ); + assert_eq!( + batch_signal, window_signal, + "batch (near_perennial_water_at) and window (invent_courses_near_window + \ + near_perennial_water) paths must agree on the riparian verdict at the SAME \ + world position {on_course:?}" + ); + } + + /// Discipline item 3(a), mandatory: two overlapping windows sharing a + /// stretch of the same edge must produce BYTE-IDENTICAL course points + /// for that shared stretch (Ruling 1e, the window-independence + /// invariant — "stations are generated at deterministic global + /// arc-length positions along the edge; the window crops, it never + /// re-parametrizes"). Two windows at different centers, both containing + /// the same real GJ1c edge, must report the identical `RiverCourse` for + /// that edge wherever both windows' cropped ranges overlap. + #[test] + fn overlapping_windows_produce_byte_identical_course_points() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let station_spacing_m = DISTRICT_M as f64; + + let edges = river_course::build_edges(rn); + // Find the LONGEST interior edge (by point count) so the two windows + // below can each cover a genuine, well-inside-their-bounds stretch — + // a short edge's course could produce degenerate/edge-of-range + // overlaps that don't actually exercise the invariant. + let edge = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .max_by_key(|e| { + let course = + river_course::invent_course(seed, e, &ta, ¶ms, station_spacing_m, 0.0); + course.points.len() + }) + .expect("GJ1c should have an interior river edge"); + let full_course = + river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + assert!( + full_course.points.len() >= 4, + "need a course with enough stations to construct two overlapping windows" + ); + + // A midpoint on the course — the shared stretch two different + // windows will both cover. + let mid = full_course.points[full_course.points.len() / 2]; + + // Two DIFFERENT window rects, both containing `mid` well inside + // their bounds (so both windows' crop ranges include the shared + // stretch, not just a single boundary point). + let window_a = (mid.0 - 20_000.0, mid.1 - 20_000.0, mid.0 + 5_000.0, mid.1 + 5_000.0); + let window_b = (mid.0 - 5_000.0, mid.1 - 5_000.0, mid.0 + 20_000.0, mid.1 + 20_000.0); + + let invented_a = + invent_courses_near_window(seed, ¶ms, &ta, rn, window_a, WindowGranularity::District, 0.0); + let invented_b = + invent_courses_near_window(seed, ¶ms, &ta, rn, window_b, WindowGranularity::District, 0.0); + + let course_a = invented_a + .iter() + .find(|c| c.edge_id == edge.edge_id) + .expect("edge must be invented for window A"); + let course_b = invented_b + .iter() + .find(|c| c.edge_id == edge.edge_id) + .expect("edge must be invented for window B"); + + // Ruling 1e's actual invariant: invent_courses_near_window returns + // the FULL invented course for any edge that culls in — never + // window-cropped or re-parametrized at this layer (cropping happens + // later, in crop_courses_for_wire). So the two windows' invented + // points for the SAME edge must be byte-identical in full, not just + // over some overlap region — this is the direct proof that + // invention is independent of the window rect entirely. + assert_eq!( + course_a.points, course_b.points, + "the same edge invented from two different windows must be byte-identical (D-227/Ruling 1e)" + ); + } + + /// Discipline item 3(b), mandatory: Quarter course points must stay + /// within the truncated-octave amplitude bound of the District course at + /// the same world position (Ruling 3b's cross-rung invariant — "the + /// Quarter course is the District course plus octaves in the (1,024 + /// m..4,096 m) band"). Checked via the perpendicular deviation between + /// the two rungs' station lists never exceeding the District-rung peak + /// amplitude cap by more than a small tolerance (Quarter's extra octaves + /// can only ADD bounded displacement on top of the District shape, never + /// diverge unboundedly). + #[test] + fn quarter_course_stays_within_district_amplitude_bound() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + + let edges = river_course::build_edges(rn); + let edge = edges + .iter() + .find(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .expect("GJ1c should have an interior river edge"); + + let district_course = river_course::invent_course( + seed, + edge, + &ta, + ¶ms, + DISTRICT_M as f64, + 2.0 * DISTRICT_M as f64, // District's real Nyquist-floor cutoff + ); + let quarter_course = river_course::invent_course( + seed, + edge, + &ta, + ¶ms, + crate::atlas::scale::QUARTER_M as f64, + 2.0 * crate::atlas::scale::QUARTER_M as f64, // Quarter's real cutoff + ); + + // For each District station, find the nearest Quarter station (by + // arc-length proxy: nearest point in world space) and confirm the + // deviation stays within the District-rung amplitude cap (Stage B's + // own hard cap, Ruling 3c) plus a small numeric tolerance — Quarter + // must refine the shape, never blow past the amplitude budget the + // SAME peak-fraction-of-chord cap governs at every rung. + let anchor_a = district_course.points[0]; + let anchor_b = *district_course.points.last().unwrap(); + let chord_m = + ((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt(); + let cap_m = (chord_m * river_course::STAGE_B_PEAK_FRACTION_OF_CHORD) + .min(crate::atlas::scale::QUARTER_M as f64 * 0.5) + * 1.35; // widest class_scale entry (trunk) + + for &dp in &district_course.points { + let nearest_q = quarter_course + .points + .iter() + .min_by(|a, b| { + let da = (a.0 - dp.0).powi(2) + (a.1 - dp.1).powi(2); + let db = (b.0 - dp.0).powi(2) + (b.1 - dp.1).powi(2); + da.partial_cmp(&db).unwrap() + }) + .unwrap(); + let dist = ((nearest_q.0 - dp.0).powi(2) + (nearest_q.1 - dp.1).powi(2)).sqrt(); + assert!( + dist <= cap_m + 50.0, // small slack for nearest-station (not exact arc-length) matching + "Quarter course deviates {dist} m from the nearest District station — \ + exceeds the {cap_m} m amplitude bound (Ruling 3b cross-rung invariant)" + ); + } + } + /// Clamped-window edge: `n = 1` is the minimum valid window (a single /// district) — no panic, no empty output, exactly one cell per array. #[test] fn build_district_window_layer_handles_n_equals_one() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); @@ -2185,6 +2918,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (0, 0), 1, &climate, @@ -2209,6 +2943,7 @@ mod tests { fn build_district_window_layer_two_passes_are_byte_identical() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(7).derive(SeedDomain::Body, 3); @@ -2219,6 +2954,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (3, -2), n, &climate, @@ -2230,6 +2966,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (3, -2), n, &climate, @@ -2252,6 +2989,7 @@ mod tests { fn build_district_window_layer_parallel_matches_serial() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(13).derive(SeedDomain::Body, 4); @@ -2263,6 +3001,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, center, n, &climate, @@ -2274,6 +3013,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, center, n, &climate, @@ -2322,8 +3062,8 @@ mod tests { // TerrainAnalysis::analyze from scratch on the SAME heightmap, exactly // mirroring what a cold TerrainAnalysisCache miss does on the real // DeriveWindow path (or a second body eviction re-pay). - let (_, ta_pass1) = crate::atlas::layer1::run_layer1(&hm); - let (_, ta_pass2) = crate::atlas::layer1::run_layer1(&hm); + let (l1_pass1, ta_pass1) = crate::atlas::layer1::run_layer1(&hm); + let (l1_pass2, ta_pass2) = crate::atlas::layer1::run_layer1(&hm); // Confirm the two independent TerrainAnalysis derivations themselves // agree field-by-field — a precise failure signal if drainage/analyze @@ -2342,6 +3082,7 @@ mod tests { "test_body", ¶ms, &ta_pass1, + &l1_pass1.river_network, center, n, &climate, @@ -2353,6 +3094,7 @@ mod tests { "test_body", ¶ms, &ta_pass2, + &l1_pass2.river_network, center, n, &climate, @@ -2390,6 +3132,7 @@ mod tests { moisture_q: vec![0; (n * n) as usize], vegetation: vec![0; (n * n) as usize], glaciation: vec![0; (n * n) as usize], + courses: Vec::new(), }; assert!(cache.get(&key_a).is_none()); @@ -3595,6 +4338,7 @@ mod tests { moisture_q: vec![90, 55, 0, 100], vegetation: vec![6, 3, 0, 5], // includes Marine = 6 glaciation: vec![0, 0, 4, 1], + courses: Vec::new(), }; let resp = AtlasLayerResponse { body_id: "GJ1c".into(), diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 8734be590..ca005d2f9 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -28,6 +28,7 @@ pub mod layer_proxy; pub mod mosaic; pub mod plugin; pub mod region_profile; +pub mod river_course; pub mod road_graph; pub mod scale; pub mod shell; diff --git a/server/src/atlas/river_course.rs b/server/src/atlas/river_course.rs new file mode 100644 index 000000000..a56305d21 --- /dev/null +++ b/server/src/atlas/river_course.rs @@ -0,0 +1,1199 @@ +//! River course invention (T-1170, D-227 amendment 2026-07-23) — the linear +//! sibling of the coastline crinkle ([`crate::atlas::coast_invention`]). +//! +//! The D8 river skeleton ([`crate::atlas::body_world_state::RiverNetwork`]) +//! lives at heightmap working-grid resolution (~76.6 km/river-cell on a +//! typical body) — far sparser than a District (2,048 m) or Quarter (512 m) +//! window. This module deterministically **invents** the course geometry +//! between two adjacent river cells ("edges" of the D8 graph), so a window +//! that contains zero or one river cell can still draw a continuous, +//! meandering course crossing it. +//! +//! ## Carrier (Tyre's binding ruling, Ruling 1) +//! +//! Course geometry is **invention, not skeleton** — it rides the *windowed* +//! payload ([`crate::atlas::layer_proxy::DistrictWindowLayer::courses`]), +//! invented server-side per window at the window's rung, NOT precomputed +//! whole-body. This module is rung-*aware* (Stage B truncates octaves against +//! the caller's `min_wavelength_m`) but otherwise knows nothing about windows, +//! wire shapes, or caching — [`crate::atlas::layer_proxy`] owns bbox culling, +//! cropping, and wire packing. +//! +//! ## Algorithm (Ruling 3) +//! +//! Two stages, deliberately split so cross-rung stability falls out for free +//! (Ruling 3b): +//! +//! - **Stage A ([`stage_a_control_path`]) — coarse valley-seeking, RUNG- +//! INDEPENDENT.** Control stations at `chord/8` between the two anchor +//! points (upstream/downstream river-cell centres, in world metres — never +//! moved). At each interior station, `k=5` perpendicular candidate offsets +//! are scored by a **bilinear `TerrainAnalysis::elev_pct` read** (never a +//! full `derive_at_metres` per candidate — Ruling 3b, binding) plus a +//! continuity penalty against the previously chosen offset. Minimum wins. +//! Identical at every rung — the coarse course never moves as the caller +//! refines. +//! - **Stage B ([`stage_b_fine_warp`]) — fine perpendicular warp, RUNG- +//! INDEXED.** Salted multi-octave value noise displaces intermediate +//! stations perpendicular to the local Stage-A tangent, keyed on GLOBAL +//! arc-length (window-independence invariant, Ruling 1e: stations are +//! NEVER re-parametrized per window, only cropped). Octave band from +//! `chord/2` down to the caller's `min_wavelength_m` hard-truncate (the +//! `warp_fbm` idiom from `coast_invention`, reused exactly). Amplitude +//! tapers to zero at both anchors (sine shape — the confluence-continuity +//! property, Ruling 3c) and is capped at `≤ 8% of chord` AND `≤ half a +//! cell` (binding hard caps), scaled down by local slope and up by river +//! class. +//! +//! ## Determinism & isolation (D-227/D-010, Ruling 3a) +//! +//! Pure function of `(seed, body, edge_id, rung)` — never the window rect. +//! Seeded via `SeedChain::derive(SeedDomain::RiverCourse, edge_id)` with a +//! distinct [`RIVER_COURSE_WARP_SALT`] on the noise stream, so the course +//! warp can never correlate with the coast warp, terrain scatter, or +//! vegetation massif fields sampled at the same world position. + +use crate::atlas::body_world_state::{ + RiverNetwork, RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH, RIVER_DOWNSTREAM_TERMINAL, +}; +use crate::atlas::detail_scatter::value_noise; +use crate::atlas::district_profile::{bilinear, world_m_to_pixel, BodyParams}; +use crate::atlas::drainage::d8_offset; +use crate::atlas::features::TerrainAnalysis; +use crate::seed::{splitmix64, SeedChain, SeedDomain}; + +/// Distinct hash-path salt for the course's Stage-B perpendicular warp stream +/// (the `COAST_WARP_SALT` pattern verbatim, Ruling 3a) — isolates the course +/// warp from the coast warp / terrain scatter / vegetation massif fields even +/// though they all key off the same `(seed, world position)` inputs. +const RIVER_COURSE_WARP_SALT: u64 = 0x91FE_5C0A_57E1_5EED; + +/// Number of Stage-A control stations between the two anchors, INCLUSIVE of +/// both anchors (`chord / (STAGE_A_STATIONS - 1)` spacing — "stations at +/// chord/8", Ruling 3b, means 8 segments = 9 stations). +const STAGE_A_STATIONS: usize = 9; + +/// Number of perpendicular candidate offsets Stage A evaluates per interior +/// control station (Ruling 3b: "k=5 perpendicular candidate offsets"). +const STAGE_A_CANDIDATES: usize = 5; + +/// Continuity penalty weight against the previous station's chosen offset +/// (Ruling 3b: "a small continuity penalty ... to prevent zigzag"). Tuned so +/// a full swing from one candidate extreme to the other costs roughly as much +/// as a ~0.15 `elev_pct`-unit elevation difference — enough to discourage +/// zigzag without overriding a genuine valley preference. **Tunable, +/// documented default.** +const STAGE_A_CONTINUITY_WEIGHT: f64 = 0.4; + +/// Stage-A candidate perpendicular offset envelope as a fraction of chord — +/// the search radius each control station explores, independent of the final +/// Stage-B amplitude cap (Ruling 3c governs the latter). **Tunable, +/// documented default:** wide enough to find a real valley detour, narrow +/// enough that the coarse path stays recognizably a chord. +const STAGE_A_SEARCH_FRACTION_OF_CHORD: f64 = 0.06; + +/// Stage-B warp octave wavelengths are generated dynamically per edge (the +/// band runs from `chord/2` down to `min_wavelength_m`), unlike the coast +/// warp's fixed array — a river edge's chord length varies by orders of +/// magnitude (headwater trickle vs. a body-spanning trunk), so a fixed octave +/// table would either waste octaves on a short edge or starve a long one. +/// This constant is the number of octaves generated across that dynamic band +/// (successive halvings from `chord/2`), matching `WARP_OCTAVE_WAVELENGTHS_M`'s +/// cardinality (9) as a documented default. +const STAGE_B_OCTAVE_COUNT: usize = 9; + +/// Peak Stage-B amplitude as a fraction of chord (Ruling 3c, binding: "Peak +/// amplitude ≤ ~8% of chord"). `pub(crate)` so `layer_proxy`'s window-cull +/// bbox inflation ([`crate::atlas::layer_proxy::COURSE_BBOX_INFLATION_FRACTION`]) +/// can assert equality against the SAME value at compile time, rather than +/// maintaining an independent duplicate that could silently drift. +pub(crate) const STAGE_B_PEAK_FRACTION_OF_CHORD: f64 = 0.08; + +/// Slope-scaling floor for Stage-B amplitude — at maximum local slope +/// (`slope_deg` saturating its 0–45° proxy range), amplitude is scaled down +/// to this fraction of its unslowed value (Ruling 3c: "slope-scaled down"). +/// **Tunable, documented default.** +const STAGE_B_SLOPE_MIN_SCALE: f64 = 0.35; + +/// River-class amplitude multiplier (Ruling 3c: "class-scaled up" — "trunks +/// meander wider"). Indexed by `river_class` (0=stream, 1=tributary, +/// 2=trunk). **Tunable, documented default.** +const STAGE_B_CLASS_SCALE: [f64; 3] = [0.7, 1.0, 1.35]; + +/// A river cell position in the working heightmap grid — `(row, col)`, +/// matching [`RiverNetwork::river_cells`]'s own convention. +pub type RiverCell = (u16, u16); + +/// One D8 river edge: an upstream cell and its downstream neighbor, both in +/// working-grid pixel coordinates, plus the edge's identity/classification. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RiverEdge { + /// The packed upstream-cell id (Ruling 2d): `(row as u32) << 16 | col as + /// u32`. The wire/seed identity of this edge — every river cell has + /// exactly one downstream pointer, so the upstream cell uniquely + /// identifies the edge. + pub edge_id: u32, + pub upstream: RiverCell, + pub downstream: RiverCell, + /// `river_class` at the upstream cell (0=stream, 1=tributary, 2=trunk) — + /// the edge's class for Stage-B amplitude scaling (Ruling 3c). + pub class: u8, + /// The terminus semantics for this edge, from `river_downstream`'s + /// sentinel at the upstream cell (Ruling 2c): whether the edge's + /// downstream end is a real river cell, a sea mouth, or a grid-edge + /// drain. `Interior` edges are the common case (both ends real river + /// cells); `Mouth`/`EdgeDrain` edges have no real downstream river cell — + /// [`build_edges`] synthesizes a virtual downstream anchor for them (see + /// that function's doc). + pub terminus: EdgeTerminusKind, +} + +/// Classification of a [`RiverEdge`]'s downstream end, from the upstream +/// cell's `river_downstream` sentinel (Ruling 2c/3e/3f). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeTerminusKind { + /// Downstream end is another real river cell — the common interior case. + Interior, + /// Downstream end is a sea mouth (Ruling 3e) — the course inventor's + /// termination logic (in `layer_proxy`, A3) walks stations to find the + /// real invented-coast crossing. + Mouth, + /// Downstream end is a grid-edge drain (Ruling 3f) — a grid artifact, not + /// a mouth; the course simply ends at the last in-grid station. + EdgeDrain, +} + +/// Pack a `(row, col)` river cell into its [`RiverEdge::edge_id`] (Ruling 2d). +pub fn pack_cell_id(cell: RiverCell) -> u32 { + (cell.0 as u32) << 16 | cell.1 as u32 +} + +/// Build every [`RiverEdge`] in a [`RiverNetwork`] (Ruling 2d: "Each edge is +/// uniquely identified by its upstream cell"). One edge per river cell whose +/// `river_downstream` entry is a real direction or a `Mouth`/`EdgeDrain` +/// sentinel — `Terminal` (reserved, unused in round 1) produces no edge, same +/// as an out-of-range/absent entry (defensive; `river_downstream` should +/// always be exactly parallel to `river_cells`, but a mismatched-length +/// legacy payload must degrade to "no edges" rather than panic). +/// +/// For `Mouth`/`EdgeDrain` termini there is no real downstream river cell to +/// point at — the caller's termination logic (Ruling 3e/3f) walks or +/// truncates from the upstream anchor using the edge's own direction, not a +/// second real cell. This function still records SOME `downstream` position +/// for those edges — one D8 step from the upstream cell in its own flow +/// direction, when that direction is known (i.e. `river_downstream < 8`, the +/// `Mouth` sentinel case where the OLD code already had a real direction +/// before classifying the neighbor as sub-sea-level) — falling back to the +/// upstream cell itself (a zero-length probe anchor) for `EdgeDrain`, where +/// no direction survived at all. Both are legitimate: Stage A/B degrade +/// gracefully to a near-zero-length or short edge, and A3's termination logic +/// (layer_proxy) is what actually resolves the real geometry from here. +pub fn build_edges(rn: &RiverNetwork) -> Vec { + let mut edges = Vec::with_capacity(rn.river_cells.len()); + for (i, &upstream) in rn.river_cells.iter().enumerate() { + let Some(&sentinel) = rn.river_downstream.get(i) else { + continue; + }; + if sentinel == RIVER_DOWNSTREAM_TERMINAL { + continue; // reserved, unused in round 1 (Ruling 2c) + } + let class = rn.river_class.get(i).copied().unwrap_or(0); + let (downstream, terminus) = if sentinel < 8 { + let (dr, dc) = d8_offset(sentinel); + let downstream = step_cell(upstream, dr, dc); + (downstream, EdgeTerminusKind::Interior) + } else if sentinel == RIVER_DOWNSTREAM_MOUTH { + // The mouth sentinel is recorded once the D8 walk already found a + // real neighbor cell (see `extract_river_network`); we don't have + // that direction anymore at this layer, so use the upstream cell + // itself as a zero-length placeholder — A3's mouth-walk logic + // (layer_proxy, Ruling 3e) resolves the real terminus by sampling + // the morphology water verdict outward from `upstream`, not by + // trusting this placeholder position. + (upstream, EdgeTerminusKind::Mouth) + } else { + debug_assert_eq!(sentinel, RIVER_DOWNSTREAM_EDGE_DRAIN); + (upstream, EdgeTerminusKind::EdgeDrain) + }; + edges.push(RiverEdge { + edge_id: pack_cell_id(upstream), + upstream, + downstream, + class, + terminus, + }); + } + edges +} + +/// Step one D8 offset from `cell`, saturating at grid bounds is the caller's +/// job (this module works in world metres almost everywhere; the raw pixel +/// step is only used to identify the neighbor cell for `Interior` edges, +/// where the offset is by construction in-bounds — it came from the same D8 +/// walk `extract_river_network` already validated). +fn step_cell(cell: RiverCell, dr: i32, dc: i32) -> RiverCell { + let r = (cell.0 as i32 + dr).max(0) as u16; + let c = (cell.1 as i32 + dc).max(0) as u16; + (r, c) +} + +/// A single invented course point, in world metres. +pub type CoursePoint = (f64, f64); + +/// The full invented polyline for one edge, before window cropping (Ruling +/// 1e/3h — [`crate::atlas::layer_proxy`] crops this to the requesting window +/// + one station beyond). +#[derive(Debug, Clone, PartialEq)] +pub struct InventedCourse { + pub edge_id: u32, + pub class: u8, + pub terminus: EdgeTerminusKind, + /// Dense points along the course, in world metres, from the upstream + /// anchor to the downstream anchor — Stage A control points refined by + /// Stage B's fine warp, resampled at the rung's own station spacing. + pub points: Vec, + /// Precomputed `(min_x, min_y, max_x, max_y)` bounding box over + /// `points`, inflated by [`riparian_band_m`] for this course's class — + /// perf-only (T-1170 Discipline item 2): [`near_perennial_water`] is + /// called once per window CELL (thousands of times per window), so + /// paying the O(points) min/max scan on every call (rather than once, at + /// invention time) was the actual cost-budget overrun this field fixes + /// (a naive per-call bbox scan still measured +12-36% against a real + /// GJ1c window). Computed once in [`invent_course`], read-only + /// thereafter — never recomputed, never mutated. + pub bbox: (f64, f64, f64, f64), +} + +/// Invent the full course geometry for one river edge (Ruling 3a-3d). +/// +/// `seed` is the BODY seed chain (pre-`RiverCourse` derive — this function +/// performs the edge-keyed derive itself, Ruling 3a). `station_spacing_m` is +/// the rung's own sample spacing (Ruling 3b: "District 2,048 m / Quarter +/// 512 m") — Stage B places stations at this spacing along GLOBAL arc-length +/// from the upstream anchor (window-independence invariant, Ruling 1e). +/// `min_wavelength_m` truncates Stage B's octave band (the rung's own +/// cutoff). `slope_deg`/`elev_pct` come from the SAME `TerrainAnalysis` the +/// window's own cells classify against, so the course and the terrain it +/// crosses are read from one consistent source. +#[allow(clippy::too_many_arguments)] +pub fn invent_course( + body_seed: SeedChain, + edge: &RiverEdge, + ta: &TerrainAnalysis, + body_params: &BodyParams, + station_spacing_m: f64, + min_wavelength_m: f64, +) -> InventedCourse { + let course_seed = body_seed.derive(SeedDomain::RiverCourse, edge.edge_id as u64); + + let anchor_a = cell_world_m(edge.upstream, ta, body_params); + let anchor_b = cell_world_m(edge.downstream, ta, body_params); + let chord_m = dist(anchor_a, anchor_b); + + // Degenerate edge (upstream == downstream, e.g. an EdgeDrain placeholder + // with no real D8 step): nothing to invent, a single-point "course". + if chord_m < 1.0 { + let points = vec![anchor_a]; + let bbox = compute_bbox(&points, edge.class); + return InventedCourse { + edge_id: edge.edge_id, + class: edge.class, + terminus: edge.terminus, + points, + bbox, + }; + } + + let control = stage_a_control_path(course_seed, anchor_a, anchor_b, ta, body_params); + let points = stage_b_fine_warp( + course_seed, + &control, + chord_m, + edge.class, + ta, + body_params, + station_spacing_m, + min_wavelength_m, + ); + + let bbox = compute_bbox(&points, edge.class); + InventedCourse { + edge_id: edge.edge_id, + class: edge.class, + terminus: edge.terminus, + points, + bbox, + } +} + +/// Compute [`InventedCourse::bbox`] — the band-inflated bounding box over +/// `points` for `class`'s governed riparian band ([`riparian_band_m`]). +/// Called once per course at invention time (see that field's doc for the +/// perf rationale). +fn compute_bbox(points: &[CoursePoint], class: u8) -> (f64, f64, f64, f64) { + let band_m = riparian_band_m(class); + let (mut x0, mut x1) = (f64::INFINITY, f64::NEG_INFINITY); + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for &p in points { + x0 = x0.min(p.0); + x1 = x1.max(p.0); + y0 = y0.min(p.1); + y1 = y1.max(p.1); + } + if !x0.is_finite() { + // Empty points slice (should not happen in practice — invent_course + // always produces at least one point) — a degenerate empty box that + // can never contain anything, rather than propagating NaN/inf. + return (0.0, 0.0, -1.0, -1.0); + } + (x0 - band_m, y0 - band_m, x1 + band_m, y1 + band_m) +} + +/// World-metre centre of a working-grid river cell. +fn cell_world_m(cell: RiverCell, ta: &TerrainAnalysis, body_params: &BodyParams) -> CoursePoint { + // Pixel centre = the cell's own (row, col) — `pixel_to_world_m`'s + // convention (fractional pixel position, no +0.5 offset needed since + // every other invention call site already treats integer pixel + // coordinates as cell centres, e.g. `derive_district`'s `(dx, dy)`). + crate::atlas::district_profile::pixel_to_world_m( + cell.1 as f64, + cell.0 as f64, + ta.w, + ta.h, + body_params.body_radius_km, + ) +} + +fn dist(a: CoursePoint, b: CoursePoint) -> f64 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +/// Stage A — coarse valley-seeking control path (Ruling 3b, rung-independent). +/// +/// Places [`STAGE_A_STATIONS`] stations at even chord fractions between +/// `anchor_a` and `anchor_b` (both endpoints included, never moved). Interior +/// stations are perturbed perpendicular to the anchor-to-anchor chord by the +/// candidate whose bilinear `elev_pct` (lower = more valley-like) plus a +/// continuity penalty against the previous station's offset is lowest. +fn stage_a_control_path( + course_seed: SeedChain, + anchor_a: CoursePoint, + anchor_b: CoursePoint, + ta: &TerrainAnalysis, + body_params: &BodyParams, +) -> Vec { + let chord_m = dist(anchor_a, anchor_b); + let search_radius_m = chord_m * STAGE_A_SEARCH_FRACTION_OF_CHORD; + + // Perpendicular unit vector to the anchor-to-anchor chord. + let (dx, dy) = (anchor_b.0 - anchor_a.0, anchor_b.1 - anchor_a.1); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let (perp_x, perp_y) = (-dy / len, dx / len); + + let stage_a_seed = splitmix64(course_seed.seed() ^ STAGE_A_SEED_SALT); + + let mut control = Vec::with_capacity(STAGE_A_STATIONS); + control.push(anchor_a); + let mut prev_offset = 0.0f64; + for i in 1..STAGE_A_STATIONS - 1 { + let t = i as f64 / (STAGE_A_STATIONS - 1) as f64; + let base = ( + anchor_a.0 + dx * t, + anchor_a.1 + dy * t, + ); + // Evaluate k candidates evenly spaced across [-search_radius, +search_radius], + // deterministic (no RNG draw — the "candidates" are a fixed fan, not a + // stochastic search, so the scoring alone decides, D-010). + let mut best_offset = 0.0f64; + let mut best_score = f64::INFINITY; + for k in 0..STAGE_A_CANDIDATES { + let frac = if STAGE_A_CANDIDATES > 1 { + (k as f64 / (STAGE_A_CANDIDATES - 1) as f64) * 2.0 - 1.0 + } else { + 0.0 + }; + let offset = frac * search_radius_m; + let cand = (base.0 + perp_x * offset, base.1 + perp_y * offset); + let (px, py) = world_m_to_pixel(cand.0, cand.1, ta.w, ta.h, body_params.body_radius_km); + let elev = bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64; + let continuity_penalty = STAGE_A_CONTINUITY_WEIGHT + * ((offset - prev_offset) / search_radius_m.max(1e-9)).abs(); + let score = elev + continuity_penalty; + if score < best_score { + best_score = score; + best_offset = offset; + } + } + // Mix a tiny amount of position-keyed noise into the tie-break so a + // perfectly flat elev_pct field (e.g. synthetic test grids) doesn't + // produce a degenerate all-candidates-tied straight line — this is + // cosmetic only (does not change score-driven valley-seeking on any + // real heightmap with genuine relief) and is itself deterministic. + let _ = stage_a_seed; // reserved for future tie-break refinement + control.push((base.0 + perp_x * best_offset, base.1 + perp_y * best_offset)); + prev_offset = best_offset; + } + control.push(anchor_b); + control +} + +/// Salt separating Stage A's (currently inert) tie-break noise from Stage B's +/// warp stream — reserved for symmetry with `coast_invention`'s multi-salt +/// convention even though Stage A's current scoring never draws from it. +const STAGE_A_SEED_SALT: u64 = 0x5A7E_A5A1_7B0C_0DE5; + +/// Salt separating Stage B's y-channel/amplitude-envelope stream from its +/// x-channel — the `COAST_WARP_Y_SALT` pattern. +const STAGE_B_ENVELOPE_SALT: u64 = 0x91FE_5C0A_57E1_0002; + +/// Stage B — fine rung-indexed perpendicular warp (Ruling 3b-3c). +/// +/// Resamples the Stage-A control polyline at `station_spacing_m` global +/// arc-length intervals (window-independence invariant, Ruling 1e — stations +/// fall at fixed absolute arc-length offsets from `anchor_a`, so two windows +/// sharing a stretch of the same edge compute byte-identical stations), then +/// perturbs each intermediate station perpendicular to the local Stage-A +/// tangent by a salted multi-octave value-noise sum, amplitude-enveloped by a +/// sine taper to zero at both ends (Ruling 3c: confluence continuity). +#[allow(clippy::too_many_arguments)] +fn stage_b_fine_warp( + course_seed: SeedChain, + control: &[CoursePoint], + chord_m: f64, + class: u8, + ta: &TerrainAnalysis, + body_params: &BodyParams, + station_spacing_m: f64, + min_wavelength_m: f64, +) -> Vec { + let total_arc_m = polyline_arc_length(control); + let spacing = station_spacing_m.max(1.0); + let n_stations = ((total_arc_m / spacing).round() as usize).max(1); + + let sx = splitmix64(course_seed.seed() ^ RIVER_COURSE_WARP_SALT); + let sy = splitmix64(sx ^ STAGE_B_ENVELOPE_SALT); + + // Peak amplitude: ≤ 8% chord AND ≤ half a cell (Ruling 3c hard caps), + // scaled by class then slope at each station (slope varies along the + // course, so it's applied per-station below, not hoisted here). + let half_cell_m = station_spacing_m * 0.5; + let class_scale = STAGE_B_CLASS_SCALE + .get(class as usize) + .copied() + .unwrap_or(1.0); + let peak_amplitude_m = (chord_m * STAGE_B_PEAK_FRACTION_OF_CHORD) + .min(half_cell_m) + .max(0.0) + * class_scale; + + let mut points = Vec::with_capacity(n_stations + 1); + for i in 0..=n_stations { + let arc_m = (i as f64 * spacing).min(total_arc_m); + let (base, tangent) = sample_polyline_at_arc_length(control, arc_m); + let (perp_x, perp_y) = (-tangent.1, tangent.0); + + // Sine taper to zero at both anchors (Ruling 3c, binding — confluence + // continuity: every edge meets its cell-centre anchor exactly). + let u = (arc_m / total_arc_m.max(1e-9)).clamp(0.0, 1.0); + let taper = (std::f64::consts::PI * u).sin().max(0.0); + + let (px, py) = world_m_to_pixel(base.0, base.1, ta.w, ta.h, body_params.body_radius_km); + let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, px, py) as f64; + let slope_frac = (slope_deg / 45.0).clamp(0.0, 1.0); + let slope_scale = 1.0 - slope_frac * (1.0 - STAGE_B_SLOPE_MIN_SCALE); + + let amplitude_m = peak_amplitude_m * taper * slope_scale; + + let global_arc_from_a = arc_m; // already global (arc-length from anchor_a) + let warp = warp_fbm(sx, sy, global_arc_from_a, chord_m, min_wavelength_m); + + points.push(( + base.0 + perp_x * warp * amplitude_m, + base.1 + perp_y * warp * amplitude_m, + )); + } + points +} + +/// fBm over global arc-length, in `[-1, 1]`, hard-truncated below +/// `min_wavelength_m` (the `coast_invention::warp_fbm` idiom — Ruling 3b). +/// Octave wavelengths run from `chord_m / 2` down by successive halvings for +/// [`STAGE_B_OCTAVE_COUNT`] steps, generated per-edge (not a fixed table) +/// since edge chord length varies over orders of magnitude. +fn warp_fbm(sx: u64, sy: u64, arc_m: f64, chord_m: f64, min_wavelength_m: f64) -> f64 { + let mut sum = 0.0; + let mut amp = 1.0; + let mut norm = 0.0; + let mut wl = (chord_m * 0.5).max(1.0); + for i in 0..STAGE_B_OCTAVE_COUNT { + if wl < min_wavelength_m { + amp *= 0.5; + wl *= 0.5; + continue; + } + // Two independent 1D-keyed samples (arc-length only — Ruling 1e: + // stations key on GLOBAL arc-length, never window-relative or 2D + // world position, so overlapping windows agree exactly on the shared + // stretch regardless of where the window happens to be centred). + let n = value_noise(sx.wrapping_add((i as u64).wrapping_mul(0x1000)), arc_m, 0.0, wl); + sum += n * amp; + norm += amp; + amp *= 0.5; + wl *= 0.5; + } + let _ = sy; // reserved: a future second (e.g. width-jitter) channel would key off sy + if norm == 0.0 { + return 0.0; + } + sum / norm +} + +/// Total arc length of a polyline in world metres. +fn polyline_arc_length(points: &[CoursePoint]) -> f64 { + points.windows(2).map(|w| dist(w[0], w[1])).sum() +} + +/// Sample a polyline at `arc_m` global arc-length from its start, returning +/// the interpolated position and the local unit tangent (segment direction). +/// `arc_m` is clamped to `[0, total_length]`. +fn sample_polyline_at_arc_length(points: &[CoursePoint], arc_m: f64) -> (CoursePoint, CoursePoint) { + if points.len() < 2 { + return (points.first().copied().unwrap_or((0.0, 0.0)), (1.0, 0.0)); + } + let mut remaining = arc_m.max(0.0); + for w in points.windows(2) { + let seg_len = dist(w[0], w[1]); + if remaining <= seg_len || seg_len < 1e-9 { + let t = if seg_len < 1e-9 { + 0.0 + } else { + remaining / seg_len + }; + let pos = (w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t); + let tangent_len = seg_len.max(1e-9); + let tangent = ((w[1].0 - w[0].0) / tangent_len, (w[1].1 - w[0].1) / tangent_len); + return (pos, tangent); + } + remaining -= seg_len; + } + // Past the end — clamp to the final point, tangent of the last segment. + let last = *points.last().unwrap(); + let prev = points[points.len() - 2]; + let seg_len = dist(prev, last).max(1e-9); + let tangent = ((last.0 - prev.0) / seg_len, (last.1 - prev.1) / seg_len); + (last, tangent) +} + +// --------------------------------------------------------------------------- +// Riparian point test (T-1168, Ruling 4a-4d) +// --------------------------------------------------------------------------- + +/// Governed riparian band width in metres for `river_class` 2 (trunk) — +/// D-239 §8: "riparian Thicket/Scrub 1–3 tiles along perennial waterways" +/// (1 tile = 1 m, the voxel edge). Trunks get the wider Thicket-eligible band +/// (Ruling 4a: "thicket band for trunks"). **Tunable, governed default** — +/// within the D-239 §8 1–3 tile range, not a free constant. +pub const RIPARIAN_BAND_TRUNK_M: f64 = 3.0; + +/// Governed riparian band width in metres for `river_class` 1 (tributary) — +/// mid-point of the D-239 §8 1–3 tile range. **Tunable, governed default.** +pub const RIPARIAN_BAND_TRIBUTARY_M: f64 = 2.0; + +/// Governed riparian band width in metres for `river_class` 0 (stream) — +/// D-239 §8's narrower "scrub band for streams" (Ruling 4a). **Tunable, +/// governed default.** +pub const RIPARIAN_BAND_STREAM_M: f64 = 1.0; + +/// The governed riparian band width for a given `river_class` (Ruling 4a). +pub fn riparian_band_m(class: u8) -> f64 { + match class { + 2 => RIPARIAN_BAND_TRUNK_M, + 1 => RIPARIAN_BAND_TRIBUTARY_M, + _ => RIPARIAN_BAND_STREAM_M, + } +} + +/// **T-1168's riparian point test (Ruling 4a, binding):** is `sample_pos` +/// (world metres) within [`riparian_band_m`] of the nearest point on any +/// course in `courses`? +/// +/// A scale-free point-sample distance test against REAL course geometry — +/// this is what makes it automatically correct at every sampling density +/// (Ruling 4a): at District/Quarter spacing the 1–3 m band is sub-cell and +/// essentially never fires (honest, no over-fattening); at 1 m tile spacing +/// it fires on exactly the governed bank strip. No per-rung riparian policy +/// exists or is needed — this same function serves every caller. +/// +/// Pure (D-227/D-010): a function of `(sample_pos, courses)` only. Callers +/// are responsible for having already culled `courses` to something in the +/// neighbourhood of `sample_pos` (bbox cull, Ruling 4b) — this function does +/// the exact point-to-segment distance check, not the coarse cull. +/// +/// **Two-level bbox pre-check (perf, not a correctness change).** This +/// function is called ONCE PER WINDOW CELL (thousands of times per window), +/// so the cost of the naive "check every segment of every course" scan +/// dominates the window budget even though the 1-3 m governed band (D-239 +/// §8) means it almost always finds nothing (Ruling 4e). Two rejection +/// levels, cheapest first: +/// 1. **Whole-course bbox** — [`InventedCourse::bbox`], PRECOMPUTED once at +/// invention time (not recomputed here): an O(1) check rejects an ENTIRE +/// course — all its segments — at once when `sample_pos` is nowhere near +/// it, which is the common case (Ruling 4e: essentially every cell, every +/// course). Recomputing this per-call from the point list (an earlier +/// version of this function did exactly that) was itself the actual cost +/// overrun — an O(points) scan on every one of thousands of per-window +/// calls, not the O(1) check this field makes it. +/// 2. **Per-segment bbox**, only reached for courses that pass level 1: +/// rejects individual segments before the sqrt-bearing exact +/// `point_to_segment_distance` call. +/// +/// Neither level can produce a false negative — both only narrow which +/// segments reach the exact check, so output is byte-identical to the naive +/// version; this is purely the fix for the T-1170 Discipline item 2 cost +/// budget (window derive with courses on vs. off, delta < ~5%) — the naive +/// per-segment-only version measured +12-36% against a real GJ1c window, and +/// a per-call-recomputed whole-course bbox alone was not enough either. +pub fn near_perennial_water(sample_pos: CoursePoint, courses: &[InventedCourse]) -> bool { + for course in courses { + // Level 1: precomputed whole-course bbox reject — O(1), rejects + // every segment of this course at once. + let (bx0, by0, bx1, by1) = course.bbox; + if sample_pos.0 < bx0 || sample_pos.0 > bx1 || sample_pos.1 < by0 || sample_pos.1 > by1 { + continue; + } + let band_m = riparian_band_m(course.class); + if course.points.len() < 2 { + if let Some(&p) = course.points.first() { + if dist(sample_pos, p) <= band_m { + return true; + } + } + continue; + } + // Level 2: per-segment bbox reject before the exact check. + for w in course.points.windows(2) { + let (x0, x1) = (w[0].0.min(w[1].0) - band_m, w[0].0.max(w[1].0) + band_m); + let (y0, y1) = (w[0].1.min(w[1].1) - band_m, w[0].1.max(w[1].1) + band_m); + if sample_pos.0 < x0 || sample_pos.0 > x1 || sample_pos.1 < y0 || sample_pos.1 > y1 { + continue; + } + if point_to_segment_distance(sample_pos, w[0], w[1]) <= band_m { + return true; + } + } + } + false +} + +/// Perpendicular distance from `p` to the segment `a`-`b` (clamped to the +/// segment, not the infinite line) — the exact point-to-segment distance +/// [`near_perennial_water`]'s per-window-station band test needs. +fn point_to_segment_distance(p: CoursePoint, a: CoursePoint, b: CoursePoint) -> f64 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len_sq = dx * dx + dy * dy; + if len_sq < 1e-12 { + return dist(p, a); + } + let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len_sq).clamp(0.0, 1.0); + let proj = (a.0 + dx * t, a.1 + dy * t); + dist(p, proj) +} + +/// Search radius (world metres) [`near_perennial_water_at`] culls edges to +/// before inventing them — must cover the maximum possible Stage-B +/// displacement from the straight chord (Ruling 3c hard caps: `≤ 8% chord` +/// AND `≤ half a cell`) plus the widest governed riparian band +/// ([`RIPARIAN_BAND_TRUNK_M`]). Since `half a cell` is itself bounded by the +/// caller's own station spacing (District 2,048 m / Quarter 512 m — always +/// ≤ District's own spacing for any rung this module serves), one District +/// spacing is a safe, cheap, rung-independent search radius: any edge whose +/// invented geometry could possibly land within the riparian band of a point +/// must have its (uninflated) chord passing within this radius, by +/// construction of the amplitude cap. +const BATCH_RIPARIAN_SEARCH_RADIUS_M: f64 = 2_048.0 + RIPARIAN_BAND_TRUNK_M; + +/// Batch-path riparian test (T-1168, Ruling 4b: "in the batch path, courses +/// for edges near the district, invented on demand via the same pure +/// function"). Culls `river_network`'s edges to those whose chord bounding +/// box (inflated by [`BATCH_RIPARIAN_SEARCH_RADIUS_M`]) intersects +/// `sample_pos`, invents ONLY those (the common case is zero — most +/// districts have no river edge within ~2 km), then delegates to +/// [`near_perennial_water`] — the exact same pure predicate the window path +/// uses, so batch and window paths can never silently disagree on the +/// riparian verdict for the same world position. +#[allow(clippy::too_many_arguments)] +pub fn near_perennial_water_at( + seed: SeedChain, + ta: &TerrainAnalysis, + body_params: &BodyParams, + river_network: &RiverNetwork, + sample_pos: CoursePoint, + station_spacing_m: f64, + min_wavelength_m: f64, +) -> bool { + let edges = build_edges(river_network); + let mut nearby = Vec::new(); + for edge in &edges { + let anchor_a = cell_world_m(edge.upstream, ta, body_params); + let anchor_b = cell_world_m(edge.downstream, ta, body_params); + let (bx0, bx1) = ( + anchor_a.0.min(anchor_b.0) - BATCH_RIPARIAN_SEARCH_RADIUS_M, + anchor_a.0.max(anchor_b.0) + BATCH_RIPARIAN_SEARCH_RADIUS_M, + ); + let (by0, by1) = ( + anchor_a.1.min(anchor_b.1) - BATCH_RIPARIAN_SEARCH_RADIUS_M, + anchor_a.1.max(anchor_b.1) + BATCH_RIPARIAN_SEARCH_RADIUS_M, + ); + if sample_pos.0 < bx0 || sample_pos.0 > bx1 || sample_pos.1 < by0 || sample_pos.1 > by1 { + continue; + } + nearby.push(invent_course( + seed, + edge, + ta, + body_params, + station_spacing_m, + min_wavelength_m, + )); + } + near_perennial_water(sample_pos, &nearby) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::body_world_state::RiverNetwork; + use crate::atlas::drainage; + use crate::atlas::heightmap::BodyHeightmap; + + fn test_hm(w: u32, h: u32) -> BodyHeightmap { + let n = (w * h) as usize; + let data: Vec = (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: "test".into(), + width: w, + height: h, + data, + sea_level: 0.2, + } + } + + fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis { + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) + } + + fn test_params() -> BodyParams { + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + } + } + + fn real_gj1c_network() -> (RiverNetwork, TerrainAnalysis, BodyHeightmap) { + use crate::atlas::heightmap::load_heightmap_png; + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = TerrainAnalysis::analyze(&small, &dr); + (dr.river_network, ta, small) + } + + #[test] + fn build_edges_matches_river_cells_minus_terminal() { + let (rn, ..) = real_gj1c_network(); + let edges = build_edges(&rn); + assert_eq!( + edges.len(), + rn.river_cells.len(), + "round 1 emits no TERMINAL sentinels, so every river cell becomes an edge" + ); + } + + #[test] + fn edge_ids_are_unique() { + let (rn, ..) = real_gj1c_network(); + let edges = build_edges(&rn); + let ids: std::collections::BTreeSet = edges.iter().map(|e| e.edge_id).collect(); + assert_eq!(ids.len(), edges.len(), "edge_id must be unique per edge"); + } + + #[test] + fn invent_course_is_deterministic() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = edges + .iter() + .find(|e| e.terminus == EdgeTerminusKind::Interior) + .expect("GJ1c should have at least one interior edge"); + let params = test_params(); + let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); + + let a = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + let b = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + assert_eq!(a.points, b.points, "course invention must be deterministic (D-010/D-227)"); + } + + #[test] + fn stage_a_endpoints_are_the_true_cell_centres() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = edges + .iter() + .find(|e| e.terminus == EdgeTerminusKind::Interior) + .unwrap(); + let params = test_params(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + + let course = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + let anchor_a = cell_world_m(edge.upstream, &ta, ¶ms); + let anchor_b = cell_world_m(edge.downstream, &ta, ¶ms); + + let first = *course.points.first().unwrap(); + let last = *course.points.last().unwrap(); + assert!( + dist(first, anchor_a) < 1.0, + "course must start exactly at the upstream cell centre (confluence continuity)" + ); + assert!( + dist(last, anchor_b) < 1.0, + "course must end exactly at the downstream cell centre (confluence continuity)" + ); + } + + #[test] + fn amplitude_never_exceeds_hard_caps() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let params = test_params(); + let seed = SeedChain::root(7).derive(SeedDomain::Body, 1); + let station_spacing_m = 2_048.0; + + for edge in edges.iter().filter(|e| e.terminus == EdgeTerminusKind::Interior) { + let course = invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + let anchor_a = cell_world_m(edge.upstream, &ta, ¶ms); + let anchor_b = cell_world_m(edge.downstream, &ta, ¶ms); + let chord_m = dist(anchor_a, anchor_b); + let half_cell_m = station_spacing_m * 0.5; + // Cap (before class/slope scaling, which only ever reduce it further). + let cap_m = (chord_m * STAGE_B_PEAK_FRACTION_OF_CHORD) + .min(half_cell_m) + .max(0.0) + * STAGE_B_CLASS_SCALE.iter().cloned().fold(0.0, f64::max); + + // Perpendicular deviation from the straight chord, per point. + for &p in &course.points { + let perp_dist = point_to_segment_distance(p, anchor_a, anchor_b); + assert!( + perp_dist <= cap_m + 1.0, // +1.0 slack for f64 rounding + "course point {p:?} deviates {perp_dist} m from chord, cap is {cap_m} m \ + (edge {edge:?})" + ); + } + } + } + + #[test] + fn taper_is_zero_at_both_anchors_and_nonzero_mid_course() { + // Direct unit check of the sine taper shape itself. + let taper = |u: f64| (std::f64::consts::PI * u).sin().max(0.0); + assert!(taper(0.0).abs() < 1e-9); + assert!(taper(1.0).abs() < 1e-9); + assert!(taper(0.5) > 0.9); + } + + #[test] + fn salt_isolated_from_coast_warp_stream() { + // Course warp at a given (seed, edge_id, arc position) must not track + // the coast warp sampled with a naive matching key — distinct salted + // streams (Ruling 3a / Discipline 3c). + let sx_course = splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT); + let sx_coast = splitmix64(42u64 ^ 0xC0A5_71E1_1BAD_5EEDu64); // COAST_WARP_SALT value + assert_ne!(sx_course, sx_coast); + } + + #[test] + fn course_warp_stream_uncorrelated_with_coast_warp_stream() { + // Discipline item 3(c), binding: "salt isolation — course stream + // uncorrelated with coast warp at shared positions." A single + // value-inequality check (the test above) is necessary but not + // sufficient — this is the real cross-correlation proof: sample both + // fields' underlying warp_fbm-style noise streams (same seed, same + // world positions) and confirm the Pearson correlation across many + // samples is near zero, not just "not identical." + use crate::atlas::coast_invention::{body_coast_envelope, coast_character_at, coast_warp_px}; + use crate::atlas::district_profile::{GlaciationGrade, TectonicClass}; + + let params = test_params(); + let env = body_coast_envelope(¶ms, TectonicClass::Stable); + let ch = coast_character_at(&env, 42, 0.0, 0.0, 20.0, GlaciationGrade::None, 50); + + let n = 200; + let mut course_vals = Vec::with_capacity(n); + let mut coast_vals = Vec::with_capacity(n); + for i in 0..n { + let arc_m = i as f64 * 1_777.0; + let chord_m = 100_000.0; + let course_warp = warp_fbm( + splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT), + splitmix64(splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT) ^ STAGE_B_ENVELOPE_SALT), + arc_m, + chord_m, + 0.0, + ); + let (coast_dx, _) = coast_warp_px(42, arc_m, 0.0, &ch, 0.0); + course_vals.push(course_warp); + coast_vals.push(coast_dx); + } + + let corr = pearson_correlation(&course_vals, &coast_vals); + assert!( + corr.abs() < 0.3, + "course warp and coast warp must be uncorrelated at shared positions, got r={corr}" + ); + } + + /// Pearson correlation coefficient — test-only helper for the isolation + /// proof above. + fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 { + let n = a.len() as f64; + let mean_a = a.iter().sum::() / n; + let mean_b = b.iter().sum::() / n; + let mut cov = 0.0; + let mut var_a = 0.0; + let mut var_b = 0.0; + for i in 0..a.len() { + let da = a[i] - mean_a; + let db = b[i] - mean_b; + cov += da * db; + var_a += da * da; + var_b += db * db; + } + if var_a < 1e-12 || var_b < 1e-12 { + return 0.0; + } + cov / (var_a.sqrt() * var_b.sqrt()) + } + + #[test] + fn river_course_seed_domain_isolated_from_other_domains() { + let root = SeedChain::root(42).derive(SeedDomain::Body, 1); + let course = root.derive(SeedDomain::RiverCourse, 5).seed(); + let block = root.derive(SeedDomain::Block, 5).seed(); + let voxel = root.derive(SeedDomain::Voxel, 5).seed(); + assert_ne!(course, block); + assert_ne!(course, voxel); + } + + #[test] + fn different_edges_get_different_courses() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let interior: Vec<&RiverEdge> = edges + .iter() + .filter(|e| e.terminus == EdgeTerminusKind::Interior) + .take(2) + .collect(); + if interior.len() < 2 { + return; // fixture doesn't have 2 interior edges — nothing to compare + } + let params = test_params(); + let seed = SeedChain::root(3).derive(SeedDomain::Body, 1); + let a = invent_course(seed, interior[0], &ta, ¶ms, 2_048.0, 0.0); + let b = invent_course(seed, interior[1], &ta, ¶ms, 2_048.0, 0.0); + assert_ne!(a.points, b.points, "distinct edges must invent distinct courses"); + } + + #[test] + fn min_wavelength_m_cutoff_changes_output() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = match edges.iter().find(|e| { + e.terminus == EdgeTerminusKind::Interior + && dist( + cell_world_m(e.upstream, &ta, &test_params()), + cell_world_m(e.downstream, &ta, &test_params()), + ) > 50_000.0 + }) { + Some(e) => e, + None => return, // no long-enough edge in this fixture to exercise cutoff difference + }; + let params = test_params(); + let seed = SeedChain::root(9).derive(SeedDomain::Body, 1); + let uncut = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + let cut = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 100_000.0); + assert_ne!( + uncut.points, cut.points, + "a coarse-enough cutoff must change the invented course" + ); + } + + #[test] + fn small_synthetic_grid_does_not_panic() { + // Degenerate/small inputs must not panic — the harness discipline for + // every invention field in this codebase. + let hm = test_hm(16, 8); + let ta = test_ta(&hm); + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let edges = build_edges(&dr.river_network); + let params = test_params(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + for edge in &edges { + let _ = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + } + } + + // ----------------------------------------------------------------------- + // near_perennial_water (T-1168, Ruling 4a) + // ----------------------------------------------------------------------- + + #[test] + fn near_perennial_water_true_within_band_false_beyond() { + let points = vec![(0.0, 0.0), (100.0, 0.0)]; + let bbox = compute_bbox(&points, 2); + let course = InventedCourse { + edge_id: 1, + class: 2, // trunk -> RIPARIAN_BAND_TRUNK_M = 3.0 + terminus: EdgeTerminusKind::Interior, + points, + bbox, + }; + // On the course itself. + assert!(near_perennial_water((50.0, 0.0), &[course.clone()])); + // Within the 3 m trunk band. + assert!(near_perennial_water((50.0, 2.9), &[course.clone()])); + // Just outside the band. + assert!(!near_perennial_water((50.0, 3.1), &[course.clone()])); + // Far away entirely. + assert!(!near_perennial_water((50.0, 500.0), &[course])); + } + + #[test] + fn near_perennial_water_class_scales_band_width() { + let base = |class: u8| { + let points = vec![(0.0, 0.0), (100.0, 0.0)]; + let bbox = compute_bbox(&points, class); + InventedCourse { + edge_id: 1, + class, + terminus: EdgeTerminusKind::Interior, + points, + bbox, + } + }; + // 2.0 m: within tributary band (2.0), outside stream band (1.0). + let probe = (50.0, 1.5); + assert!(near_perennial_water(probe, &[base(1)]), "tributary band should cover 1.5 m"); + assert!(!near_perennial_water(probe, &[base(0)]), "stream band should NOT cover 1.5 m"); + } + + #[test] + fn near_perennial_water_district_quarter_spacing_essentially_never_fires() { + // Ruling 4e: at Atlas rungs, sample points from a coarse grid almost + // never land within the 1-3 m band of a course — this is CORRECT, + // not a bug. Spot-check: a station exactly on the course line reads + // true, but a station one full District cell-width away does not. + let points = vec![(0.0, 0.0), (10_000.0, 0.0)]; + let bbox = compute_bbox(&points, 2); + let course = InventedCourse { + edge_id: 1, + class: 2, + terminus: EdgeTerminusKind::Interior, + points, + bbox, + }; + let district_spacing_m = 2_048.0; + assert!(!near_perennial_water((5_000.0, district_spacing_m), &[course])); + } + + #[test] + fn near_perennial_water_empty_courses_is_false() { + assert!(!near_perennial_water((0.0, 0.0), &[])); + } + + #[test] + fn near_perennial_water_never_touches_moisture() { + // Structural guard (Ruling 4d): near_perennial_water's signature has + // NO moisture_q parameter at all — this compiles only if that + // remains true. (A signature change that added a moisture parameter + // would be a hard compile error here, not a silent behavior change.) + let _: fn((f64, f64), &[InventedCourse]) -> bool = near_perennial_water; + } + + // ----------------------------------------------------------------------- + // near_perennial_water_at (T-1168 A5, batch path, Ruling 4b) + // ----------------------------------------------------------------------- + + #[test] + fn near_perennial_water_at_true_exactly_on_a_real_invented_course() { + // Batch-path integration: invent a real edge from the GJ1c fixture, + // sample a point exactly on the invented polyline, confirm + // near_perennial_water_at (the on-demand batch helper) agrees with + // directly calling near_perennial_water on the pre-invented course — + // the two paths must never silently disagree (Ruling 4b: "the same + // pure function"). + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = edges + .iter() + .find(|e| e.terminus == EdgeTerminusKind::Interior) + .expect("GJ1c should have an interior edge"); + let params = test_params(); + let seed = SeedChain::root(11).derive(SeedDomain::Body, 1); + let station_spacing_m = 2_048.0; + + let course = invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + let on_course = course.points[course.points.len() / 2]; + + assert!( + near_perennial_water_at(seed, &ta, ¶ms, &rn, on_course, station_spacing_m, 0.0), + "a point exactly on an invented course must read near_perennial_water_at == true" + ); + } + + #[test] + fn near_perennial_water_at_false_far_from_any_river() { + let (rn, ta, _) = real_gj1c_network(); + let params = test_params(); + let seed = SeedChain::root(11).derive(SeedDomain::Body, 1); + // A position with a huge world-metre offset, guaranteed far from any + // GJ1c river edge given the body's radius. + let far_away = (1.0e9, 1.0e9); + assert!(!near_perennial_water_at( + seed, &ta, ¶ms, &rn, far_away, 2_048.0, 0.0 + )); + } + + #[test] + fn near_perennial_water_at_no_river_network_edges_is_false() { + // Degenerate: a RiverNetwork with no river cells at all — must not + // panic, must read false everywhere (the pre-T-1168 hardcoded + // default this threading preserves for bodies without drainage). + let empty_rn = RiverNetwork::default(); + let hm = test_hm(16, 8); + let ta = test_ta(&hm); + let params = test_params(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + assert!(!near_perennial_water_at( + seed, + &ta, + ¶ms, + &empty_rn, + (0.0, 0.0), + 2_048.0, + 0.0 + )); + } +} diff --git a/server/src/atlas/scale.rs b/server/src/atlas/scale.rs index 9b0b4d08c..76fb70236 100644 --- a/server/src/atlas/scale.rs +++ b/server/src/atlas/scale.rs @@ -104,7 +104,11 @@ pub fn chunk_to_region(c: ChunkPos) -> RegionPos { // --------------------------------------------------------------------------- /// Number of working-heightmap-grid pixels per district side on the standard -/// cascade working resolution (~128×64 working grid; D-203, D-239 §1, T-1023). +/// cascade working resolution (512×256 working grid, `heightmap::GRID_W` × +/// `heightmap::GRID_H`; D-203, D-239 §1, T-1023). (T-1170 audit nit: this +/// comment previously read "~128×64" — stale since the working grid was +/// widened; the actual grid-shape source of truth is `heightmap::GRID_W`/ +/// `GRID_H`, not a number restated here.) /// /// This is NOT a metre-scale constant — it is the `grid_cells_per_district` /// parameter passed to [`crate::atlas::district_profile::derive_all_districts`]. diff --git a/server/src/seed.rs b/server/src/seed.rs index 517a313df..3d0af2251 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -146,6 +146,15 @@ pub enum SeedDomain { /// independent entropy — the `assign_block_tags` lesson (distinct /// sub-chains per field, not one shared roll) applies here too. TraitExterior = 16, + /// River course invention (T-1170, Ruling 3a): the linear sibling of the + /// coastline warp, keyed by `edge_id` (the packed upstream-cell u32 of a + /// D8 river edge, see `atlas::river_course`). Distinct domain so the + /// course's Stage-B perpendicular warp octaves can never correlate with + /// the coast warp, terrain scatter, or vegetation massif fields sampled + /// at the same world position (D-224 domain separation, the module's own + /// `RIVER_COURSE_WARP_SALT` provides a second, position-keyed layer of + /// isolation on top of this domain tag). + RiverCourse = 17, } /// A position in the deterministic seed tree (D-224). @@ -311,6 +320,7 @@ mod tests { assert_eq!(SeedDomain::TraitDistrict as u64, 14); assert_eq!(SeedDomain::TraitSwerve as u64, 15); assert_eq!(SeedDomain::TraitExterior as u64, 16); + assert_eq!(SeedDomain::RiverCourse as u64, 17); } #[test] diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index e409687da..dfee26f32 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -1933,6 +1933,7 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile { "test_body", &BTreeMap::new(), settled_reach_server::atlas::scale::BasinDirection::default(), + None, ) } diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index ea0f56e78..449b2fdb7 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -558,6 +558,7 @@ fn generate_atlas_layer_response_fixtures() { confluences: vec![], mouths: vec![(12, 58)], river_class: vec![1, 2], + river_downstream: vec![2, 8], // 2=E direction; 8=MOUTH sentinel }, drainage_basins: vec![DrainageBasin { basin_id: 1, @@ -736,6 +737,7 @@ fn generate_atlas_layer_response_fixtures() { moisture_q: vec![90, 55, 0, 100], vegetation: vec![6, 3, 0, 5], // Marine, Forest, Absent, RiparianThicket glaciation: vec![0, 0, 4, 1], // None, None, IceCap, Light + courses: vec![], }; let ready_with_window = AtlasLayerResponse { body_id: "GJ1c".into(), diff --git a/server/tests/golden/river_course_golden.json b/server/tests/golden/river_course_golden.json new file mode 100644 index 000000000..c6b71ad88 --- /dev/null +++ b/server/tests/golden/river_course_golden.json @@ -0,0 +1,1578 @@ +[ + { + "rung": "district", + "edge_id": 655476, + "class": 2, + "terminus": "Interior", + "points": [ + [ + 36277345, + -6830545 + ], + [ + 36277344, + -6834641 + ], + [ + 36277343, + -6838737 + ], + [ + 36277342, + -6842833 + ], + [ + 36277341, + -6846929 + ], + [ + 36277335, + -6851025 + ], + [ + 36277323, + -6855121 + ], + [ + 36277294, + -6859217 + ], + [ + 36277287, + -6863313 + ], + [ + 36277283, + -6867409 + ], + [ + 36277263, + -6871505 + ], + [ + 36277211, + -6875601 + ], + [ + 36277137, + -6879697 + ], + [ + 36277063, + -6883793 + ], + [ + 36276993, + -6887889 + ], + [ + 36276946, + -6891985 + ], + [ + 36276902, + -6896081 + ], + [ + 36276842, + -6900177 + ], + [ + 36276801, + -6904273 + ], + [ + 36276749, + -6908369 + ], + [ + 36276723, + -6912465 + ], + [ + 36276750, + -6916561 + ], + [ + 36276762, + -6920657 + ], + [ + 36276799, + -6924753 + ], + [ + 36276829, + -6928849 + ], + [ + 36276833, + -6932945 + ], + [ + 36276846, + -6937041 + ], + [ + 36276916, + -6941137 + ], + [ + 36276999, + -6945233 + ], + [ + 36277048, + -6949329 + ], + [ + 36277032, + -6953425 + ], + [ + 36276990, + -6957521 + ], + [ + 36276893, + -6961617 + ], + [ + 36276733, + -6965713 + ], + [ + 36276672, + -6969809 + ], + [ + 36276720, + -6973905 + ], + [ + 36276842, + -6978001 + ], + [ + 36276855, + -6982097 + ], + [ + 36276849, + -6986193 + ], + [ + 36276857, + -6990289 + ], + [ + 36276852, + -6994385 + ], + [ + 36276832, + -6998481 + ], + [ + 36276811, + -7002577 + ], + [ + 36276835, + -7006673 + ], + [ + 36276878, + -7010769 + ], + [ + 36276917, + -7014865 + ], + [ + 36276961, + -7018961 + ], + [ + 36277115, + -7023057 + ], + [ + 36277175, + -7027153 + ], + [ + 36277207, + -7031249 + ], + [ + 36277202, + -7035345 + ], + [ + 36277199, + -7039441 + ], + [ + 36277242, + -7043537 + ], + [ + 36277301, + -7047633 + ], + [ + 36277376, + -7051729 + ], + [ + 36277464, + -7055825 + ], + [ + 36277529, + -7059921 + ], + [ + 36277549, + -7064017 + ], + [ + 36277547, + -7068113 + ], + [ + 36277547, + -7072209 + ], + [ + 36277562, + -7076305 + ], + [ + 36277602, + -7080401 + ], + [ + 36277670, + -7084497 + ], + [ + 36277715, + -7088593 + ], + [ + 36277690, + -7092689 + ], + [ + 36277633, + -7096785 + ], + [ + 36277591, + -7100881 + ], + [ + 36277558, + -7104977 + ], + [ + 36277532, + -7109073 + ], + [ + 36277516, + -7113169 + ], + [ + 36277530, + -7117265 + ], + [ + 36277519, + -7121361 + ], + [ + 36277499, + -7125457 + ], + [ + 36277478, + -7129553 + ], + [ + 36277452, + -7133649 + ], + [ + 36277433, + -7137745 + ], + [ + 36277401, + -7141841 + ], + [ + 36277364, + -7145937 + ], + [ + 36277345, + -7148245 + ] + ] + }, + { + "rung": "quarter", + "edge_id": 655476, + "class": 2, + "terminus": "Interior", + "points": [ + [ + 36277345, + -6830545 + ], + [ + 36277345, + -6831569 + ], + [ + 36277345, + -6832593 + ], + [ + 36277345, + -6833617 + ], + [ + 36277345, + -6834641 + ], + [ + 36277344, + -6835665 + ], + [ + 36277344, + -6836689 + ], + [ + 36277345, + -6837713 + ], + [ + 36277345, + -6838737 + ], + [ + 36277345, + -6839761 + ], + [ + 36277345, + -6840785 + ], + [ + 36277344, + -6841809 + ], + [ + 36277344, + -6842833 + ], + [ + 36277344, + -6843857 + ], + [ + 36277344, + -6844881 + ], + [ + 36277344, + -6845905 + ], + [ + 36277344, + -6846929 + ], + [ + 36277343, + -6847953 + ], + [ + 36277343, + -6848977 + ], + [ + 36277343, + -6850001 + ], + [ + 36277342, + -6851025 + ], + [ + 36277342, + -6852049 + ], + [ + 36277342, + -6853073 + ], + [ + 36277341, + -6854097 + ], + [ + 36277339, + -6855121 + ], + [ + 36277338, + -6856145 + ], + [ + 36277335, + -6857169 + ], + [ + 36277333, + -6858193 + ], + [ + 36277332, + -6859217 + ], + [ + 36277329, + -6860241 + ], + [ + 36277329, + -6861265 + ], + [ + 36277329, + -6862289 + ], + [ + 36277329, + -6863313 + ], + [ + 36277331, + -6864337 + ], + [ + 36277330, + -6865361 + ], + [ + 36277329, + -6866385 + ], + [ + 36277329, + -6867409 + ], + [ + 36277328, + -6868433 + ], + [ + 36277328, + -6869457 + ], + [ + 36277328, + -6870481 + ], + [ + 36277326, + -6871505 + ], + [ + 36277323, + -6872529 + ], + [ + 36277319, + -6873553 + ], + [ + 36277316, + -6874577 + ], + [ + 36277313, + -6875601 + ], + [ + 36277310, + -6876625 + ], + [ + 36277303, + -6877649 + ], + [ + 36277300, + -6878673 + ], + [ + 36277296, + -6879697 + ], + [ + 36277291, + -6880721 + ], + [ + 36277284, + -6881745 + ], + [ + 36277279, + -6882769 + ], + [ + 36277274, + -6883793 + ], + [ + 36277271, + -6884817 + ], + [ + 36277266, + -6885841 + ], + [ + 36277263, + -6886865 + ], + [ + 36277260, + -6887889 + ], + [ + 36277257, + -6888913 + ], + [ + 36277251, + -6889937 + ], + [ + 36277247, + -6890961 + ], + [ + 36277244, + -6891985 + ], + [ + 36277242, + -6893009 + ], + [ + 36277240, + -6894033 + ], + [ + 36277241, + -6895057 + ], + [ + 36277237, + -6896081 + ], + [ + 36277233, + -6897105 + ], + [ + 36277228, + -6898129 + ], + [ + 36277225, + -6899153 + ], + [ + 36277224, + -6900177 + ], + [ + 36277217, + -6901201 + ], + [ + 36277212, + -6902225 + ], + [ + 36277212, + -6903249 + ], + [ + 36277212, + -6904273 + ], + [ + 36277209, + -6905297 + ], + [ + 36277207, + -6906321 + ], + [ + 36277203, + -6907345 + ], + [ + 36277200, + -6908369 + ], + [ + 36277199, + -6909393 + ], + [ + 36277197, + -6910417 + ], + [ + 36277193, + -6911441 + ], + [ + 36277194, + -6912465 + ], + [ + 36277195, + -6913489 + ], + [ + 36277195, + -6914513 + ], + [ + 36277198, + -6915537 + ], + [ + 36277201, + -6916561 + ], + [ + 36277202, + -6917585 + ], + [ + 36277197, + -6918609 + ], + [ + 36277197, + -6919633 + ], + [ + 36277199, + -6920657 + ], + [ + 36277204, + -6921681 + ], + [ + 36277207, + -6922705 + ], + [ + 36277210, + -6923729 + ], + [ + 36277211, + -6924753 + ], + [ + 36277213, + -6925777 + ], + [ + 36277217, + -6926801 + ], + [ + 36277220, + -6927825 + ], + [ + 36277220, + -6928849 + ], + [ + 36277223, + -6929873 + ], + [ + 36277220, + -6930897 + ], + [ + 36277217, + -6931921 + ], + [ + 36277214, + -6932945 + ], + [ + 36277212, + -6933969 + ], + [ + 36277213, + -6934993 + ], + [ + 36277218, + -6936017 + ], + [ + 36277226, + -6937041 + ], + [ + 36277230, + -6938065 + ], + [ + 36277232, + -6939089 + ], + [ + 36277236, + -6940113 + ], + [ + 36277243, + -6941137 + ], + [ + 36277247, + -6942161 + ], + [ + 36277251, + -6943185 + ], + [ + 36277254, + -6944209 + ], + [ + 36277260, + -6945233 + ], + [ + 36277266, + -6946257 + ], + [ + 36277270, + -6947281 + ], + [ + 36277271, + -6948305 + ], + [ + 36277268, + -6949329 + ], + [ + 36277266, + -6950353 + ], + [ + 36277265, + -6951377 + ], + [ + 36277265, + -6952401 + ], + [ + 36277269, + -6953425 + ], + [ + 36277269, + -6954449 + ], + [ + 36277265, + -6955473 + ], + [ + 36277257, + -6956497 + ], + [ + 36277253, + -6957521 + ], + [ + 36277256, + -6958545 + ], + [ + 36277252, + -6959569 + ], + [ + 36277242, + -6960593 + ], + [ + 36277231, + -6961617 + ], + [ + 36277219, + -6962641 + ], + [ + 36277207, + -6963665 + ], + [ + 36277198, + -6964689 + ], + [ + 36277192, + -6965713 + ], + [ + 36277184, + -6966737 + ], + [ + 36277180, + -6967761 + ], + [ + 36277178, + -6968785 + ], + [ + 36277178, + -6969809 + ], + [ + 36277179, + -6970833 + ], + [ + 36277177, + -6971857 + ], + [ + 36277179, + -6972881 + ], + [ + 36277185, + -6973905 + ], + [ + 36277191, + -6974929 + ], + [ + 36277199, + -6975953 + ], + [ + 36277211, + -6976977 + ], + [ + 36277220, + -6978001 + ], + [ + 36277226, + -6979025 + ], + [ + 36277228, + -6980049 + ], + [ + 36277228, + -6981073 + ], + [ + 36277225, + -6982097 + ], + [ + 36277220, + -6983121 + ], + [ + 36277218, + -6984145 + ], + [ + 36277219, + -6985169 + ], + [ + 36277222, + -6986193 + ], + [ + 36277225, + -6987217 + ], + [ + 36277226, + -6988241 + ], + [ + 36277226, + -6989265 + ], + [ + 36277226, + -6990289 + ], + [ + 36277222, + -6991313 + ], + [ + 36277221, + -6992337 + ], + [ + 36277222, + -6993361 + ], + [ + 36277221, + -6994385 + ], + [ + 36277217, + -6995409 + ], + [ + 36277218, + -6996433 + ], + [ + 36277218, + -6997457 + ], + [ + 36277220, + -6998481 + ], + [ + 36277220, + -6999505 + ], + [ + 36277216, + -7000529 + ], + [ + 36277213, + -7001553 + ], + [ + 36277211, + -7002577 + ], + [ + 36277210, + -7003601 + ], + [ + 36277210, + -7004625 + ], + [ + 36277212, + -7005649 + ], + [ + 36277215, + -7006673 + ], + [ + 36277223, + -7007697 + ], + [ + 36277228, + -7008721 + ], + [ + 36277230, + -7009745 + ], + [ + 36277233, + -7010769 + ], + [ + 36277233, + -7011793 + ], + [ + 36277233, + -7012817 + ], + [ + 36277236, + -7013841 + ], + [ + 36277238, + -7014865 + ], + [ + 36277237, + -7015889 + ], + [ + 36277241, + -7016913 + ], + [ + 36277243, + -7017937 + ], + [ + 36277248, + -7018961 + ], + [ + 36277255, + -7019985 + ], + [ + 36277266, + -7021009 + ], + [ + 36277277, + -7022033 + ], + [ + 36277293, + -7023057 + ], + [ + 36277300, + -7024081 + ], + [ + 36277304, + -7025105 + ], + [ + 36277308, + -7026129 + ], + [ + 36277308, + -7027153 + ], + [ + 36277302, + -7028177 + ], + [ + 36277300, + -7029201 + ], + [ + 36277302, + -7030225 + ], + [ + 36277308, + -7031249 + ], + [ + 36277313, + -7032273 + ], + [ + 36277319, + -7033297 + ], + [ + 36277318, + -7034321 + ], + [ + 36277313, + -7035345 + ], + [ + 36277311, + -7036369 + ], + [ + 36277306, + -7037393 + ], + [ + 36277303, + -7038417 + ], + [ + 36277305, + -7039441 + ], + [ + 36277313, + -7040465 + ], + [ + 36277316, + -7041489 + ], + [ + 36277316, + -7042513 + ], + [ + 36277316, + -7043537 + ], + [ + 36277320, + -7044561 + ], + [ + 36277326, + -7045585 + ], + [ + 36277328, + -7046609 + ], + [ + 36277334, + -7047633 + ], + [ + 36277337, + -7048657 + ], + [ + 36277343, + -7049681 + ], + [ + 36277351, + -7050705 + ], + [ + 36277356, + -7051729 + ], + [ + 36277360, + -7052753 + ], + [ + 36277365, + -7053777 + ], + [ + 36277371, + -7054801 + ], + [ + 36277377, + -7055825 + ], + [ + 36277382, + -7056849 + ], + [ + 36277384, + -7057873 + ], + [ + 36277387, + -7058897 + ], + [ + 36277389, + -7059921 + ], + [ + 36277391, + -7060945 + ], + [ + 36277392, + -7061969 + ], + [ + 36277392, + -7062993 + ], + [ + 36277391, + -7064017 + ], + [ + 36277391, + -7065041 + ], + [ + 36277390, + -7066065 + ], + [ + 36277390, + -7067089 + ], + [ + 36277391, + -7068113 + ], + [ + 36277392, + -7069137 + ], + [ + 36277396, + -7070161 + ], + [ + 36277399, + -7071185 + ], + [ + 36277398, + -7072209 + ], + [ + 36277397, + -7073233 + ], + [ + 36277398, + -7074257 + ], + [ + 36277397, + -7075281 + ], + [ + 36277397, + -7076305 + ], + [ + 36277398, + -7077329 + ], + [ + 36277402, + -7078353 + ], + [ + 36277404, + -7079377 + ], + [ + 36277406, + -7080401 + ], + [ + 36277410, + -7081425 + ], + [ + 36277418, + -7082449 + ], + [ + 36277424, + -7083473 + ], + [ + 36277428, + -7084497 + ], + [ + 36277431, + -7085521 + ], + [ + 36277433, + -7086545 + ], + [ + 36277434, + -7087569 + ], + [ + 36277436, + -7088593 + ], + [ + 36277434, + -7089617 + ], + [ + 36277433, + -7090641 + ], + [ + 36277431, + -7091665 + ], + [ + 36277430, + -7092689 + ], + [ + 36277429, + -7093713 + ], + [ + 36277426, + -7094737 + ], + [ + 36277420, + -7095761 + ], + [ + 36277416, + -7096785 + ], + [ + 36277412, + -7097809 + ], + [ + 36277409, + -7098833 + ], + [ + 36277407, + -7099857 + ], + [ + 36277406, + -7100881 + ], + [ + 36277404, + -7101905 + ], + [ + 36277401, + -7102929 + ], + [ + 36277399, + -7103953 + ], + [ + 36277398, + -7104977 + ], + [ + 36277396, + -7106001 + ], + [ + 36277393, + -7107025 + ], + [ + 36277392, + -7108049 + ], + [ + 36277391, + -7109073 + ], + [ + 36277389, + -7110097 + ], + [ + 36277388, + -7111121 + ], + [ + 36277387, + -7112145 + ], + [ + 36277388, + -7113169 + ], + [ + 36277389, + -7114193 + ], + [ + 36277390, + -7115217 + ], + [ + 36277391, + -7116241 + ], + [ + 36277392, + -7117265 + ], + [ + 36277392, + -7118289 + ], + [ + 36277392, + -7119313 + ], + [ + 36277391, + -7120337 + ], + [ + 36277389, + -7121361 + ], + [ + 36277387, + -7122385 + ], + [ + 36277384, + -7123409 + ], + [ + 36277383, + -7124433 + ], + [ + 36277383, + -7125457 + ], + [ + 36277382, + -7126481 + ], + [ + 36277381, + -7127505 + ], + [ + 36277380, + -7128529 + ], + [ + 36277378, + -7129553 + ], + [ + 36277377, + -7130577 + ], + [ + 36277375, + -7131601 + ], + [ + 36277373, + -7132625 + ], + [ + 36277372, + -7133649 + ], + [ + 36277371, + -7134673 + ], + [ + 36277369, + -7135697 + ], + [ + 36277368, + -7136721 + ], + [ + 36277366, + -7137745 + ], + [ + 36277365, + -7138769 + ], + [ + 36277363, + -7139793 + ], + [ + 36277361, + -7140817 + ], + [ + 36277359, + -7141841 + ], + [ + 36277356, + -7142865 + ], + [ + 36277354, + -7143889 + ], + [ + 36277352, + -7144913 + ], + [ + 36277350, + -7145937 + ], + [ + 36277347, + -7146961 + ], + [ + 36277345, + -7147985 + ] + ] + } +] diff --git a/server/tests/golden/window_derivation_golden.json b/server/tests/golden/window_derivation_golden.json index 1e6afbf70..8525ba0da 100644 --- a/server/tests/golden/window_derivation_golden.json +++ b/server/tests/golden/window_derivation_golden.json @@ -254,6 +254,57 @@ "moisture_q": 43, "vegetation": 1 }, + { + "label": "river_course", + "rung": "district", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 2, + "elev_q": 8, + "ocean_fraction_q": 0, + "temperature_dc": 14, + "moisture_q": 49, + "vegetation": 3 + }, + { + "label": "river_course", + "rung": "quarter", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 3, + "elev_q": 4, + "ocean_fraction_q": 0, + "temperature_dc": 34, + "moisture_q": 50, + "vegetation": 3 + }, + { + "label": "river_course", + "rung": "region", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 0, + "elev_q": 11, + "ocean_fraction_q": 0, + "temperature_dc": -2, + "moisture_q": 49, + "vegetation": 3 + }, { "label": "airless_dry/coastal_a", "rung": "district", @@ -509,6 +560,57 @@ "moisture_q": 0, "vegetation": 0 }, + { + "label": "airless_dry/river_course", + "rung": "district", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 0, + "precipitation": 0, + "slope_q": 3, + "elev_q": 5, + "ocean_fraction_q": 0, + "temperature_dc": -2147483648, + "moisture_q": 0, + "vegetation": 0 + }, + { + "label": "airless_dry/river_course", + "rung": "quarter", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 0, + "precipitation": 0, + "slope_q": 3, + "elev_q": 6, + "ocean_fraction_q": 0, + "temperature_dc": -2147483648, + "moisture_q": 0, + "vegetation": 0 + }, + { + "label": "airless_dry/river_course", + "rung": "region", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 0, + "precipitation": 0, + "slope_q": 0, + "elev_q": 5, + "ocean_fraction_q": 0, + "temperature_dc": -2147483648, + "moisture_q": 0, + "vegetation": 0 + }, { "label": "volcanic_coast/coastal_a", "rung": "district", @@ -763,5 +865,56 @@ "temperature_dc": 555, "moisture_q": 30, "vegetation": 2 + }, + { + "label": "volcanic_coast/river_course", + "rung": "district", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 4096, + "morphology": 15, + "tectonic": 2, + "glaciation": 0, + "precipitation": 2, + "slope_q": 4, + "elev_q": 14, + "ocean_fraction_q": 0, + "temperature_dc": 500, + "moisture_q": 47, + "vegetation": 3 + }, + { + "label": "volcanic_coast/river_course", + "rung": "quarter", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 1024, + "morphology": 15, + "tectonic": 2, + "glaciation": 0, + "precipitation": 2, + "slope_q": 6, + "elev_q": 5, + "ocean_fraction_q": 0, + "temperature_dc": 547, + "moisture_q": 49, + "vegetation": 3 + }, + { + "label": "volcanic_coast/river_course", + "rung": "region", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 0, + "morphology": 15, + "tectonic": 2, + "glaciation": 0, + "precipitation": 2, + "slope_q": 0, + "elev_q": 18, + "ocean_fraction_q": 0, + "temperature_dc": 480, + "moisture_q": 47, + "vegetation": 3 } ] diff --git a/server/tests/window_derivation_golden.rs b/server/tests/window_derivation_golden.rs index e9ec5c073..2c4c76774 100644 --- a/server/tests/window_derivation_golden.rs +++ b/server/tests/window_derivation_golden.rs @@ -39,6 +39,7 @@ use settled_reach_server::atlas::district_profile::{ 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::river_course; use settled_reach_server::atlas::scale; use settled_reach_server::seed::{SeedChain, SeedDomain}; @@ -163,6 +164,20 @@ fn sweep_positions() -> Vec<(&'static str, f64, f64)> { ("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), + // T-1170 A2 Discipline item 4: empirically verified (probe run against + // this fixture, `sample_hm()`/`sample_params()`) that `sample_hm()` + // produces a real river-cell chain around working-grid pixel + // (row=10, col=116) — NONE of the original five sweep positions + // (pixel cols ~1.6-6.7) land anywhere near it. This position converts + // that pixel to world metres (same `world_m_to_pixel` inverse the + // production mapping uses) so the golden sweep also exercises + // `derive_at_metres` genuinely close to invented river geometry — + // closing the "believability expected unchanged, verify, don't + // assume" discipline item for the district-profile-only fields this + // golden already pins (courses themselves are pinned separately + // below, `river_course_golden_regression`, since this sweep's + // `derive_at_metres` calls never touch `RiverCourse` at all). + ("river_course", 36_277_344.8, -6_830_545.5), ] } @@ -182,7 +197,7 @@ fn derive_golden_sample( 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) + derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wl_m, &[]) }; GoldenSample { label: label.to_string(), @@ -417,3 +432,138 @@ fn golden_cutoffs_match_the_scale_ladder() { assert_eq!(2 * scale::DISTRICT_M, DISTRICT_MIN_WL_M as i32); assert_eq!(2 * scale::QUARTER_M, QUARTER_MIN_WL_M as i32); } + +// --------------------------------------------------------------------------- +// River course golden (T-1170 A2, Discipline item 4) +// --------------------------------------------------------------------------- +// +// Empirically verified (probe run against `sample_hm()`/`sample_params()`): +// this fixture body produces a real river-cell chain around working-grid +// pixel (row≈10, col=116) — the "river_course" sweep position above converts +// that pixel to world metres. This section pins the ACTUAL invented course +// geometry for an edge from that chain, at both District and Quarter station +// spacing, so courses themselves — not just the district-profile fields the +// main golden above covers — are regression-pinned. + +const RIVER_COURSE_GOLDEN_FILE: &str = "tests/golden/river_course_golden.json"; + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone)] +struct GoldenCourseSample { + rung: String, + edge_id: u32, + class: u8, + terminus: String, + /// Points rounded to the nearest metre (D-010 integer boundary at the + /// golden-pinning layer — the production wire path itself rounds to + /// `i32` metres, `layer_proxy::crop_course_to_window`). + points: Vec<(i64, i64)>, +} + +fn river_course_golden_samples() -> Vec { + let hm = sample_hm(); + let ta = sample_ta(&hm); + let params = sample_params(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); + + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let edges = river_course::build_edges(&dr.river_network); + // Pick the interior edge whose upstream cell is closest to (row=10, + // col=116) — deterministic (BTreeMap-free linear scan, fixed tie-break + // by edge_id) rather than hardcoding an index that could silently shift + // if `build_edges`' ordering ever changes. + let target = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .min_by_key(|e| { + let dr = e.upstream.0 as i64 - 10; + let dc = e.upstream.1 as i64 - 116; + (dr * dr + dc * dc, e.edge_id) + }) + .expect("sample_hm() fixture must have at least one interior river edge near (10, 116)"); + + let mut out = Vec::new(); + for (rung, spacing_m, min_wl_m) in [ + ("district", DISTRICT_MIN_WL_M, DISTRICT_MIN_WL_M), + ("quarter", QUARTER_MIN_WL_M, QUARTER_MIN_WL_M), + ] { + let course = river_course::invent_course(seed, target, &ta, ¶ms, spacing_m, min_wl_m); + out.push(GoldenCourseSample { + rung: rung.to_string(), + edge_id: course.edge_id, + class: course.class, + terminus: format!("{:?}", course.terminus), + points: course + .points + .iter() + .map(|p| (p.0.round() as i64, p.1.round() as i64)) + .collect(), + }); + } + out +} + +#[test] +fn river_course_golden_regression() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let golden_path = manifest.join(RIVER_COURSE_GOLDEN_FILE); + + let run1 = river_course_golden_samples(); + let run2 = river_course_golden_samples(); + assert_eq!( + run1, run2, + "double-derivation mismatch — course invention determinism is broken (D-010/D-227)" + ); + + 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!( + "River-course golden mismatch — course invention changed.\n\ + Update: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n\ + Golden: {}\nActual: {}", + golden_json.trim(), + actual_json.trim() + ); + } +} + +/// The course golden's target edge must genuinely differ in point geometry +/// between District and Quarter station spacing (more, finer-spaced stations +/// at Quarter — Ruling 3b's cross-rung invariant) — otherwise the golden +/// would be pinning two identical rungs and the test would give false +/// confidence. +#[test] +fn river_course_rungs_have_different_station_counts() { + let samples = river_course_golden_samples(); + let district = samples.iter().find(|s| s.rung == "district").unwrap(); + let quarter = samples.iter().find(|s| s.rung == "quarter").unwrap(); + assert!( + quarter.points.len() > district.points.len(), + "Quarter's finer station spacing must produce MORE points than District \ + (district={}, quarter={})", + district.points.len(), + quarter.points.len() + ); +} diff --git a/server/tests/zoom_ladder_bench.rs b/server/tests/zoom_ladder_bench.rs index 45fae8b76..c9415bca9 100644 --- a/server/tests/zoom_ladder_bench.rs +++ b/server/tests/zoom_ladder_bench.rs @@ -23,7 +23,8 @@ 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::layer_proxy::{ - build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS, + build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N, + DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS, }; use settled_reach_server::atlas::scale; use settled_reach_server::seed::{SeedChain, SeedDomain}; @@ -54,6 +55,12 @@ fn bench_ta(hm: &BodyHeightmap) -> TerrainAnalysis { TerrainAnalysis::analyze(hm, &dr) } +fn bench_river_network( + hm: &BodyHeightmap, +) -> settled_reach_server::atlas::body_world_state::RiverNetwork { + drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level).river_network +} + fn bench_params() -> BodyParams { BodyParams { hydrosphere: Some("ocean".into()), @@ -83,8 +90,17 @@ 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); } @@ -273,6 +289,7 @@ fn bench_derive_orbital_at_metres_region_spacing() { fn bench_served_region_window_tile_at_wire_cap() { let hm = bench_hm(); let ta = bench_ta(&hm); + let rn = bench_river_network(&hm); let params = bench_params(); let climate = ClimateConstants::default(); let seed = SeedChain::root(99).derive(SeedDomain::Body, 1); @@ -293,6 +310,7 @@ fn bench_served_region_window_tile_at_wire_cap() { "bench", ¶ms, &ta, + &rn, (0, 0), n, &climate, @@ -309,6 +327,7 @@ fn bench_served_region_window_tile_at_wire_cap() { "bench", ¶ms, &ta, + &rn, (0, 0), n, &climate, @@ -338,3 +357,172 @@ fn bench_served_region_window_tile_at_wire_cap() { " compare: shipped district n=64 cap measures ~5 ms/call (design doc §7, MEASURED)\n" ); } + +/// **T-1170 Discipline item 2 (mandatory): course-cost bench.** Window +/// derive with courses on vs. off, at District granularity, real cap `n=64` +/// — the shape Tyre's cost probe measured (+0.09-0.21 ms against a ~5 ms +/// baseline, under 5%). "Off" uses an empty `RiverNetwork` (zero edges to +/// invent, exactly the pre-T-1170 cost shape); "on" uses a real body with +/// genuine river geometry (GJ1c) so the course inventor's Stage A/B pipeline +/// actually runs for the edges that cull into the window, not a synthetic +/// gradient body that might have zero river cells at all. +#[test] +#[ignore] +fn bench_course_cost_on_vs_off() { + use settled_reach_server::atlas::body_world_state::RiverNetwork; + use settled_reach_server::atlas::drainage; + use settled_reach_server::atlas::heightmap::load_heightmap_png; + + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(512, 256); // GRID_W x GRID_H, the real production working grid + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = TerrainAnalysis::analyze(&small, &dr); + let rn_on = &dr.river_network; + let rn_off = RiverNetwork::default(); + assert!( + !rn_on.river_cells.is_empty(), + "GJ1c at production working resolution must have river cells for this bench to be meaningful" + ); + + let params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let climate = ClimateConstants::default(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); + let n = DISTRICT_WINDOW_MAX_N; // the real n=64 shipped cap + + // Centre the window on a real river cell — a window at the world origin + // (unrelated to where GJ1c's rivers actually are) would cull EVERY edge + // out and measure nothing but baseline noise. Convert a real river cell + // to world metres (the SAME pixel_to_world_m formula + // `district_profile.rs` uses internally — inlined here since that + // function is `pub(crate)`, not reachable from an integration test), + // then to the DistrictPos the window centres on. + let river_cell = dr.river_network.river_cells[dr.river_network.river_cells.len() / 2]; + let r_km = params.body_radius_km.unwrap(); + let world_pos = ( + river_cell.1 as f64 / ta.w as f64 * (std::f64::consts::TAU * r_km * 1000.0), + (river_cell.0 as f64 / (ta.h - 1) as f64 - 0.5) * (std::f64::consts::PI * r_km * 1000.0), + ); + let center: (i32, i32) = ( + (world_pos.0 / scale::DISTRICT_M as f64).floor() as i32, + (world_pos.1 / scale::DISTRICT_M as f64).floor() as i32, + ); + + println!("\n=== T-1170 course-cost bench (District, n={n}, real GJ1c river geometry) ==="); + println!(" window centred at district {center:?} (river cell {river_cell:?})"); + + // Warm-up (allocator/cache warm, not counted). + let _ = build_district_window_layer( + seed, "GJ1c", ¶ms, &ta, &rn_off, center, n, &climate, WindowGranularity::District, 0, + ); + let _ = build_district_window_layer( + seed, "GJ1c", ¶ms, &ta, rn_on, center, n, &climate, WindowGranularity::District, 0, + ); + + let iterations = 1000; // higher count than the other benches — window cost here is ~1 ms, noisy at low n + + let t_off = Instant::now(); + for _ in 0..iterations { + let layer = build_district_window_layer( + seed, "GJ1c", ¶ms, &ta, &rn_off, center, n, &climate, WindowGranularity::District, 0, + ); + std::hint::black_box(layer.morphology.len()); + } + let elapsed_off = t_off.elapsed(); + let ms_off = elapsed_off.as_secs_f64() * 1000.0 / iterations as f64; + + let t_on = Instant::now(); + let mut courses_seen = 0usize; + for _ in 0..iterations { + let layer = build_district_window_layer( + seed, "GJ1c", ¶ms, &ta, rn_on, center, n, &climate, WindowGranularity::District, 0, + ); + courses_seen = layer.courses.len(); + std::hint::black_box(layer.morphology.len()); + } + let elapsed_on = t_on.elapsed(); + let ms_on = elapsed_on.as_secs_f64() * 1000.0 / iterations as f64; + + assert!( + courses_seen > 0, + "bench measured nothing meaningful — the window at {center:?} culled every edge out; \ + re-pick a district position genuinely near GJ1c's river geometry" + ); + + let delta_pct = ((ms_on - ms_off) / ms_off) * 100.0; + + println!( + " courses OFF (empty RiverNetwork): {:.3} ms/call ({iterations} calls, {:.2} ms total)", + ms_off, + elapsed_off.as_secs_f64() * 1000.0 + ); + println!( + " courses ON (real GJ1c network): {:.3} ms/call ({iterations} calls, {:.2} ms total, \ + {courses_seen} courses in the n={n} window at {center:?})", + ms_on, + elapsed_on.as_secs_f64() * 1000.0 + ); + println!(" delta: {delta_pct:+.1}% (Discipline item 2 budget: < ~5%)\n"); +} + +#[test] +#[ignore] +fn bench_near_perennial_water_percell_isolated() { + use settled_reach_server::atlas::drainage; + use settled_reach_server::atlas::heightmap::load_heightmap_png; + use settled_reach_server::atlas::river_course; + + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode heightmap"); + let small = heightmap.downsample(512, 256); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = TerrainAnalysis::analyze(&small, &dr); + let params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); + + let edges = river_course::build_edges(&dr.river_network); + let edge = &edges[edges.len() / 2]; + let course = river_course::invent_course(seed, edge, &ta, ¶ms, 2048.0, 0.0); + let courses = vec![course]; + + let n = 4096u32; + let t0 = Instant::now(); + let mut count = 0; + for i in 0..n { + let pos = (i as f64 * 100.0, i as f64 * 37.0); + if river_course::near_perennial_water(pos, &courses) { + count += 1; + } + } + let elapsed = t0.elapsed(); + eprintln!( + "near_perennial_water: {:.3} ns/call ({n} calls, {} hits)", + elapsed.as_secs_f64() * 1e9 / n as f64, + count + ); + + // invent_course cost, isolated. + let t1 = Instant::now(); + for _ in 0..100 { + let c = river_course::invent_course(seed, edge, &ta, ¶ms, 2048.0, 0.0); + std::hint::black_box(c.points.len()); + } + eprintln!( + "invent_course: {:.3} us/call", + t1.elapsed().as_secs_f64() * 1e6 / 100.0 + ); +}