feat(simulation): T-1170 A1 — river_downstream pointers; pole-edge drains are not mouths

RiverNetwork gains river_downstream: Vec<u8> (serde-default, parallel
to river_cells): values 0-7 index drainage::D8 (row,col deltas, N/S/E/
W/NE/NW/SE/SW order); sentinels MOUTH=8, EDGE_DRAIN=9, TERMINAL=10
(reserved — the future endorheic-basin hook, Ruling 2c/7b). Captured
in extract_river_network's existing pass (fdir already in scope, one
map, no new grid pass). Pole-edge D8 exits reclassify as EDGE_DRAIN
and leave the mouths list (Ruling 3f); flat-peak interior no-outflow
cells get EDGE_DRAIN too. cascade_golden deliberately re-pinned: GJ1c
mouths 19->3 — sixteen were pole-edge artifacts, exactly Jeroen's
'circles with no sea in sight'; river_cells/attractors counts
unchanged (pure reclassification + additive field). 21/21 drainage
tests incl. mouth-sentinel/mouths-list bijection on the real fixture
and a synthetic pole-draining-grid case.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:39:47 +02:00
co-authored by Claude Fable 5
parent 884f698a77
commit 0fea69feb2
4 changed files with 711 additions and 474 deletions
+49
View File
@@ -54,8 +54,57 @@ pub struct RiverNetwork {
/// empty, never a decode error (the additive T-1124 §1 pattern).
#[serde(default)]
pub river_class: Vec<u8>,
/// Per-`river_cells`-entry D8 downstream pointer (T-1170 Ruling 2a) — same
/// index, same length as `river_cells`/`river_class`. Each river cell has
/// exactly one downstream direction, so this is the exact same shape as
/// `river_class`, captured in `extract_river_network` where `fdir[i]` is
/// already in scope (one `.map()`, no new grid pass).
///
/// **Values 07:** an index into `drainage::D8` — the downstream neighbor
/// direction, i.e. "this river cell flows toward `D8[value]`".
///
/// **Sentinel values above 7 (Ruling 2c):**
/// - [`RIVER_DOWNSTREAM_MOUTH`] — flow reaches a raw-sea cell (the river's
/// mouth in the D8 sense; T-1170's course inventor walks stations from
/// here to the invented-coast terminus, Ruling 3e).
/// - [`RIVER_DOWNSTREAM_EDGE_DRAIN`] — flow exits the grid's top/bottom
/// edge. This is a **grid artifact, not a mouth** (Ruling 3f) — pole-edge
/// exits are deliberately excluded from `mouths` at extraction (see
/// `extract_river_network`'s doc).
/// - [`RIVER_DOWNSTREAM_TERMINAL`] — **reserved, unused in round 1.** Flow
/// ends in an interior sink (future endorheic basin / inland delta,
/// Ruling 7b). Reserving the value now keeps that future path additive
/// (no wire migration) rather than requiring a new sentinel later.
///
/// Why persist rather than reconstruct from adjacency alone (Ruling 2b):
/// at a 3-river-neighbor confluence, adjacency cannot distinguish inflow
/// from outflow, and re-deriving direction from elevation is re-running D8
/// badly — the true answer (`fdir[i]`) is already computed and in scope at
/// extraction time; discarding it and re-deriving later is strictly worse
/// than keeping the ~1 byte/river-cell projection. The former D-203
/// memory-frugality rationale for discarding covered the 131 KB *full*
/// `fdir` grid, not this per-river-cell projection.
///
/// `#[serde(default)]` — same additive pattern as `river_class`: an absent
/// array (pre-T-1170 payload/fixture) decodes to empty, never an error.
#[serde(default)]
pub river_downstream: Vec<u8>,
}
/// [`RiverNetwork::river_downstream`] sentinel: this river cell's D8 flow
/// reaches a raw-sea cell (T-1170 Ruling 2c). Values 07 are real D8 direction
/// indices, so sentinels start at 8.
pub const RIVER_DOWNSTREAM_MOUTH: u8 = 8;
/// [`RiverNetwork::river_downstream`] sentinel: this river cell's flow exits
/// the grid's top/bottom (polar) edge — a grid artifact, never a mouth
/// (T-1170 Ruling 2c/3f).
pub const RIVER_DOWNSTREAM_EDGE_DRAIN: u8 = 9;
/// [`RiverNetwork::river_downstream`] sentinel: **reserved, unused in round
/// 1.** Flow terminates in an interior sink (future endorheic basin / inland
/// delta, T-1170 Ruling 2c/7b). Reserving this value now is what makes that
/// future extension additive rather than a wire migration.
pub const RIVER_DOWNSTREAM_TERMINAL: u8 = 10;
/// One drainage basin / province derived from watershed analysis (D-205).
/// Stub — boundary polyline data comes from atlas_province_boundaries.
#[derive(Debug, Clone, Serialize, Deserialize)]
+157 -12
View File
@@ -19,7 +19,9 @@
use std::collections::VecDeque;
use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
use crate::atlas::body_world_state::{
DrainageBasin, RiverNetwork, RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH,
};
use crate::simulation::generator::TerritorialStatus;
/// A cell is a river cell when its flow accumulation exceeds this threshold (D-208).
@@ -316,28 +318,53 @@ fn extract_river_network(
.map(|i| ((i / w) as u16, (i % w) as u16))
.collect();
// Mouths: river cells that flow to a sea cell or to the polar edge.
let mouths: Vec<(u16, u16)> = (0..n)
.filter(|&i| {
if !is_river[i] {
return false;
}
// Mouths + river_downstream (T-1170 Ruling 2a/2c/3f): a single pass over
// `river_cells`, in the SAME order, computing both the D8-downstream
// sentinel/pointer AND whether this cell is a mouth. `fdir[i]` is already
// in scope here — capturing it as `river_downstream` costs one extra push
// per river cell, no new grid pass (Ruling 2a's binding requirement).
//
// **Pole-edge exits are NOT mouths (Ruling 3f, binding).** A river cell
// with no outflow because its D8 walk ran off the grid's top/bottom row
// is a grid artifact — the equirectangular projection simply stops there,
// there is no sea. The former code classified this the same as a real
// sea-adjacent mouth, which rendered double-ring mouth markers in polar
// ice with no sea in sight (Jeroen's capture question, T-1170 ticket).
// `RIVER_DOWNSTREAM_EDGE_DRAIN` cells are excluded from `mouths` here;
// T-1170's course inventor (Ruling 3f) ends their course geometry at the
// last in-grid station with no mouth flag.
//
// A flat-peak interior cell (no outflow, but NOT at a pole row) is D8's
// other `k < 0` case — vanishingly rare for a cell that also cleared
// `RIVER_THRESHOLD`, but handled the same as `EDGE_DRAIN` (no downstream
// neighbor to point at, not a sea mouth) rather than crashing the
// pointer's "always points somewhere real" contract.
let mut mouths: Vec<(u16, u16)> = Vec::new();
let river_downstream: Vec<u8> = (0..n)
.filter(|&i| is_river[i])
.map(|i| {
let r = i / w;
let c = i % w;
let k = fdir[i];
if k < 0 {
return true; // no outflow — edge
// No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f).
return RIVER_DOWNSTREAM_EDGE_DRAIN;
}
let (dr, dc) = D8[k as usize];
let nr = r as i32 + dr;
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
if nr < 0 || nr >= h as i32 {
return true; // polar edge
// Flow direction points off the polar edge — a grid artifact,
// not a mouth (Ruling 3f, the pole-edge-drain fix).
return RIVER_DOWNSTREAM_EDGE_DRAIN;
}
// Flows into a sub-sea-level cell = mouth
elevation[nr as usize * w + nc] < sea_level
if elevation[nr as usize * w + nc] < sea_level {
// Flows into a sub-sea-level cell — a real mouth.
mouths.push((r as u16, c as u16));
return RIVER_DOWNSTREAM_MOUTH;
}
k as u8
})
.map(|i| ((i / w) as u16, (i % w) as u16))
.collect();
RiverNetwork {
@@ -345,6 +372,7 @@ fn extract_river_network(
confluences,
mouths,
river_class,
river_downstream,
}
}
@@ -1040,4 +1068,121 @@ mod tests {
"the body's max river-cell accumulation should be trunk"
);
}
// -----------------------------------------------------------------------
// river_downstream (T-1170 Ruling 2a/2c/3f)
// -----------------------------------------------------------------------
#[test]
fn river_downstream_parallel_to_river_cells() {
let elev = slope_grid(512, 256);
let result = analyze(&elev, 512, 256, 0.3);
let rn = &result.river_network;
assert_eq!(
rn.river_cells.len(),
rn.river_downstream.len(),
"river_downstream must be parallel/aligned with river_cells"
);
assert!(!rn.river_cells.is_empty());
}
#[test]
fn river_downstream_values_are_direction_or_sentinel() {
// Every entry is either a real D8 index (0-7) or one of the T-1170
// sentinels (MOUTH=8, EDGE_DRAIN=9); TERMINAL=10 is reserved/unused.
let elev = slope_grid(512, 256);
let result = analyze(&elev, 512, 256, 0.3);
for &v in &result.river_network.river_downstream {
assert!(
v <= RIVER_DOWNSTREAM_EDGE_DRAIN,
"unexpected river_downstream value {v} (round-1 only emits 0-7, MOUTH=8, \
EDGE_DRAIN=9 — TERMINAL=10 is reserved and unused)"
);
}
}
#[test]
fn river_downstream_mouth_sentinel_matches_mouths_list() {
// Every river cell whose river_downstream is MOUTH must appear in
// `mouths`, and every entry of `mouths` must have MOUTH as its
// river_downstream — the two are the same underlying classification,
// captured in the same pass (Ruling 2a/3f).
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 result = analyze(&small.data, small.width, small.height, small.sea_level);
let rn = &result.river_network;
assert!(!rn.mouths.is_empty(), "GJ1c should have real sea mouths");
let mouth_set: std::collections::BTreeSet<(u16, u16)> =
rn.mouths.iter().copied().collect();
for (i, &pos) in rn.river_cells.iter().enumerate() {
let is_mouth_sentinel = rn.river_downstream[i] == RIVER_DOWNSTREAM_MOUTH;
let is_in_mouths_list = mouth_set.contains(&pos);
assert_eq!(
is_mouth_sentinel, is_in_mouths_list,
"cell {pos:?}: MOUTH sentinel ({is_mouth_sentinel}) must agree with \
mouths-list membership ({is_in_mouths_list})"
);
}
}
#[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);
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 elev: Vec<f32> = (0..n)
.map(|i| {
let r = i / w;
0.2 + (r as f32 / h as f32) * 0.7
})
.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
);
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"
);
assert!(
!rn.river_downstream
.iter()
.any(|&v| v == RIVER_DOWNSTREAM_MOUTH),
"an all-land world must never emit a MOUTH sentinel"
);
}
#[test]
fn river_downstream_deterministic() {
let elev = slope_grid(64, 32);
let r1 = analyze(&elev, 64, 32, 0.3);
let r2 = analyze(&elev, 64, 32, 0.3);
assert_eq!(
r1.river_network.river_downstream, r2.river_network.river_downstream,
"river_downstream must be deterministic (D-010/D-208)"
);
}
}
+12
View File
@@ -18,6 +18,18 @@
//! Golden captured on x86_64. The downsample and sub-biome cost use f32, so a
//! different architecture could in principle round differently — regenerate
//! per-arch if CI ever moves off x86_64.
//!
//! **Deliberate re-pin (T-1170 Ruling 2a/2c/3f, A1):** `RiverNetwork` gained
//! an additive `river_downstream: Vec<u8>` field (per-`river_cells`-entry D8
//! downstream pointer + MOUTH/EDGE_DRAIN sentinel), and `extract_river_network`
//! stopped classifying pole-edge D8 exits (flow running off the grid's
//! top/bottom row) as `mouths` — they are grid artifacts, not river-meets-sea
//! events (Ruling 3f, Jeroen's capture question). On this fixture (GJ1c,
//! 256×128 downsample) that drops `mouths` from 19 to 3: 16 of the 19 were
//! pole-edge exits (now `RIVER_DOWNSTREAM_EDGE_DRAIN`), leaving the 3 real
//! sea-adjacent mouths (`RIVER_DOWNSTREAM_MOUTH`). `river_cells`/`attractors`
//! counts are unchanged (93/256) — this is a pure re-classification + one new
//! additive field, not a drainage-algorithm change.
use std::path::PathBuf;
File diff suppressed because it is too large Load Diff