From ff98a10d4210d17031ee06ed7672e2bfb471781c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Jul 2026 08:10:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20T-1156=20=E2=80=94=20quanti?= =?UTF-8?q?zed=20river=5Fclass=20on=20RiverNetwork=20(trunk/tributary/stre?= =?UTF-8?q?am)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one additive server change of wave 1 per Tyre's carrier ruling: river_class: Vec parallel to river_cells, serde-default-safe, no new wire field. Log-scaled binning between RIVER_THRESHOLD and the RIVER-RESTRICTED max accumulation (max over cells passing the is_river elevation filter — NOT the grid-wide max, which peaks past the coastline on wet large-ocean bodies and would starve the trunk class exactly where Araminta's District-shows-trunk-only table needs it; caught in lead review of round 1, fixed round 2). Log not linear because accumulation grows combinatorially downstream. By construction every body with rivers has trunk cells — pinned by a non-synthetic regression on the committed GJ1c heightmap at the golden's exact downsample. cascade_layer1.json regenerated: every pre-existing field byte-identical (python-verified), only the new river_class arrays added — GJ1c distribution stream 72 / tributary 18 / trunk 3. drainage 16/16 (+10), layer1 3/3, layer_proxy 50/50, build --tests clean; revert-verified (non-monotonic binning fails the monotonicity test by name). Tickets: T-1156 Co-Authored-By: Claude Fable 5 --- server/src/atlas/body_world_state.rs | 12 ++ server/src/atlas/drainage.rs | 235 +++++++++++++++++++++++- server/tests/gen_fixtures.rs | 1 + server/tests/golden/cascade_layer1.json | 95 ++++++++++ 4 files changed, 341 insertions(+), 2 deletions(-) diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index e3284c137..b3d17dcff 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -42,6 +42,18 @@ pub struct RiverNetwork { pub confluences: Vec<(u16, u16)>, /// Positions where rivers reach sea level or the heightmap edge. pub mouths: Vec<(u16, u16)>, + /// Quantized river class per entry of `river_cells` (same index, same + /// length) — 0=stream, 1=tributary, 2=trunk (T-1156 wave 1). Deterministic + /// per body+seed (D-010/D-208): a monotonic function of each cell's flow + /// accumulation, binned by `drainage::classify_river_cell`. This is the + /// carrier for client-side per-rung filtering (Tyre's binding ruling — no + /// new wire field beyond this array; ladder rungs decide which classes to + /// draw by filtering this list, not by a server-side windowed query). + /// `#[serde(default)]` so pre-T-1156 payloads/consumers (and any golden + /// fixture predating this field) still decode — an absent array becomes + /// empty, never a decode error (the additive T-1124 §1 pattern). + #[serde(default)] + pub river_class: Vec, } /// One drainage basin / province derived from watershed analysis (D-205). diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index e8a09b5b4..ea433f3e2 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -91,7 +91,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr // 4. Flow accumulation. let accum = flow_accumulation(&fdir, w, h); - // 5. River network. + // 5. River network. River-class banding (T-1156) anchors on its own + // river-restricted max internally — see `extract_river_network` — not on + // the grid-wide max computed below, so no dependency ordering between + // the two is needed. let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation); // 6. Basin labeling. @@ -104,7 +107,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr let drainage_basins = build_basins(&labels, w, h); // Max accumulation for D-209 strength normalization (clamped ≥ 1 so the - // division is always well-defined, even on a flat/empty world). + // division is always well-defined, even on a flat/empty world). This is + // the grid-wide max (includes below-sea-level cells) — distinct from the + // river-restricted max `extract_river_network` uses for its own T-1156 + // river-class banding. let max_accumulation = accum.iter().copied().max().unwrap_or(1).max(1); DrainageResult { @@ -258,6 +264,30 @@ fn extract_river_network( .map(|i| ((i / w) as u16, (i % w) as u16)) .collect(); + // River-restricted max accumulation — the ceiling for the T-1156 log-band + // classifier below. Deliberately NOT the grid-wide `max_accumulation` + // (DrainageResult's D-209 normalization denominator, which includes + // below-sea-level ocean cells where accumulation typically peaks, just + // past a river's mouth): anchoring on that grid-wide value would classify + // a wet, large-ocean body's actual wettest *river* cell short of trunk, + // producing an entirely riverless District rung (Araminta's per-rung + // table shows trunk only at District) on exactly the bodies with the + // most river to show. Anchoring on the max among cells that passed the + // `is_river` filter guarantees every body with any river cells has its + // wettest one classified trunk, by construction — see + // `classify_river_cell`'s doc comment. + let river_max_accumulation = (0..n) + .filter(|&i| is_river[i]) + .map(|i| accum[i]) + .max() + .unwrap_or(RIVER_THRESHOLD + 1); // unused when river_cells is empty + + // River class per entry of `river_cells`, same order (T-1156 wave 1). + let river_class: Vec = (0..n) + .filter(|&i| is_river[i]) + .map(|i| classify_river_cell(accum[i], river_max_accumulation)) + .collect(); + // Confluences: river cells with 2+ river neighbors flowing into them. let mut inflow_count = vec![0u8; n]; for r in 0..h { @@ -314,6 +344,77 @@ fn extract_river_network( river_cells, confluences, mouths, + river_class, + } +} + +/// Bin a river cell's flow accumulation into a quantized class (T-1156 wave 1): +/// 0=stream, 1=tributary, 2=trunk. The client filters the ladder rung's river +/// draw by this class (Araminta's per-rung table: Region shows trunk only, +/// District adds tributary, Quarter shows everything) — no new wire field, +/// this is the sole carrier (Tyre's ruling). +/// +/// **Binning: log-scaled fraction of the log-range between `RIVER_THRESHOLD` +/// (the accumulation floor below which a cell isn't a river cell at all) and +/// `river_max_accumulation` (the highest flow accumulation among this body's +/// own river cells), split into equal thirds.** Rationale for log rather than +/// linear: flow accumulation grows combinatorially downstream (each +/// confluence roughly sums its tributaries), so a linear split over-populates +/// the trunk band with anything past the halfway point and starves it on +/// modest bodies. Log-scaling spreads the bands evenly across orders of +/// magnitude instead, so a river's headwaters (streams), mid-course +/// tributaries, and lower trunk read as three roughly even bands on both a +/// wet, many-confluence body and a dry, single-channel one. +/// +/// **The ceiling must be `river_max_accumulation` (max over cells that pass +/// the `is_river` filter — `accum > RIVER_THRESHOLD && elevation >= +/// sea_level`), never `DrainageResult::max_accumulation` (the grid-wide max +/// used elsewhere for D-209 strength normalization).** Flow accumulation +/// peaks right at a river's mouth, typically on the ocean-side cell just past +/// the coastline — a cell that is *never* a river cell by definition +/// (`is_river` requires `elevation >= sea_level`). Anchoring on the grid-wide +/// max therefore admits a ceiling no river cell can ever reach: on a wet body +/// with a large ocean, where accumulation piles up hardest past the +/// coastline, every actual river cell would land short of trunk and the +/// District rung (trunk-only per Araminta's table) would render riverless — +/// exactly backwards, since that is the body with the most river to show. +/// Anchoring on `river_max_accumulation` instead guarantees, by construction, +/// that a body's own wettest *river* cell — not its wettest cell overall — +/// always lands in the trunk band. Every body with any river cells gets a +/// trunk, scaled to its own wet/dry character, which is what "this body's +/// main river" should mean, and it holds unconditionally (not merely "if the +/// wettest water happens to be fluvial"). +/// +/// Using a per-body-relative ceiling at all (rather than an absolute multiple +/// of `RIVER_THRESHOLD`, e.g. trunk = accum ≥ 800) is itself deliberate: a +/// body whose single river barely clears the threshold would classify every +/// cell as `stream` under an absolute scheme, reading as "no real river" +/// even though it has exactly one. +/// +/// Determinism (D-010/D-208): pure integer/float arithmetic on +/// `(accum, river_max_accumulation)`, no RNG, same body+seed → same class +/// every run. Monotonic by construction: `log` and the linear division into +/// thirds are both non-decreasing in `accum`, so a strictly higher +/// accumulation never produces a strictly lower class. +fn classify_river_cell(accum: i32, river_max_accumulation: i32) -> u8 { + // Callers only invoke this for cells that passed `is_river` (accum > + // RIVER_THRESHOLD == 200), and `river_max_accumulation` is the max over + // that same cell set, so both logs below are well-defined (positive + // arguments) and `river_max_accumulation > RIVER_THRESHOLD` always holds + // when there is at least one river cell. + let floor = (RIVER_THRESHOLD as f64).ln(); + let ceil = (river_max_accumulation as f64) + .max(RIVER_THRESHOLD as f64 + 1.0) + .ln(); + let span = (ceil - floor).max(f64::EPSILON); + let frac = ((accum as f64).ln() - floor) / span; + let frac = frac.clamp(0.0, 1.0); + if frac >= 2.0 / 3.0 { + 2 // trunk + } else if frac >= 1.0 / 3.0 { + 1 // tributary + } else { + 0 // stream } } @@ -806,4 +907,134 @@ mod tests { let n = res.drainage_basins.len(); assert!((1..=12).contains(&n), "basin count {n} out of range"); } + + // ----------------------------------------------------------------------- + // River class (T-1156 wave 1) + // ----------------------------------------------------------------------- + + #[test] + fn every_river_cell_has_a_class() { + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + assert_eq!( + result.river_network.river_cells.len(), + result.river_network.river_class.len(), + "river_class must be parallel/aligned with river_cells" + ); + assert!( + !result.river_network.river_cells.is_empty(), + "test grid should produce river cells" + ); + } + + #[test] + fn river_class_monotonic_with_accumulation() { + // A cell with higher accumulation must never have a lower class than + // a cell with lower accumulation — the core binning contract. + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + let rn = &result.river_network; + assert!(!rn.river_cells.is_empty()); + + // Recover each river cell's accumulation and pair it with its class. + let w = 512usize; + let mut pairs: Vec<(i32, u8)> = rn + .river_cells + .iter() + .zip(rn.river_class.iter()) + .map(|(&(r, c), &class)| { + let idx = r as usize * w + c as usize; + (result.flow_accumulation[idx], class) + }) + .collect(); + pairs.sort_by_key(|&(accum, _)| accum); + + let mut max_class_seen = 0u8; + for (_, class) in pairs { + assert!( + class >= max_class_seen, + "monotonicity violated: saw class {class} after class {max_class_seen} \ + in ascending-accumulation order" + ); + max_class_seen = max_class_seen.max(class); + } + } + + #[test] + fn at_least_one_trunk_cell_when_rivers_exist() { + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + assert!(!result.river_network.river_cells.is_empty()); + assert!( + result.river_network.river_class.contains(&2), + "a body with any rivers must have at least one trunk (class 2) cell — \ + this is the classify_river_cell river_max_accumulation-anchoring guarantee" + ); + } + + #[test] + fn at_least_one_trunk_cell_on_a_real_body_with_a_large_ocean() { + // Regression for the grid-wide-max anchoring bug: GJ1c is exactly the + // "wet body with a large ocean" shape where flow accumulation peaks + // past the coastline (a non-river cell), which starved the trunk band + // when the ceiling was anchored on the grid-wide max instead of the + // river-restricted max. Same body + downsample as the cascade golden + // (tests/golden/cascade_layer1.json) — 93 river cells there, so this + // is a real, non-synthetic exercise of the guarantee. + use crate::atlas::heightmap::load_heightmap_png; + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let result = analyze(&small.data, small.width, small.height, small.sea_level); + assert!( + !result.river_network.river_cells.is_empty(), + "GJ1c should have river cells at this downsample" + ); + assert!( + result.river_network.river_class.contains(&2), + "GJ1c's own wettest river cell must classify as trunk — river-restricted \ + anchoring must not be starved by ocean-cell accumulation past the coastline" + ); + } + + #[test] + fn river_class_deterministic() { + let elev = slope_grid(64, 32); + let r1 = analyze(&elev, 64, 32, 0.3); + let r2 = analyze(&elev, 64, 32, 0.3); + assert_eq!( + r1.river_network.river_class, r2.river_network.river_class, + "river_class must be deterministic (D-010/D-208)" + ); + } + + #[test] + fn classify_river_cell_barely_above_threshold_still_gets_a_trunk() { + // A body whose single river barely clears RIVER_THRESHOLD must still + // classify its own maximum as trunk — the whole point of anchoring + // the log-range ceiling at river_max_accumulation instead of an + // absolute multiple of RIVER_THRESHOLD. + let river_max_accumulation = RIVER_THRESHOLD + 5; + assert_eq!( + classify_river_cell(river_max_accumulation, river_max_accumulation), + 2, + "the body's own max river-cell accumulation must always classify as trunk" + ); + } + + #[test] + fn classify_river_cell_spans_all_three_classes_on_wide_range() { + // Sanity check on the log-binning: a body with a wide dynamic range + // (headwater trickles up to a major trunk) should exercise all three + // classes, not collapse to two. + let river_max_accumulation = 131_000; + let low = classify_river_cell(RIVER_THRESHOLD + 1, river_max_accumulation); + let mid = classify_river_cell(5_000, river_max_accumulation); + let high = classify_river_cell(river_max_accumulation, river_max_accumulation); + assert_eq!(low, 0, "just above threshold should be a stream"); + assert_eq!(mid, 1, "mid-range accumulation should be a tributary"); + assert_eq!(high, 2, "the body's max river-cell accumulation should be trunk"); + } } diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 5b0f48cda..ea0f56e78 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -557,6 +557,7 @@ fn generate_atlas_layer_response_fixtures() { river_cells: vec![(12, 58), (12, 59)], confluences: vec![], mouths: vec![(12, 58)], + river_class: vec![1, 2], }, drainage_basins: vec![DrainageBasin { basin_id: 1, diff --git a/server/tests/golden/cascade_layer1.json b/server/tests/golden/cascade_layer1.json index a7ef1bc41..434b7e1a1 100644 --- a/server/tests/golden/cascade_layer1.json +++ b/server/tests/golden/cascade_layer1.json @@ -8216,6 +8216,101 @@ 124, 239 ] + ], + "river_class": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 2, + 0, + 0, + 1, + 0, + 2, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0 ] } },