feat(simulation): T-1170 A2/A3 + T-1168 A5 — course inventor, coast termination, riparian signal
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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, 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, 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<f64>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, 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<f64>
|
||||
/// - `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<RegionPos, RegionProfile>,
|
||||
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<DistrictPos, BasinDirection>>,
|
||||
river_network: Option<&RiverNetwork>,
|
||||
) -> BTreeMap<DistrictPos, DistrictProfile> {
|
||||
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");
|
||||
|
||||
@@ -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<f32> = (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
|
||||
|
||||
@@ -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<String, (TerrainAnalysis, u64)>,
|
||||
entries: std::collections::BTreeMap<String, (Layer1Output, TerrainAnalysis, u64)>,
|
||||
/// 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
|
||||
|
||||
@@ -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<u8>,
|
||||
/// `GlaciationGrade` discriminant, 0-4 (T-1127).
|
||||
pub glaciation: Vec<u8>,
|
||||
/// 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<RiverCourse>,
|
||||
}
|
||||
|
||||
/// 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<InventedCourse> {
|
||||
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<RiverCourse> {
|
||||
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<RiverCourse> {
|
||||
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<usize> = None;
|
||||
let mut last_in: Option<usize> = 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<WindowCell> = (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<InventedCourse>`) 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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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`].
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1933,6 +1933,7 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile {
|
||||
"test_body",
|
||||
&BTreeMap::new(),
|
||||
settled_reach_server::atlas::scale::BasinDirection::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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<GoldenCourseSample> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user