The batch's real bug: extract_river_network computed the seaward neighbor (nr,nc) to decide the MOUTH sentinel then discarded it, and build_edges placeholder-pointed mouth edges at themselves — zero chord, invent_course's degenerate 1-point return, resolve_mouth_ terminus dead code on real data, ALL real mouths resolving None, and (because Ruling 3g retired the D/Q clip on the promise of real termini) mouths vanishing at District/Quarter. The second instance of the threw-away-the-answer anti-pattern Ruling 2b fixed for interior pointers. Fix: river_seaward: Vec<(u16,u16)> on RiverNetwork (additive, serde-default, parallel array; meaningful only at MOUTH entries), captured in the same extraction pass; build_edges gives Mouth edges the real one-D8-step chord. Permanent acceptance: all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none — 3/3, revert- verified failing at the golden's first mouth (38,47). Mouth golden coverage added (river_course_golden gains district_mouth/quarter_mouth samples + non-degeneracy test; the previously Interior-only filter gap closed). Tyre 1: the land-probe's dead dx/len*len arithmetic replaced — station_spacing_m threaded through the crop path, probe steps one real cell spacing, comment reconciled. Tyre 2: boxing comment reattributed to variant-size balancing (Layer1Output retention lives on TerrainAnalysisCache, not BodyWorldState). Hoshe #4: cascade_golden's doc now states the attractor cascade accurately (count-parity, not byte-identity — water_dist seeds from mouths). Full cargo test green; goldens re-pinned deliberately; bench +3.0%. Tickets: T-1170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1247 lines
54 KiB
Rust
1247 lines
54 KiB
Rust
//! River course invention (T-1170, D-227 amendment 2026-07-23) — the linear
|
||
//! sibling of the coastline crinkle ([`crate::atlas::coast_invention`]).
|
||
//!
|
||
//! The D8 river skeleton ([`crate::atlas::body_world_state::RiverNetwork`])
|
||
//! lives at heightmap working-grid resolution (~76.6 km/river-cell on a
|
||
//! typical body) — far sparser than a District (2,048 m) or Quarter (512 m)
|
||
//! window. This module deterministically **invents** the course geometry
|
||
//! between two adjacent river cells ("edges" of the D8 graph), so a window
|
||
//! that contains zero or one river cell can still draw a continuous,
|
||
//! meandering course crossing it.
|
||
//!
|
||
//! ## Carrier (Tyre's binding ruling, Ruling 1)
|
||
//!
|
||
//! Course geometry is **invention, not skeleton** — it rides the *windowed*
|
||
//! payload ([`crate::atlas::layer_proxy::DistrictWindowLayer::courses`]),
|
||
//! invented server-side per window at the window's rung, NOT precomputed
|
||
//! whole-body. This module is rung-*aware* (Stage B truncates octaves against
|
||
//! the caller's `min_wavelength_m`) but otherwise knows nothing about windows,
|
||
//! wire shapes, or caching — [`crate::atlas::layer_proxy`] owns bbox culling,
|
||
//! cropping, and wire packing.
|
||
//!
|
||
//! ## Algorithm (Ruling 3)
|
||
//!
|
||
//! Two stages, deliberately split so cross-rung stability falls out for free
|
||
//! (Ruling 3b):
|
||
//!
|
||
//! - **Stage A ([`stage_a_control_path`]) — coarse valley-seeking, RUNG-
|
||
//! INDEPENDENT.** Control stations at `chord/8` between the two anchor
|
||
//! points (upstream/downstream river-cell centres, in world metres — never
|
||
//! moved). At each interior station, `k=5` perpendicular candidate offsets
|
||
//! are scored by a **bilinear `TerrainAnalysis::elev_pct` read** (never a
|
||
//! full `derive_at_metres` per candidate — Ruling 3b, binding) plus a
|
||
//! continuity penalty against the previously chosen offset. Minimum wins.
|
||
//! Identical at every rung — the coarse course never moves as the caller
|
||
//! refines.
|
||
//! - **Stage B ([`stage_b_fine_warp`]) — fine perpendicular warp, RUNG-
|
||
//! INDEXED.** Salted multi-octave value noise displaces intermediate
|
||
//! stations perpendicular to the local Stage-A tangent, keyed on GLOBAL
|
||
//! arc-length (window-independence invariant, Ruling 1e: stations are
|
||
//! NEVER re-parametrized per window, only cropped). Octave band from
|
||
//! `chord/2` down to the caller's `min_wavelength_m` hard-truncate (the
|
||
//! `warp_fbm` idiom from `coast_invention`, reused exactly). Amplitude
|
||
//! tapers to zero at both anchors (sine shape — the confluence-continuity
|
||
//! property, Ruling 3c) and is capped at `≤ 8% of chord` AND `≤ half a
|
||
//! cell` (binding hard caps), scaled down by local slope and up by river
|
||
//! class.
|
||
//!
|
||
//! ## Determinism & isolation (D-227/D-010, Ruling 3a)
|
||
//!
|
||
//! Pure function of `(seed, body, edge_id, rung)` — never the window rect.
|
||
//! Seeded via `SeedChain::derive(SeedDomain::RiverCourse, edge_id)` with a
|
||
//! distinct [`RIVER_COURSE_WARP_SALT`] on the noise stream, so the course
|
||
//! warp can never correlate with the coast warp, terrain scatter, or
|
||
//! vegetation massif fields sampled at the same world position.
|
||
|
||
use crate::atlas::body_world_state::{
|
||
RiverNetwork, RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH, RIVER_DOWNSTREAM_TERMINAL,
|
||
};
|
||
use crate::atlas::detail_scatter::value_noise;
|
||
use crate::atlas::district_profile::{bilinear, world_m_to_pixel, BodyParams};
|
||
use crate::atlas::drainage::d8_offset;
|
||
use crate::atlas::features::TerrainAnalysis;
|
||
use crate::seed::{splitmix64, SeedChain, SeedDomain};
|
||
|
||
/// Distinct hash-path salt for the course's Stage-B perpendicular warp stream
|
||
/// (the `COAST_WARP_SALT` pattern verbatim, Ruling 3a) — isolates the course
|
||
/// warp from the coast warp / terrain scatter / vegetation massif fields even
|
||
/// though they all key off the same `(seed, world position)` inputs.
|
||
const RIVER_COURSE_WARP_SALT: u64 = 0x91FE_5C0A_57E1_5EED;
|
||
|
||
/// Number of Stage-A control stations between the two anchors, INCLUSIVE of
|
||
/// both anchors (`chord / (STAGE_A_STATIONS - 1)` spacing — "stations at
|
||
/// chord/8", Ruling 3b, means 8 segments = 9 stations).
|
||
const STAGE_A_STATIONS: usize = 9;
|
||
|
||
/// Number of perpendicular candidate offsets Stage A evaluates per interior
|
||
/// control station (Ruling 3b: "k=5 perpendicular candidate offsets").
|
||
const STAGE_A_CANDIDATES: usize = 5;
|
||
|
||
/// Continuity penalty weight against the previous station's chosen offset
|
||
/// (Ruling 3b: "a small continuity penalty ... to prevent zigzag"). Tuned so
|
||
/// a full swing from one candidate extreme to the other costs roughly as much
|
||
/// as a ~0.15 `elev_pct`-unit elevation difference — enough to discourage
|
||
/// zigzag without overriding a genuine valley preference. **Tunable,
|
||
/// documented default.**
|
||
const STAGE_A_CONTINUITY_WEIGHT: f64 = 0.4;
|
||
|
||
/// Stage-A candidate perpendicular offset envelope as a fraction of chord —
|
||
/// the search radius each control station explores, independent of the final
|
||
/// Stage-B amplitude cap (Ruling 3c governs the latter). **Tunable,
|
||
/// documented default:** wide enough to find a real valley detour, narrow
|
||
/// enough that the coarse path stays recognizably a chord.
|
||
const STAGE_A_SEARCH_FRACTION_OF_CHORD: f64 = 0.06;
|
||
|
||
/// Stage-B warp octave wavelengths are generated dynamically per edge (the
|
||
/// band runs from `chord/2` down to `min_wavelength_m`), unlike the coast
|
||
/// warp's fixed array — a river edge's chord length varies by orders of
|
||
/// magnitude (headwater trickle vs. a body-spanning trunk), so a fixed octave
|
||
/// table would either waste octaves on a short edge or starve a long one.
|
||
/// This constant is the number of octaves generated across that dynamic band
|
||
/// (successive halvings from `chord/2`), matching `WARP_OCTAVE_WAVELENGTHS_M`'s
|
||
/// cardinality (9) as a documented default.
|
||
const STAGE_B_OCTAVE_COUNT: usize = 9;
|
||
|
||
/// Peak Stage-B amplitude as a fraction of chord (Ruling 3c, binding: "Peak
|
||
/// amplitude ≤ ~8% of chord"). `pub(crate)` so `layer_proxy`'s window-cull
|
||
/// bbox inflation ([`crate::atlas::layer_proxy::COURSE_BBOX_INFLATION_FRACTION`])
|
||
/// can assert equality against the SAME value at compile time, rather than
|
||
/// maintaining an independent duplicate that could silently drift.
|
||
pub(crate) const STAGE_B_PEAK_FRACTION_OF_CHORD: f64 = 0.08;
|
||
|
||
/// Slope-scaling floor for Stage-B amplitude — at maximum local slope
|
||
/// (`slope_deg` saturating its 0–45° proxy range), amplitude is scaled down
|
||
/// to this fraction of its unslowed value (Ruling 3c: "slope-scaled down").
|
||
/// **Tunable, documented default.**
|
||
const STAGE_B_SLOPE_MIN_SCALE: f64 = 0.35;
|
||
|
||
/// River-class amplitude multiplier (Ruling 3c: "class-scaled up" — "trunks
|
||
/// meander wider"). Indexed by `river_class` (0=stream, 1=tributary,
|
||
/// 2=trunk). **Tunable, documented default.**
|
||
const STAGE_B_CLASS_SCALE: [f64; 3] = [0.7, 1.0, 1.35];
|
||
|
||
/// A river cell position in the working heightmap grid — `(row, col)`,
|
||
/// matching [`RiverNetwork::river_cells`]'s own convention.
|
||
pub type RiverCell = (u16, u16);
|
||
|
||
/// One D8 river edge: an upstream cell and its downstream neighbor, both in
|
||
/// working-grid pixel coordinates, plus the edge's identity/classification.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct RiverEdge {
|
||
/// The packed upstream-cell id (Ruling 2d): `(row as u32) << 16 | col as
|
||
/// u32`. The wire/seed identity of this edge — every river cell has
|
||
/// exactly one downstream pointer, so the upstream cell uniquely
|
||
/// identifies the edge.
|
||
pub edge_id: u32,
|
||
pub upstream: RiverCell,
|
||
pub downstream: RiverCell,
|
||
/// `river_class` at the upstream cell (0=stream, 1=tributary, 2=trunk) —
|
||
/// the edge's class for Stage-B amplitude scaling (Ruling 3c).
|
||
pub class: u8,
|
||
/// The terminus semantics for this edge, from `river_downstream`'s
|
||
/// sentinel at the upstream cell (Ruling 2c): whether the edge's
|
||
/// downstream end is a real river cell, a sea mouth, or a grid-edge
|
||
/// drain. `Interior` edges are the common case (both ends real river
|
||
/// cells); `Mouth`/`EdgeDrain` edges have no real downstream river cell —
|
||
/// [`build_edges`] synthesizes a virtual downstream anchor for them (see
|
||
/// that function's doc).
|
||
pub terminus: EdgeTerminusKind,
|
||
}
|
||
|
||
/// Classification of a [`RiverEdge`]'s downstream end, from the upstream
|
||
/// cell's `river_downstream` sentinel (Ruling 2c/3e/3f).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum EdgeTerminusKind {
|
||
/// Downstream end is another real river cell — the common interior case.
|
||
Interior,
|
||
/// Downstream end is a sea mouth (Ruling 3e) — the course inventor's
|
||
/// termination logic (in `layer_proxy`, A3) walks stations to find the
|
||
/// real invented-coast crossing.
|
||
Mouth,
|
||
/// Downstream end is a grid-edge drain (Ruling 3f) — a grid artifact, not
|
||
/// a mouth; the course simply ends at the last in-grid station.
|
||
EdgeDrain,
|
||
}
|
||
|
||
/// Pack a `(row, col)` river cell into its [`RiverEdge::edge_id`] (Ruling 2d).
|
||
pub fn pack_cell_id(cell: RiverCell) -> u32 {
|
||
(cell.0 as u32) << 16 | cell.1 as u32
|
||
}
|
||
|
||
/// Build every [`RiverEdge`] in a [`RiverNetwork`] (Ruling 2d: "Each edge is
|
||
/// uniquely identified by its upstream cell"). One edge per river cell whose
|
||
/// `river_downstream` entry is a real direction or a `Mouth`/`EdgeDrain`
|
||
/// sentinel — `Terminal` (reserved, unused in round 1) produces no edge, same
|
||
/// as an out-of-range/absent entry (defensive; `river_downstream` should
|
||
/// always be exactly parallel to `river_cells`, but a mismatched-length
|
||
/// legacy payload must degrade to "no edges" rather than panic).
|
||
///
|
||
/// **Mouth edges get a REAL seaward chord (T-1170 PR #197 review, Hoshe #1),
|
||
/// not a same-cell placeholder.** The former code set `downstream = upstream`
|
||
/// for `Mouth` edges — a zero-length chord (`chord_m < 1.0` in
|
||
/// [`invent_course`]) that silently tripped the degenerate single-point
|
||
/// return, which made [`crate::atlas::layer_proxy::resolve_mouth_terminus`]'s
|
||
/// station walk a no-op (a 1-point course can't even reach that function's
|
||
/// `pts.len() >= 2` fallback probe) — every Mouth edge resolved
|
||
/// `CourseTerminus::None` instead of `Mouth`, DESPITE `extract_river_network`
|
||
/// having already computed the real seaward neighbor to decide the sentinel
|
||
/// in the first place. `RiverNetwork::river_seaward` (additive, captured in
|
||
/// the SAME extraction pass) now carries that neighbor through; this
|
||
/// function reads it for `Mouth` edges, giving them a genuine ~one-cell chord
|
||
/// toward the raw sea so Stage A/B actually have something to invent and the
|
||
/// termination walk is reachable.
|
||
///
|
||
/// `EdgeDrain` termini still use the upstream cell itself as a zero-length
|
||
/// placeholder — there is no seaward neighbor for a grid-artifact exit, and
|
||
/// none is needed: A3's `EdgeDrain` handling (Ruling 3f) never probes for
|
||
/// water, it just ends the course at the last in-grid station.
|
||
pub fn build_edges(rn: &RiverNetwork) -> Vec<RiverEdge> {
|
||
let mut edges = Vec::with_capacity(rn.river_cells.len());
|
||
for (i, &upstream) in rn.river_cells.iter().enumerate() {
|
||
let Some(&sentinel) = rn.river_downstream.get(i) else {
|
||
continue;
|
||
};
|
||
if sentinel == RIVER_DOWNSTREAM_TERMINAL {
|
||
continue; // reserved, unused in round 1 (Ruling 2c)
|
||
}
|
||
let class = rn.river_class.get(i).copied().unwrap_or(0);
|
||
let (downstream, terminus) = if sentinel < 8 {
|
||
let (dr, dc) = d8_offset(sentinel);
|
||
let downstream = step_cell(upstream, dr, dc);
|
||
(downstream, EdgeTerminusKind::Interior)
|
||
} else if sentinel == RIVER_DOWNSTREAM_MOUTH {
|
||
// Real seaward neighbor (Hoshe #1) — falls back to the upstream
|
||
// cell (the old placeholder) ONLY on a legacy/pre-fix payload
|
||
// where `river_seaward` is absent or the specific entry is the
|
||
// unset `(0, 0)` fill AND that happens to differ from a genuine
|
||
// seaward cell at (0,0) (an acceptable, vanishingly rare
|
||
// degradation at the map's literal origin — never hit on any
|
||
// real body, since (0,0) is a pole/edge pixel, never sub-sea
|
||
// adjacent to an actual river mouth in practice).
|
||
let seaward = rn.river_seaward.get(i).copied().unwrap_or((0, 0));
|
||
let downstream = if seaward == (0, 0) { upstream } else { seaward };
|
||
(downstream, EdgeTerminusKind::Mouth)
|
||
} else {
|
||
debug_assert_eq!(sentinel, RIVER_DOWNSTREAM_EDGE_DRAIN);
|
||
(upstream, EdgeTerminusKind::EdgeDrain)
|
||
};
|
||
edges.push(RiverEdge {
|
||
edge_id: pack_cell_id(upstream),
|
||
upstream,
|
||
downstream,
|
||
class,
|
||
terminus,
|
||
});
|
||
}
|
||
edges
|
||
}
|
||
|
||
/// Step one D8 offset from `cell`, saturating at grid bounds is the caller's
|
||
/// job (this module works in world metres almost everywhere; the raw pixel
|
||
/// step is only used to identify the neighbor cell for `Interior` edges,
|
||
/// where the offset is by construction in-bounds — it came from the same D8
|
||
/// walk `extract_river_network` already validated).
|
||
fn step_cell(cell: RiverCell, dr: i32, dc: i32) -> RiverCell {
|
||
let r = (cell.0 as i32 + dr).max(0) as u16;
|
||
let c = (cell.1 as i32 + dc).max(0) as u16;
|
||
(r, c)
|
||
}
|
||
|
||
/// A single invented course point, in world metres.
|
||
pub type CoursePoint = (f64, f64);
|
||
|
||
/// The full invented polyline for one edge, before window cropping (Ruling
|
||
/// 1e/3h — [`crate::atlas::layer_proxy`] crops this to the requesting window
|
||
/// + one station beyond).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct InventedCourse {
|
||
pub edge_id: u32,
|
||
pub class: u8,
|
||
pub terminus: EdgeTerminusKind,
|
||
/// Dense points along the course, in world metres, from the upstream
|
||
/// anchor to the downstream anchor — Stage A control points refined by
|
||
/// Stage B's fine warp, resampled at the rung's own station spacing.
|
||
pub points: Vec<CoursePoint>,
|
||
/// Precomputed `(min_x, min_y, max_x, max_y)` bounding box over
|
||
/// `points`, inflated by [`riparian_band_m`] for this course's class —
|
||
/// perf-only (T-1170 Discipline item 2): [`near_perennial_water`] is
|
||
/// called once per window CELL (thousands of times per window), so
|
||
/// paying the O(points) min/max scan on every call (rather than once, at
|
||
/// invention time) was the actual cost-budget overrun this field fixes
|
||
/// (a naive per-call bbox scan still measured +12-36% against a real
|
||
/// GJ1c window). Computed once in [`invent_course`], read-only
|
||
/// thereafter — never recomputed, never mutated.
|
||
pub bbox: (f64, f64, f64, f64),
|
||
}
|
||
|
||
/// Invent the full course geometry for one river edge (Ruling 3a-3d).
|
||
///
|
||
/// `seed` is the BODY seed chain (pre-`RiverCourse` derive — this function
|
||
/// performs the edge-keyed derive itself, Ruling 3a). `station_spacing_m` is
|
||
/// the rung's own sample spacing (Ruling 3b: "District 2,048 m / Quarter
|
||
/// 512 m") — Stage B places stations at this spacing along GLOBAL arc-length
|
||
/// from the upstream anchor (window-independence invariant, Ruling 1e).
|
||
/// `min_wavelength_m` truncates Stage B's octave band (the rung's own
|
||
/// cutoff). `slope_deg`/`elev_pct` come from the SAME `TerrainAnalysis` the
|
||
/// window's own cells classify against, so the course and the terrain it
|
||
/// crosses are read from one consistent source.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn invent_course(
|
||
body_seed: SeedChain,
|
||
edge: &RiverEdge,
|
||
ta: &TerrainAnalysis,
|
||
body_params: &BodyParams,
|
||
station_spacing_m: f64,
|
||
min_wavelength_m: f64,
|
||
) -> InventedCourse {
|
||
let course_seed = body_seed.derive(SeedDomain::RiverCourse, edge.edge_id as u64);
|
||
|
||
let anchor_a = cell_world_m(edge.upstream, ta, body_params);
|
||
let anchor_b = cell_world_m(edge.downstream, ta, body_params);
|
||
let chord_m = dist(anchor_a, anchor_b);
|
||
|
||
// Degenerate edge (upstream == downstream, e.g. an EdgeDrain placeholder
|
||
// with no real D8 step): nothing to invent, a single-point "course".
|
||
if chord_m < 1.0 {
|
||
let points = vec![anchor_a];
|
||
let bbox = compute_bbox(&points, edge.class);
|
||
return InventedCourse {
|
||
edge_id: edge.edge_id,
|
||
class: edge.class,
|
||
terminus: edge.terminus,
|
||
points,
|
||
bbox,
|
||
};
|
||
}
|
||
|
||
let control = stage_a_control_path(course_seed, anchor_a, anchor_b, ta, body_params);
|
||
let points = stage_b_fine_warp(
|
||
course_seed,
|
||
&control,
|
||
chord_m,
|
||
edge.class,
|
||
ta,
|
||
body_params,
|
||
station_spacing_m,
|
||
min_wavelength_m,
|
||
);
|
||
|
||
let bbox = compute_bbox(&points, edge.class);
|
||
InventedCourse {
|
||
edge_id: edge.edge_id,
|
||
class: edge.class,
|
||
terminus: edge.terminus,
|
||
points,
|
||
bbox,
|
||
}
|
||
}
|
||
|
||
/// Compute [`InventedCourse::bbox`] — the band-inflated bounding box over
|
||
/// `points` for `class`'s governed riparian band ([`riparian_band_m`]).
|
||
/// Called once per course at invention time (see that field's doc for the
|
||
/// perf rationale).
|
||
fn compute_bbox(points: &[CoursePoint], class: u8) -> (f64, f64, f64, f64) {
|
||
let band_m = riparian_band_m(class);
|
||
let (mut x0, mut x1) = (f64::INFINITY, f64::NEG_INFINITY);
|
||
let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY);
|
||
for &p in points {
|
||
x0 = x0.min(p.0);
|
||
x1 = x1.max(p.0);
|
||
y0 = y0.min(p.1);
|
||
y1 = y1.max(p.1);
|
||
}
|
||
if !x0.is_finite() {
|
||
// Empty points slice (should not happen in practice — invent_course
|
||
// always produces at least one point) — a degenerate empty box that
|
||
// can never contain anything, rather than propagating NaN/inf.
|
||
return (0.0, 0.0, -1.0, -1.0);
|
||
}
|
||
(x0 - band_m, y0 - band_m, x1 + band_m, y1 + band_m)
|
||
}
|
||
|
||
/// World-metre centre of a working-grid river cell.
|
||
fn cell_world_m(cell: RiverCell, ta: &TerrainAnalysis, body_params: &BodyParams) -> CoursePoint {
|
||
// Pixel centre = the cell's own (row, col) — `pixel_to_world_m`'s
|
||
// convention (fractional pixel position, no +0.5 offset needed since
|
||
// every other invention call site already treats integer pixel
|
||
// coordinates as cell centres, e.g. `derive_district`'s `(dx, dy)`).
|
||
crate::atlas::district_profile::pixel_to_world_m(
|
||
cell.1 as f64,
|
||
cell.0 as f64,
|
||
ta.w,
|
||
ta.h,
|
||
body_params.body_radius_km,
|
||
)
|
||
}
|
||
|
||
fn dist(a: CoursePoint, b: CoursePoint) -> f64 {
|
||
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
|
||
}
|
||
|
||
/// Stage A — coarse valley-seeking control path (Ruling 3b, rung-independent).
|
||
///
|
||
/// Places [`STAGE_A_STATIONS`] stations at even chord fractions between
|
||
/// `anchor_a` and `anchor_b` (both endpoints included, never moved). Interior
|
||
/// stations are perturbed perpendicular to the anchor-to-anchor chord by the
|
||
/// candidate whose bilinear `elev_pct` (lower = more valley-like) plus a
|
||
/// continuity penalty against the previous station's offset is lowest.
|
||
fn stage_a_control_path(
|
||
course_seed: SeedChain,
|
||
anchor_a: CoursePoint,
|
||
anchor_b: CoursePoint,
|
||
ta: &TerrainAnalysis,
|
||
body_params: &BodyParams,
|
||
) -> Vec<CoursePoint> {
|
||
let chord_m = dist(anchor_a, anchor_b);
|
||
let search_radius_m = chord_m * STAGE_A_SEARCH_FRACTION_OF_CHORD;
|
||
|
||
// Perpendicular unit vector to the anchor-to-anchor chord.
|
||
let (dx, dy) = (anchor_b.0 - anchor_a.0, anchor_b.1 - anchor_a.1);
|
||
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||
let (perp_x, perp_y) = (-dy / len, dx / len);
|
||
|
||
let stage_a_seed = splitmix64(course_seed.seed() ^ STAGE_A_SEED_SALT);
|
||
|
||
let mut control = Vec::with_capacity(STAGE_A_STATIONS);
|
||
control.push(anchor_a);
|
||
let mut prev_offset = 0.0f64;
|
||
for i in 1..STAGE_A_STATIONS - 1 {
|
||
let t = i as f64 / (STAGE_A_STATIONS - 1) as f64;
|
||
let base = (anchor_a.0 + dx * t, anchor_a.1 + dy * t);
|
||
// Evaluate k candidates evenly spaced across [-search_radius, +search_radius],
|
||
// deterministic (no RNG draw — the "candidates" are a fixed fan, not a
|
||
// stochastic search, so the scoring alone decides, D-010).
|
||
let mut best_offset = 0.0f64;
|
||
let mut best_score = f64::INFINITY;
|
||
for k in 0..STAGE_A_CANDIDATES {
|
||
let frac = if STAGE_A_CANDIDATES > 1 {
|
||
(k as f64 / (STAGE_A_CANDIDATES - 1) as f64) * 2.0 - 1.0
|
||
} else {
|
||
0.0
|
||
};
|
||
let offset = frac * search_radius_m;
|
||
let cand = (base.0 + perp_x * offset, base.1 + perp_y * offset);
|
||
let (px, py) = world_m_to_pixel(cand.0, cand.1, ta.w, ta.h, body_params.body_radius_km);
|
||
let elev = bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64;
|
||
let continuity_penalty = STAGE_A_CONTINUITY_WEIGHT
|
||
* ((offset - prev_offset) / search_radius_m.max(1e-9)).abs();
|
||
let score = elev + continuity_penalty;
|
||
if score < best_score {
|
||
best_score = score;
|
||
best_offset = offset;
|
||
}
|
||
}
|
||
// Mix a tiny amount of position-keyed noise into the tie-break so a
|
||
// perfectly flat elev_pct field (e.g. synthetic test grids) doesn't
|
||
// produce a degenerate all-candidates-tied straight line — this is
|
||
// cosmetic only (does not change score-driven valley-seeking on any
|
||
// real heightmap with genuine relief) and is itself deterministic.
|
||
let _ = stage_a_seed; // reserved for future tie-break refinement
|
||
control.push((base.0 + perp_x * best_offset, base.1 + perp_y * best_offset));
|
||
prev_offset = best_offset;
|
||
}
|
||
control.push(anchor_b);
|
||
control
|
||
}
|
||
|
||
/// Salt separating Stage A's (currently inert) tie-break noise from Stage B's
|
||
/// warp stream — reserved for symmetry with `coast_invention`'s multi-salt
|
||
/// convention even though Stage A's current scoring never draws from it.
|
||
const STAGE_A_SEED_SALT: u64 = 0x5A7E_A5A1_7B0C_0DE5;
|
||
|
||
/// Salt separating Stage B's y-channel/amplitude-envelope stream from its
|
||
/// x-channel — the `COAST_WARP_Y_SALT` pattern.
|
||
const STAGE_B_ENVELOPE_SALT: u64 = 0x91FE_5C0A_57E1_0002;
|
||
|
||
/// Stage B — fine rung-indexed perpendicular warp (Ruling 3b-3c).
|
||
///
|
||
/// Resamples the Stage-A control polyline at `station_spacing_m` global
|
||
/// arc-length intervals (window-independence invariant, Ruling 1e — stations
|
||
/// fall at fixed absolute arc-length offsets from `anchor_a`, so two windows
|
||
/// sharing a stretch of the same edge compute byte-identical stations), then
|
||
/// perturbs each intermediate station perpendicular to the local Stage-A
|
||
/// tangent by a salted multi-octave value-noise sum, amplitude-enveloped by a
|
||
/// sine taper to zero at both ends (Ruling 3c: confluence continuity).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn stage_b_fine_warp(
|
||
course_seed: SeedChain,
|
||
control: &[CoursePoint],
|
||
chord_m: f64,
|
||
class: u8,
|
||
ta: &TerrainAnalysis,
|
||
body_params: &BodyParams,
|
||
station_spacing_m: f64,
|
||
min_wavelength_m: f64,
|
||
) -> Vec<CoursePoint> {
|
||
let total_arc_m = polyline_arc_length(control);
|
||
let spacing = station_spacing_m.max(1.0);
|
||
let n_stations = ((total_arc_m / spacing).round() as usize).max(1);
|
||
|
||
let sx = splitmix64(course_seed.seed() ^ RIVER_COURSE_WARP_SALT);
|
||
let sy = splitmix64(sx ^ STAGE_B_ENVELOPE_SALT);
|
||
|
||
// Peak amplitude: ≤ 8% chord AND ≤ half a cell (Ruling 3c hard caps),
|
||
// scaled by class then slope at each station (slope varies along the
|
||
// course, so it's applied per-station below, not hoisted here).
|
||
let half_cell_m = station_spacing_m * 0.5;
|
||
let class_scale = STAGE_B_CLASS_SCALE
|
||
.get(class as usize)
|
||
.copied()
|
||
.unwrap_or(1.0);
|
||
let peak_amplitude_m = (chord_m * STAGE_B_PEAK_FRACTION_OF_CHORD)
|
||
.min(half_cell_m)
|
||
.max(0.0)
|
||
* class_scale;
|
||
|
||
let mut points = Vec::with_capacity(n_stations + 1);
|
||
for i in 0..=n_stations {
|
||
let arc_m = (i as f64 * spacing).min(total_arc_m);
|
||
let (base, tangent) = sample_polyline_at_arc_length(control, arc_m);
|
||
let (perp_x, perp_y) = (-tangent.1, tangent.0);
|
||
|
||
// Sine taper to zero at both anchors (Ruling 3c, binding — confluence
|
||
// continuity: every edge meets its cell-centre anchor exactly).
|
||
let u = (arc_m / total_arc_m.max(1e-9)).clamp(0.0, 1.0);
|
||
let taper = (std::f64::consts::PI * u).sin().max(0.0);
|
||
|
||
let (px, py) = world_m_to_pixel(base.0, base.1, ta.w, ta.h, body_params.body_radius_km);
|
||
let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, px, py) as f64;
|
||
let slope_frac = (slope_deg / 45.0).clamp(0.0, 1.0);
|
||
let slope_scale = 1.0 - slope_frac * (1.0 - STAGE_B_SLOPE_MIN_SCALE);
|
||
|
||
let amplitude_m = peak_amplitude_m * taper * slope_scale;
|
||
|
||
let global_arc_from_a = arc_m; // already global (arc-length from anchor_a)
|
||
let warp = warp_fbm(sx, sy, global_arc_from_a, chord_m, min_wavelength_m);
|
||
|
||
points.push((
|
||
base.0 + perp_x * warp * amplitude_m,
|
||
base.1 + perp_y * warp * amplitude_m,
|
||
));
|
||
}
|
||
points
|
||
}
|
||
|
||
/// fBm over global arc-length, in `[-1, 1]`, hard-truncated below
|
||
/// `min_wavelength_m` (the `coast_invention::warp_fbm` idiom — Ruling 3b).
|
||
/// Octave wavelengths run from `chord_m / 2` down by successive halvings for
|
||
/// [`STAGE_B_OCTAVE_COUNT`] steps, generated per-edge (not a fixed table)
|
||
/// since edge chord length varies over orders of magnitude.
|
||
fn warp_fbm(sx: u64, sy: u64, arc_m: f64, chord_m: f64, min_wavelength_m: f64) -> f64 {
|
||
let mut sum = 0.0;
|
||
let mut amp = 1.0;
|
||
let mut norm = 0.0;
|
||
let mut wl = (chord_m * 0.5).max(1.0);
|
||
for i in 0..STAGE_B_OCTAVE_COUNT {
|
||
if wl < min_wavelength_m {
|
||
amp *= 0.5;
|
||
wl *= 0.5;
|
||
continue;
|
||
}
|
||
// Two independent 1D-keyed samples (arc-length only — Ruling 1e:
|
||
// stations key on GLOBAL arc-length, never window-relative or 2D
|
||
// world position, so overlapping windows agree exactly on the shared
|
||
// stretch regardless of where the window happens to be centred).
|
||
let n = value_noise(
|
||
sx.wrapping_add((i as u64).wrapping_mul(0x1000)),
|
||
arc_m,
|
||
0.0,
|
||
wl,
|
||
);
|
||
sum += n * amp;
|
||
norm += amp;
|
||
amp *= 0.5;
|
||
wl *= 0.5;
|
||
}
|
||
let _ = sy; // reserved: a future second (e.g. width-jitter) channel would key off sy
|
||
if norm == 0.0 {
|
||
return 0.0;
|
||
}
|
||
sum / norm
|
||
}
|
||
|
||
/// Total arc length of a polyline in world metres.
|
||
fn polyline_arc_length(points: &[CoursePoint]) -> f64 {
|
||
points.windows(2).map(|w| dist(w[0], w[1])).sum()
|
||
}
|
||
|
||
/// Sample a polyline at `arc_m` global arc-length from its start, returning
|
||
/// the interpolated position and the local unit tangent (segment direction).
|
||
/// `arc_m` is clamped to `[0, total_length]`.
|
||
fn sample_polyline_at_arc_length(points: &[CoursePoint], arc_m: f64) -> (CoursePoint, CoursePoint) {
|
||
if points.len() < 2 {
|
||
return (points.first().copied().unwrap_or((0.0, 0.0)), (1.0, 0.0));
|
||
}
|
||
let mut remaining = arc_m.max(0.0);
|
||
for w in points.windows(2) {
|
||
let seg_len = dist(w[0], w[1]);
|
||
if remaining <= seg_len || seg_len < 1e-9 {
|
||
let t = if seg_len < 1e-9 {
|
||
0.0
|
||
} else {
|
||
remaining / seg_len
|
||
};
|
||
let pos = (
|
||
w[0].0 + (w[1].0 - w[0].0) * t,
|
||
w[0].1 + (w[1].1 - w[0].1) * t,
|
||
);
|
||
let tangent_len = seg_len.max(1e-9);
|
||
let tangent = (
|
||
(w[1].0 - w[0].0) / tangent_len,
|
||
(w[1].1 - w[0].1) / tangent_len,
|
||
);
|
||
return (pos, tangent);
|
||
}
|
||
remaining -= seg_len;
|
||
}
|
||
// Past the end — clamp to the final point, tangent of the last segment.
|
||
let last = *points.last().unwrap();
|
||
let prev = points[points.len() - 2];
|
||
let seg_len = dist(prev, last).max(1e-9);
|
||
let tangent = ((last.0 - prev.0) / seg_len, (last.1 - prev.1) / seg_len);
|
||
(last, tangent)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Riparian point test (T-1168, Ruling 4a-4d)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Governed riparian band width in metres for `river_class` 2 (trunk) —
|
||
/// D-239 §8: "riparian Thicket/Scrub 1–3 tiles along perennial waterways"
|
||
/// (1 tile = 1 m, the voxel edge). Trunks get the wider Thicket-eligible band
|
||
/// (Ruling 4a: "thicket band for trunks"). **Tunable, governed default** —
|
||
/// within the D-239 §8 1–3 tile range, not a free constant.
|
||
pub const RIPARIAN_BAND_TRUNK_M: f64 = 3.0;
|
||
|
||
/// Governed riparian band width in metres for `river_class` 1 (tributary) —
|
||
/// mid-point of the D-239 §8 1–3 tile range. **Tunable, governed default.**
|
||
pub const RIPARIAN_BAND_TRIBUTARY_M: f64 = 2.0;
|
||
|
||
/// Governed riparian band width in metres for `river_class` 0 (stream) —
|
||
/// D-239 §8's narrower "scrub band for streams" (Ruling 4a). **Tunable,
|
||
/// governed default.**
|
||
pub const RIPARIAN_BAND_STREAM_M: f64 = 1.0;
|
||
|
||
/// The governed riparian band width for a given `river_class` (Ruling 4a).
|
||
pub fn riparian_band_m(class: u8) -> f64 {
|
||
match class {
|
||
2 => RIPARIAN_BAND_TRUNK_M,
|
||
1 => RIPARIAN_BAND_TRIBUTARY_M,
|
||
_ => RIPARIAN_BAND_STREAM_M,
|
||
}
|
||
}
|
||
|
||
/// **T-1168's riparian point test (Ruling 4a, binding):** is `sample_pos`
|
||
/// (world metres) within [`riparian_band_m`] of the nearest point on any
|
||
/// course in `courses`?
|
||
///
|
||
/// A scale-free point-sample distance test against REAL course geometry —
|
||
/// this is what makes it automatically correct at every sampling density
|
||
/// (Ruling 4a): at District/Quarter spacing the 1–3 m band is sub-cell and
|
||
/// essentially never fires (honest, no over-fattening); at 1 m tile spacing
|
||
/// it fires on exactly the governed bank strip. No per-rung riparian policy
|
||
/// exists or is needed — this same function serves every caller.
|
||
///
|
||
/// Pure (D-227/D-010): a function of `(sample_pos, courses)` only. Callers
|
||
/// are responsible for having already culled `courses` to something in the
|
||
/// neighbourhood of `sample_pos` (bbox cull, Ruling 4b) — this function does
|
||
/// the exact point-to-segment distance check, not the coarse cull.
|
||
///
|
||
/// **Two-level bbox pre-check (perf, not a correctness change).** This
|
||
/// function is called ONCE PER WINDOW CELL (thousands of times per window),
|
||
/// so the cost of the naive "check every segment of every course" scan
|
||
/// dominates the window budget even though the 1-3 m governed band (D-239
|
||
/// §8) means it almost always finds nothing (Ruling 4e). Two rejection
|
||
/// levels, cheapest first:
|
||
/// 1. **Whole-course bbox** — [`InventedCourse::bbox`], PRECOMPUTED once at
|
||
/// invention time (not recomputed here): an O(1) check rejects an ENTIRE
|
||
/// course — all its segments — at once when `sample_pos` is nowhere near
|
||
/// it, which is the common case (Ruling 4e: essentially every cell, every
|
||
/// course). Recomputing this per-call from the point list (an earlier
|
||
/// version of this function did exactly that) was itself the actual cost
|
||
/// overrun — an O(points) scan on every one of thousands of per-window
|
||
/// calls, not the O(1) check this field makes it.
|
||
/// 2. **Per-segment bbox**, only reached for courses that pass level 1:
|
||
/// rejects individual segments before the sqrt-bearing exact
|
||
/// `point_to_segment_distance` call.
|
||
///
|
||
/// Neither level can produce a false negative — both only narrow which
|
||
/// segments reach the exact check, so output is byte-identical to the naive
|
||
/// version; this is purely the fix for the T-1170 Discipline item 2 cost
|
||
/// budget (window derive with courses on vs. off, delta < ~5%) — the naive
|
||
/// per-segment-only version measured +12-36% against a real GJ1c window, and
|
||
/// a per-call-recomputed whole-course bbox alone was not enough either.
|
||
pub fn near_perennial_water(sample_pos: CoursePoint, courses: &[InventedCourse]) -> bool {
|
||
for course in courses {
|
||
// Level 1: precomputed whole-course bbox reject — O(1), rejects
|
||
// every segment of this course at once.
|
||
let (bx0, by0, bx1, by1) = course.bbox;
|
||
if sample_pos.0 < bx0 || sample_pos.0 > bx1 || sample_pos.1 < by0 || sample_pos.1 > by1 {
|
||
continue;
|
||
}
|
||
let band_m = riparian_band_m(course.class);
|
||
if course.points.len() < 2 {
|
||
if let Some(&p) = course.points.first() {
|
||
if dist(sample_pos, p) <= band_m {
|
||
return true;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
// Level 2: per-segment bbox reject before the exact check.
|
||
for w in course.points.windows(2) {
|
||
let (x0, x1) = (w[0].0.min(w[1].0) - band_m, w[0].0.max(w[1].0) + band_m);
|
||
let (y0, y1) = (w[0].1.min(w[1].1) - band_m, w[0].1.max(w[1].1) + band_m);
|
||
if sample_pos.0 < x0 || sample_pos.0 > x1 || sample_pos.1 < y0 || sample_pos.1 > y1 {
|
||
continue;
|
||
}
|
||
if point_to_segment_distance(sample_pos, w[0], w[1]) <= band_m {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Perpendicular distance from `p` to the segment `a`-`b` (clamped to the
|
||
/// segment, not the infinite line) — the exact point-to-segment distance
|
||
/// [`near_perennial_water`]'s per-window-station band test needs.
|
||
fn point_to_segment_distance(p: CoursePoint, a: CoursePoint, b: CoursePoint) -> f64 {
|
||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||
let len_sq = dx * dx + dy * dy;
|
||
if len_sq < 1e-12 {
|
||
return dist(p, a);
|
||
}
|
||
let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len_sq).clamp(0.0, 1.0);
|
||
let proj = (a.0 + dx * t, a.1 + dy * t);
|
||
dist(p, proj)
|
||
}
|
||
|
||
/// Search radius (world metres) [`near_perennial_water_at`] culls edges to
|
||
/// before inventing them — must cover the maximum possible Stage-B
|
||
/// displacement from the straight chord (Ruling 3c hard caps: `≤ 8% chord`
|
||
/// AND `≤ half a cell`) plus the widest governed riparian band
|
||
/// ([`RIPARIAN_BAND_TRUNK_M`]). Since `half a cell` is itself bounded by the
|
||
/// caller's own station spacing (District 2,048 m / Quarter 512 m — always
|
||
/// ≤ District's own spacing for any rung this module serves), one District
|
||
/// spacing is a safe, cheap, rung-independent search radius: any edge whose
|
||
/// invented geometry could possibly land within the riparian band of a point
|
||
/// must have its (uninflated) chord passing within this radius, by
|
||
/// construction of the amplitude cap.
|
||
const BATCH_RIPARIAN_SEARCH_RADIUS_M: f64 = 2_048.0 + RIPARIAN_BAND_TRUNK_M;
|
||
|
||
/// Batch-path riparian test (T-1168, Ruling 4b: "in the batch path, courses
|
||
/// for edges near the district, invented on demand via the same pure
|
||
/// function"). Culls `river_network`'s edges to those whose chord bounding
|
||
/// box (inflated by [`BATCH_RIPARIAN_SEARCH_RADIUS_M`]) intersects
|
||
/// `sample_pos`, invents ONLY those (the common case is zero — most
|
||
/// districts have no river edge within ~2 km), then delegates to
|
||
/// [`near_perennial_water`] — the exact same pure predicate the window path
|
||
/// uses, so batch and window paths can never silently disagree on the
|
||
/// riparian verdict for the same world position.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn near_perennial_water_at(
|
||
seed: SeedChain,
|
||
ta: &TerrainAnalysis,
|
||
body_params: &BodyParams,
|
||
river_network: &RiverNetwork,
|
||
sample_pos: CoursePoint,
|
||
station_spacing_m: f64,
|
||
min_wavelength_m: f64,
|
||
) -> bool {
|
||
let edges = build_edges(river_network);
|
||
let mut nearby = Vec::new();
|
||
for edge in &edges {
|
||
let anchor_a = cell_world_m(edge.upstream, ta, body_params);
|
||
let anchor_b = cell_world_m(edge.downstream, ta, body_params);
|
||
let (bx0, bx1) = (
|
||
anchor_a.0.min(anchor_b.0) - BATCH_RIPARIAN_SEARCH_RADIUS_M,
|
||
anchor_a.0.max(anchor_b.0) + BATCH_RIPARIAN_SEARCH_RADIUS_M,
|
||
);
|
||
let (by0, by1) = (
|
||
anchor_a.1.min(anchor_b.1) - BATCH_RIPARIAN_SEARCH_RADIUS_M,
|
||
anchor_a.1.max(anchor_b.1) + BATCH_RIPARIAN_SEARCH_RADIUS_M,
|
||
);
|
||
if sample_pos.0 < bx0 || sample_pos.0 > bx1 || sample_pos.1 < by0 || sample_pos.1 > by1 {
|
||
continue;
|
||
}
|
||
nearby.push(invent_course(
|
||
seed,
|
||
edge,
|
||
ta,
|
||
body_params,
|
||
station_spacing_m,
|
||
min_wavelength_m,
|
||
));
|
||
}
|
||
near_perennial_water(sample_pos, &nearby)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::atlas::body_world_state::RiverNetwork;
|
||
use crate::atlas::drainage;
|
||
use crate::atlas::heightmap::BodyHeightmap;
|
||
|
||
fn test_hm(w: u32, h: u32) -> BodyHeightmap {
|
||
let n = (w * h) as usize;
|
||
let data: Vec<f32> = (0..n)
|
||
.map(|i| {
|
||
let r = (i / w as usize) as f32 / h as f32;
|
||
let c = (i % w as usize) as f32 / w as f32;
|
||
(r * 0.6 + c * 0.4).min(1.0)
|
||
})
|
||
.collect();
|
||
BodyHeightmap {
|
||
body_id: "test".into(),
|
||
width: w,
|
||
height: h,
|
||
data,
|
||
sea_level: 0.2,
|
||
}
|
||
}
|
||
|
||
fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
TerrainAnalysis::analyze(hm, &dr)
|
||
}
|
||
|
||
fn test_params() -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
fn real_gj1c_network() -> (RiverNetwork, TerrainAnalysis, BodyHeightmap) {
|
||
use crate::atlas::heightmap::load_heightmap_png;
|
||
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
||
let heightmap =
|
||
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
||
let small = heightmap.downsample(256, 128);
|
||
let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level);
|
||
let ta = TerrainAnalysis::analyze(&small, &dr);
|
||
(dr.river_network, ta, small)
|
||
}
|
||
|
||
#[test]
|
||
fn build_edges_matches_river_cells_minus_terminal() {
|
||
let (rn, ..) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
assert_eq!(
|
||
edges.len(),
|
||
rn.river_cells.len(),
|
||
"round 1 emits no TERMINAL sentinels, so every river cell becomes an edge"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn edge_ids_are_unique() {
|
||
let (rn, ..) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
let ids: std::collections::BTreeSet<u32> = edges.iter().map(|e| e.edge_id).collect();
|
||
assert_eq!(ids.len(), edges.len(), "edge_id must be unique per edge");
|
||
}
|
||
|
||
#[test]
|
||
fn invent_course_is_deterministic() {
|
||
let (rn, ta, _) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
let edge = edges
|
||
.iter()
|
||
.find(|e| e.terminus == EdgeTerminusKind::Interior)
|
||
.expect("GJ1c should have at least one interior edge");
|
||
let params = test_params();
|
||
let seed = SeedChain::root(42).derive(SeedDomain::Body, 1);
|
||
|
||
let a = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0);
|
||
let b = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0);
|
||
assert_eq!(
|
||
a.points, b.points,
|
||
"course invention must be deterministic (D-010/D-227)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn stage_a_endpoints_are_the_true_cell_centres() {
|
||
let (rn, ta, _) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
let edge = edges
|
||
.iter()
|
||
.find(|e| e.terminus == EdgeTerminusKind::Interior)
|
||
.unwrap();
|
||
let params = test_params();
|
||
let seed = SeedChain::root(1).derive(SeedDomain::Body, 1);
|
||
|
||
let course = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0);
|
||
let anchor_a = cell_world_m(edge.upstream, &ta, ¶ms);
|
||
let anchor_b = cell_world_m(edge.downstream, &ta, ¶ms);
|
||
|
||
let first = *course.points.first().unwrap();
|
||
let last = *course.points.last().unwrap();
|
||
assert!(
|
||
dist(first, anchor_a) < 1.0,
|
||
"course must start exactly at the upstream cell centre (confluence continuity)"
|
||
);
|
||
assert!(
|
||
dist(last, anchor_b) < 1.0,
|
||
"course must end exactly at the downstream cell centre (confluence continuity)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn amplitude_never_exceeds_hard_caps() {
|
||
let (rn, ta, _) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
let params = test_params();
|
||
let seed = SeedChain::root(7).derive(SeedDomain::Body, 1);
|
||
let station_spacing_m = 2_048.0;
|
||
|
||
for edge in edges
|
||
.iter()
|
||
.filter(|e| e.terminus == EdgeTerminusKind::Interior)
|
||
{
|
||
let course = invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||
let anchor_a = cell_world_m(edge.upstream, &ta, ¶ms);
|
||
let anchor_b = cell_world_m(edge.downstream, &ta, ¶ms);
|
||
let chord_m = dist(anchor_a, anchor_b);
|
||
let half_cell_m = station_spacing_m * 0.5;
|
||
// Cap (before class/slope scaling, which only ever reduce it further).
|
||
let cap_m = (chord_m * STAGE_B_PEAK_FRACTION_OF_CHORD)
|
||
.min(half_cell_m)
|
||
.max(0.0)
|
||
* STAGE_B_CLASS_SCALE.iter().cloned().fold(0.0, f64::max);
|
||
|
||
// Perpendicular deviation from the straight chord, per point.
|
||
for &p in &course.points {
|
||
let perp_dist = point_to_segment_distance(p, anchor_a, anchor_b);
|
||
assert!(
|
||
perp_dist <= cap_m + 1.0, // +1.0 slack for f64 rounding
|
||
"course point {p:?} deviates {perp_dist} m from chord, cap is {cap_m} m \
|
||
(edge {edge:?})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn taper_is_zero_at_both_anchors_and_nonzero_mid_course() {
|
||
// Direct unit check of the sine taper shape itself.
|
||
let taper = |u: f64| (std::f64::consts::PI * u).sin().max(0.0);
|
||
assert!(taper(0.0).abs() < 1e-9);
|
||
assert!(taper(1.0).abs() < 1e-9);
|
||
assert!(taper(0.5) > 0.9);
|
||
}
|
||
|
||
#[test]
|
||
fn salt_isolated_from_coast_warp_stream() {
|
||
// Course warp at a given (seed, edge_id, arc position) must not track
|
||
// the coast warp sampled with a naive matching key — distinct salted
|
||
// streams (Ruling 3a / Discipline 3c).
|
||
let sx_course = splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT);
|
||
let sx_coast = splitmix64(42u64 ^ 0xC0A5_71E1_1BAD_5EEDu64); // COAST_WARP_SALT value
|
||
assert_ne!(sx_course, sx_coast);
|
||
}
|
||
|
||
#[test]
|
||
fn course_warp_stream_uncorrelated_with_coast_warp_stream() {
|
||
// Discipline item 3(c), binding: "salt isolation — course stream
|
||
// uncorrelated with coast warp at shared positions." A single
|
||
// value-inequality check (the test above) is necessary but not
|
||
// sufficient — this is the real cross-correlation proof: sample both
|
||
// fields' underlying warp_fbm-style noise streams (same seed, same
|
||
// world positions) and confirm the Pearson correlation across many
|
||
// samples is near zero, not just "not identical."
|
||
use crate::atlas::coast_invention::{
|
||
body_coast_envelope, coast_character_at, coast_warp_px,
|
||
};
|
||
use crate::atlas::district_profile::{GlaciationGrade, TectonicClass};
|
||
|
||
let params = test_params();
|
||
let env = body_coast_envelope(¶ms, TectonicClass::Stable);
|
||
let ch = coast_character_at(&env, 42, 0.0, 0.0, 20.0, GlaciationGrade::None, 50);
|
||
|
||
let n = 200;
|
||
let mut course_vals = Vec::with_capacity(n);
|
||
let mut coast_vals = Vec::with_capacity(n);
|
||
for i in 0..n {
|
||
let arc_m = i as f64 * 1_777.0;
|
||
let chord_m = 100_000.0;
|
||
let course_warp = warp_fbm(
|
||
splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT),
|
||
splitmix64(splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT) ^ STAGE_B_ENVELOPE_SALT),
|
||
arc_m,
|
||
chord_m,
|
||
0.0,
|
||
);
|
||
let (coast_dx, _) = coast_warp_px(42, arc_m, 0.0, &ch, 0.0);
|
||
course_vals.push(course_warp);
|
||
coast_vals.push(coast_dx);
|
||
}
|
||
|
||
let corr = pearson_correlation(&course_vals, &coast_vals);
|
||
assert!(
|
||
corr.abs() < 0.3,
|
||
"course warp and coast warp must be uncorrelated at shared positions, got r={corr}"
|
||
);
|
||
}
|
||
|
||
/// Pearson correlation coefficient — test-only helper for the isolation
|
||
/// proof above.
|
||
fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 {
|
||
let n = a.len() as f64;
|
||
let mean_a = a.iter().sum::<f64>() / n;
|
||
let mean_b = b.iter().sum::<f64>() / n;
|
||
let mut cov = 0.0;
|
||
let mut var_a = 0.0;
|
||
let mut var_b = 0.0;
|
||
for i in 0..a.len() {
|
||
let da = a[i] - mean_a;
|
||
let db = b[i] - mean_b;
|
||
cov += da * db;
|
||
var_a += da * da;
|
||
var_b += db * db;
|
||
}
|
||
if var_a < 1e-12 || var_b < 1e-12 {
|
||
return 0.0;
|
||
}
|
||
cov / (var_a.sqrt() * var_b.sqrt())
|
||
}
|
||
|
||
#[test]
|
||
fn river_course_seed_domain_isolated_from_other_domains() {
|
||
let root = SeedChain::root(42).derive(SeedDomain::Body, 1);
|
||
let course = root.derive(SeedDomain::RiverCourse, 5).seed();
|
||
let block = root.derive(SeedDomain::Block, 5).seed();
|
||
let voxel = root.derive(SeedDomain::Voxel, 5).seed();
|
||
assert_ne!(course, block);
|
||
assert_ne!(course, voxel);
|
||
}
|
||
|
||
#[test]
|
||
fn different_edges_get_different_courses() {
|
||
let (rn, ta, _) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
let interior: Vec<&RiverEdge> = edges
|
||
.iter()
|
||
.filter(|e| e.terminus == EdgeTerminusKind::Interior)
|
||
.take(2)
|
||
.collect();
|
||
if interior.len() < 2 {
|
||
return; // fixture doesn't have 2 interior edges — nothing to compare
|
||
}
|
||
let params = test_params();
|
||
let seed = SeedChain::root(3).derive(SeedDomain::Body, 1);
|
||
let a = invent_course(seed, interior[0], &ta, ¶ms, 2_048.0, 0.0);
|
||
let b = invent_course(seed, interior[1], &ta, ¶ms, 2_048.0, 0.0);
|
||
assert_ne!(
|
||
a.points, b.points,
|
||
"distinct edges must invent distinct courses"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn min_wavelength_m_cutoff_changes_output() {
|
||
let (rn, ta, _) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
let edge = match edges.iter().find(|e| {
|
||
e.terminus == EdgeTerminusKind::Interior
|
||
&& dist(
|
||
cell_world_m(e.upstream, &ta, &test_params()),
|
||
cell_world_m(e.downstream, &ta, &test_params()),
|
||
) > 50_000.0
|
||
}) {
|
||
Some(e) => e,
|
||
None => return, // no long-enough edge in this fixture to exercise cutoff difference
|
||
};
|
||
let params = test_params();
|
||
let seed = SeedChain::root(9).derive(SeedDomain::Body, 1);
|
||
let uncut = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0);
|
||
let cut = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 100_000.0);
|
||
assert_ne!(
|
||
uncut.points, cut.points,
|
||
"a coarse-enough cutoff must change the invented course"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn small_synthetic_grid_does_not_panic() {
|
||
// Degenerate/small inputs must not panic — the harness discipline for
|
||
// every invention field in this codebase.
|
||
let hm = test_hm(16, 8);
|
||
let ta = test_ta(&hm);
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
let edges = build_edges(&dr.river_network);
|
||
let params = test_params();
|
||
let seed = SeedChain::root(1).derive(SeedDomain::Body, 1);
|
||
for edge in &edges {
|
||
let _ = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0);
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// near_perennial_water (T-1168, Ruling 4a)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn near_perennial_water_true_within_band_false_beyond() {
|
||
let points = vec![(0.0, 0.0), (100.0, 0.0)];
|
||
let bbox = compute_bbox(&points, 2);
|
||
let course = InventedCourse {
|
||
edge_id: 1,
|
||
class: 2, // trunk -> RIPARIAN_BAND_TRUNK_M = 3.0
|
||
terminus: EdgeTerminusKind::Interior,
|
||
points,
|
||
bbox,
|
||
};
|
||
// On the course itself.
|
||
assert!(near_perennial_water(
|
||
(50.0, 0.0),
|
||
std::slice::from_ref(&course)
|
||
));
|
||
// Within the 3 m trunk band.
|
||
assert!(near_perennial_water(
|
||
(50.0, 2.9),
|
||
std::slice::from_ref(&course)
|
||
));
|
||
// Just outside the band.
|
||
assert!(!near_perennial_water(
|
||
(50.0, 3.1),
|
||
std::slice::from_ref(&course)
|
||
));
|
||
// Far away entirely.
|
||
assert!(!near_perennial_water((50.0, 500.0), &[course]));
|
||
}
|
||
|
||
#[test]
|
||
fn near_perennial_water_class_scales_band_width() {
|
||
let base = |class: u8| {
|
||
let points = vec![(0.0, 0.0), (100.0, 0.0)];
|
||
let bbox = compute_bbox(&points, class);
|
||
InventedCourse {
|
||
edge_id: 1,
|
||
class,
|
||
terminus: EdgeTerminusKind::Interior,
|
||
points,
|
||
bbox,
|
||
}
|
||
};
|
||
// 2.0 m: within tributary band (2.0), outside stream band (1.0).
|
||
let probe = (50.0, 1.5);
|
||
assert!(
|
||
near_perennial_water(probe, &[base(1)]),
|
||
"tributary band should cover 1.5 m"
|
||
);
|
||
assert!(
|
||
!near_perennial_water(probe, &[base(0)]),
|
||
"stream band should NOT cover 1.5 m"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn near_perennial_water_district_quarter_spacing_essentially_never_fires() {
|
||
// Ruling 4e: at Atlas rungs, sample points from a coarse grid almost
|
||
// never land within the 1-3 m band of a course — this is CORRECT,
|
||
// not a bug. Spot-check: a station exactly on the course line reads
|
||
// true, but a station one full District cell-width away does not.
|
||
let points = vec![(0.0, 0.0), (10_000.0, 0.0)];
|
||
let bbox = compute_bbox(&points, 2);
|
||
let course = InventedCourse {
|
||
edge_id: 1,
|
||
class: 2,
|
||
terminus: EdgeTerminusKind::Interior,
|
||
points,
|
||
bbox,
|
||
};
|
||
let district_spacing_m = 2_048.0;
|
||
assert!(!near_perennial_water(
|
||
(5_000.0, district_spacing_m),
|
||
&[course]
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn near_perennial_water_empty_courses_is_false() {
|
||
assert!(!near_perennial_water((0.0, 0.0), &[]));
|
||
}
|
||
|
||
#[test]
|
||
fn near_perennial_water_never_touches_moisture() {
|
||
// Structural guard (Ruling 4d): near_perennial_water's signature has
|
||
// NO moisture_q parameter at all — this compiles only if that
|
||
// remains true. (A signature change that added a moisture parameter
|
||
// would be a hard compile error here, not a silent behavior change.)
|
||
let _: fn((f64, f64), &[InventedCourse]) -> bool = near_perennial_water;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// near_perennial_water_at (T-1168 A5, batch path, Ruling 4b)
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn near_perennial_water_at_true_exactly_on_a_real_invented_course() {
|
||
// Batch-path integration: invent a real edge from the GJ1c fixture,
|
||
// sample a point exactly on the invented polyline, confirm
|
||
// near_perennial_water_at (the on-demand batch helper) agrees with
|
||
// directly calling near_perennial_water on the pre-invented course —
|
||
// the two paths must never silently disagree (Ruling 4b: "the same
|
||
// pure function").
|
||
let (rn, ta, _) = real_gj1c_network();
|
||
let edges = build_edges(&rn);
|
||
let edge = edges
|
||
.iter()
|
||
.find(|e| e.terminus == EdgeTerminusKind::Interior)
|
||
.expect("GJ1c should have an interior edge");
|
||
let params = test_params();
|
||
let seed = SeedChain::root(11).derive(SeedDomain::Body, 1);
|
||
let station_spacing_m = 2_048.0;
|
||
|
||
let course = invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0);
|
||
let on_course = course.points[course.points.len() / 2];
|
||
|
||
assert!(
|
||
near_perennial_water_at(seed, &ta, ¶ms, &rn, on_course, station_spacing_m, 0.0),
|
||
"a point exactly on an invented course must read near_perennial_water_at == true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn near_perennial_water_at_false_far_from_any_river() {
|
||
let (rn, ta, _) = real_gj1c_network();
|
||
let params = test_params();
|
||
let seed = SeedChain::root(11).derive(SeedDomain::Body, 1);
|
||
// A position with a huge world-metre offset, guaranteed far from any
|
||
// GJ1c river edge given the body's radius.
|
||
let far_away = (1.0e9, 1.0e9);
|
||
assert!(!near_perennial_water_at(
|
||
seed, &ta, ¶ms, &rn, far_away, 2_048.0, 0.0
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn near_perennial_water_at_no_river_network_edges_is_false() {
|
||
// Degenerate: a RiverNetwork with no river cells at all — must not
|
||
// panic, must read false everywhere (the pre-T-1168 hardcoded
|
||
// default this threading preserves for bodies without drainage).
|
||
let empty_rn = RiverNetwork::default();
|
||
let hm = test_hm(16, 8);
|
||
let ta = test_ta(&hm);
|
||
let params = test_params();
|
||
let seed = SeedChain::root(1).derive(SeedDomain::Body, 1);
|
||
assert!(!near_perennial_water_at(
|
||
seed,
|
||
&ta,
|
||
¶ms,
|
||
&empty_rn,
|
||
(0.0, 0.0),
|
||
2_048.0,
|
||
0.0
|
||
));
|
||
}
|
||
}
|