fix(simulation): one absolute-metre derive core (D-256, T-1174)

derive_district_profile is now a thin wrapper over the shared
derive_at_metres_with_riparian core at survey-cell-centre world metres
— one derive core, two position sets. The batch pseudo-grid and the
true D-243 district grid were two coordinate systems sharing one bare
(i32,i32) type; the new SurveyCellPos newtype re-keys every batch
product (BodyWorldState.districts, Layer1Output.survey_basin_dirs) so
the compiler rejects cross-namespace passing.

Fixes two latent same-position divergences the T-1174 investigation
surfaced: three inconsistent latitude conventions collapse into the
core's single inverse mapping, and the region-climate baseline now
floor-divides true world metres instead of collapsing the whole body
onto region (0,0)'s baseline — batch climate becomes latitude/region
graded (D-245 direction: every changed believability metric increased).

Binding preservations per D-256(c): basin_direction rides a post-call
override with the true L1 D8 survey-cell aggregate (layer1's map
re-keyed to SurveyCellPos, identity lookup — a floor-divide lookup
against the pseudo-keyed map would have silently defaulted every cell
North); the riparian verdict comes from near_perennial_water_at, never
the empty-slice default (which would have flipped riverside
vegetation_class).

Quarter-skeleton morphology_zone now resolves at the settlement's
exact world position via derive_at_metres at work-item execution
(where TerrainAnalysisCache lives), replacing the survey-cell-centre
map lookup (D-256(d)); settlement_district_pos fixed to true-district
floor-division in passing (same doc/impl mismatch class). Second
pixel-vs-metre conflation fixed in aliveness_probe's anchor-walk math.

Window path byte-unchanged (window_derivation_golden 6/6 byte-
identical); derivation_harness golden untouched; believability golden
regenerated. Full lib + integration suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:40:13 +02:00
co-authored by Claude Fable 5
parent 35a4137304
commit 933e1f4ee4
15 changed files with 1272 additions and 479 deletions
+47 -37
View File
@@ -27,7 +27,7 @@ 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::scale::{BasinDirection, HEIGHTMAP_CELLS_PER_DISTRICT, SurveyCellPos};
use crate::atlas::subbiome;
use crate::simulation::generator::{AttractorType, GeographicAttractor};
use serde::{Deserialize, Serialize};
@@ -47,23 +47,30 @@ 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`
/// 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 district. Keyed by `DistrictPos` using `HEIGHTMAP_CELLS_PER_DISTRICT`
/// as the grid-to-district mapping.
/// 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).
///
/// 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).
/// 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-district direction is
/// persisted on `DistrictProfile.basin_direction` (`BodyWorldState.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 district_basin_dirs: BTreeMap<DistrictPos, BasinDirection>,
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
}
/// Run the Layer-1 topography pipeline for a single body.
@@ -95,9 +102,10 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
})
.collect();
// 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
// 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):
@@ -107,8 +115,8 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
// 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 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(),
@@ -117,32 +125,34 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
attractors,
grid_w: hm.width,
grid_h: hm.height,
district_basin_dirs,
survey_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.
/// 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). Districts with no valid votes default to `North`.
/// precedence (N > S > E > W). Survey cells with no valid votes default to
/// `North`.
///
/// Integer arithmetic throughout (D-010).
fn aggregate_district_basin_dirs(
fn aggregate_survey_basin_dirs(
fdir: &[i8],
width: u32,
height: u32,
ocean_mask: &[bool],
) -> BTreeMap<DistrictPos, BasinDirection> {
) -> BTreeMap<SurveyCellPos, 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();
// 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 {
@@ -163,19 +173,19 @@ fn aggregate_district_basin_dirs(
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;
let cell_pos = SurveyCellPos((c / gcpd) as i32, (r / gcpd) as i32);
votes.entry(cell_pos).or_insert([0i32; 4])[vote] += 1;
}
}
// For each district, pick the cardinal with the most votes.
// For each survey cell, 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 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..district_rows {
for dx in 0..district_cols {
let pos: DistrictPos = (dx, dy);
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;
@@ -195,7 +205,7 @@ fn aggregate_district_basin_dirs(
}
best_dir
} else {
BasinDirection::North // ocean-only or empty district: default
BasinDirection::North // ocean-only or empty cell: default
};
out.insert(pos, dir);
}
@@ -300,11 +310,11 @@ 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);
// survey_basin_dirs is deterministic and non-empty on a slope grid.
assert_eq!(o1.survey_basin_dirs, o2.survey_basin_dirs);
assert!(
!o1.district_basin_dirs.is_empty(),
"slope grid must produce district basin directions"
!o1.survey_basin_dirs.is_empty(),
"slope grid must produce survey-cell basin directions"
);
}