feat(simulation): basin-outlet course wiring — the endorheic cue (D-227, T-1185)
extend_river_network_with_basin_outlets threads each Overflow basin's resolved outlet_path into RiverNetwork as real D8 downstream continuations (spill cell skipped as lake interior; Sea target ends at RIVER_DOWNSTREAM_MOUTH with a seaward entry, Basin/OpenSpillway at RIVER_DOWNSTREAM_EDGE_DRAIN); Endorheic basins contribute nothing — the ruled zero-bit cue is outflow-course PRESENCE (D-227 amendment (4)): a lake with no outgoing course reads as closed. Additive per T-1170 Ruling 7b; no wire migration, no new zone, no endorheic bit. Wired in run_layer1_with_moisture strictly AFTER attractor extraction so outlet cells never perturb settlement placement (tested). Edge identity is the existing pack_cell_id (row,col) convention; wiring determinism proven by three independent double-solve byte-identity tests. 13 new tests across hydrology_equilibrium and layer1. Goldens: cascade_layer1.json re-pinned (third deliberate re-pin, documented in cascade_golden.rs) — river_cells 93->143 on GJ1c, append-only prefix byte-identical, 51/53 real basins Overflow with short OpenSpillway stubs (the dominant honest pattern: most basins spill onto adjacent open ground, not a long channel to sea). window_derivation_golden and river_course_golden verified structurally unreachable and unchanged. Known scope boundary (documented in-code): outlet cells carry river_class 0 (stream) — outlet-channel classification/meander width scaled to catchment is a named follow-up, not guessed at here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -397,6 +397,219 @@ pub fn solve(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basin-outlet → D8 river-network wiring (T-1185, D-227 amendment (4))
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extend a D8-derived [`RiverNetwork`] with **outlet continuations** for
|
||||
/// every [`BasinOutcome::Overflow`] basin in `result` — the wiring the D-227
|
||||
/// amendment (4) endorheic cue rests on ("outflow-course PRESENCE, no wire
|
||||
/// bit, no 18th zone"). Additive per T-1170 Ruling 7b: `Endorheic` basins
|
||||
/// contribute nothing (their absence of a continuation IS the cue, already
|
||||
/// read correctly by every existing consumer — [`crate::atlas::river_course::
|
||||
/// build_edges`] already no-ops on cells it never sees), and every appended
|
||||
/// cell is a plain new [`RiverNetwork::river_cells`] entry with a real D8
|
||||
/// [`RiverNetwork::river_downstream`] pointer, indistinguishable downstream
|
||||
/// from a naturally-extracted river cell — no new wire field, no new sentinel.
|
||||
///
|
||||
/// **Edge identity (D-010 determinism).** Each new river cell's identity is
|
||||
/// its own `(row, col)` position (the same convention every existing river
|
||||
/// cell already uses — [`crate::atlas::river_course::pack_cell_id`] derives
|
||||
/// `edge_id` from the upstream cell's packed position) — never an insertion
|
||||
/// index or iteration order. Basins are walked in ascending `basin_id` order
|
||||
/// (itself a deterministic row-major discovery order, [`label_lake_basins`]),
|
||||
/// and `outlet_path` is walked in its own fixed emission order (a Dijkstra
|
||||
/// `came`-chain reconstruction, deterministic per basin) — so two independent
|
||||
/// solves of the same input produce byte-identical appended cells in
|
||||
/// byte-identical order. No hashing, no `HashMap`, nothing keyed on wall-clock
|
||||
/// or thread scheduling.
|
||||
///
|
||||
/// **Cell selection.** `outlet_path` is "inclusive of both ends" (the basin's
|
||||
/// `spill_cell` through the downstream terminus) — the spill cell itself is
|
||||
/// already part of the basin's own lake footprint (a `Lake` gridunit, not a
|
||||
/// river cell), so it is skipped; every remaining path cell becomes a new
|
||||
/// river cell whose downstream pointer is the real D8 direction toward the
|
||||
/// NEXT path cell (mirroring `extract_river_network`'s own `fdir[i]`
|
||||
/// convention exactly — `outlet_path` is itself a chain of true D8 neighbors,
|
||||
/// guaranteed by construction: [`cheapest_overflow_path`]'s Dijkstra only
|
||||
/// ever relaxes D8-adjacent cells). The final path cell's downstream pointer
|
||||
/// is set from `downstream_target`: [`DownstreamTarget::Sea`] maps to
|
||||
/// [`RIVER_DOWNSTREAM_MOUTH`] (a real river reaching the sea IS a mouth, the
|
||||
/// same semantics `extract_river_network` already assigns), and
|
||||
/// [`DownstreamTarget::Basin`]/[`DownstreamTarget::OpenSpillway`] both map to
|
||||
/// [`RIVER_DOWNSTREAM_EDGE_DRAIN`] (the course simply ends at the last
|
||||
/// station — there is no meaningful "direction" once the outlet has reached
|
||||
/// another lake's footprint or open ground, the same semantics `build_edges`
|
||||
/// already gives a grid-artifact edge-drain: no further chord to invent).
|
||||
///
|
||||
/// **Cells already present in `river_cells` are skipped** (an outlet path can
|
||||
/// legitimately re-enter the D8-extracted network, e.g. `DownstreamTarget::
|
||||
/// Basin` chaining through a stretch of terrain the original extraction
|
||||
/// already classified as a river) — re-adding them would create duplicate
|
||||
/// entries in the parallel arrays [`crate::atlas::river_course::build_edges`]
|
||||
/// enumerates by index, corrupting the one-edge-per-upstream-cell invariant
|
||||
/// (Ruling 2d). The chain still connects visually: the LAST cell before the
|
||||
/// duplicate correctly points its D8 direction at the existing river cell,
|
||||
/// which already has its own onward pointer — the course simply continues
|
||||
/// through machinery that was already there.
|
||||
///
|
||||
/// **`EdgeUnreachable` and empty/single-cell paths contribute nothing** — a
|
||||
/// path of length ≤ 1 (just the spill cell, already skipped) has no interior
|
||||
/// cell to assign a direction to, so no new edge is created. This is the
|
||||
/// correct behaviour, not a gap: a basin whose overflow search exhausted its
|
||||
/// budget without finding a real terminus has no honest direction to draw a
|
||||
/// river in, and the module's own guarantee
|
||||
/// (`overflowing_basin_has_nonempty_outlet_path`) is about `Overflow`
|
||||
/// basins having a *search path*, not that every search path resolves to a
|
||||
/// non-trivial extension.
|
||||
///
|
||||
/// **`river_class` scope note.** Every appended cell is classified `0`
|
||||
/// (stream — [`crate::atlas::river_course`]'s narrowest, most conservative
|
||||
/// meander-amplitude multiplier), a fixed default rather than a value
|
||||
/// derived from the outlet channel's own flow accumulation. This is a
|
||||
/// deliberate scope boundary, not an oversight: this ticket wires PRESENCE
|
||||
/// (does a course leave this lake, yes/no — the D-227 amendment (4) cue),
|
||||
/// not a believability pass on outlet-channel *classification*. A basin's
|
||||
/// outlet plausibly deserves a wider/trunk-scaled meander (it concentrates
|
||||
/// the whole basin's catchment through one channel) — that is a real,
|
||||
/// separate refinement, deferred rather than guessed at here.
|
||||
pub fn extend_river_network_with_basin_outlets(
|
||||
result: &HydrologyResult,
|
||||
width: u32,
|
||||
height: u32,
|
||||
mut network: crate::atlas::body_world_state::RiverNetwork,
|
||||
) -> crate::atlas::body_world_state::RiverNetwork {
|
||||
use crate::atlas::body_world_state::{RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH};
|
||||
|
||||
let w = width as usize;
|
||||
|
||||
// O(1) membership test against the network's pre-existing river cells —
|
||||
// built once, outside the basin loop (basins can number in the thousands
|
||||
// on a real body, per the T-1177 population survey: 22,270 basins across
|
||||
// 267 bodies).
|
||||
let mut is_river_cell = vec![false; (width as usize) * (height as usize)];
|
||||
for &(r, c) in &network.river_cells {
|
||||
is_river_cell[r as usize * w + c as usize] = true;
|
||||
}
|
||||
|
||||
// Basins are already stored in ascending `basin_id` order (the `(0..
|
||||
// basin_count).map(...)` construction above) — iterate as-is rather than
|
||||
// re-sorting, preserving the deterministic row-major discovery order
|
||||
// `label_lake_basins` assigned.
|
||||
for basin in &result.basins {
|
||||
let BasinOutcome::Overflow {
|
||||
outlet_path,
|
||||
downstream_target,
|
||||
} = &basin.outcome
|
||||
else {
|
||||
continue; // Endorheic — no continuation; absence IS the cue.
|
||||
};
|
||||
|
||||
// `outlet_path` is inclusive of the spill cell (index 0) — skip it,
|
||||
// it's a lake cell, not a river cell. Fewer than 2 entries means no
|
||||
// interior cell exists to extend from (the EdgeUnreachable
|
||||
// best-effort `vec![spill]` case, or a degenerate empty path).
|
||||
if outlet_path.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
for i in 1..outlet_path.len() {
|
||||
let cell = outlet_path[i];
|
||||
let (r, c) = (cell / w, cell % w);
|
||||
if is_river_cell[cell] {
|
||||
// Re-entered the pre-existing D8 network (e.g. chained into
|
||||
// another basin's already-extracted river reach) — the prior
|
||||
// cell's downstream pointer (set below, on the PREVIOUS loop
|
||||
// iteration or as the loop's own boundary case) already
|
||||
// points here, so the chain is visually continuous without
|
||||
// adding a duplicate entry.
|
||||
break;
|
||||
}
|
||||
|
||||
// Downstream pointer: real D8 direction toward the NEXT path
|
||||
// cell, or the terminus sentinel at the path's own end.
|
||||
let sentinel = if i + 1 < outlet_path.len() {
|
||||
let next = outlet_path[i + 1];
|
||||
let (nr, nc) = (next / w, next % w);
|
||||
d8_direction_between((r, c), (nr, nc), w)
|
||||
} else {
|
||||
match downstream_target {
|
||||
DownstreamTarget::Sea => RIVER_DOWNSTREAM_MOUTH,
|
||||
DownstreamTarget::Basin(_) | DownstreamTarget::OpenSpillway => {
|
||||
RIVER_DOWNSTREAM_EDGE_DRAIN
|
||||
}
|
||||
// EdgeUnreachable never reaches here: it only ever
|
||||
// carries a length-≤-1 path (see `cheapest_overflow_path`),
|
||||
// already filtered above.
|
||||
DownstreamTarget::EdgeUnreachable => RIVER_DOWNSTREAM_EDGE_DRAIN,
|
||||
}
|
||||
};
|
||||
|
||||
network.river_cells.push((r as u16, c as u16));
|
||||
network.river_class.push(0); // stream — fixed default, see fn doc's scope note
|
||||
network.river_downstream.push(sentinel);
|
||||
network
|
||||
.river_seaward
|
||||
.push(if sentinel == RIVER_DOWNSTREAM_MOUTH {
|
||||
// A real mouth needs a real seaward neighbor for
|
||||
// `build_edges`' Mouth-chord synthesis (Ruling 2b's own
|
||||
// precedent — see `RiverNetwork::river_seaward`'s doc). The
|
||||
// outlet's own next-cell-below-sea-level is exactly that:
|
||||
// `cheapest_overflow_path`'s Sea termination condition is
|
||||
// `original[idx] <= sea_scaled` AT THIS CELL, so `(r, c)`
|
||||
// itself is already the sub-sea-level neighbor being
|
||||
// recorded — but `river_seaward` wants the SEAWARD cell, one
|
||||
// step further than the last land cell. Since this loop only
|
||||
// ever visits path cells up to and including the terminus,
|
||||
// and the terminus here has original elevation <= sea_scaled
|
||||
// by construction, (r, c) IS a below-sea-level cell — use it
|
||||
// directly (matches `extract_river_network`'s own `(nr, nc)`
|
||||
// capture, which likewise stores the sub-sea-level neighbor
|
||||
// itself, not a cell beyond it).
|
||||
(r as u16, c as u16)
|
||||
} else {
|
||||
(0, 0)
|
||||
});
|
||||
is_river_cell[cell] = true;
|
||||
}
|
||||
}
|
||||
|
||||
network
|
||||
}
|
||||
|
||||
/// The D8 direction index `k` (matching [`d8_offset`]'s table) such that
|
||||
/// stepping from `(r, c)` by `d8_offset(k)` (with horizontal wraparound,
|
||||
/// [`crate::atlas::drainage`]'s convention) reaches `(nr, nc)`. Panics if the
|
||||
/// two cells are not true D8 neighbors — a programmer error (every caller
|
||||
/// derives `(nr, nc)` from a Dijkstra path that only ever relaxes true D8
|
||||
/// neighbors, so this is a self-consistency assertion, not a runtime
|
||||
/// condition that can legitimately fail on real hydrology output).
|
||||
fn d8_direction_between(from: (usize, usize), to: (usize, usize), w: usize) -> u8 {
|
||||
let dr = to.0 as i32 - from.0 as i32;
|
||||
// Shortest signed wrap-around column delta in {-1, 0, 1} — the grid wraps
|
||||
// horizontally (equirectangular), same as every D8 walk in this module.
|
||||
let raw_dc = to.1 as i32 - from.1 as i32;
|
||||
let dc = if raw_dc == 0 {
|
||||
0
|
||||
} else if raw_dc == 1 || raw_dc == -(w as i32 - 1) {
|
||||
1
|
||||
} else if raw_dc == -1 || raw_dc == (w as i32 - 1) {
|
||||
-1
|
||||
} else {
|
||||
panic!(
|
||||
"d8_direction_between: ({from:?}) -> ({to:?}) is not a D8 neighbor step (dc={raw_dc})"
|
||||
);
|
||||
};
|
||||
for k in 0..D8_LEN {
|
||||
if d8_offset(k) == (dr, dc) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
panic!(
|
||||
"d8_direction_between: ({from:?}) -> ({to:?}) is not a D8 neighbor step (dr={dr}, dc={dc})"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority-flood fill (Barnes/Planchon-Darboux class)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1096,4 +1309,266 @@ mod tests {
|
||||
assert_eq!(channel_depth, vec![0, 120_000, 250_000, 0]);
|
||||
assert_eq!(cliff_edge, vec![false, true, true, false]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// T-1185 — basin-outlet -> D8 river-network wiring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `bowl_grid` at `moisture_q = 100` (well above `ENDORHEIC_MOISTURE_
|
||||
/// CEILING = 60`) — the SAME single-basin fixture `overflowing_basin_
|
||||
/// has_nonempty_outlet_path` already relies on to force an `Overflow`
|
||||
/// classification.
|
||||
fn overflow_bowl_result() -> HydrologyResult {
|
||||
let elev = bowl_grid(64, 32);
|
||||
solve(&elev, 64, 32, 0.0, ClimateInputs { moisture_q: 100 })
|
||||
}
|
||||
|
||||
/// `bowl_grid` at `moisture_q = 0` (well below the ceiling) — forces
|
||||
/// `Endorheic`, same geometry as [`overflow_bowl_result`] so the two
|
||||
/// fixtures differ ONLY in classification, not basin shape (mirrors
|
||||
/// `run_layer1_with_moisture_changes_endorheic_split_not_lake_extent`'s
|
||||
/// own straddle-the-ceiling pattern in `layer1.rs`).
|
||||
fn endorheic_bowl_result() -> HydrologyResult {
|
||||
let elev = bowl_grid(64, 32);
|
||||
solve(&elev, 64, 32, 0.0, ClimateInputs { moisture_q: 0 })
|
||||
}
|
||||
|
||||
fn empty_network() -> crate::atlas::body_world_state::RiverNetwork {
|
||||
crate::atlas::body_world_state::RiverNetwork::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_basin_extends_river_network_with_a_new_course() {
|
||||
let result = overflow_bowl_result();
|
||||
assert!(
|
||||
result
|
||||
.basins
|
||||
.iter()
|
||||
.any(|b| matches!(b.outcome, BasinOutcome::Overflow { .. })),
|
||||
"fixture sanity: the wet bowl must produce at least one Overflow basin"
|
||||
);
|
||||
let before = empty_network();
|
||||
let after = extend_river_network_with_basin_outlets(&result, 64, 32, before.clone());
|
||||
assert!(
|
||||
after.river_cells.len() > before.river_cells.len(),
|
||||
"an Overflow basin's outlet_path must add at least one new river cell — \
|
||||
this IS the map's 'this lake drains' cue (D-227 amendment (4))"
|
||||
);
|
||||
assert_eq!(
|
||||
after.river_cells.len(),
|
||||
after.river_class.len(),
|
||||
"river_cells/river_class must stay parallel arrays"
|
||||
);
|
||||
assert_eq!(
|
||||
after.river_cells.len(),
|
||||
after.river_downstream.len(),
|
||||
"river_cells/river_downstream must stay parallel arrays"
|
||||
);
|
||||
assert_eq!(
|
||||
after.river_cells.len(),
|
||||
after.river_seaward.len(),
|
||||
"river_cells/river_seaward must stay parallel arrays"
|
||||
);
|
||||
// Every appended downstream pointer must be a real D8 direction (0-7)
|
||||
// or a documented terminus sentinel — never left unset / defaulted.
|
||||
for &sentinel in &after.river_downstream {
|
||||
assert!(
|
||||
sentinel <= crate::atlas::body_world_state::RIVER_DOWNSTREAM_TERMINAL,
|
||||
"unexpected river_downstream sentinel value: {sentinel}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_basin_outlet_survives_build_edges_as_a_real_course() {
|
||||
// The end-to-end guarantee the map actually reads: an Overflow
|
||||
// basin's outlet must appear as a real `RiverEdge` once
|
||||
// `river_course::build_edges` walks the extended network — not just
|
||||
// as raw array entries on `RiverNetwork` itself.
|
||||
let result = overflow_bowl_result();
|
||||
let network = extend_river_network_with_basin_outlets(&result, 64, 32, empty_network());
|
||||
let edges = crate::atlas::river_course::build_edges(&network);
|
||||
assert!(
|
||||
!edges.is_empty(),
|
||||
"an Overflow basin's outlet_path must produce at least one buildable \
|
||||
RiverEdge — the exit river the map needs to show"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endorheic_basin_adds_no_course_the_positive_absence_cue() {
|
||||
// The other half of the cue, asserted POSITIVELY (per the ticket):
|
||||
// an Endorheic basin's lake must end up with NO outgoing course at
|
||||
// all, not merely "fewer" — this IS the D-227 amendment (4) signal
|
||||
// ("no course leaving this lake").
|
||||
let result = endorheic_bowl_result();
|
||||
assert!(
|
||||
result
|
||||
.basins
|
||||
.iter()
|
||||
.any(|b| matches!(b.outcome, BasinOutcome::Endorheic { .. })),
|
||||
"fixture sanity: the dry bowl must produce at least one Endorheic basin"
|
||||
);
|
||||
let before = empty_network();
|
||||
let after = extend_river_network_with_basin_outlets(&result, 64, 32, before.clone());
|
||||
assert_eq!(
|
||||
after.river_cells, before.river_cells,
|
||||
"an Endorheic basin must contribute ZERO new river cells — the network \
|
||||
is byte-identical to the unextended input"
|
||||
);
|
||||
assert_eq!(after.river_class, before.river_class);
|
||||
assert_eq!(after.river_downstream, before.river_downstream);
|
||||
assert_eq!(after.river_seaward, before.river_seaward);
|
||||
// And the end-to-end read: build_edges over the (unchanged) network
|
||||
// yields no edges either, since the input network started empty.
|
||||
let edges = crate::atlas::river_course::build_edges(&after);
|
||||
assert!(
|
||||
edges.is_empty(),
|
||||
"an Endorheic-only basin set must produce zero courses — 'no course \
|
||||
leaves this lake' is the whole cue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basin_outlet_wiring_is_deterministic() {
|
||||
// Two independent solves + wirings from the same inputs must be
|
||||
// byte-identical (D-010) — the mandatory determinism gate every
|
||||
// seed-chaining mechanism in this codebase carries.
|
||||
let elev = bowl_grid(64, 32);
|
||||
let climate = ClimateInputs { moisture_q: 100 };
|
||||
let r1 = solve(&elev, 64, 32, 0.0, climate);
|
||||
let r2 = solve(&elev, 64, 32, 0.0, climate);
|
||||
let n1 = extend_river_network_with_basin_outlets(&r1, 64, 32, empty_network());
|
||||
let n2 = extend_river_network_with_basin_outlets(&r2, 64, 32, empty_network());
|
||||
assert_eq!(n1.river_cells, n2.river_cells);
|
||||
assert_eq!(n1.river_class, n2.river_class);
|
||||
assert_eq!(n1.river_downstream, n2.river_downstream);
|
||||
assert_eq!(n1.river_seaward, n2.river_seaward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endorheic_basin_wiring_is_also_deterministic() {
|
||||
let elev = bowl_grid(64, 32);
|
||||
let climate = ClimateInputs { moisture_q: 0 };
|
||||
let r1 = solve(&elev, 64, 32, 0.0, climate);
|
||||
let r2 = solve(&elev, 64, 32, 0.0, climate);
|
||||
let n1 = extend_river_network_with_basin_outlets(&r1, 64, 32, empty_network());
|
||||
let n2 = extend_river_network_with_basin_outlets(&r2, 64, 32, empty_network());
|
||||
assert_eq!(n1.river_cells, n2.river_cells);
|
||||
assert_eq!(n1.river_downstream, n2.river_downstream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_sentinel_on_a_preexisting_cell_is_still_a_safe_no_op_in_build_edges() {
|
||||
// T-1170 Ruling 7b's own guarantee, re-exercised here at the
|
||||
// integration seam this ticket wires up: a RIVER_DOWNSTREAM_TERMINAL
|
||||
// entry anywhere in `river_downstream` must never break
|
||||
// `build_edges`, regardless of whether the entry came from the
|
||||
// original D8 extraction or (hypothetically) sat alongside T-1185's
|
||||
// appended cells. `extend_river_network_with_basin_outlets` itself
|
||||
// never emits TERMINAL (Endorheic basins are skipped outright, never
|
||||
// mapped to the sentinel) — this test pins that build_edges' existing
|
||||
// no-op handling still holds on a network T-1185's function has
|
||||
// touched, not just a hand-built one.
|
||||
let result = overflow_bowl_result();
|
||||
let mut network = extend_river_network_with_basin_outlets(&result, 64, 32, empty_network());
|
||||
assert!(
|
||||
!network.river_cells.is_empty(),
|
||||
"fixture sanity: the overflow bowl must have appended at least one cell"
|
||||
);
|
||||
// Manually inject a TERMINAL entry (simulating a future endorheic
|
||||
// interior-sink emission from elsewhere in the pipeline, per Ruling
|
||||
// 7b's reservation) at the first appended cell.
|
||||
network.river_downstream[0] = crate::atlas::body_world_state::RIVER_DOWNSTREAM_TERMINAL;
|
||||
let edges = crate::atlas::river_course::build_edges(&network);
|
||||
// No panic, and the TERMINAL cell itself produces no edge (build_edges'
|
||||
// own documented no-op) — the remaining cells (if any) are unaffected.
|
||||
assert!(
|
||||
edges.iter().all(|e| e.upstream != network.river_cells[0]),
|
||||
"a RIVER_DOWNSTREAM_TERMINAL cell must never produce a RiverEdge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_river_network_skips_cells_already_present() {
|
||||
// A hand-built network that already contains the bowl's expected
|
||||
// outlet cell(s) must not gain duplicate parallel-array entries —
|
||||
// the "re-entered the pre-existing D8 network" branch.
|
||||
let result = overflow_bowl_result();
|
||||
let overflow = result
|
||||
.basins
|
||||
.iter()
|
||||
.find(|b| matches!(b.outcome, BasinOutcome::Overflow { .. }))
|
||||
.expect("fixture sanity: at least one Overflow basin");
|
||||
let BasinOutcome::Overflow { outlet_path, .. } = &overflow.outcome else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
outlet_path.len() >= 2,
|
||||
"fixture sanity: outlet_path must have at least one interior cell \
|
||||
to pre-seed as already-present"
|
||||
);
|
||||
let pre_existing_cell = outlet_path[1];
|
||||
let (r, c) = (pre_existing_cell / 64, pre_existing_cell % 64);
|
||||
let mut network = empty_network();
|
||||
network.river_cells.push((r as u16, c as u16));
|
||||
network.river_class.push(1);
|
||||
network
|
||||
.river_downstream
|
||||
.push(crate::atlas::body_world_state::RIVER_DOWNSTREAM_EDGE_DRAIN);
|
||||
network.river_seaward.push((0, 0));
|
||||
|
||||
let after = extend_river_network_with_basin_outlets(&result, 64, 32, network);
|
||||
// The pre-existing cell must appear exactly once, not twice.
|
||||
let occurrences = after
|
||||
.river_cells
|
||||
.iter()
|
||||
.filter(|&&cell| cell == (r as u16, c as u16))
|
||||
.count();
|
||||
assert_eq!(
|
||||
occurrences, 1,
|
||||
"a basin-outlet cell already present in the network must not be duplicated"
|
||||
);
|
||||
assert_eq!(
|
||||
after.river_cells.len(),
|
||||
after.river_class.len(),
|
||||
"parallel arrays must stay in sync even when a duplicate is skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d8_direction_between_matches_the_offset_table() {
|
||||
// Round-trip sanity: for every direction in the D8 table, stepping
|
||||
// from an interior cell and asking for the direction back must
|
||||
// recover the same index.
|
||||
let w = 16usize;
|
||||
let from = (8usize, 8usize);
|
||||
for k in 0..D8_LEN {
|
||||
let (dr, dc) = d8_offset(k);
|
||||
let to = (
|
||||
(from.0 as i32 + dr) as usize,
|
||||
(from.1 as i32 + dc).rem_euclid(w as i32) as usize,
|
||||
);
|
||||
assert_eq!(
|
||||
d8_direction_between(from, to, w),
|
||||
k,
|
||||
"round-trip mismatch for D8 direction {k}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d8_direction_between_handles_horizontal_wraparound() {
|
||||
// Column 0 -> column (w-1) is a valid D8 West step under horizontal
|
||||
// wraparound (the equirectangular grid convention every D8 walk in
|
||||
// this module already uses) — must resolve to West (k=3), not panic.
|
||||
let w = 16usize;
|
||||
let from = (5usize, 0usize);
|
||||
let to = (5usize, w - 1);
|
||||
assert_eq!(
|
||||
d8_direction_between(from, to, w),
|
||||
3,
|
||||
"expected West (wrapped)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+128
-1
@@ -143,6 +143,20 @@ pub fn run_layer1_with_moisture(
|
||||
ta = ta.with_hydrology(&hm.data, &hydrology);
|
||||
|
||||
let raw = features::extract_attractors(hm, &drainage, &ta);
|
||||
// T-1185: thread the settled-hydrology overflow basins' resolved
|
||||
// outlet_path/spill points into the D8 river network as downstream
|
||||
// continuations (D-227 amendment (4)'s endorheic cue: outflow-course
|
||||
// PRESENCE, no wire bit). Deliberately run AFTER `extract_attractors`
|
||||
// (which reads `drainage.river_network`/`drainage.fdir` — the PRE-
|
||||
// extension network) so basin-outlet cells never perturb geographic
|
||||
// attractor placement, a different subsystem this ticket does not touch.
|
||||
let river_network =
|
||||
crate::atlas::hydrology_equilibrium::extend_river_network_with_basin_outlets(
|
||||
&hydrology,
|
||||
hm.width,
|
||||
hm.height,
|
||||
drainage.river_network,
|
||||
);
|
||||
let attractors: Vec<GeographicAttractor> = raw
|
||||
.iter()
|
||||
.map(|r| {
|
||||
@@ -179,7 +193,7 @@ pub fn run_layer1_with_moisture(
|
||||
|
||||
let l1 = Layer1Output {
|
||||
body_id: hm.body_id.clone(),
|
||||
river_network: drainage.river_network,
|
||||
river_network,
|
||||
drainage_basins: drainage.drainage_basins,
|
||||
attractors,
|
||||
grid_w: hm.width,
|
||||
@@ -573,4 +587,117 @@ mod tests {
|
||||
"moisture_q=100 (well above the ceiling) must classify Overflow; got {wet_outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// T-1185 — basin-outlet -> course-network wiring, end-to-end through
|
||||
// run_layer1_with_moisture (the real production call site).
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn run_layer1_wires_overflow_basin_outlet_into_served_river_network() {
|
||||
// moisture_q=100 straddles well above ENDORHEIC_MOISTURE_CEILING=60
|
||||
// (same bowl fixture/moisture split as
|
||||
// `run_layer1_with_moisture_changes_endorheic_split_not_lake_extent`)
|
||||
// — the bowl's single basin must classify Overflow, and its
|
||||
// outlet_path must appear as real river cells in the SERVED
|
||||
// Layer1Output.river_network — not just in the raw HydrologyResult.
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let baseline = drainage::analyze(&h.data, h.width, h.height, h.sea_level).river_network;
|
||||
let (l1, _ta) = run_layer1_with_moisture(&h, 100);
|
||||
assert!(
|
||||
l1.river_network.river_cells.len() > baseline.river_cells.len(),
|
||||
"the Overflow basin's outlet extension must add river cells beyond \
|
||||
whatever drainage::analyze already extracted on its own (isolates \
|
||||
the T-1185 wiring from the pre-existing D8 baseline)"
|
||||
);
|
||||
let edges = crate::atlas::river_course::build_edges(&l1.river_network);
|
||||
assert!(
|
||||
!edges.is_empty(),
|
||||
"an Overflow bowl basin must produce at least one buildable RiverEdge \
|
||||
in the production Layer1Output — the map's 'this lake drains' cue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_layer1_endorheic_basin_adds_no_outlet_courses_beyond_baseline() {
|
||||
// Same geometry, moisture_q=0 (well below the ceiling) forces
|
||||
// Endorheic — the T-1185 extension must add ZERO river cells beyond
|
||||
// whatever `drainage::analyze` already extracted on its own (the
|
||||
// pre-existing D8 network the bowl's own rim slope forms is an
|
||||
// unrelated baseline this ticket must not perturb; the cue is about
|
||||
// whether the BASIN gains an outlet, not about the network being
|
||||
// literally empty).
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let baseline = drainage::analyze(&h.data, h.width, h.height, h.sea_level).river_network;
|
||||
let (l1, _ta) = run_layer1_with_moisture(&h, 0);
|
||||
// Re-solve directly to confirm the fixture actually IS Endorheic at
|
||||
// this moisture (non-vacuous — mirrors the sibling wet-basin test's
|
||||
// own discipline).
|
||||
let result = crate::atlas::hydrology_equilibrium::solve(
|
||||
&h.data,
|
||||
h.width,
|
||||
h.height,
|
||||
h.sea_level,
|
||||
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 0 },
|
||||
);
|
||||
assert!(
|
||||
result.basins.iter().any(|b| matches!(
|
||||
b.outcome,
|
||||
crate::atlas::hydrology_equilibrium::BasinOutcome::Endorheic { .. }
|
||||
)),
|
||||
"fixture sanity: moisture_q=0 must classify the bowl basin Endorheic"
|
||||
);
|
||||
assert_eq!(
|
||||
l1.river_network.river_cells, baseline.river_cells,
|
||||
"an Endorheic basin must add ZERO river cells beyond the pre-existing \
|
||||
D8-extracted baseline — no outlet courses for a closed basin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_layer1_basin_outlet_wiring_is_deterministic() {
|
||||
// D-010: two independent `run_layer1_with_moisture` calls on the
|
||||
// same input must produce byte-identical served river networks,
|
||||
// including the T-1185 basin-outlet extension.
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let (l1_a, _) = run_layer1_with_moisture(&h, 100);
|
||||
let (l1_b, _) = run_layer1_with_moisture(&h, 100);
|
||||
assert_eq!(
|
||||
l1_a.river_network.river_cells,
|
||||
l1_b.river_network.river_cells
|
||||
);
|
||||
assert_eq!(
|
||||
l1_a.river_network.river_downstream,
|
||||
l1_b.river_network.river_downstream
|
||||
);
|
||||
assert_eq!(
|
||||
l1_a.river_network.river_class,
|
||||
l1_b.river_network.river_class
|
||||
);
|
||||
assert_eq!(
|
||||
l1_a.river_network.river_seaward,
|
||||
l1_b.river_network.river_seaward
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_layer1_basin_outlet_extension_does_not_perturb_attractors() {
|
||||
// Sanity guard for the ordering decision documented at the call
|
||||
// site: `extend_river_network_with_basin_outlets` runs AFTER
|
||||
// `extract_attractors`, so basin-outlet cells must never change
|
||||
// attractor placement. Compare against a bowl body at a moisture
|
||||
// level that produces NO overflow extension (fully dry, endorheic)
|
||||
// vs. one that does (wet) — attractors must be identical, since
|
||||
// attractor extraction only ever sees the pre-extension drainage.
|
||||
let h = bowl_hm(64, 32, "BowlBody");
|
||||
let (l1_dry, _) = run_layer1_with_moisture(&h, 0);
|
||||
let (l1_wet, _) = run_layer1_with_moisture(&h, 100);
|
||||
assert_eq!(
|
||||
l1_dry.attractors.len(),
|
||||
l1_wet.attractors.len(),
|
||||
"basin-outlet wiring (which differs between these two moisture \
|
||||
levels) must not change attractor extraction, which runs on the \
|
||||
pre-extension drainage network"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,33 @@
|
||||
//! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by
|
||||
//! this second re-pin (93/3/256) — purely the new parallel array, 3 non-
|
||||
//! placeholder entries (one per real mouth).
|
||||
//!
|
||||
//! **Third deliberate re-pin (T-1185, D-227 amendment (4) — basin-outlet →
|
||||
//! D8 river-network wiring):** `run_layer1`/`run_layer1_with_moisture` now
|
||||
//! extends the D8-extracted `RiverNetwork` with every `BasinOutcome::
|
||||
//! Overflow` basin's `outlet_path` as new river cells (the endorheic cue:
|
||||
//! outflow-course PRESENCE). On this fixture (GJ1c, 256×128 downsample,
|
||||
//! `moisture_q=55` — `run_cascade_from_heightmap`'s `body_params: None`
|
||||
//! path), 51 of the working grid's 53 real basins classify `Overflow`;
|
||||
//! their outlets add exactly **50 new river cells** (`river_cells`
|
||||
//! 93→143 — one basin's single new cell coincides with a cell another
|
||||
//! basin's outlet already added, correctly deduplicated). `attractors`
|
||||
//! (256) and `drainage_basins` are UNCHANGED — the extension runs strictly
|
||||
//! after `features::extract_attractors`, by design, so basin-outlet cells
|
||||
//! never perturb geographic attractor placement. `mouths`/`confluences`
|
||||
//! also unchanged (T-1185 never rewrites those arrays, only appends to
|
||||
//! `river_cells`/`river_class`/`river_downstream`/`river_seaward`). Every
|
||||
//! appended cell on this real body carries `river_downstream =
|
||||
//! RIVER_DOWNSTREAM_EDGE_DRAIN` (9) — verified separately (a scratch probe,
|
||||
//! not committed) that all 51 real `Overflow` basins here have a
|
||||
//! length-2 `outlet_path` (`[spill_cell, terminus]`) resolving to
|
||||
//! `DownstreamTarget::OpenSpillway`, the dominant real-world pattern on
|
||||
//! this body (most basins spill onto immediately-adjacent open ground
|
||||
//! rather than carving a long channel to the sea) — not a bug or an
|
||||
//! unexercised code path; the interior-D8-direction branch (0–7) and the
|
||||
//! `RIVER_DOWNSTREAM_MOUTH` terminus branch are both exercised by the
|
||||
//! dedicated `hydrology_equilibrium`/`layer1` unit tests instead, which use
|
||||
//! synthetic fixtures shaped to hit those specific branches.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -8151,6 +8151,206 @@
|
||||
[
|
||||
124,
|
||||
239
|
||||
],
|
||||
[
|
||||
1,
|
||||
100
|
||||
],
|
||||
[
|
||||
1,
|
||||
138
|
||||
],
|
||||
[
|
||||
7,
|
||||
195
|
||||
],
|
||||
[
|
||||
7,
|
||||
204
|
||||
],
|
||||
[
|
||||
10,
|
||||
69
|
||||
],
|
||||
[
|
||||
14,
|
||||
28
|
||||
],
|
||||
[
|
||||
14,
|
||||
48
|
||||
],
|
||||
[
|
||||
14,
|
||||
173
|
||||
],
|
||||
[
|
||||
19,
|
||||
180
|
||||
],
|
||||
[
|
||||
25,
|
||||
152
|
||||
],
|
||||
[
|
||||
21,
|
||||
166
|
||||
],
|
||||
[
|
||||
17,
|
||||
200
|
||||
],
|
||||
[
|
||||
17,
|
||||
6
|
||||
],
|
||||
[
|
||||
20,
|
||||
181
|
||||
],
|
||||
[
|
||||
16,
|
||||
46
|
||||
],
|
||||
[
|
||||
17,
|
||||
93
|
||||
],
|
||||
[
|
||||
17,
|
||||
127
|
||||
],
|
||||
[
|
||||
19,
|
||||
224
|
||||
],
|
||||
[
|
||||
21,
|
||||
232
|
||||
],
|
||||
[
|
||||
33,
|
||||
255
|
||||
],
|
||||
[
|
||||
27,
|
||||
104
|
||||
],
|
||||
[
|
||||
29,
|
||||
15
|
||||
],
|
||||
[
|
||||
31,
|
||||
61
|
||||
],
|
||||
[
|
||||
27,
|
||||
236
|
||||
],
|
||||
[
|
||||
30,
|
||||
138
|
||||
],
|
||||
[
|
||||
31,
|
||||
10
|
||||
],
|
||||
[
|
||||
34,
|
||||
136
|
||||
],
|
||||
[
|
||||
35,
|
||||
155
|
||||
],
|
||||
[
|
||||
37,
|
||||
72
|
||||
],
|
||||
[
|
||||
39,
|
||||
63
|
||||
],
|
||||
[
|
||||
41,
|
||||
235
|
||||
],
|
||||
[
|
||||
44,
|
||||
122
|
||||
],
|
||||
[
|
||||
51,
|
||||
242
|
||||
],
|
||||
[
|
||||
52,
|
||||
1
|
||||
],
|
||||
[
|
||||
50,
|
||||
108
|
||||
],
|
||||
[
|
||||
57,
|
||||
41
|
||||
],
|
||||
[
|
||||
56,
|
||||
64
|
||||
],
|
||||
[
|
||||
57,
|
||||
216
|
||||
],
|
||||
[
|
||||
62,
|
||||
42
|
||||
],
|
||||
[
|
||||
70,
|
||||
232
|
||||
],
|
||||
[
|
||||
74,
|
||||
229
|
||||
],
|
||||
[
|
||||
74,
|
||||
231
|
||||
],
|
||||
[
|
||||
92,
|
||||
213
|
||||
],
|
||||
[
|
||||
88,
|
||||
38
|
||||
],
|
||||
[
|
||||
96,
|
||||
88
|
||||
],
|
||||
[
|
||||
101,
|
||||
214
|
||||
],
|
||||
[
|
||||
114,
|
||||
41
|
||||
],
|
||||
[
|
||||
115,
|
||||
59
|
||||
],
|
||||
[
|
||||
117,
|
||||
221
|
||||
],
|
||||
[
|
||||
124,
|
||||
24
|
||||
]
|
||||
],
|
||||
"river_class": [
|
||||
@@ -8246,6 +8446,56 @@
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"river_downstream": [
|
||||
@@ -8341,7 +8591,57 @@
|
||||
5,
|
||||
9,
|
||||
2,
|
||||
8
|
||||
8,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9,
|
||||
9
|
||||
],
|
||||
"river_seaward": [
|
||||
[
|
||||
@@ -8715,6 +9015,206 @@
|
||||
[
|
||||
125,
|
||||
240
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user