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:
@@ -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 0–7:** 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 0–7 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
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user