diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index 8a0e8be32..b11e83253 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -89,6 +89,36 @@ pub struct RiverNetwork { /// array (pre-T-1170 payload/fixture) decodes to empty, never an error. #[serde(default)] pub river_downstream: Vec, + /// Per-`river_cells`-entry seaward neighbor position (T-1170 PR #197 + /// review, Hoshe #1) — same index/length shape as `river_class`/ + /// `river_downstream`. **Only meaningful where `river_downstream[i] == + /// RIVER_DOWNSTREAM_MOUTH`**; every other entry (interior direction or + /// `EDGE_DRAIN`) carries the placeholder `(0, 0)` and must not be read. + /// + /// **Why this exists — the second instance of the Ruling-2b anti-pattern.** + /// `extract_river_network` already computes the real sub-sea-level + /// neighbor `(nr, nc)` to decide the `MOUTH` sentinel (the elevation + /// check that flips `river_downstream[i]` to + /// [`RIVER_DOWNSTREAM_MOUTH`]) — the exact same "true answer already in + /// scope, then discarded" shape Ruling 2b fixed for interior D8 pointers + /// via `river_downstream` itself. Discarding `(nr, nc)` a second time + /// left `river_course::build_edges` with nothing to build a real chord + /// from for Mouth edges: it filled `downstream = upstream` as a + /// placeholder, which zeroes the chord (`chord_m < 1.0`), which trips + /// `invent_course`'s single-point degenerate return, which makes + /// `resolve_mouth_terminus`'s station walk a no-op (a 1-point course + /// never enters the `pts.len() >= 2` probe branch either) — every Mouth + /// edge silently resolved `CourseTerminus::None` instead of `Mouth`. + /// Capturing `(nr, nc)` here (one more push per mouth cell — mouths are + /// a small fraction of river cells, not a new grid pass) is what makes + /// `build_edges` give Mouth edges a real ~one-cell chord toward the raw + /// sea, so the termination walk + bisect + one-segment D8-probe fallback + /// (Ruling 3e) are actually reachable. + /// + /// `#[serde(default)]` — same additive pattern as `river_class`/ + /// `river_downstream`. + #[serde(default)] + pub river_seaward: Vec<(u16, u16)>, } /// [`RiverNetwork::river_downstream`] sentinel: this river cell's D8 flow diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index 7eb26bbf8..09d381438 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -351,33 +351,51 @@ fn extract_river_network( // `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. + // + // river_seaward (T-1170 PR #197 review, Hoshe #1): captured in the SAME + // pass, parallel to river_downstream — `(nr, nc)` is already computed + // here to decide the MOUTH sentinel; discarding it after the elevation + // check (the former code) was the second instance of the exact + // discard-then-need-it-later anti-pattern Ruling 2b fixed for + // river_downstream itself. Non-mouth entries get the `(0, 0)` placeholder + // (documented on the field as unreadable outside the MOUTH case). let mut mouths: Vec<(u16, u16)> = Vec::new(); - let river_downstream: Vec = (0..n) - .filter(|&i| is_river[i]) - .map(|i| { - let r = i / w; - let c = i % w; - let k = fdir[i]; - if k < 0 { - // 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 { - // 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; - } - 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 - }) - .collect(); + let mut river_downstream: Vec = Vec::new(); + let mut river_seaward: Vec<(u16, u16)> = Vec::new(); + for i in 0..n { + if !is_river[i] { + continue; + } + let r = i / w; + let c = i % w; + let k = fdir[i]; + if k < 0 { + // No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f). + river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN); + river_seaward.push((0, 0)); + continue; + } + 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 { + // Flow direction points off the polar edge — a grid artifact, + // not a mouth (Ruling 3f, the pole-edge-drain fix). + river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN); + river_seaward.push((0, 0)); + continue; + } + if elevation[nr as usize * w + nc] < sea_level { + // Flows into a sub-sea-level cell — a real mouth. Capture the + // seaward neighbor position (Hoshe #1) alongside the sentinel. + mouths.push((r as u16, c as u16)); + river_downstream.push(RIVER_DOWNSTREAM_MOUTH); + river_seaward.push((nr as u16, nc as u16)); + continue; + } + river_downstream.push(k as u8); + river_seaward.push((0, 0)); + } RiverNetwork { river_cells, @@ -385,6 +403,7 @@ fn extract_river_network( mouths, river_class, river_downstream, + river_seaward, } } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 53b23f3ad..688a8b924 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -272,9 +272,23 @@ pub enum GenCompletion { BodyAnalyzed { body_id: String, /// The computed world state, ready for `BodyWorldStateCache::insert`. - /// Boxed: `BodyWorldState` grew past clippy's large-enum-variant - /// threshold when it began retaining `Layer1Output` (T-1170 Ruling 4b) - /// — the box keeps the enum small while the state moves by pointer. + /// Boxed to keep `GenCompletion` variant sizes balanced — the same + /// discipline as `SkeletonGenerated`/`ChunkFilled`/`WindowDerived` + /// below, all boxed for the same reason (`GenCompletion`'s overall + /// size is bounded by its largest unboxed variant; a `BodyWorldState` + /// field carried by value here would force every other variant to + /// pay for its full stack size on every match/move). + /// + /// **Not this variant's own recent growth** (T-1170 PR #197 review, + /// Tyre issue 2 — the prior comment here overattributed the boxing + /// rationale): `BodyWorldState` itself only grew by + /// `RiverNetwork`'s two new `Vec` fields (`river_downstream`, + /// `river_seaward` — a few bytes/river-cell, Ruling 2a / Hoshe #1). + /// The much larger `Layer1Output` retention (T-1170 Ruling 4b) lives + /// on `TerrainAnalysisCache` — a queue-scoped struct further down + /// this file, never part of `BodyWorldState`/`BodyWorldStateCache` at + /// all. The box here predates T-1170 and stays for the pre-existing + /// variant-size-balancing reason, unrelated to this ticket's growth. state: Box, }, SkeletonGenerated { diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index bbaa64f63..564f99dbc 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -1237,6 +1237,11 @@ fn invent_courses_near_window( /// Crop the window's already-invented courses ([`invent_courses_near_window`]) /// to the wire [`RiverCourse`] shape (Ruling 3h) — window rect + one station /// beyond each edge, terminus resolution (A3, Ruling 3e/3f). +/// +/// `station_spacing_m` is the rung's own cell spacing (`granularity.spacing_m()` +/// — District 2,048 m / Quarter 512 m) — threaded to [`resolve_mouth_terminus`]'s +/// land-at-final-anchor probe, which extends "one cell length" (Ruling 3e's own +/// words), not one Stage-B segment length (Tyre, PR #197 review issue 1). #[allow(clippy::too_many_arguments)] fn crop_courses_for_wire( invented: &[InventedCourse], @@ -1247,6 +1252,7 @@ fn crop_courses_for_wire( ta: &crate::atlas::features::TerrainAnalysis, climate: &crate::atlas::district_profile::ClimateConstants, min_wavelength_m: f64, + station_spacing_m: f64, ) -> Vec { invented .iter() @@ -1260,6 +1266,7 @@ fn crop_courses_for_wire( ta, climate, min_wavelength_m, + station_spacing_m, ) }) .collect() @@ -1280,6 +1287,7 @@ fn crop_course_to_window( ta: &crate::atlas::features::TerrainAnalysis, climate: &crate::atlas::district_profile::ClimateConstants, min_wavelength_m: f64, + station_spacing_m: f64, ) -> Option { let (x0, y0, x1, y1) = window_rect; let inside = |p: &(f64, f64)| p.0 >= x0 && p.0 <= x1 && p.1 >= y0 && p.1 <= y1; @@ -1328,6 +1336,7 @@ fn crop_course_to_window( ta, climate, min_wavelength_m, + station_spacing_m, ) { Some(mouth_point) => { // Replace the cropped course's tail with the resolved @@ -1373,6 +1382,12 @@ const MOUTH_BISECT_ITERATIONS: u32 = 6; /// probe past the final anchor) samples water, returns `None` — the /// degenerate "drawn coast receded past this edge" case (Ruling 3e), which /// the caller renders with no mouth flag. +/// +/// `station_spacing_m` is the rung's own cell spacing — the probe extends +/// exactly "one cell length" past the final anchor (Ruling 3e's own words), +/// in the direction of the final Stage-B segment, but scaled to +/// `station_spacing_m` rather than that segment's own (possibly much +/// shorter, near-zero at a taper-to-zero anchor) length. fn resolve_mouth_terminus( course: &InventedCourse, seed: SeedChain, @@ -1381,6 +1396,7 @@ fn resolve_mouth_terminus( ta: &crate::atlas::features::TerrainAnalysis, climate: &crate::atlas::district_profile::ClimateConstants, min_wavelength_m: f64, + station_spacing_m: f64, ) -> Option<(f64, f64)> { let is_water = |p: (f64, f64)| -> bool { // `&[]`: the mouth-termination water-verdict probe has no use for @@ -1418,16 +1434,19 @@ fn resolve_mouth_terminus( } prev_land = p; } - // Final anchor still land: extend one cell length along the segment's - // own direction as a single probe (Ruling 3e: "extend along the D8 - // direction up to one cell length probing"). + // Final anchor still land: extend ONE CELL LENGTH (`station_spacing_m` — + // Ruling 3e's own words, "up to one cell length probing", not one + // Stage-B segment length, which can be much shorter near a + // taper-to-zero anchor — Tyre, PR #197 review issue 1) along the final + // segment's own direction, as a single probe. if pts.len() >= 2 { let a = pts[pts.len() - 2]; let b = pts[pts.len() - 1]; let (dx, dy) = (b.0 - a.0, b.1 - a.1); let len = (dx * dx + dy * dy).sqrt(); if len > 1e-6 { - let probe = (b.0 + dx / len * len, b.1 + dy / len * len); // one more segment-length step + let (ux, uy) = (dx / len, dy / len); // unit direction of the final segment + let probe = (b.0 + ux * station_spacing_m, b.1 + uy * station_spacing_m); if is_water(probe) { return Some(bisect_to_waterline(b, probe, is_water)); } @@ -1589,6 +1608,7 @@ pub fn build_district_window_layer( ta, climate, min_wavelength_m, + step_m, ); DistrictWindowLayer { @@ -1686,6 +1706,7 @@ fn build_district_window_layer_serial( ta, climate, min_wavelength_m, + step_m, ); DistrictWindowLayer { center, @@ -2946,6 +2967,144 @@ mod tests { } } + /// **T-1170 PR #197 review, Hoshe #1 acceptance test (blocking, permanent + /// — not a throwaway probe).** Every real Mouth edge on the GJ1c golden + /// fixture (256×128 downsample, the SAME fixture `cascade_golden.rs` + /// pins — 3 mouths: `[(38,47), (38,98), (124,239)]`) must resolve + /// `CourseTerminus::Mouth`, not `CourseTerminus::None`. + /// + /// **What this guards:** before the fix, `build_edges` set + /// `downstream = upstream` for every Mouth edge (a same-cell + /// placeholder — the SAME discard-then-need-it-later anti-pattern + /// Ruling 2b's `river_downstream` field fixed for interior pointers, + /// applied a second time to the seaward neighbor `extract_river_network` + /// already computes and then threw away). That zeroed the chord + /// (`chord_m < 1.0`), which tripped `invent_course`'s degenerate + /// single-point return, which made `resolve_mouth_terminus`'s station + /// walk a no-op (a 1-point course can't reach the `pts.len() >= 2` + /// fallback probe either) — all 3 real GJ1c mouths silently resolved + /// `CourseTerminus::None` instead of `Mouth`, and since Ruling 3g retired + /// the District/Quarter draw-time clip on the promise of real termini, + /// mouths would have disappeared entirely at those rungs. The fix: + /// `RiverNetwork::river_seaward` (additive, captured in the same + /// `extract_river_network` pass) carries the real seaward neighbor + /// through to `build_edges`, giving Mouth edges a genuine ~one-cell + /// chord to invent a course along. + #[test] + fn all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let station_spacing_m = DISTRICT_M as f64; + + let edges = river_course::build_edges(rn); + let mouth_edges: Vec<_> = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth) + .collect(); + assert_eq!( + mouth_edges.len(), + rn.mouths.len(), + "build_edges must produce exactly one Mouth edge per RiverNetwork.mouths entry" + ); + assert_eq!( + mouth_edges.len(), + 3, + "GJ1c at this downsample is expected to have 3 real mouths (matches the \ + committed cascade_golden fixture) — if this count changes, re-verify against \ + tests/golden/cascade_layer1.json before updating this assertion" + ); + + let mut resolved_mouth_count = 0; + for edge in &mouth_edges { + // Sanity: the fix means Mouth edges get a real, non-degenerate + // chord toward the seaward neighbor — never upstream==downstream. + assert_ne!( + edge.upstream, edge.downstream, + "Mouth edge {:?} still has a same-cell placeholder downstream — \ + river_seaward threading regressed", + edge.edge_id + ); + + let course = + river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + assert!( + course.points.len() >= 2, + "Mouth edge {:?} invented a degenerate {}-point course — the chord-length \ + fix regressed", + edge.edge_id, + course.points.len() + ); + + // Window rect generous enough to contain the whole short mouth + // course (mouths are ~one cell chord, so a wide margin is cheap). + let (min_x, max_x) = course + .points + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| { + (lo.min(p.0), hi.max(p.0)) + }); + let (min_y, max_y) = course + .points + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| { + (lo.min(p.1), hi.max(p.1)) + }); + let margin = 50_000.0; + let window_rect = (min_x - margin, min_y - margin, max_x + margin, max_y + margin); + + let wire = crop_course_to_window( + &course, + window_rect, + seed, + "GJ1c", + ¶ms, + &ta, + &climate, + 0.0, + station_spacing_m, + ) + .unwrap_or_else(|| panic!("Mouth edge {:?} cropped to nothing in its own window", edge.edge_id)); + + assert_eq!( + wire.terminus, + CourseTerminus::Mouth, + "Mouth edge {:?} (upstream {:?}, downstream {:?}) resolved {:?} instead of \ + CourseTerminus::Mouth", + edge.edge_id, + edge.upstream, + edge.downstream, + wire.terminus + ); + resolved_mouth_count += 1; + } + + assert_eq!( + resolved_mouth_count, 3, + "acceptance criterion (Hoshe #1): all 3 real GJ1c mouths must resolve \ + CourseTerminus::Mouth" + ); + } + /// Clamped-window edge: `n = 1` is the minimum valid window (a single /// district) — no panic, no empty output, exactly one cell per array. #[test] diff --git a/server/src/atlas/river_course.rs b/server/src/atlas/river_course.rs index a2336a9e4..ff98f153f 100644 --- a/server/src/atlas/river_course.rs +++ b/server/src/atlas/river_course.rs @@ -176,18 +176,25 @@ pub fn pack_cell_id(cell: RiverCell) -> u32 { /// always be exactly parallel to `river_cells`, but a mismatched-length /// legacy payload must degrade to "no edges" rather than panic). /// -/// For `Mouth`/`EdgeDrain` termini there is no real downstream river cell to -/// point at — the caller's termination logic (Ruling 3e/3f) walks or -/// truncates from the upstream anchor using the edge's own direction, not a -/// second real cell. This function still records SOME `downstream` position -/// for those edges — one D8 step from the upstream cell in its own flow -/// direction, when that direction is known (i.e. `river_downstream < 8`, the -/// `Mouth` sentinel case where the OLD code already had a real direction -/// before classifying the neighbor as sub-sea-level) — falling back to the -/// upstream cell itself (a zero-length probe anchor) for `EdgeDrain`, where -/// no direction survived at all. Both are legitimate: Stage A/B degrade -/// gracefully to a near-zero-length or short edge, and A3's termination logic -/// (layer_proxy) is what actually resolves the real geometry from here. +/// **Mouth edges get a REAL seaward chord (T-1170 PR #197 review, Hoshe #1), +/// not a same-cell placeholder.** The former code set `downstream = upstream` +/// for `Mouth` edges — a zero-length chord (`chord_m < 1.0` in +/// [`invent_course`]) that silently tripped the degenerate single-point +/// return, which made [`crate::atlas::layer_proxy::resolve_mouth_terminus`]'s +/// station walk a no-op (a 1-point course can't even reach that function's +/// `pts.len() >= 2` fallback probe) — every Mouth edge resolved +/// `CourseTerminus::None` instead of `Mouth`, DESPITE `extract_river_network` +/// having already computed the real seaward neighbor to decide the sentinel +/// in the first place. `RiverNetwork::river_seaward` (additive, captured in +/// the SAME extraction pass) now carries that neighbor through; this +/// function reads it for `Mouth` edges, giving them a genuine ~one-cell chord +/// toward the raw sea so Stage A/B actually have something to invent and the +/// termination walk is reachable. +/// +/// `EdgeDrain` termini still use the upstream cell itself as a zero-length +/// placeholder — there is no seaward neighbor for a grid-artifact exit, and +/// none is needed: A3's `EdgeDrain` handling (Ruling 3f) never probes for +/// water, it just ends the course at the last in-grid station. pub fn build_edges(rn: &RiverNetwork) -> Vec { let mut edges = Vec::with_capacity(rn.river_cells.len()); for (i, &upstream) in rn.river_cells.iter().enumerate() { @@ -203,14 +210,17 @@ pub fn build_edges(rn: &RiverNetwork) -> Vec { let downstream = step_cell(upstream, dr, dc); (downstream, EdgeTerminusKind::Interior) } else if sentinel == RIVER_DOWNSTREAM_MOUTH { - // The mouth sentinel is recorded once the D8 walk already found a - // real neighbor cell (see `extract_river_network`); we don't have - // that direction anymore at this layer, so use the upstream cell - // itself as a zero-length placeholder — A3's mouth-walk logic - // (layer_proxy, Ruling 3e) resolves the real terminus by sampling - // the morphology water verdict outward from `upstream`, not by - // trusting this placeholder position. - (upstream, EdgeTerminusKind::Mouth) + // Real seaward neighbor (Hoshe #1) — falls back to the upstream + // cell (the old placeholder) ONLY on a legacy/pre-fix payload + // where `river_seaward` is absent or the specific entry is the + // unset `(0, 0)` fill AND that happens to differ from a genuine + // seaward cell at (0,0) (an acceptable, vanishingly rare + // degradation at the map's literal origin — never hit on any + // real body, since (0,0) is a pole/edge pixel, never sub-sea + // adjacent to an actual river mouth in practice). + let seaward = rn.river_seaward.get(i).copied().unwrap_or((0, 0)); + let downstream = if seaward == (0, 0) { upstream } else { seaward }; + (downstream, EdgeTerminusKind::Mouth) } else { debug_assert_eq!(sentinel, RIVER_DOWNSTREAM_EDGE_DRAIN); (upstream, EdgeTerminusKind::EdgeDrain) diff --git a/server/tests/cascade_golden.rs b/server/tests/cascade_golden.rs index acd7c32e0..a919061eb 100644 --- a/server/tests/cascade_golden.rs +++ b/server/tests/cascade_golden.rs @@ -30,6 +30,32 @@ //! 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. +//! +//! **Attractor cascade correction (T-1170 PR #197 review, Hoshe #4):** the A1 +//! commit's "pure reclassification" framing overclaimed — `attractors` is +//! count-PARITY (256/256), not byte-identical. Mouths dropping 19→3 shrinks +//! `TerrainAnalysis::water_dist`'s seed set (`compute_water_dist` seeds from +//! `river_network.mouths`), which shifts the water-distance field, which +//! feeds `RawAttractor::strength` scoring in `features::extract_attractors`. +//! This is a principled, in-scope cascade (the pole-edge cells genuinely +//! aren't water-distance sources anymore) — not a bug — but it is a REAL +//! field-level change, not a no-op re-tagging. Restated accurately here so +//! the record doesn't imply byte-identical attractor output. +//! +//! **Second deliberate re-pin (T-1170 PR #197 review, Hoshe #1):** +//! `RiverNetwork` gained a second additive field, `river_seaward: +//! Vec<(u16,u16)>` — the real seaward neighbor position for MOUTH-sentinel +//! river cells, captured in the SAME `extract_river_network` pass (the +//! elevation check that already computes `(nr, nc)` to decide the MOUTH +//! sentinel). Fixes a second instance of the discard-then-need-it-later +//! anti-pattern Ruling 2b's `river_downstream` field fixed for interior D8 +//! pointers: `river_course::build_edges` needs a real seaward cell to give +//! Mouth edges a non-degenerate chord (see `river_course.rs`'s +//! `build_edges` doc and `layer_proxy::tests:: +//! all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` for the full +//! 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). use std::path::PathBuf; diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 449b2fdb7..0bb622068 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -559,6 +559,7 @@ fn generate_atlas_layer_response_fixtures() { mouths: vec![(12, 58)], river_class: vec![1, 2], river_downstream: vec![2, 8], // 2=E direction; 8=MOUTH sentinel + river_seaward: vec![(0, 0), (12, 60)], // meaningful only for the MOUTH entry }, drainage_basins: vec![DrainageBasin { basin_id: 1, diff --git a/server/tests/golden/cascade_layer1.json b/server/tests/golden/cascade_layer1.json index cdfcc0e37..0e8c58627 100644 --- a/server/tests/golden/cascade_layer1.json +++ b/server/tests/golden/cascade_layer1.json @@ -8342,6 +8342,380 @@ 9, 2, 8 + ], + "river_seaward": [ + [ + 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 + ], + [ + 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 + ], + [ + 39, + 47 + ], + [ + 38, + 99 + ], + [ + 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 + ], + [ + 125, + 240 + ] ] } }, diff --git a/server/tests/golden/river_course_golden.json b/server/tests/golden/river_course_golden.json index c6b71ad88..b848bdc35 100644 --- a/server/tests/golden/river_course_golden.json +++ b/server/tests/golden/river_course_golden.json @@ -323,6 +323,330 @@ ] ] }, + { + "rung": "district_mouth", + "edge_id": 262260, + "class": 2, + "terminus": "Mouth", + "points": [ + [ + 36277345, + -8736744 + ], + [ + 36277338, + -8740840 + ], + [ + 36277338, + -8744936 + ], + [ + 36277335, + -8749032 + ], + [ + 36277332, + -8753128 + ], + [ + 36277336, + -8757224 + ], + [ + 36277339, + -8761320 + ], + [ + 36277365, + -8765416 + ], + [ + 36277386, + -8769512 + ], + [ + 36277383, + -8773608 + ], + [ + 36277381, + -8777704 + ], + [ + 36277385, + -8781800 + ], + [ + 36277361, + -8785896 + ], + [ + 36277304, + -8789992 + ], + [ + 36277258, + -8794088 + ], + [ + 36277258, + -8798184 + ], + [ + 36277264, + -8802280 + ], + [ + 36277303, + -8806376 + ], + [ + 36277317, + -8810472 + ], + [ + 36277403, + -8814568 + ], + [ + 36277443, + -8818664 + ], + [ + 36277478, + -8822760 + ], + [ + 36277537, + -8826856 + ], + [ + 36277573, + -8830952 + ], + [ + 36277600, + -8835048 + ], + [ + 36277705, + -8839144 + ], + [ + 36277806, + -8843240 + ], + [ + 36277912, + -8847336 + ], + [ + 36278073, + -8851432 + ], + [ + 36278206, + -8855528 + ], + [ + 36278273, + -8859624 + ], + [ + 36278356, + -8863720 + ], + [ + 36278441, + -8867816 + ], + [ + 36278515, + -8871912 + ], + [ + 36278500, + -8876008 + ], + [ + 36278590, + -8880104 + ], + [ + 36278657, + -8884200 + ], + [ + 36278739, + -8888296 + ], + [ + 36278795, + -8892392 + ], + [ + 36278765, + -8896488 + ], + [ + 36278787, + -8900584 + ], + [ + 36278685, + -8904680 + ], + [ + 36278570, + -8908776 + ], + [ + 36278497, + -8912872 + ], + [ + 36278512, + -8916968 + ], + [ + 36278603, + -8921064 + ], + [ + 36278637, + -8925160 + ], + [ + 36278576, + -8929256 + ], + [ + 36278560, + -8933352 + ], + [ + 36278562, + -8937448 + ], + [ + 36278633, + -8941544 + ], + [ + 36278762, + -8945640 + ], + [ + 36278721, + -8949736 + ], + [ + 36278683, + -8953832 + ], + [ + 36278633, + -8957928 + ], + [ + 36278541, + -8962024 + ], + [ + 36278438, + -8966120 + ], + [ + 36278420, + -8970216 + ], + [ + 36278325, + -8974312 + ], + [ + 36278270, + -8978408 + ], + [ + 36278171, + -8982504 + ], + [ + 36278088, + -8986600 + ], + [ + 36278015, + -8990696 + ], + [ + 36277945, + -8994792 + ], + [ + 36277900, + -8998888 + ], + [ + 36277850, + -9002984 + ], + [ + 36277797, + -9007080 + ], + [ + 36277740, + -9011176 + ], + [ + 36277685, + -9015272 + ], + [ + 36277642, + -9019368 + ], + [ + 36277579, + -9023464 + ], + [ + 36277519, + -9027560 + ], + [ + 36277464, + -9031656 + ], + [ + 36277426, + -9035752 + ], + [ + 36277395, + -9039848 + ], + [ + 36277371, + -9043944 + ], + [ + 36277356, + -9048040 + ], + [ + 36277348, + -9052136 + ], + [ + 36277345, + -9054444 + ] + ] + }, { "rung": "quarter", "edge_id": 655476, @@ -1574,5 +1898,1257 @@ -7147985 ] ] + }, + { + "rung": "quarter_mouth", + "edge_id": 262260, + "class": 2, + "terminus": "Mouth", + "points": [ + [ + 36277345, + -8736744 + ], + [ + 36277344, + -8737768 + ], + [ + 36277344, + -8738792 + ], + [ + 36277343, + -8739816 + ], + [ + 36277343, + -8740840 + ], + [ + 36277343, + -8741864 + ], + [ + 36277343, + -8742888 + ], + [ + 36277343, + -8743912 + ], + [ + 36277343, + -8744936 + ], + [ + 36277343, + -8745960 + ], + [ + 36277343, + -8746984 + ], + [ + 36277343, + -8748008 + ], + [ + 36277342, + -8749032 + ], + [ + 36277342, + -8750056 + ], + [ + 36277342, + -8751080 + ], + [ + 36277342, + -8752104 + ], + [ + 36277341, + -8753128 + ], + [ + 36277341, + -8754152 + ], + [ + 36277342, + -8755176 + ], + [ + 36277343, + -8756200 + ], + [ + 36277343, + -8757224 + ], + [ + 36277343, + -8758248 + ], + [ + 36277344, + -8759272 + ], + [ + 36277343, + -8760296 + ], + [ + 36277344, + -8761320 + ], + [ + 36277346, + -8762344 + ], + [ + 36277348, + -8763368 + ], + [ + 36277349, + -8764392 + ], + [ + 36277349, + -8765416 + ], + [ + 36277350, + -8766440 + ], + [ + 36277351, + -8767464 + ], + [ + 36277353, + -8768488 + ], + [ + 36277355, + -8769512 + ], + [ + 36277356, + -8770536 + ], + [ + 36277355, + -8771560 + ], + [ + 36277356, + -8772584 + ], + [ + 36277356, + -8773608 + ], + [ + 36277355, + -8774632 + ], + [ + 36277354, + -8775656 + ], + [ + 36277354, + -8776680 + ], + [ + 36277354, + -8777704 + ], + [ + 36277353, + -8778728 + ], + [ + 36277353, + -8779752 + ], + [ + 36277356, + -8780776 + ], + [ + 36277357, + -8781800 + ], + [ + 36277353, + -8782824 + ], + [ + 36277350, + -8783848 + ], + [ + 36277349, + -8784872 + ], + [ + 36277346, + -8785896 + ], + [ + 36277344, + -8786920 + ], + [ + 36277343, + -8787944 + ], + [ + 36277339, + -8788968 + ], + [ + 36277335, + -8789992 + ], + [ + 36277332, + -8791016 + ], + [ + 36277328, + -8792040 + ], + [ + 36277324, + -8793064 + ], + [ + 36277323, + -8794088 + ], + [ + 36277321, + -8795112 + ], + [ + 36277324, + -8796136 + ], + [ + 36277323, + -8797160 + ], + [ + 36277323, + -8798184 + ], + [ + 36277323, + -8799208 + ], + [ + 36277323, + -8800232 + ], + [ + 36277326, + -8801256 + ], + [ + 36277324, + -8802280 + ], + [ + 36277325, + -8803304 + ], + [ + 36277329, + -8804328 + ], + [ + 36277332, + -8805352 + ], + [ + 36277333, + -8806376 + ], + [ + 36277336, + -8807400 + ], + [ + 36277337, + -8808424 + ], + [ + 36277339, + -8809448 + ], + [ + 36277339, + -8810472 + ], + [ + 36277341, + -8811496 + ], + [ + 36277348, + -8812520 + ], + [ + 36277351, + -8813544 + ], + [ + 36277356, + -8814568 + ], + [ + 36277362, + -8815592 + ], + [ + 36277365, + -8816616 + ], + [ + 36277365, + -8817640 + ], + [ + 36277365, + -8818664 + ], + [ + 36277371, + -8819688 + ], + [ + 36277373, + -8820712 + ], + [ + 36277375, + -8821736 + ], + [ + 36277378, + -8822760 + ], + [ + 36277381, + -8823784 + ], + [ + 36277384, + -8824808 + ], + [ + 36277389, + -8825832 + ], + [ + 36277393, + -8826856 + ], + [ + 36277394, + -8827880 + ], + [ + 36277395, + -8828904 + ], + [ + 36277398, + -8829928 + ], + [ + 36277399, + -8830952 + ], + [ + 36277400, + -8831976 + ], + [ + 36277401, + -8833000 + ], + [ + 36277403, + -8834024 + ], + [ + 36277405, + -8835048 + ], + [ + 36277410, + -8836072 + ], + [ + 36277415, + -8837096 + ], + [ + 36277422, + -8838120 + ], + [ + 36277432, + -8839144 + ], + [ + 36277443, + -8840168 + ], + [ + 36277448, + -8841192 + ], + [ + 36277455, + -8842216 + ], + [ + 36277464, + -8843240 + ], + [ + 36277467, + -8844264 + ], + [ + 36277474, + -8845288 + ], + [ + 36277482, + -8846312 + ], + [ + 36277484, + -8847336 + ], + [ + 36277494, + -8848360 + ], + [ + 36277507, + -8849384 + ], + [ + 36277519, + -8850408 + ], + [ + 36277528, + -8851432 + ], + [ + 36277534, + -8852456 + ], + [ + 36277539, + -8853480 + ], + [ + 36277550, + -8854504 + ], + [ + 36277562, + -8855528 + ], + [ + 36277566, + -8856552 + ], + [ + 36277568, + -8857576 + ], + [ + 36277572, + -8858600 + ], + [ + 36277574, + -8859624 + ], + [ + 36277574, + -8860648 + ], + [ + 36277584, + -8861672 + ], + [ + 36277594, + -8862696 + ], + [ + 36277599, + -8863720 + ], + [ + 36277602, + -8864744 + ], + [ + 36277601, + -8865768 + ], + [ + 36277612, + -8866792 + ], + [ + 36277620, + -8867816 + ], + [ + 36277627, + -8868840 + ], + [ + 36277631, + -8869864 + ], + [ + 36277631, + -8870888 + ], + [ + 36277635, + -8871912 + ], + [ + 36277631, + -8872936 + ], + [ + 36277632, + -8873960 + ], + [ + 36277633, + -8874984 + ], + [ + 36277632, + -8876008 + ], + [ + 36277637, + -8877032 + ], + [ + 36277641, + -8878056 + ], + [ + 36277645, + -8879080 + ], + [ + 36277652, + -8880104 + ], + [ + 36277658, + -8881128 + ], + [ + 36277661, + -8882152 + ], + [ + 36277661, + -8883176 + ], + [ + 36277666, + -8884200 + ], + [ + 36277675, + -8885224 + ], + [ + 36277680, + -8886248 + ], + [ + 36277686, + -8887272 + ], + [ + 36277691, + -8888296 + ], + [ + 36277698, + -8889320 + ], + [ + 36277699, + -8890344 + ], + [ + 36277701, + -8891368 + ], + [ + 36277700, + -8892392 + ], + [ + 36277698, + -8893416 + ], + [ + 36277695, + -8894440 + ], + [ + 36277692, + -8895464 + ], + [ + 36277696, + -8896488 + ], + [ + 36277696, + -8897512 + ], + [ + 36277699, + -8898536 + ], + [ + 36277705, + -8899560 + ], + [ + 36277706, + -8900584 + ], + [ + 36277695, + -8901608 + ], + [ + 36277685, + -8902632 + ], + [ + 36277678, + -8903656 + ], + [ + 36277673, + -8904680 + ], + [ + 36277669, + -8905704 + ], + [ + 36277662, + -8906728 + ], + [ + 36277654, + -8907752 + ], + [ + 36277646, + -8908776 + ], + [ + 36277641, + -8909800 + ], + [ + 36277637, + -8910824 + ], + [ + 36277630, + -8911848 + ], + [ + 36277629, + -8912872 + ], + [ + 36277627, + -8913896 + ], + [ + 36277632, + -8914920 + ], + [ + 36277634, + -8915944 + ], + [ + 36277634, + -8916968 + ], + [ + 36277636, + -8917992 + ], + [ + 36277647, + -8919016 + ], + [ + 36277657, + -8920040 + ], + [ + 36277660, + -8921064 + ], + [ + 36277660, + -8922088 + ], + [ + 36277663, + -8923112 + ], + [ + 36277662, + -8924136 + ], + [ + 36277662, + -8925160 + ], + [ + 36277660, + -8926184 + ], + [ + 36277655, + -8927208 + ], + [ + 36277650, + -8928232 + ], + [ + 36277647, + -8929256 + ], + [ + 36277645, + -8930280 + ], + [ + 36277642, + -8931304 + ], + [ + 36277641, + -8932328 + ], + [ + 36277641, + -8933352 + ], + [ + 36277642, + -8934376 + ], + [ + 36277644, + -8935400 + ], + [ + 36277647, + -8936424 + ], + [ + 36277647, + -8937448 + ], + [ + 36277647, + -8938472 + ], + [ + 36277646, + -8939496 + ], + [ + 36277650, + -8940520 + ], + [ + 36277662, + -8941544 + ], + [ + 36277674, + -8942568 + ], + [ + 36277682, + -8943592 + ], + [ + 36277687, + -8944616 + ], + [ + 36277690, + -8945640 + ], + [ + 36277689, + -8946664 + ], + [ + 36277690, + -8947688 + ], + [ + 36277688, + -8948712 + ], + [ + 36277685, + -8949736 + ], + [ + 36277684, + -8950760 + ], + [ + 36277681, + -8951784 + ], + [ + 36277674, + -8952808 + ], + [ + 36277673, + -8953832 + ], + [ + 36277671, + -8954856 + ], + [ + 36277666, + -8955880 + ], + [ + 36277663, + -8956904 + ], + [ + 36277662, + -8957928 + ], + [ + 36277659, + -8958952 + ], + [ + 36277653, + -8959976 + ], + [ + 36277649, + -8961000 + ], + [ + 36277645, + -8962024 + ], + [ + 36277637, + -8963048 + ], + [ + 36277624, + -8964072 + ], + [ + 36277618, + -8965096 + ], + [ + 36277617, + -8966120 + ], + [ + 36277615, + -8967144 + ], + [ + 36277616, + -8968168 + ], + [ + 36277616, + -8969192 + ], + [ + 36277612, + -8970216 + ], + [ + 36277607, + -8971240 + ], + [ + 36277598, + -8972264 + ], + [ + 36277589, + -8973288 + ], + [ + 36277583, + -8974312 + ], + [ + 36277581, + -8975336 + ], + [ + 36277576, + -8976360 + ], + [ + 36277575, + -8977384 + ], + [ + 36277570, + -8978408 + ], + [ + 36277567, + -8979432 + ], + [ + 36277562, + -8980456 + ], + [ + 36277554, + -8981480 + ], + [ + 36277545, + -8982504 + ], + [ + 36277542, + -8983528 + ], + [ + 36277536, + -8984552 + ], + [ + 36277530, + -8985576 + ], + [ + 36277526, + -8986600 + ], + [ + 36277523, + -8987624 + ], + [ + 36277521, + -8988648 + ], + [ + 36277518, + -8989672 + ], + [ + 36277512, + -8990696 + ], + [ + 36277506, + -8991720 + ], + [ + 36277501, + -8992744 + ], + [ + 36277494, + -8993768 + ], + [ + 36277490, + -8994792 + ], + [ + 36277488, + -8995816 + ], + [ + 36277488, + -8996840 + ], + [ + 36277486, + -8997864 + ], + [ + 36277483, + -8998888 + ], + [ + 36277483, + -8999912 + ], + [ + 36277477, + -9000936 + ], + [ + 36277472, + -9001960 + ], + [ + 36277468, + -9002984 + ], + [ + 36277464, + -9004008 + ], + [ + 36277460, + -9005032 + ], + [ + 36277458, + -9006056 + ], + [ + 36277457, + -9007080 + ], + [ + 36277454, + -9008104 + ], + [ + 36277450, + -9009128 + ], + [ + 36277445, + -9010152 + ], + [ + 36277440, + -9011176 + ], + [ + 36277437, + -9012200 + ], + [ + 36277435, + -9013224 + ], + [ + 36277432, + -9014248 + ], + [ + 36277428, + -9015272 + ], + [ + 36277426, + -9016296 + ], + [ + 36277424, + -9017320 + ], + [ + 36277421, + -9018344 + ], + [ + 36277417, + -9019368 + ], + [ + 36277415, + -9020392 + ], + [ + 36277413, + -9021416 + ], + [ + 36277409, + -9022440 + ], + [ + 36277404, + -9023464 + ], + [ + 36277399, + -9024488 + ], + [ + 36277396, + -9025512 + ], + [ + 36277391, + -9026536 + ], + [ + 36277388, + -9027560 + ], + [ + 36277385, + -9028584 + ], + [ + 36277383, + -9029608 + ], + [ + 36277378, + -9030632 + ], + [ + 36277374, + -9031656 + ], + [ + 36277371, + -9032680 + ], + [ + 36277369, + -9033704 + ], + [ + 36277367, + -9034728 + ], + [ + 36277364, + -9035752 + ], + [ + 36277362, + -9036776 + ], + [ + 36277361, + -9037800 + ], + [ + 36277360, + -9038824 + ], + [ + 36277358, + -9039848 + ], + [ + 36277356, + -9040872 + ], + [ + 36277354, + -9041896 + ], + [ + 36277353, + -9042920 + ], + [ + 36277351, + -9043944 + ], + [ + 36277350, + -9044968 + ], + [ + 36277349, + -9045992 + ], + [ + 36277349, + -9047016 + ], + [ + 36277348, + -9048040 + ], + [ + 36277347, + -9049064 + ], + [ + 36277346, + -9050088 + ], + [ + 36277346, + -9051112 + ], + [ + 36277346, + -9052136 + ], + [ + 36277345, + -9053160 + ], + [ + 36277345, + -9054184 + ] + ] } ] diff --git a/server/tests/window_derivation_golden.rs b/server/tests/window_derivation_golden.rs index 2c4c76774..6b6f10137 100644 --- a/server/tests/window_derivation_golden.rs +++ b/server/tests/window_derivation_golden.rs @@ -444,6 +444,20 @@ fn golden_cutoffs_match_the_scale_ladder() { // geometry for an edge from that chain, at both District and Quarter station // spacing, so courses themselves — not just the district-profile fields the // main golden above covers — are regression-pinned. +// +// **Mouth coverage (T-1170 PR #197 review, Hoshe #2):** the sample set below +// also includes a real Mouth-terminus edge from `sample_hm()` (empirically +// probed: 4 mouth edges exist in this fixture) — `invent_course`'s raw output +// for that edge is pinned here (edge_id/class/terminus/points), closing the +// "zero Mouth coverage in any golden" gap at the invention layer. The FULL +// end-to-end path (invent → crop → `resolve_mouth_terminus` → +// `CourseTerminus::Mouth`) is covered separately and permanently by +// `layer_proxy::tests::all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` +// (in-crate, since `crop_course_to_window`/`resolve_mouth_terminus` are +// private to `layer_proxy.rs` and unreachable from this integration test) — +// that test is the actual Hoshe #1 acceptance criterion (3/3 real mouths); +// this golden's job is regression-pinning the raw invented geometry, not +// re-proving the crop/resolve path. const RIVER_COURSE_GOLDEN_FILE: &str = "tests/golden/river_course_golden.json"; @@ -481,6 +495,15 @@ fn river_course_golden_samples() -> Vec { }) .expect("sample_hm() fixture must have at least one interior river edge near (10, 116)"); + // Hoshe #2: a real Mouth-terminus edge, deterministically selected as the + // lowest edge_id among the fixture's mouth edges (fixed tie-break, no + // hardcoded index). + let mouth_target = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth) + .min_by_key(|e| e.edge_id) + .expect("sample_hm() fixture must have at least one Mouth edge (empirically verified: 4)"); + let mut out = Vec::new(); for (rung, spacing_m, min_wl_m) in [ ("district", DISTRICT_MIN_WL_M, DISTRICT_MIN_WL_M), @@ -498,6 +521,20 @@ fn river_course_golden_samples() -> Vec { .map(|p| (p.0.round() as i64, p.1.round() as i64)) .collect(), }); + + let mouth_course = + river_course::invent_course(seed, mouth_target, &ta, ¶ms, spacing_m, min_wl_m); + out.push(GoldenCourseSample { + rung: format!("{rung}_mouth"), + edge_id: mouth_course.edge_id, + class: mouth_course.class, + terminus: format!("{:?}", mouth_course.terminus), + points: mouth_course + .points + .iter() + .map(|p| (p.0.round() as i64, p.1.round() as i64)) + .collect(), + }); } out } @@ -567,3 +604,29 @@ fn river_course_rungs_have_different_station_counts() { quarter.points.len() ); } + +/// **T-1170 PR #197 review, Hoshe #2 (golden coverage):** the pinned Mouth +/// edge samples must be genuine, non-degenerate courses (`terminus == "Mouth"`, +/// `points.len() >= 2`) — the direct golden-level check that the Hoshe #1 fix +/// (real seaward chord via `RiverNetwork::river_seaward`) reaches this fixture +/// too, not just the dedicated GJ1c acceptance test. +#[test] +fn river_course_mouth_samples_are_non_degenerate() { + let samples = river_course_golden_samples(); + for rung in ["district_mouth", "quarter_mouth"] { + let sample = samples + .iter() + .find(|s| s.rung == rung) + .unwrap_or_else(|| panic!("missing golden sample for rung {rung}")); + assert_eq!( + sample.terminus, "Mouth", + "{rung}: build_edges must produce a Mouth-terminus RiverEdge for the pinned target" + ); + assert!( + sample.points.len() >= 2, + "{rung}: Mouth edge invented a degenerate {}-point course — the chord-length fix \ + (Hoshe #1) regressed for this fixture", + sample.points.len() + ); + } +}