diff --git a/server/src/atlas/hydrology_equilibrium.rs b/server/src/atlas/hydrology_equilibrium.rs index 50b963990..1244cceb5 100644 --- a/server/src/atlas/hydrology_equilibrium.rs +++ b/server/src/atlas/hydrology_equilibrium.rs @@ -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)" + ); + } } diff --git a/server/src/atlas/layer1.rs b/server/src/atlas/layer1.rs index ccbe8fb1f..178f25490 100644 --- a/server/src/atlas/layer1.rs +++ b/server/src/atlas/layer1.rs @@ -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 = 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" + ); + } } diff --git a/server/tests/cascade_golden.rs b/server/tests/cascade_golden.rs index a919061eb..9701a4b9f 100644 --- a/server/tests/cascade_golden.rs +++ b/server/tests/cascade_golden.rs @@ -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; diff --git a/server/tests/golden/cascade_layer1.json b/server/tests/golden/cascade_layer1.json index 0e8c58627..d0c67175d 100644 --- a/server/tests/golden/cascade_layer1.json +++ b/server/tests/golden/cascade_layer1.json @@ -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 ] ] }