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
+35 -3
View File
@@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize};
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::region_profile::{self, RegionProfile};
use crate::atlas::scale::{self, RegionPos};
use crate::atlas::scale::{self, BasinDirection, RegionPos};
use crate::seed::SeedChain;
use crate::simulation::generator::MorphologyZone;
@@ -232,6 +232,18 @@ pub struct DistrictProfile {
/// Riparian variants override the base class in the 1–3 tile band along
/// perennial waterways (D-239 §8 climate→vegetation law).
pub vegetation_class: VegetationClass,
/// Dominant D8 thalweg direction for this district (T-1047, D-239 §8).
///
/// The **true** D8-computed dominant flow direction aggregated from the full
/// `fdir` grid in `run_layer1` — not a seed-bit proxy. Threaded here from
/// `Layer1Output.district_basin_dirs` so `derive_chunk_context` reads it
/// directly instead of calling the formerly-false `derive_basin_direction`.
///
/// `#[serde(default)]` ensures backward compatibility when deserializing
/// stored profiles that predate this field (T-1047).
#[serde(default)]
pub basin_direction: BasinDirection,
}
// ---------------------------------------------------------------------------
@@ -1007,6 +1019,7 @@ pub fn derive_district_profile(
climate: &ClimateConstants,
body_id: &str,
region_cache: &BTreeMap<RegionPos, RegionProfile>,
basin_direction: BasinDirection,
) -> DistrictProfile {
let (rx, ry) = pos;
let w = ta.w;
@@ -1068,6 +1081,7 @@ pub fn derive_district_profile(
elev_q,
ocean_fraction_q,
region_baseline_c,
basin_direction,
)
}
@@ -1100,6 +1114,7 @@ fn build_district_profile(
elev_q: i32,
ocean_fraction_q: i32,
region_baseline_c: Option<f32>,
basin_direction: BasinDirection,
) -> DistrictProfile {
let tectonic_class = derive_tectonic_class(body_params);
@@ -1162,6 +1177,7 @@ fn build_district_profile(
temperature_c,
moisture_q,
vegetation_class,
basin_direction,
}
}
@@ -1271,6 +1287,9 @@ pub fn derive_district(
None, // no pre-built cache; derive on-the-fly
);
// derive_district is the on-demand path (arbitrary DistrictPos, no L1 working
// grid); basin_direction defaults to North here. The batch path
// (derive_all_districts) threads the true D8 thalweg direction from L1.
build_district_profile(
seed,
&params,
@@ -1279,6 +1298,7 @@ pub fn derive_district(
elev_q,
ocean_fraction_q,
region_baseline_c,
BasinDirection::default(),
)
}
@@ -1353,12 +1373,18 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
///
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
/// warp domain separation.
/// `basin_dirs` is the per-district dominant D8 thalweg direction computed in
/// `run_layer1` (T-1047). When `Some`, each district's `basin_direction` is read
/// from the map; missing entries (edge districts with no land cells) default to
/// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs),
/// every district gets `BasinDirection::North`.
pub fn derive_all_districts(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
grid_cells_per_district: usize,
body_id: &str,
basin_dirs: Option<&BTreeMap<DistrictPos, BasinDirection>>,
) -> BTreeMap<DistrictPos, DistrictProfile> {
let climate = ClimateConstants::default();
let gcpr = grid_cells_per_district.max(1);
@@ -1409,6 +1435,9 @@ pub fn derive_all_districts(
latitude_deg: lat_deg,
..body_params.clone()
};
let basin_direction = basin_dirs
.and_then(|m| m.get(&pos).copied())
.unwrap_or_default();
let profile = derive_district_profile(
seed,
&district_params,
@@ -1418,6 +1447,7 @@ pub fn derive_all_districts(
&climate,
body_id,
&region_cache,
basin_direction,
);
out.insert(pos, profile);
}
@@ -1469,7 +1499,7 @@ mod tests {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let districts = derive_all_districts(test_seed(), &params, &ta, 8, "test_body");
let districts = derive_all_districts(test_seed(), &params, &ta, 8, "test_body", None);
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
assert_eq!(districts.len(), 32, "district count mismatch");
@@ -1593,6 +1623,7 @@ mod tests {
&climate,
"test_body",
&BTreeMap::new(),
BasinDirection::North,
);
let p2 = derive_district_profile(
test_seed(),
@@ -1603,6 +1634,7 @@ mod tests {
&climate,
"test_body",
&BTreeMap::new(),
BasinDirection::North,
);
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
assert_eq!(
@@ -1697,7 +1729,7 @@ mod tests {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let districts = derive_all_districts(test_seed(), &params, &ta, 8, "test_body");
let districts = derive_all_districts(test_seed(), &params, &ta, 8, "test_body", None);
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
let first = districts.keys().next().expect("at least one district");
assert_eq!(*first, (0, 0), "first district must be at origin");