feat(simulation): cache TerrainAnalysis + derive basin_direction from the D8 thalweg (T-1044, T-1047)

T-1044: run_layer1 now returns TerrainAnalysis (carried transiently on
CascadeSnapshot, dropped after the district + road-graph passes), eliminating the
redundant per-body drainage::analyze + TerrainAnalysis::analyze re-run flagged by
PERF/TODO(T-1044). Not persisted on the LRU-cached state (D-203/T-1048 size concern).

T-1047: basin_direction is now derived from the real D8 thalweg. run_layer1
aggregates a per-district dominant D8 direction from the live fdir grid (carried
transiently on DrainageResult), threaded via Layer1Output.district_basin_dirs ->
derive_all_districts -> DistrictProfile.basin_direction; derive_chunk_context reads
it directly. Removed the false derive_basin_direction (it branched on ocean_fraction_q
then read seed bits despite a doc comment claiming an elev_q/slope_q D8 proxy) +
corrected the module contract. D-239 §8 (D8 thalweg) now actually honoured.

1559 tests pass; golden byte-identical (district_basin_dirs is #[serde(skip)], transient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 09:46:42 +02:00
co-authored by Claude Opus 4.8
parent 711ac65d20
commit 0263b68df9
12 changed files with 349 additions and 139 deletions
+141 -6
View File
@@ -6,19 +6,28 @@
//! 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, DistrictPos, HEIGHTMAP_CELLS_PER_DISTRICT};
use crate::atlas::subbiome;
use crate::simulation::generator::{AttractorType, GeographicAttractor};
use serde::{Deserialize, Serialize};
@@ -38,10 +47,32 @@ pub struct Layer1Output {
/// 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 district, 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 district. Keyed by `DistrictPos` using `HEIGHTMAP_CELLS_PER_DISTRICT`
/// as the grid-to-district mapping.
///
/// This is 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).
///
/// **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-district direction is
/// persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
/// after the cascade consumes it.
#[serde(skip)]
pub district_basin_dirs: BTreeMap<DistrictPos, BasinDirection>,
}
/// Run the Layer-1 topography pipeline for a single body.
pub fn run_layer1(hm: &BodyHeightmap) -> Layer1Output {
///
/// 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).
pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
let drainage: DrainageResult = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage);
@@ -64,14 +95,112 @@ pub fn run_layer1(hm: &BodyHeightmap) -> Layer1Output {
})
.collect();
Layer1Output {
// Aggregate per-district dominant D8 direction from the fdir grid (T-1047,
// D-239 §8). fdir is available here before it is discarded — do NOT expose
// the full grid on DrainageResult externally. The compact per-district 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 district_basin_dirs =
aggregate_district_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,
district_basin_dirs,
};
(l1, ta)
}
/// Aggregate a per-district 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 districts 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). Districts with no valid votes default to `North`.
///
/// Integer arithmetic throughout (D-010).
fn aggregate_district_basin_dirs(
fdir: &[i8],
width: u32,
height: u32,
ocean_mask: &[bool],
) -> BTreeMap<DistrictPos, BasinDirection> {
let w = width as usize;
let h = height as usize;
let gcpd = HEIGHTMAP_CELLS_PER_DISTRICT;
// Per-district vote counts: [N, S, E, W].
let mut votes: BTreeMap<DistrictPos, [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 district_pos: DistrictPos = ((c / gcpd) as i32, (r / gcpd) as i32);
votes.entry(district_pos).or_insert([0i32; 4])[vote] += 1;
}
}
// For each district, pick the cardinal with the most votes.
// Tie-breaking order: N > S > E > W (matches D8 priority).
let district_cols = w.div_ceil(gcpd) as i32;
let district_rows = h.div_ceil(gcpd) as i32;
let mut out = BTreeMap::new();
for dy in 0..district_rows {
for dx in 0..district_cols {
let pos: DistrictPos = (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 district: default
};
out.insert(pos, dir);
}
}
out
}
/// Attach pool names (D-223) to the largest computed rivers and mountains.
@@ -160,8 +289,8 @@ mod tests {
#[test]
fn run_layer1_is_deterministic() {
let h = hm(128, 64);
let o1 = run_layer1(&h);
let o2 = run_layer1(&h);
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);
@@ -171,11 +300,17 @@ mod tests {
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
}
assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells);
// district_basin_dirs is deterministic and non-empty on a slope grid.
assert_eq!(o1.district_basin_dirs, o2.district_basin_dirs);
assert!(
!o1.district_basin_dirs.is_empty(),
"slope grid must produce district basin directions"
);
}
#[test]
fn produces_attractors_and_costs() {
let o = run_layer1(&hm(256, 128));
let (o, _ta) = run_layer1(&hm(256, 128));
assert!(!o.attractors.is_empty(), "expected some attractors");
assert!(o
.attractors
@@ -186,7 +321,7 @@ mod tests {
#[test]
fn name_attachment_respects_pool_size() {
let o = run_layer1(&hm(256, 128));
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());