Files
settled-reach/server/src/atlas/layer1.rs
T
jpmschweitzerandClaude Fable 5 24fad7090f feat(simulation): lakes from settled hydrology (D-227, T-1184)
Productionizes the T-1177 equilibrium solver: run_layer1 now solves
hydrology once per body (~24ms, mirrors drainage::analyze) and carries
it as TerrainAnalysis.hydrology; run_layer1_with_moisture threads the
real body moisture ceiling (extracted derive_moisture_ceiling_q), with
the T-1177 population-survey default as fallback. The resident rung-0
global tier does not exist yet (T-1181's scope) — hydrology rides
TerrainAnalysis and lands in that tier for free when it is built
(deviation recorded on the ticket).

MorphologyZone::Lake is now sourced from the settled solver at derive
time: a gridunit is Lake when bilinear-sampled filled surface exceeds
bilinear-sampled original elevation at the sample's own (px, py) — the
continuous comparison, so lake edges refine with rung like coastlines;
never a discrete basin-cell projection. The gate sits strictly between
OpenOcean (>= 80) and the old ocean_fraction heuristic (>= 60), which
survives as the derive-fresh fallback when no solve is attached —
byte-identical to pre-T-1184 output in that case. Static
classification, distinct from the sim-state flooded plane; no
endorheic bit (the drains-vs-closed cue is T-1185's outlet-course
presence, per the D-227 amendment (4) sequencing). Zero new wire
bytes.

Acceptance: lake_classification_cache_hit_equals_cache_miss (solve
twice independently, byte-identical zones, non-vacuous Lake hit) plus
hydrology determinism tests. Golden fidelity: the window golden
fixture now builds TerrainAnalysis through the production entry point
(run_layer1_with_moisture, per-body), and a dedicated lake_bowl golden
body pins the hydrology-sourced Lake path (morphology 1 at
ocean_fraction_q 0 — provably not the heuristic); the 108 pre-existing
golden rows are byte-identical (pure append). believability.json moved
by one lake-shaped line (GJ338Bd voxel_relief_m 27->28, a correctly
reclassified lake district leaving the dry-relief sample set).
river_course and derivation-harness goldens unchanged. Full suite:
2114 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 02:26:22 +02:00

510 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Layer 1 orchestrator — empty-world topography (#953).
//!
//! Runs the full Layer-1 pipeline for one body, in order:
//! 1. D8 priority-flood drainage (D-208) → river network + basins
//! 2. shared terrain analysis (ocean/lake masks, water distance, slope,
//! elevation percentile) — D-209/D-210 inputs
//! 3. 7-tag geographic feature extraction (D-209)
//! 4. sub-biome + terrain_modification_cost classification (D-210)
//! 5. per-district dominant D8 basin direction (T-1047, D-239 §8)
//!
//! Output is the in-memory `Layer1Output`, which maps directly onto
//! `BodyWorldState` (D-203). Name attachment (D-223) is a separate, cheap step
//! (`attach_feature_names`) so the compute can be benchmarked in isolation and
//! names sourced from the DB pool independently.
//!
//! `run_layer1` returns `(Layer1Output, TerrainAnalysis)` so `cascade.rs` can
//! reuse the `TerrainAnalysis` held on `CascadeSnapshot.terrain_analysis`
//! (transient — dropped after DistrictProfile + RoadGraph consume it; D-203 /
//! T-1044) without re-running the ~45 ms drainage pass per body.
//!
//! **Determinism (D-010 #4):** every stage is deterministic; the same heightmap
//! yields bit-identical attractors and river networks.
use std::collections::BTreeMap;
use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
use crate::atlas::drainage::{self, DrainageResult};
use crate::atlas::features::{self, TerrainAnalysis};
use crate::atlas::heightmap::BodyHeightmap;
use crate::atlas::scale::{BasinDirection, SurveyCellPos, HEIGHTMAP_CELLS_PER_DISTRICT};
use crate::atlas::subbiome;
use crate::simulation::generator::{AttractorType, GeographicAttractor};
use serde::{Deserialize, Serialize};
/// Full Layer-1 result for one body.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Layer1Output {
pub body_id: String,
pub river_network: RiverNetwork,
pub drainage_basins: Vec<DrainageBasin>,
/// Geographic attractors (D-209) with sub-biome + cost (D-210), sorted by
/// `(attractor_type, row, col)`.
pub attractors: Vec<GeographicAttractor>,
/// Working-grid dimensions every position in this output (river cells, basin
/// boundaries, attractor positions) is expressed in — equals the downsampled
/// heightmap size. The client maps these onto the displayed heightmap (#960),
/// so the overlay scale stays correct for any source resolution (mod-safe).
pub grid_w: u32,
pub grid_h: u32,
/// Dominant D8 thalweg direction per SURVEY CELL, aggregated from the `fdir`
/// grid during the Layer-1 drainage pass (T-1047, D-239 §8). Each entry
/// holds the cardinal direction with the most votes among non-ocean cells in
/// that cell's covering 8×8 working-grid pixel block. Keyed by
/// [`SurveyCellPos`] (D-256(b)) using `HEIGHTMAP_CELLS_PER_DISTRICT` as the
/// grid-to-cell mapping — the SAME survey raster `derive_all_districts`
/// builds `DistrictProfile`s over, not the true D-243 `DistrictPos` grid
/// (D-256: this field predates the newtype and escaped the initial sweep;
/// the aggregate is honestly a survey-cell aggregate — it votes over
/// exactly the pixel block one `DistrictProfile` summarizes — so
/// `SurveyCellPos` is its correct, not just convenient, key).
///
/// The VALUES are the **true D8-computed direction** — not a seed-bit
/// proxy — so `DistrictProfile.basin_direction` (and downstream
/// `ChunkContext`) respect drainage monotonicity (D-239 §8: respect the D8
/// thalweg). Only the KEY space is the coarse survey raster.
///
/// **Transient:** skipped in serialization (`#[serde(skip)]`) — this field is
/// a cascade-internal transport from `run_layer1` to `derive_all_districts`
/// and is re-derived on each `run_layer1` call. The per-survey-cell direction
/// is persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
/// after the cascade consumes it.
#[serde(skip)]
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
}
/// Body-wide moisture ceiling fallback for [`run_layer1`]'s hydrology solve
/// when no [`crate::atlas::district_profile::BodyParams`] is available to
/// derive a real one from (`run_layer1`'s signature is heightmap-only, matching
/// `drainage::analyze`'s own "same way it already runs once per body today"
/// shape per T-1177's scope). Matches the T-1177 prototype's own
/// `ClimateInputs { moisture_q: 55 }` population-survey default (moderate
/// hydrosphere, breathable atmosphere) — a reasonable body-agnostic guess,
/// used ONLY by [`run_layer1`]'s two-arg form; [`run_layer1_with_moisture`]
/// (called by every production site that has real `BodyParams` in scope) never
/// reaches this constant.
const DEFAULT_HYDROLOGY_MOISTURE_Q: i32 = 55;
/// Run the Layer-1 topography pipeline for a single body.
///
/// Returns `(Layer1Output, TerrainAnalysis)`. The `TerrainAnalysis` is carried
/// transiently on `CascadeSnapshot.terrain_analysis` so `cascade.rs` can pass
/// it to `derive_all_districts` and `build_road_graph` without re-running the
/// full D8 drainage pass (T-1044 — eliminates the PERF/TODO re-run).
///
/// Solves settled-equilibrium hydrology (T-1177/T-1184, D-227 amendment (4))
/// once per body as part of this same pass, using
/// [`DEFAULT_HYDROLOGY_MOISTURE_Q`] as the body-wide moisture ceiling — this
/// two-arg form has no `BodyParams` to derive a real one from. Every
/// production call site that DOES have `BodyParams` in scope
/// (`cascade::run_cascade_from_heightmap`,
/// `gen_queue::TerrainAnalysisCache::get_or_derive`) calls
/// [`run_layer1_with_moisture`] instead, so this fallback is only ever
/// exercised by call sites (mostly tests) that never had body params to begin
/// with — never a silent downgrade of a real value.
pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
run_layer1_with_moisture(hm, DEFAULT_HYDROLOGY_MOISTURE_Q)
}
/// [`run_layer1`], with the body-wide hydrology moisture ceiling
/// (`ClimateInputs::moisture_q`, T-1177) supplied explicitly rather than
/// defaulted. Callers with a real [`crate::atlas::district_profile::BodyParams`]
/// in scope should derive it via
/// [`crate::atlas::district_profile::derive_moisture_ceiling_q`] and pass the
/// result here, so the endorheic-vs-overflow basin split reflects the body's
/// actual hydrosphere/atmosphere instead of the fallback constant.
///
/// Determinism (D-010): pure function of `(hm, moisture_q)` — same inputs,
/// byte-identical `TerrainAnalysis.hydrology` every time (inherits
/// `hydrology_equilibrium::solve`'s own determinism guarantee).
pub fn run_layer1_with_moisture(
hm: &BodyHeightmap,
moisture_q: i32,
) -> (Layer1Output, TerrainAnalysis) {
let drainage: DrainageResult = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let mut ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage);
// T-1184: solve settled-equilibrium hydrology once per body (the
// AnalyzeBody cascade populate point, D-227 amendment (4)) and fold the
// continuous filled-surface field into this TerrainAnalysis so every
// derive-core caller downstream (`derive_at_metres_with_riparian`) can
// bilinearly sample it for lake sourcing — mechanism B, D-255(f): a
// coarse continuous primitive computed once, sampled fresh at every rung,
// never re-solved. `hm.data` (the raw [0,1] elevation this analysis was
// built from) is the SAME grid the solver runs on, so the two fields
// `with_hydrology` stores are always the correct pairing.
let hydrology = crate::atlas::hydrology_equilibrium::solve(
&hm.data,
hm.width,
hm.height,
hm.sea_level,
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q },
);
ta = ta.with_hydrology(&hm.data, &hydrology);
let raw = features::extract_attractors(hm, &drainage, &ta);
let attractors: Vec<GeographicAttractor> = raw
.iter()
.map(|r| {
let (sub_biome, terrain_modification_cost) =
subbiome::classify(&ta, r.row as usize, r.col as usize);
GeographicAttractor {
position: (r.row, r.col),
attractor_type: r.attractor_type,
strength: r.strength,
sub_biome,
terrain_modification_cost,
// Layer-1 water-direction extraction (#957, D-234) — feeds D-213
// founding orientation + the D-234 waterfront rule.
water_bearing: ta.water_bearing(r.row as usize, r.col as usize),
}
})
.collect();
// Aggregate per-survey-cell dominant D8 direction from the fdir grid
// (T-1047, D-239 §8; D-256(b) survey raster — NOT the true D-243 district
// grid). fdir is available here before it is discarded — do NOT expose
// the full grid on DrainageResult externally. The compact per-cell map
// (~6 000 entries) is what propagates into Layer1Output and DistrictProfile.
//
// Mapping fdir index → 4-way cardinal (D-010 integer; matches D8 table):
// 0 N, 1 S, 2 E, 3 W (pure cardinals)
// 4 NE → N (|dr|=|dc|=1; row component wins per D8 priority order)
// 5 NW → N
// 6 SE → S
// 7 SW → S
// -1 → skip (no outflow: edge, flat peak, ocean)
let survey_basin_dirs =
aggregate_survey_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
let l1 = Layer1Output {
body_id: hm.body_id.clone(),
river_network: drainage.river_network,
drainage_basins: drainage.drainage_basins,
attractors,
grid_w: hm.width,
grid_h: hm.height,
survey_basin_dirs,
};
(l1, ta)
}
/// Aggregate a per-survey-cell dominant D8 flow direction from the full-grid
/// `fdir` (index into the D8 table, -1 = no outflow). Ocean-masked cells are
/// excluded from voting so coastal cells do not skew toward the ocean sink
/// direction.
///
/// Each non-ocean, non-sink cell casts one vote for its cardinal direction
/// (diagonals NE/NW fold to N, SE/SW fold to S). Ties broken by cardinal
/// precedence (N > S > E > W). Survey cells with no valid votes default to
/// `North`.
///
/// Integer arithmetic throughout (D-010).
fn aggregate_survey_basin_dirs(
fdir: &[i8],
width: u32,
height: u32,
ocean_mask: &[bool],
) -> BTreeMap<SurveyCellPos, BasinDirection> {
let w = width as usize;
let h = height as usize;
let gcpd = HEIGHTMAP_CELLS_PER_DISTRICT;
// Per-survey-cell vote counts: [N, S, E, W].
let mut votes: BTreeMap<SurveyCellPos, [i32; 4]> = BTreeMap::new();
for r in 0..h {
for c in 0..w {
let i = r * w + c;
let k = fdir[i];
if k < 0 || ocean_mask[i] {
continue; // no-outflow or ocean — skip
}
// Map D8 index to 4-way cardinal vote index: [N=0, S=1, E=2, W=3].
let vote = match k {
0 => 0, // N
1 => 1, // S
2 => 2, // E
3 => 3, // W
4 => 0, // NE → N (row component wins; |dr|=|dc|=1)
5 => 0, // NW → N
6 => 1, // SE → S
7 => 1, // SW → S
_ => continue,
};
let cell_pos = SurveyCellPos((c / gcpd) as i32, (r / gcpd) as i32);
votes.entry(cell_pos).or_insert([0i32; 4])[vote] += 1;
}
}
// For each survey cell, pick the cardinal with the most votes.
// Tie-breaking order: N > S > E > W (matches D8 priority).
let survey_cols = w.div_ceil(gcpd) as i32;
let survey_rows = h.div_ceil(gcpd) as i32;
let mut out = BTreeMap::new();
for dy in 0..survey_rows {
for dx in 0..survey_cols {
let pos = SurveyCellPos(dx, dy);
let dir = if let Some(v) = votes.get(&pos) {
// N=0, S=1, E=2, W=3 in descending priority for tie-breaking.
let mut best_votes = -1i32;
let mut best_dir = BasinDirection::North;
for (cardinal_idx, &count) in v.iter().enumerate() {
// Strictly greater-than preserves the first (highest-priority)
// cardinal in case of tie.
if count > best_votes {
best_votes = count;
best_dir = match cardinal_idx {
0 => BasinDirection::North,
1 => BasinDirection::South,
2 => BasinDirection::East,
_ => BasinDirection::West,
};
}
}
best_dir
} else {
BasinDirection::North // ocean-only or empty cell: default
};
out.insert(pos, dir);
}
}
out
}
/// Attach pool names (D-223) to the largest computed rivers and mountains.
///
/// Rivers are ranked by mouth strength (a proxy for catchment size) descending;
/// `RiverMouth` attractors take names from `river_names` in that order. Mountain
/// names attach to the highest-elevation `Alpine`/`PassEntrance` attractors.
/// Returns `(river_assignments, mountain_assignments)` as `(position, name)`
/// pairs; positions that outrun the pool get no name (the pool is finite).
pub fn attach_feature_names(
output: &Layer1Output,
river_names: &[String],
mountain_names: &[String],
) -> (Vec<((u16, u16), String)>, Vec<((u16, u16), String)>) {
// Rivers: RiverMouth attractors, strongest first (ties by row, col).
let mut mouths: Vec<&GeographicAttractor> = output
.attractors
.iter()
.filter(|a| a.attractor_type == AttractorType::RiverMouth)
.collect();
mouths.sort_by(|a, b| {
// strength is integer now — rank directly (descending).
b.strength
.cmp(&a.strength)
.then(a.position.0.cmp(&b.position.0))
.then(a.position.1.cmp(&b.position.1))
});
let rivers = mouths
.iter()
.zip(river_names.iter())
.map(|(a, n)| (a.position, n.clone()))
.collect();
// Mountains: Alpine attractors, strongest first.
let mut peaks: Vec<&GeographicAttractor> = output
.attractors
.iter()
.filter(|a| {
matches!(
a.sub_biome,
crate::simulation::generator::SubBiomeVariant::Alpine
)
})
.collect();
peaks.sort_by(|a, b| {
// strength is integer now — rank directly (descending).
b.strength
.cmp(&a.strength)
.then(a.position.0.cmp(&b.position.0))
.then(a.position.1.cmp(&b.position.1))
});
let mountains = peaks
.iter()
.zip(mountain_names.iter())
.map(|(a, n)| (a.position, n.clone()))
.collect();
(rivers, mountains)
}
#[cfg(test)]
mod tests {
use super::*;
fn slope_grid(w: u32, h: u32) -> Vec<f32> {
let n = (w * h) as usize;
(0..n)
.map(|i| {
let r = i / w as usize;
let c = i % w as usize;
1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5)
})
.collect()
}
fn hm(w: u32, h: u32) -> BodyHeightmap {
BodyHeightmap {
body_id: "TestBody".into(),
width: w,
height: h,
data: slope_grid(w, h),
sea_level: 0.3,
}
}
#[test]
fn run_layer1_is_deterministic() {
let h = hm(128, 64);
let (o1, _ta1) = run_layer1(&h);
let (o2, _ta2) = run_layer1(&h);
assert_eq!(o1.attractors.len(), o2.attractors.len());
for (a, b) in o1.attractors.iter().zip(o2.attractors.iter()) {
assert_eq!(a.position, b.position);
assert_eq!(a.attractor_type, b.attractor_type);
assert_eq!(a.strength, b.strength);
assert_eq!(a.sub_biome, b.sub_biome);
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
}
assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells);
// survey_basin_dirs is deterministic and non-empty on a slope grid.
assert_eq!(o1.survey_basin_dirs, o2.survey_basin_dirs);
assert!(
!o1.survey_basin_dirs.is_empty(),
"slope grid must produce survey-cell basin directions"
);
}
#[test]
fn produces_attractors_and_costs() {
let (o, _ta) = run_layer1(&hm(256, 128));
assert!(!o.attractors.is_empty(), "expected some attractors");
assert!(o
.attractors
.iter()
.all(|a| a.terrain_modification_cost >= 100));
assert!(o.attractors.iter().all(|a| (0..=100).contains(&a.strength)));
}
#[test]
fn name_attachment_respects_pool_size() {
let (o, _ta) = run_layer1(&hm(256, 128));
let names = vec!["Aldren".to_string(), "Brook".to_string()];
let (rivers, _mtn) = attach_feature_names(&o, &names, &[]);
assert!(rivers.len() <= names.len());
}
// -------------------------------------------------------------------
// T-1184 — hydrology productionization
// -------------------------------------------------------------------
/// Bowl-shaped fixture (high rim, low centre) — the same shape
/// `hydrology_equilibrium.rs`'s own `bowl_grid` test fixture uses,
/// reproduced here (not imported — that one is `#[cfg(test)]`-private to
/// its own module) so `run_layer1`'s hydrology wiring can be exercised
/// end-to-end without depending on solver-internal test helpers.
/// `sea_level: 0.0` keeps the whole grid land except the filled basin, so
/// a resulting `MorphologyZone::Lake` can only be hydrology-sourced, never
/// the `ocean_fraction_q` heuristic fallback.
fn bowl_hm(w: u32, h: u32, body_id: &str) -> BodyHeightmap {
let n = (w * h) as usize;
let cx = w as f32 / 2.0;
let cy = h as f32 / 2.0;
let max_r = cx.min(cy).max(1.0);
let data = (0..n)
.map(|i| {
let r = (i / w as usize) as f32;
let c = (i % w as usize) as f32;
let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0);
0.1 + d * 0.8
})
.collect();
BodyHeightmap {
body_id: body_id.into(),
width: w,
height: h,
data,
sea_level: 0.0,
}
}
#[test]
fn run_layer1_populates_hydrology_on_terrain_analysis() {
let h = bowl_hm(64, 32, "BowlBody");
let (_o, ta) = run_layer1(&h);
let hydro = ta
.hydrology
.as_ref()
.expect("run_layer1 must populate TerrainAnalysis.hydrology (T-1184)");
assert_eq!(hydro.elevation.len(), (64 * 32) as usize);
assert_eq!(hydro.filled.len(), (64 * 32) as usize);
// The bowl centre must be a lake cell: filled strictly exceeds original.
let centre_idx = (16 * 64 + 32) as usize; // row 16, col 32 — the bowl centre
assert!(
hydro.filled[centre_idx] > hydro.elevation[centre_idx],
"bowl centre must be filled above its original elevation"
);
}
#[test]
fn run_layer1_hydrology_is_deterministic() {
let h = bowl_hm(64, 32, "BowlBody");
let (_o1, ta1) = run_layer1(&h);
let (_o2, ta2) = run_layer1(&h);
let h1 = ta1.hydrology.expect("first run must populate hydrology");
let h2 = ta2.hydrology.expect("second run must populate hydrology");
assert_eq!(
h1.elevation, h2.elevation,
"D-010: identical inputs must produce byte-identical elevation carry"
);
assert_eq!(
h1.filled, h2.filled,
"D-010: identical inputs must produce byte-identical filled-surface field"
);
}
/// The D-255(f) mandatory determinism gate: whether hydrology is solved
/// with the fallback default moisture (`run_layer1`) or an explicit
/// caller-supplied moisture that happens to equal the default
/// (`run_layer1_with_moisture`), the two code paths must produce
/// byte-identical `TerrainAnalysis.hydrology` output — the "cache-hit
/// path == cache-miss path" shape applied to the two entry points that
/// stand in for it here (both are genuinely fresh `derive()` calls; T-1184
/// has no separate cached-coarser-canvas to compare against yet, since
/// that tier is T-1181's rung-0 scope — this test instead pins that
/// `run_layer1`'s convenience wrapper and its explicit-moisture sibling
/// never silently diverge, which is the property the next ticket's
/// resident-cache read will depend on staying true).
#[test]
fn run_layer1_default_and_explicit_moisture_agree_at_the_default_value() {
let h = bowl_hm(64, 32, "BowlBody");
let (_o1, ta1) = run_layer1(&h);
let (_o2, ta2) = run_layer1_with_moisture(&h, DEFAULT_HYDROLOGY_MOISTURE_Q);
let h1 = ta1.hydrology.expect("run_layer1 must populate hydrology");
let h2 = ta2
.hydrology
.expect("run_layer1_with_moisture must populate hydrology");
assert_eq!(h1.elevation, h2.elevation);
assert_eq!(h1.filled, h2.filled);
}
#[test]
fn run_layer1_with_moisture_changes_endorheic_split_not_lake_extent() {
// T-1184 scope note (ticket text): moisture affects the
// endorheic-vs-overflow split only, never lake EXTENT (filled_scaled
// is a pure function of elevation/sea_level, moisture-independent).
let h = bowl_hm(64, 32, "BowlBody");
let (_o_dry, ta_dry) = run_layer1_with_moisture(&h, 0);
let (_o_wet, ta_wet) = run_layer1_with_moisture(&h, 100);
let hydro_dry = ta_dry.hydrology.expect("dry run must populate hydrology");
let hydro_wet = ta_wet.hydrology.expect("wet run must populate hydrology");
assert_eq!(
hydro_dry.filled, hydro_wet.filled,
"lake extent (filled_scaled) must be moisture-independent — only \
the endorheic/overflow split may vary with moisture_q"
);
}
}