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:
+85
-64
@@ -81,6 +81,14 @@ pub struct CascadeSnapshot {
|
||||
/// Layer 2 — inter-settlement road/rail graph. `Some` once
|
||||
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
|
||||
pub road_graph: Option<RoadGraph>,
|
||||
/// **Transient** — the `TerrainAnalysis` produced by the Layer-1 drainage
|
||||
/// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once
|
||||
/// both `DistrictProfile` and `RoadGraph` have consumed it.
|
||||
///
|
||||
/// NOT persisted on `BodyWorldState` or the LRU cache (D-203 / T-1048 size
|
||||
/// concern — `TerrainAnalysis` is ~2 MB of full-grid Vecs). Callers that
|
||||
/// need it after the cascade must re-derive from `run_layer1`.
|
||||
pub terrain_analysis: Option<TerrainAnalysis>,
|
||||
}
|
||||
|
||||
/// DistrictProfile layer output (T-1023, D-239 §1): per-district (~1 km) terrain
|
||||
@@ -102,6 +110,9 @@ impl CascadeSnapshot {
|
||||
/// heightmap raster and the Layer-1 outputs in; `last_accessed` starts at 0
|
||||
/// (the cache stamps it on read). A snapshot that stopped at Layer 0 yields
|
||||
/// empty river/basin/attractor data.
|
||||
///
|
||||
/// `terrain_analysis` (transient, ~2 MB) is **dropped here** — it is not
|
||||
/// persisted on `BodyWorldState` per the D-203/T-1048 size budget.
|
||||
pub fn into_body_world_state(self) -> BodyWorldState {
|
||||
let (river_network, drainage_basins, attractors) = match self.layer1 {
|
||||
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
|
||||
@@ -113,6 +124,8 @@ impl CascadeSnapshot {
|
||||
.map(|lr| lr.districts)
|
||||
.unwrap_or_default();
|
||||
let road_graph = self.road_graph.unwrap_or_default();
|
||||
// terrain_analysis (transient) is intentionally dropped here.
|
||||
let _ = self.terrain_analysis;
|
||||
BodyWorldState {
|
||||
body_id: self.body_id,
|
||||
heightmap: self.heightmap.data,
|
||||
@@ -190,6 +203,7 @@ pub fn run_cascade_from_heightmap(
|
||||
layer3: None,
|
||||
layer_district: None,
|
||||
road_graph: None,
|
||||
terrain_analysis: None,
|
||||
};
|
||||
|
||||
// TerritorialStatus is derived once per body from the system's dominant
|
||||
@@ -197,13 +211,17 @@ pub fn run_cascade_from_heightmap(
|
||||
let territorial_status = territorial_status_from_faction(dominant_faction);
|
||||
|
||||
// Layer 1 — topography (RNG-free; pure function of the heightmap).
|
||||
// run_layer1 now returns (Layer1Output, TerrainAnalysis); the TerrainAnalysis
|
||||
// is carried transiently on the snapshot so DistrictProfile + RoadGraph can
|
||||
// reuse it without the former ~45 ms redundant drainage re-run (T-1044).
|
||||
if up_to >= CascadeLayer::Topography {
|
||||
let mut l1 = layer1::run_layer1(&snapshot.heightmap);
|
||||
let (mut l1, ta) = layer1::run_layer1(&snapshot.heightmap);
|
||||
// Stamp the province TerritorialStatus (D-212) onto each basin.
|
||||
for basin in &mut l1.drainage_basins {
|
||||
basin.territorial_status = territorial_status.clone();
|
||||
}
|
||||
snapshot.layer1 = Some(l1);
|
||||
snapshot.terrain_analysis = Some(ta);
|
||||
}
|
||||
|
||||
// Layer 3 — settlement placement (D-211). Requires Layer 1 attractors, which
|
||||
@@ -227,76 +245,79 @@ pub fn run_cascade_from_heightmap(
|
||||
snapshot.layer3 = Some(l3);
|
||||
}
|
||||
|
||||
// DistrictProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need a
|
||||
// TerrainAnalysis, which needs a drainage pass. Layer 1 already ran drainage
|
||||
// inside run_layer1, but neither result is stored on Layer1Output, so we re-run
|
||||
// both here once and share them. Pure → determinism preserved, but the drainage
|
||||
// re-run is NOT free at the ~6 000-districts/body working scale (D-203).
|
||||
// PERF/TODO(T-1044): cache TerrainAnalysis on Layer1Output to drop this
|
||||
// redundant drainage pass, and validate the combined cost against the D-239 §10
|
||||
// ~45 ms/body budget in the T-1031 verification harness. This is a LIVE
|
||||
// production cost: T-1032 wired the real body_params read, so every analyzed
|
||||
// body runs this path.
|
||||
// DistrictProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need
|
||||
// a TerrainAnalysis. The Layer-1 pass already produced one and stored it
|
||||
// transiently on `snapshot.terrain_analysis` — reuse it here instead of
|
||||
// re-running the full ~45 ms drainage pass (T-1044 eliminates the former
|
||||
// PERF/TODO re-run). The analysis is valid as long as the heightmap has not
|
||||
// changed, which is guaranteed by cascade invariant (pure, deterministic).
|
||||
//
|
||||
// The terrain analysis is computed only when it will actually be used:
|
||||
// body_params present (DistrictProfile) or RoadGraph requested. DistrictProfile
|
||||
// skips silently without body_params (e.g. unit tests without DB), but the
|
||||
// road graph needs no body params, so RoadGraph runs regardless.
|
||||
// The terrain_analysis is consumed after DistrictProfile + RoadGraph are
|
||||
// built; it is dropped (not stored on BodyWorldState) per D-203/T-1048.
|
||||
if up_to >= CascadeLayer::DistrictProfile
|
||||
&& (body_params.is_some() || up_to >= CascadeLayer::RoadGraph)
|
||||
{
|
||||
use crate::atlas::drainage;
|
||||
let dr = drainage::analyze(
|
||||
&snapshot.heightmap.data,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
snapshot.heightmap.sea_level,
|
||||
);
|
||||
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
|
||||
// Borrow the transient TerrainAnalysis produced by Layer 1. If Layer 1
|
||||
// did not run (e.g. up_to < Topography — impossible given the guard
|
||||
// above, since DistrictProfile > Topography in CascadeLayer Ord) this
|
||||
// is None and both consumers below will short-circuit gracefully.
|
||||
if let Some(ta) = snapshot.terrain_analysis.as_ref() {
|
||||
// DistrictProfile layer — pure derivation from body params + terrain.
|
||||
if let Some(params) = body_params {
|
||||
// Canonical cells-per-district for the working grid (T-1039):
|
||||
// shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs
|
||||
// converts CityPlacement pixel coords with the same constant.
|
||||
// body_id is required for the D-243 §4 climate edge-fuzz warp
|
||||
// domain separation — derive_all_districts builds the region
|
||||
// cache internally.
|
||||
//
|
||||
// district_basin_dirs from Layer1Output threads the true D8
|
||||
// thalweg direction into each DistrictProfile.basin_direction
|
||||
// (T-1047). Pass the map through derive_all_districts.
|
||||
let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs);
|
||||
let districts = district_profile::derive_all_districts(
|
||||
body_seed,
|
||||
params,
|
||||
ta,
|
||||
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
|
||||
&snapshot.body_id,
|
||||
basin_dirs,
|
||||
);
|
||||
snapshot.layer_district = Some(LayerDistrictOutput { districts });
|
||||
}
|
||||
|
||||
// DistrictProfile layer — pure derivation from body params + terrain.
|
||||
if let Some(params) = body_params {
|
||||
// Canonical cells-per-district for the working grid (T-1039):
|
||||
// shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs converts
|
||||
// CityPlacement pixel coords with the same constant.
|
||||
// body_id is required for the D-243 §4 climate edge-fuzz warp domain
|
||||
// separation — derive_all_districts builds the region cache internally.
|
||||
let districts = district_profile::derive_all_districts(
|
||||
body_seed,
|
||||
params,
|
||||
&ta,
|
||||
scale::HEIGHTMAP_CELLS_PER_DISTRICT,
|
||||
&snapshot.body_id,
|
||||
);
|
||||
snapshot.layer_district = Some(LayerDistrictOutput { districts });
|
||||
// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure
|
||||
// function of (Layer-3 placements, Layer-1 terrain). The named-route
|
||||
// pool is empty for now (atlas_roads/atlas_railroads carry no rows
|
||||
// post-D-223), so the named-route identity join is a designed-for
|
||||
// no-op.
|
||||
if up_to >= CascadeLayer::RoadGraph {
|
||||
let placements = snapshot
|
||||
.layer3
|
||||
.as_ref()
|
||||
.map(|l3| l3.placements.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let river_cells = snapshot
|
||||
.layer1
|
||||
.as_ref()
|
||||
.map(|l1| l1.river_network.river_cells.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let graph = road_graph::build_road_graph(
|
||||
placements,
|
||||
ta,
|
||||
river_cells,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
&territorial_status,
|
||||
&[],
|
||||
);
|
||||
snapshot.road_graph = Some(graph);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure
|
||||
// function of (Layer-3 placements, Layer-1 terrain). The named-route pool
|
||||
// is empty for now (atlas_roads/atlas_railroads carry no rows post-D-223),
|
||||
// so the named-route identity join is a designed-for no-op.
|
||||
if up_to >= CascadeLayer::RoadGraph {
|
||||
let placements = snapshot
|
||||
.layer3
|
||||
.as_ref()
|
||||
.map(|l3| l3.placements.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let river_cells = snapshot
|
||||
.layer1
|
||||
.as_ref()
|
||||
.map(|l1| l1.river_network.river_cells.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let graph = road_graph::build_road_graph(
|
||||
placements,
|
||||
&ta,
|
||||
river_cells,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
&territorial_status,
|
||||
&[],
|
||||
);
|
||||
snapshot.road_graph = Some(graph);
|
||||
}
|
||||
// Drop the transient TerrainAnalysis — both consumers are done.
|
||||
// Not stored on BodyWorldState (D-203/T-1048 size budget: ~2 MB per body).
|
||||
snapshot.terrain_analysis = None;
|
||||
}
|
||||
|
||||
snapshot
|
||||
|
||||
Reference in New Issue
Block a user