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>
510 lines
22 KiB
Rust
510 lines
22 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|