From 933e1f4ee45e76569cd6f4c1a6a5c61c844c1f5a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 00:40:13 +0200 Subject: [PATCH] fix(simulation): one absolute-metre derive core (D-256, T-1174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/atlas/believability.rs | 115 +++++-- server/src/atlas/body_world_state.rs | 20 +- server/src/atlas/cascade.rs | 43 ++- server/src/atlas/district_profile.rs | 423 +++++++++++++++---------- server/src/atlas/drainage.rs | 7 +- server/src/atlas/gen_queue.rs | 139 +++++++- server/src/atlas/layer1.rs | 84 ++--- server/src/atlas/layer_proxy.rs | 320 +++++++++++++++++-- server/src/atlas/plugin.rs | 365 ++++++++++++++------- server/src/atlas/scale.rs | 54 +++- server/src/bin/aliveness_probe.rs | 153 ++++++--- server/tests/believability_harness.rs | 16 +- server/tests/derivation_harness.rs | 2 +- server/tests/gen_fixtures.rs | 2 +- server/tests/golden/believability.json | 8 +- 15 files changed, 1272 insertions(+), 479 deletions(-) diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs index 1cf14b7f1..3327890dc 100644 --- a/server/src/atlas/believability.rs +++ b/server/src/atlas/believability.rs @@ -24,18 +24,23 @@ //! //! ## District tier (today) //! -//! Per T-1083, analysis is addressed in **district** space: no production code maps a -//! world chunk → its covering [`DistrictProfile`] yet (the voxel layer is a walking -//! skeleton). The `DistrictProfile` (2 km) is the finest *authoritative* nature unit the -//! cascade produces; the voxel-derived metrics sample a representative chunk per district. +//! Per T-1083, analysis is addressed in **survey-cell** space (D-256(b)): no production +//! code maps a world chunk → its covering [`DistrictProfile`] yet (the voxel layer is a +//! walking skeleton). Each survey cell's `DistrictProfile` is derived at its D-256(b) +//! centre world metres via the same [`crate::atlas::district_profile::derive_at_metres`] +//! family every other rung uses — the finest *authoritative* nature unit the cascade +//! produces; the voxel-derived metrics sample a representative chunk at that SAME centre +//! position (`district_profile::survey_cell_centre_world_m`), never the survey cell's own +//! pseudo-grid coordinates scaled as if they were a true district. //! //! ## Determinism //! -//! [`analyze`] is a pure, deterministic function of `(world_seed, body_id, districts)`: -//! scalar/categorical contrast is computed over **all** districts; the voxel-derived -//! metrics sample a **seeded spread** of [`VOXEL_SAMPLE_DISTRICTS`] across the body -//! (deterministic from the world seed — D-245's "randomly-sampled locations", and -//! unbiased unlike a contiguous corner). Suitable for golden-snapshot regression. +//! [`analyze`] is a pure, deterministic function of `(world_seed, body_id, districts, +//! heightmap_width, heightmap_height, body_radius_km)`: scalar/categorical contrast is +//! computed over **all** survey cells; the voxel-derived metrics sample a **seeded +//! spread** of [`VOXEL_SAMPLE_DISTRICTS`] across the body (deterministic from the world +//! seed — D-245's "randomly-sampled locations", and unbiased unlike a contiguous corner). +//! Suitable for golden-snapshot regression. use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; @@ -47,9 +52,9 @@ use crate::atlas::body_params_reader::BodyParamsReader; use crate::atlas::body_world_state::BodyWorldState; use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer, CascadeSnapshot}; use crate::atlas::chunk_context::derive_chunk_context; -use crate::atlas::district_profile::{BodyParams, DistrictProfile}; +use crate::atlas::district_profile::{self, BodyParams, DistrictProfile}; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; -use crate::atlas::scale::{ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M}; +use crate::atlas::scale::{self, ChunkPos, SurveyCellPos, CHUNKS_PER_DISTRICT, CHUNK_M}; use crate::atlas::voxel::{derive_voxel_column, Vegetation, Water}; use crate::seed::SeedChain; use crate::simulation::generator::SettlementClass; @@ -169,13 +174,27 @@ pub struct Criterion { // Analysis // --------------------------------------------------------------------------- -/// Compute the [`BelievabilityReport`] for a body's per-district cascade output. +/// Compute the [`BelievabilityReport`] for a body's per-survey-cell cascade +/// output. /// -/// Pure + deterministic (see module docs). `districts` is `BodyWorldState.districts`. +/// Pure + deterministic (see module docs). `districts` is +/// `BodyWorldState.districts` — keyed by [`SurveyCellPos`] (D-256(b)), NOT the +/// true D-243 district grid. `heightmap_width`/`heightmap_height`/ +/// `body_radius_km` are needed to resolve each sampled survey cell's centre +/// world metres (`district_profile::pixel_to_world_m`, the same D-256(b) +/// bridge the survey raster itself uses) into the TRUE chunk position the +/// voxel-derived metrics sample — a survey cell's own pseudo-grid coordinates +/// are NOT a district position and must never be scaled by +/// `CHUNKS_PER_DISTRICT` directly (the class of bug D-256's `SurveyCellPos` +/// newtype exists to make the compiler reject). +#[allow(clippy::too_many_arguments)] pub fn analyze( world_seed: u64, body_id: &str, - districts: &BTreeMap, + districts: &BTreeMap, + heightmap_width: u32, + heightmap_height: u32, + body_radius_km: Option, ) -> BelievabilityReport { // ── Contrast: scalar + categorical over ALL districts (cheap, no voxels) ── let contrast_scalar = ContrastMetrics { @@ -213,12 +232,23 @@ pub fn analyze( // sampled vegetated-land districts — the worst patch, per the D-245 per-patch floor. let mut min_habitat_distinct = usize::MAX; - let keys: Vec = districts.keys().copied().collect(); + let keys: Vec = districts.keys().copied().collect(); for idx in sample_indices(world_seed, keys.len(), VOXEL_SAMPLE_DISTRICTS) { let dp = keys[idx]; let prof = &districts[&dp]; sampled += 1; - let chunk = district_centre_chunk(dp); + // D-256(d): resolve this survey cell's OWN centre world metres (the + // same D-256(b) bridge that derived `prof` in the first place), then + // convert to the TRUE chunk covering that position — never scale the + // survey pseudo-grid coordinates directly. + let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m( + dp, + scale::HEIGHTMAP_CELLS_PER_DISTRICT, + heightmap_width as usize, + heightmap_height as usize, + body_radius_km, + ); + let chunk = world_m_to_chunk(world_x_m, world_y_m); let ctx = derive_chunk_context(world_seed, body_id, prof, chunk, None); let mut any_wet = false; @@ -294,11 +324,21 @@ pub fn analyze( // districts with one stray dry point would otherwise contribute a 0 range and // bias the mean toward "flat" on an ocean world (a measurement artifact, not // flat land). + // + // D-256(d): the transect origin is the TRUE district (not the survey + // cell) containing `chunk` — `chunk` was resolved from the survey + // cell's own centre world metres above, so flooring it to its + // covering district's chunk-grid corner keeps the transect inside the + // SAME district the sample chunk itself falls in. + let district_origin_chunk = ( + chunk.0.div_euclid(CHUNKS_PER_DISTRICT) * CHUNKS_PER_DISTRICT, + chunk.1.div_euclid(CHUNKS_PER_DISTRICT) * CHUNKS_PER_DISTRICT, + ); let (mut relief_lo, mut relief_hi, mut relief_dry) = (i32::MAX, i32::MIN, 0i32); for &co in &RELIEF_TRANSECT_CHUNK_OFFSETS { let cpos = ( - dp.0 * CHUNKS_PER_DISTRICT + co, - dp.1 * CHUNKS_PER_DISTRICT + co, + district_origin_chunk.0 + co, + district_origin_chunk.1 + co, ); let cctx = derive_chunk_context(world_seed, body_id, prof, cpos, None); let tx = cpos.0 * CHUNK_M + CHUNK_M / 2; @@ -441,12 +481,19 @@ fn distinct(vals: impl Iterator) -> usize { vals.collect::>().len() } -/// The chunk at the centre of a district (32 chunks/district) — the representative -/// chunk the voxel-derived metrics sample. -fn district_centre_chunk(dp: DistrictPos) -> ChunkPos { +/// The chunk covering a world-metres position (D-256(d)) — the representative +/// chunk the voxel-derived metrics sample. `world_x_m`/`world_y_m` is the +/// sampled survey cell's OWN centre position (`district_profile::pixel_to_world_m` +/// applied to its covering pixel block — the same D-256(b) bridge the survey +/// raster itself uses to derive the cell's `DistrictProfile`), never the +/// pseudo-grid coordinates scaled directly: a `SurveyCellPos` is not a +/// district position and multiplying it by `CHUNKS_PER_DISTRICT` would sample +/// a chunk that has nothing to do with the profile actually being probed. +fn world_m_to_chunk(world_x_m: f64, world_y_m: f64) -> ChunkPos { + let cm = crate::atlas::scale::CHUNK_M as f64; ( - dp.0 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2, - dp.1 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2, + (world_x_m / cm).floor() as i32, + (world_y_m / cm).floor() as i32, ) } @@ -679,11 +726,11 @@ mod tests { /// This is the case the naive per-tile metric called ALIVE. #[test] fn uniform_world_fails_contrast_criteria() { - let mut districts: BTreeMap = BTreeMap::new(); + let mut districts: BTreeMap = BTreeMap::new(); for x in 0..8 { for y in 0..8 { districts.insert( - (x, y), + SurveyCellPos(x, y), district( MorphologyZone::AlluvialPlain, 20, @@ -695,7 +742,7 @@ mod tests { ); } } - let report = analyze(42, "uniform", &districts); + let report = analyze(42, "uniform", &districts, 64, 64, None); assert_eq!(report.contrast.moisture_q.distinct, 1, "moisture is flat"); assert_eq!(report.contrast.morphology_zones, 1); assert_eq!(report.contrast.elev_q.spread(), 0); @@ -727,12 +774,12 @@ mod tests { VegetationClass::Forest, VegetationClass::RiparianThicket, ]; - let mut districts: BTreeMap = BTreeMap::new(); + let mut districts: BTreeMap = BTreeMap::new(); for x in 0..8 { for y in 0..8 { let i = (x * 8 + y) as usize; districts.insert( - (x, y), + SurveyCellPos(x, y), district( zones[i % zones.len()], (i as i32 * 7) % 100, // varied elevation @@ -744,7 +791,7 @@ mod tests { ); } } - let report = analyze(42, "varied", &districts); + let report = analyze(42, "varied", &districts, 64, 64, None); assert!(report.contrast.moisture_q.distinct >= 3); assert!(report.contrast.morphology_zones >= 2); assert!(report.contrast.elev_q.spread() >= 10); @@ -758,9 +805,9 @@ mod tests { #[test] fn analyze_is_deterministic() { - let mut districts: BTreeMap = BTreeMap::new(); + let mut districts: BTreeMap = BTreeMap::new(); districts.insert( - (0, 0), + SurveyCellPos(0, 0), district( MorphologyZone::MeanderReach, 30, @@ -771,7 +818,7 @@ mod tests { ), ); districts.insert( - (1, 0), + SurveyCellPos(1, 0), district( MorphologyZone::CliffCoast, 70, @@ -781,8 +828,8 @@ mod tests { VegetationClass::Scrub, ), ); - let a = analyze(7, "GJ1c", &districts); - let b = analyze(7, "GJ1c", &districts); + let a = analyze(7, "GJ1c", &districts, 64, 64, Some(6371.0)); + let b = analyze(7, "GJ1c", &districts, 64, 64, Some(6371.0)); assert_eq!(a, b); } diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index b11e83253..147e65aa8 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -14,10 +14,10 @@ use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::attractor_matching::CityPlacement; -use crate::atlas::district_profile::{DistrictPos, DistrictProfile}; +use crate::atlas::district_profile::DistrictProfile; use crate::atlas::region_profile::RegionProfile; use crate::atlas::road_graph::RoadGraph; -use crate::atlas::scale::RegionPos; +use crate::atlas::scale::{RegionPos, SurveyCellPos}; use crate::simulation::generator::{ GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus, }; @@ -169,6 +169,13 @@ pub struct BodyWorldState { pub heightmap: Vec, pub heightmap_width: u32, pub heightmap_height: u32, + /// Elevation fraction below which terrain is ocean/sea (T-1174/D-256) — + /// carried alongside the working-grid data above so a `BodyHeightmap` can + /// be reconstructed in-memory (no disk re-read) wherever a `TerrainAnalysis` + /// needs re-deriving from this cached body (e.g. + /// `TerrainAnalysisCache::get_or_derive` for an exact-position skeleton + /// judgment, D-256(d)). Sourced from `CascadeSnapshot.heightmap.sea_level`. + pub sea_level: f32, /// D8 drainage analysis output (D-208). Empty until drainage task completes. pub river_network: RiverNetwork, /// Drainage basins from watershed analysis (D-205). @@ -187,12 +194,14 @@ pub struct BodyWorldState { /// Populated by `GenCompletion::SkeletonGenerated` after the plan phase /// completes for each city. `BTreeMap` for D-010 determinism. pub quarters: BTreeMap, - /// Per-district (~1 km) profiles derived from body params + terrain (T-1023, D-239 §1). + /// Per-survey-cell profiles derived from body params + terrain (T-1023, + /// D-239 §1) — the D-256(b) coarse planning raster, NOT the true D-243 + /// district grid (see [`SurveyCellPos`]). /// /// Populated by the background cascade after Layer 1 completes. - /// `BTreeMap` keyed by `DistrictPos` for D-010 determinism. + /// `BTreeMap` keyed by `SurveyCellPos` for D-010 determinism. /// Empty until the DistrictProfile layer has run. - pub districts: BTreeMap, + pub districts: BTreeMap, /// Per-region (~205 km) climate context — season/weather/temperature /// baseline cells (D-243 §3, T-1113). /// @@ -319,6 +328,7 @@ mod tests { heightmap: vec![0.5; 16], heightmap_width: 4, heightmap_height: 4, + sea_level: 0.3, river_network: RiverNetwork::default(), drainage_basins: vec![], attractors: vec![], diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 989ca9af1..559c9bf74 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -24,13 +24,13 @@ use crate::atlas::attractor_matching::{ match_cities, territorial_status_from_faction, CityPlacement, CityRecord, }; use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork}; -use crate::atlas::district_profile::{self, BodyParams, DistrictPos, DistrictProfile}; +use crate::atlas::district_profile::{self, BodyParams, DistrictProfile}; use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError}; use crate::atlas::layer1::{self, Layer1Output}; use crate::atlas::region_profile::{self, RegionProfile}; use crate::atlas::road_graph::{self, RoadGraph}; -use crate::atlas::scale::{self, RegionPos}; +use crate::atlas::scale::{self, RegionPos, SurveyCellPos}; use crate::seed::SeedChain; use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus}; @@ -105,11 +105,11 @@ pub struct CascadeSnapshot { pub terrain_analysis: Option, } -/// DistrictProfile layer output (T-1023, D-239 §1): per-district (~1 km) terrain -/// profiles covering the whole body. Stored in `BodyWorldState.districts`. +/// DistrictProfile layer output (T-1023, D-239 §1): per-survey-cell (D-256(b)) +/// terrain profiles covering the whole body. Stored in `BodyWorldState.districts`. #[derive(Debug, Clone, Default)] pub struct LayerDistrictOutput { - pub districts: std::collections::BTreeMap, + pub districts: std::collections::BTreeMap, } /// Region climate layer output (T-1113, D-243 §3): per-region (~205 km) climate @@ -120,6 +120,12 @@ pub struct LayerDistrictOutput { /// cache pads a neighbour ring because its edge-fuzz blend samples across /// boundaries; that padding is a blend implementation detail, not part of the /// body's own region grid, and a dense Atlas wire encoding wants exact dims.) +/// +/// **D-256(f):** the build still keys off the SurveyCellPos-shaped pseudo-grid +/// dims rather than the true D-243 district grid — a fenced, deliberate defer +/// to T-1181's rung-0 Global canvas, not a fix this ticket makes. Verified +/// safe: the sole reader is the `region_grid` Atlas overlay, which reads no +/// `DistrictProfile` climate to disagree with. #[derive(Debug, Clone, Default)] pub struct LayerRegionOutput { pub regions: std::collections::BTreeMap, @@ -154,11 +160,13 @@ impl CascadeSnapshot { let road_graph = self.road_graph.unwrap_or_default(); // terrain_analysis (transient) is intentionally dropped here. let _ = self.terrain_analysis; + let sea_level = self.heightmap.sea_level; BodyWorldState { body_id: self.body_id, heightmap: self.heightmap.data, heightmap_width: self.heightmap.width, heightmap_height: self.heightmap.height, + sea_level, river_network, drainage_basins, attractors, @@ -301,10 +309,13 @@ pub fn run_cascade_from_heightmap( // 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); + // Layer1Output.survey_basin_dirs threads the true D8 thalweg + // direction into each DistrictProfile.basin_direction (T-1047) + // — the VALUES are true D8 aggregates, the KEYS are survey + // cells (D-256(b); `derive_all_districts` looks it up by + // identity, matching this map's own key space). Pass the map + // through derive_all_districts. + let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.survey_basin_dirs); // T-1168 Ruling 4c: same `snapshot.layer1` source as // `basin_dirs`/`river_cells` (the `road_graph` precedent // below) — the river network for the batch-path riparian @@ -372,6 +383,18 @@ pub fn run_cascade_from_heightmap( // no TerrainAnalysis needed, so it runs outside the transient-borrow block // above. Gates on body_params like the DistrictProfile layer (no params → // no climate inputs → the layer skips, `regions` stays empty). + // + // D-256(f) FENCED, NOT FIXED BY THIS TICKET: this block still treats the + // survey-raster dims (`district_cols`/`district_rows` below, really + // SurveyCellPos counts) as if they were true district counts and maps + // them straight through `scale::district_to_region` — the SAME pseudo-grid + // collapse D-256(c) fixed for `DistrictProfile`'s own region baseline. + // Deliberately deferred to T-1181's rung-0 Global canvas (D-256 ruling's + // tripwire, verified: the sole production reader of `LayerRegionOutput`/ + // `regions` is the `region_grid` body-view overlay — no consumer reads it + // against `DistrictProfile` climate, so this collapse never disagrees + // with anything this ticket's scope touches). The overlay stays visibly + // stale until T-1181 replaces it — accepted, noted, not silently ignored. if up_to >= CascadeLayer::Region { if let Some(params) = body_params { // The covering region grid: the same district dims the district @@ -589,7 +612,7 @@ mod tests { assert_eq!(p1.river_threshold, p2.river_threshold); assert_eq!(p1.tectonic_class, p2.tectonic_class); assert_eq!(p1.glaciation_grade, p2.glaciation_grade); - // basin_direction threads run_layer1 -> district_basin_dirs -> here; + // basin_direction threads run_layer1 -> survey_basin_dirs -> here; // guard the full chain's determinism (T-1047). assert_eq!(p1.basin_direction, p2.basin_direction); } diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 6e5333ecc..9adb4dcce 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -27,7 +27,7 @@ use crate::atlas::coast_invention; use crate::atlas::features::TerrainAnalysis; use crate::atlas::region_profile::{self, RegionProfile}; use crate::atlas::river_course; -use crate::atlas::scale::{self, BasinDirection, RegionPos}; +use crate::atlas::scale::{self, BasinDirection, RegionPos, SurveyCellPos}; use crate::seed::SeedChain; use crate::simulation::generator::MorphologyZone; @@ -247,8 +247,10 @@ pub struct DistrictProfile { /// /// 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`. + /// `Layer1Output.survey_basin_dirs` (D-256(b): a survey-cell-keyed aggregate, + /// looked up by identity in `derive_all_districts` — see that function's + /// doc) 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). @@ -1228,9 +1230,11 @@ fn invent_primitives( } /// Fractional working-grid position → absolute world metres — the inverse of -/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam), used by -/// the batch path so both derivation paths key the invention noise fields on -/// the same world-metre convention. Radius-less bodies fall back to the +/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam). Used by +/// [`derive_district_profile`] (D-256(b)) to convert a survey cell's centre +/// pixel to the world metres it hands to the shared [`derive_at_metres_with_riparian`] +/// core — both derivation paths key their invention noise fields on the same +/// world-metre convention this way. Radius-less bodies fall back to the /// 1-working-pixel = 1-district convention `derive_district` uses. /// /// `pub(crate)` (T-1170): also used by the river course inventor @@ -1297,21 +1301,40 @@ pub(crate) fn world_m_to_pixel( // Public derivation function // --------------------------------------------------------------------------- -/// Derive a `DistrictProfile` for the district at `pos` on a `~1km` grid. +/// Derive a `DistrictProfile` for the survey cell at `pos` on the D-256(b) +/// survey raster — a thin wrapper over the shared [`derive_at_metres_with_riparian`] +/// core at the survey cell's centre world metres (D-256(c): "one derive core, +/// two position sets"). /// /// Pure (no I/O, no side effects). Inputs are the body's params and the /// pre-computed `TerrainAnalysis` from Layer 1. /// -/// `grid_cells_per_district` controls how many heightmap cells map to one district -/// cell; default is 8 (at 128×64 working grid, that yields ~80×64 districts ≈ -/// ~5 000 districts/body, within the D-203 ~6 000/body budget). +/// `grid_cells_per_district` controls how many heightmap cells map to one survey +/// cell; default is 8 (`scale::HEIGHTMAP_CELLS_PER_DISTRICT`, at the standard +/// working grid `heightmap::GRID_W` × `heightmap::GRID_H`, T-1170) — several +/// thousand cells/body, within the D-203 budget. /// -/// Temperature and moisture are derived via the D-243 §3/§4 two-phase stack: -/// the edge-fuzz-blended region baseline (from `region_cache` / on-the-fly -/// derivation via [`region_profile::region_baseline_at_district`]) feeds the -/// district modulation ([`derive_district_temperature_c`]). Pass a -/// `&ClimateConstants` to control the tuning constants. The seed chain provides -/// the body-scoped seed. +/// ## Survey-cell-centre position (D-256(b)) +/// +/// The centre pixel is the midpoint of the cell's covering pixel block, +/// `8·rx + 3.5` in the interior (the geometrically correct centre of the +/// 8-point bilinear sample lattice — kept exactly as before this ticket, NOT +/// an error) — clamped at the grid edges where the block is truncated +/// (`saturating_add`/`.min(w)`/`.min(h)`), matching this function's +/// pre-D-256 pixel-range math exactly. That pixel is converted to world +/// metres via [`pixel_to_world_m`], then handed to the shared core with +/// `min_wavelength_m = 0.0` (no octave cutoff, matching [`derive_district`]'s +/// own default). +/// +/// ## Region baseline + latitude (D-256(c)) +/// +/// The core derives its own latitude and region-climate baseline from the +/// survey-cell-centre world metres — this is what auto-fixes the two latent +/// same-position divergences the D-256 investigation found: one inverse +/// mapping computed once cannot disagree with itself (the former three +/// inconsistent latitudes collapse to one), and the region baseline now +/// floor-divides the TRUE world metres instead of keying off the pseudo-grid +/// index (the former body-uniform region-(0,0) climate collapse). /// /// ## Parameters /// @@ -1320,9 +1343,14 @@ pub(crate) fn world_m_to_pixel( /// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`]; /// if a neighbour region is missing it is derived on the fly. Build with /// [`region_profile::derive_regions_for_body`] before calling this in a loop. +/// - `basin_direction` — the true L1 D8 thalweg direction for this cell. +/// `basin_direction` is an inert pass-through field (nothing in the +/// derivation reads it — the D-256 ruling's proof), so it is applied via a +/// post-call field override on the core's returned profile rather than +/// threaded through the core itself. /// - `river_network` — the body's [`RiverNetwork`] (T-1168, Ruling 4b/4c), /// consulted for the riparian point test via -/// [`river_course::near_perennial_water_at`] (edges near this district +/// [`river_course::near_perennial_water_at`] (edges near this cell /// invented on demand, the same pure function the window path uses). /// `None` when no river network is available (e.g. a body with no Layer-1 /// drainage pass, or a caller that predates T-1168) — the riparian signal @@ -1332,7 +1360,7 @@ pub fn derive_district_profile( seed: SeedChain, body_params: &BodyParams, ta: &TerrainAnalysis, - pos: DistrictPos, + pos: SurveyCellPos, grid_cells_per_district: usize, climate: &ClimateConstants, body_id: &str, @@ -1340,19 +1368,14 @@ pub fn derive_district_profile( basin_direction: BasinDirection, river_network: Option<&RiverNetwork>, ) -> DistrictProfile { - let (rx, ry) = pos; + let SurveyCellPos(rx, ry) = pos; let w = ta.w; let h = ta.h; let gcpr = grid_cells_per_district.max(1); - // T-1125: the batch path runs the SAME invention as the on-demand path — an - // invented centre-point sample of the continuous field (warped coastline + - // slope-independent scatter via `invent_primitives`) instead of the former - // scatter-free cell-aggregate mean. One invention path, shared with - // [`derive_district`], so the two can never silently diverge again; the - // pseudo-grid samples the same truth the 2 km carrier does. (The former mean - // smoothed away exactly the variance the believability contrast metrics - // measure — and carried no invention at all.) + // D-256(b): the survey cell's covering pixel block, clamped at the grid + // edges — UNCHANGED from the pre-D-256 cell-aggregate-centre math (only + // the position TYPE changed, not the arithmetic). let row_start = (ry as usize).saturating_mul(gcpr).min(h); let row_end = row_start.saturating_add(gcpr).min(h); let col_start = (rx as usize).saturating_mul(gcpr).min(w); @@ -1361,36 +1384,8 @@ pub fn derive_district_profile( let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0; let (world_x_m, world_y_m) = pixel_to_world_m(px, py, w, h, body_params.body_radius_km); - // D-243 §3/§4: compute the edge-fuzz-blended region baseline for this district, - // then pass it through build_district_profile so the two-phase derivation path runs. - // The warp uses `seed.seed()` (the body-scoped seed) for domain separation. - // Hoisted above the primitives (T-1125): the invention's driver tier needs - // the baseline for its one-step-stale climate estimate. - let region_baseline_c = region_profile::region_baseline_at_district( - seed.seed(), - body_id, - pos, - body_params, - climate, - seed, - Some(region_cache), - ); - - let prims = invent_primitives( - seed, - body_params, - climate, - ta, - px, - py, - world_x_m, - world_y_m, - region_baseline_c, - 0.0, // batch path — no octave cutoff, matches derive_district's default - ); - // T-1168 Ruling 4b: batch-path riparian signal — edges near this - // district invented on demand via the SAME pure function the window + // cell invented on demand via the SAME pure function the window // path uses. `river_network.is_none()` degrades to `false` (see this // function's doc), never a panic. let near_perennial_water = river_network @@ -1407,26 +1402,37 @@ pub fn derive_district_profile( }) .unwrap_or(false); - build_district_profile( + // D-256(c): the shared core, at the survey-cell-centre world metres, with + // the pre-built region cache (batch performance — thousands of cells + // sharing one derived region set). + let mut profile = derive_at_metres_with_riparian( seed, + body_id, body_params, - climate, - prims.slope_q, - prims.elev_q, - prims.ocean_fraction_q, - region_baseline_c, - basin_direction, + ta, world_x_m, world_y_m, + climate, 0.0, // batch path — no octave cutoff, matches derive_district's default near_perennial_water, - ) + Some(region_cache), + ); + + // D-256(c) binding requirement 1: basin_direction is inert to every other + // field's derivation (proven in the D-256 ruling) — a post-call override + // with the true L1 D8 value is exactly equivalent to threading it through + // the core, and keeps the core itself free of a field only the batch + // path can supply. + profile.basin_direction = basin_direction; + profile } /// Build the climate + morphology fields of a `DistrictProfile` from its three /// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail -/// of every derivation path (cell-aggregate [`derive_district_profile`] and the -/// interpolation+scatter [`derive_district`]). Pure (T-1024, D-239 §2 / D-240). +/// every position-derivation caller reaches (D-256(c): `derive_district_profile`'s +/// survey-cell-centre position and `derive_district`'s exact district position +/// both route through it via [`derive_at_metres_with_riparian`]). Pure (T-1024, +/// D-239 §2 / D-240). /// /// ## Region baseline parameter (D-243 §3, T-1078) /// @@ -1666,6 +1672,61 @@ pub fn derive_at_metres( climate: &ClimateConstants, min_wavelength_m: f64, nearby_courses: &[river_course::InventedCourse], +) -> DistrictProfile { + // T-1168 Ruling 4a: the riparian point test against the caller-supplied + // (already-culled) course slice — the SAME pure predicate the batch path + // uses via `near_perennial_water_at`. Computed here, BEFORE the core call, + // so the core itself never touches course geometry (D-256(c) binding + // requirement 2: a naive wrapper passing `&[]` internally would silently + // regress every riverside cell to `false` — this public signature and its + // byte-behavior are unchanged by the D-256 extraction). + let near_perennial_water = river_course::near_perennial_water((wx, wy), nearby_courses); + derive_at_metres_with_riparian( + seed, + body_id, + body_params, + ta, + wx, + wy, + climate, + min_wavelength_m, + near_perennial_water, + None, // no pre-built region cache — on-demand on-the-fly derivation, exactly as before extraction + ) +} + +/// The D-256(c) shared derive core — `derive_at_metres`'s former inline body, +/// extracted so [`derive_district_profile`] can become a thin wrapper over the +/// SAME metres-addressable derivation instead of re-implementing it. Private: +/// the only two sanctioned callers are `derive_at_metres` (which computes +/// `near_perennial_water` from its public `nearby_courses` slice exactly as +/// before, and passes `None` for `region_cache` — an on-the-fly region +/// baseline derivation, matching its pre-extraction behavior byte-for-byte) +/// and `derive_district_profile` (which computes `near_perennial_water` via +/// `near_perennial_water_at`, the on-demand course inventor, and passes its +/// pre-built per-body `region_cache` for the same performance reason the +/// batch path built one in the first place — thousands of district calls +/// sharing one derived region set rather than each re-deriving up to 4 +/// baselines). +/// +/// `basin_direction` on the returned profile is always [`BasinDirection::default`] +/// (North) here — the caller-supplied true L1 D8 value, when available, is +/// applied as a post-call field override (D-256(c) binding requirement 1: the +/// field is inert to every other field's derivation, proven in the D-256 +/// ruling, so an override after the fact is exactly equivalent to threading it +/// through). +#[allow(clippy::too_many_arguments)] +fn derive_at_metres_with_riparian( + seed: SeedChain, + body_id: &str, + body_params: &BodyParams, + ta: &TerrainAnalysis, + wx: f64, + wy: f64, + climate: &ClimateConstants, + min_wavelength_m: f64, + near_perennial_water: bool, + region_cache: Option<&BTreeMap>, ) -> DistrictProfile { // World metres -> fractional heightmap pixel + latitude. Mirrors // `derive_district`'s former inline mapping exactly, just keyed on @@ -1702,9 +1763,11 @@ pub fn derive_at_metres( ..body_params.clone() }; - // D-243 §3/§4: compute the edge-fuzz-blended region baseline on-the-fly for - // this position. No pre-built cache here — the on-demand path derives the - // four surrounding region baselines directly. Pure, deterministic, cheap. + // D-243 §3/§4: compute the edge-fuzz-blended region baseline for this + // position. `region_cache` is `None` for the on-demand caller + // (`derive_at_metres`, derives the four surrounding region baselines + // directly — pure, deterministic, cheap) or `Some` for the batch caller + // (`derive_district_profile`, reuses its pre-built per-body cache). // `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation. // Hoisted above the primitives (T-1125): the invention's driver tier needs // the baseline for its one-step-stale climate estimate. @@ -1717,6 +1780,11 @@ pub fn derive_at_metres( // step at every district boundary at every rung, by construction — it does // not refine continuously the way elevation/slope do under a finer // min_wavelength_m. + // + // D-256(c): this is also what auto-fixes the batch path's former + // region-(0,0) collapse — `derive_district_profile` now reaches this same + // floor-divide on its own survey-cell-centre world metres instead of + // keying off a pseudo-grid index. let district_pos: DistrictPos = ( (wx / scale::DISTRICT_M as f64).floor() as i32, (wy / scale::DISTRICT_M as f64).floor() as i32, @@ -1728,7 +1796,7 @@ pub fn derive_at_metres( ¶ms, climate, seed, - None, // no pre-built cache; derive on-the-fly + region_cache, ); // T-1125: invented primitives — warped coastline (invented bays/capes) + @@ -1747,21 +1815,6 @@ pub fn derive_at_metres( min_wavelength_m, ); - // derive_at_metres is the on-demand path (arbitrary world position, no L1 - // working grid). basin_direction is an ACCEPTED LIMITATION here: it - // defaults to North (a fallback, not a computed value) because the D8 - // thalweg is only available from the L1 fdir grid the batch path holds. - // Production voxel generation runs through the batch path - // (derive_all_districts), which threads the true D8 direction from L1; - // this on-demand path is the fallback for positions derived outside that - // pass, where a meaningful basin_direction isn't available. - // - // T-1168 Ruling 4a: the riparian point test against the caller-supplied - // (already-culled) course slice — the SAME pure predicate the batch path - // uses via `near_perennial_water_at`. - let near_perennial_water = - river_course::near_perennial_water((world_x_m, world_y_m), nearby_courses); - build_district_profile( seed, ¶ms, @@ -1971,37 +2024,52 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 { /// Eagerly derive a coarse profile grid covering the body, by direct heightmap /// tiling (`grid_cells_per_district` cells per cell). /// -/// **Scale note (D-243, T-1077):** this is the *coarse* eager grid — one cell per -/// `gcpr` heightmap pixels (tens-to-hundreds of km) — kept as the Atlas zone -/// overlay source. The **corrected 2 km carrier** the voxel chain consumes is the -/// on-demand [`derive_district`] (heightmap interpolation + detail-scatter via the -/// elastic seam). Swapping production from this eager grid to on-demand -/// district/region surfacing is the chartered job of T-1046; T-1077 provides the -/// correct on-demand derivation, not the eager/Atlas restructure. +/// **Scale note (D-256(b)):** this is the *survey raster* — the coarse eager +/// grid (one cell per `gcpr` heightmap pixels, tens-to-hundreds of km) kept as +/// the Atlas zone overlay source and the L2/L3 planning input (settlement +/// placement context, believability sampling, skeleton dispatch context). The +/// **corrected 2 km carrier** the voxel chain consumes is the on-demand +/// [`derive_district`] (heightmap interpolation + detail-scatter via the +/// elastic seam). Both now route through the SAME [`derive_at_metres_with_riparian`] +/// core (D-256(c)) — one derive core, two position sets. /// -/// Returns a `BTreeMap` covering the full -/// heightmap at the given district-grid resolution. +/// Returns a `BTreeMap` covering the full +/// heightmap at the given survey-grid resolution. /// -/// `grid_cells_per_district = 8` means each district is 8×8 heightmap cells. +/// `grid_cells_per_district = 8` means each survey cell is 8×8 heightmap cells. /// -/// District latitude is derived from the row index: ry=0 maps to the north pole -/// (+90°), ry=district_rows-1 maps to the south pole (-90°). This is a linear -/// mapping across the equirectangular heightmap. +/// ## Region baseline (D-256(c)) /// -/// ## Region cache (D-243 §3/§4, T-1078) -/// -/// A [`RegionProfile`] cache is built once per body from all region positions -/// that cover the district grid, then passed to each [`derive_district_profile`] -/// call so the D-243 §4 edge-fuzz blend reads a consistent set of region -/// baselines — all four corners of every blend come from the same derived set. +/// No pre-built region cache here — the pre-D-256 pre-build keyed on the +/// SURVEY grid's own pseudo-coordinates (`district_to_region((rx, ry))`), +/// which does not correspond to the true region a survey cell's world-metres +/// centre actually falls in (the survey grid can span up to ~2048 distinct +/// true regions on a big body — it covers the whole body surface in metres, +/// not a handful of degenerate cells). Since the shared core now floor-divides +/// the survey cell's TRUE world metres for its own region lookup +/// ([`derive_at_metres_with_riparian`]'s `district_pos`), the simplest +/// deterministic option is to let each cell resolve its region baseline +/// on-the-fly through [`derive_district_profile`]'s own `region_cache` +/// parameter — passing an empty map here means every lookup misses and +/// derives on the fly (pure, cheap, four `derive_region_baseline_c` calls per +/// miss; D-227-legal). This also auto-fixes the former body-uniform +/// region-(0,0) climate collapse: every survey cell now reads its OWN +/// region's baseline instead of the pseudo-grid's degenerate region index. /// /// `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 +/// +/// `basin_dirs` is the per-SURVEY-CELL dominant D8 thalweg direction computed +/// in `run_layer1` (T-1047) — `Layer1Output::survey_basin_dirs`. It is +/// **honestly a survey-cell aggregate**: each entry votes over exactly the +/// 8×8 working-pixel block one `DistrictProfile` here summarizes, so +/// [`SurveyCellPos`] is its correct key, not merely a convenient one, and the +/// lookup below is identity (`m.get(&pos)`) — NOT a world-metres floor-divide +/// into the true D-243 district grid (that would be the wrong map: the +/// aggregate's own key space is the survey raster, never was the true grid). +/// Missing entries (edge cells with no land cells) default to /// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs), -/// every district gets `BasinDirection::North`. +/// every cell gets `BasinDirection::North`. /// /// `river_network` (T-1168, Ruling 4c) is threaded straight through to every /// [`derive_district_profile`] call for the batch-path riparian signal — the @@ -2013,64 +2081,33 @@ pub fn derive_all_districts( ta: &TerrainAnalysis, grid_cells_per_district: usize, body_id: &str, - basin_dirs: Option<&BTreeMap>, + basin_dirs: Option<&BTreeMap>, river_network: Option<&RiverNetwork>, -) -> BTreeMap { +) -> BTreeMap { let climate = ClimateConstants::default(); let gcpr = grid_cells_per_district.max(1); - let district_cols = ta.w.div_ceil(gcpr) as i32; - let district_rows = ta.h.div_ceil(gcpr) as i32; + let survey_cols = ta.w.div_ceil(gcpr) as i32; + let survey_rows = ta.h.div_ceil(gcpr) as i32; - // Build the region cache once for the whole body before district derivation. - // Collect all unique region positions that cover this district grid, plus - // their immediate neighbours (the edge-fuzz blend samples up to one region - // beyond the district's own region). Using a BTreeSet for determinism (D-010). - let region_positions: std::collections::BTreeSet = { - let mut set = std::collections::BTreeSet::new(); - for ry in 0..district_rows { - for rx in 0..district_cols { - let district_pos = (rx, ry); - let rpos = scale::district_to_region(district_pos); - // The edge-fuzz blend samples the base region and one neighbour - // in each axis direction (±1). Pre-populate all 9 candidates so - // cache hits dominate and on-the-fly derivations are rare. - for dy in -1i32..=1 { - for dx in -1i32..=1 { - set.insert((rpos.0 + dx, rpos.1 + dy)); - } - } - } - } - set - }; - let region_cache = - region_profile::derive_regions_for_body(seed, body_params, &climate, region_positions); + // D-256(c): no pre-built region cache (see this function's doc) — every + // survey cell derives its region baseline on-the-fly through the empty + // map below, keyed on the cell's TRUE world-metres region, not a + // pseudo-grid index. + let region_cache: BTreeMap = BTreeMap::new(); let mut out = BTreeMap::new(); - for ry in 0..district_rows { - // Map ry to latitude: row 0 → +90°, row (rows-1) → -90°. - // For a single-row grid, latitude is 0°. - let lat_deg = if district_rows > 1 { - 90.0 - (ry as f64 / (district_rows - 1) as f64) * 180.0 - } else { - 0.0 - }; - for rx in 0..district_cols { - let pos = (rx, ry); - // Build per-district params: body-level params + this district's latitude. - // Per-district elevation is NOT set here — derive_district_profile owns it, - // deriving elevation_km from the district's own elev_q (not the caller's - // body_params.elevation_km). Per-cell refinement happens at ChunkContext (D-239). - let district_params = BodyParams { - latitude_deg: lat_deg, - ..body_params.clone() - }; + for ry in 0..survey_rows { + for rx in 0..survey_cols { + let pos = SurveyCellPos(rx, ry); + // Identity lookup: basin_dirs is keyed by SurveyCellPos (see this + // function's doc) — the SAME survey cell this loop is deriving a + // profile for, no position translation needed or correct. let basin_direction = basin_dirs .and_then(|m| m.get(&pos).copied()) .unwrap_or_default(); let profile = derive_district_profile( seed, - &district_params, + body_params, ta, pos, gcpr, @@ -2086,6 +2123,35 @@ pub fn derive_all_districts( out } +/// The survey cell's centre pixel → world metres, per [`derive_district_profile`]'s +/// own D-256(b) `8·rx + 3.5` (clamped) convention — factored out so any +/// caller resolving a `SurveyCellPos` to a world position (e.g. +/// [`derive_all_districts`]'s `basin_dirs` lookup, or `believability::analyze`'s +/// voxel-sample chunk resolution) reaches the SAME world position the profile +/// itself is centred on, rather than a second, possibly-disagreeing mapping. +/// +/// `pub` (D-256): `believability.rs` and `bin/aliveness_probe.rs` (a separate +/// crate) both need this bridge and have no `TerrainAnalysis`/`BodyParams` in +/// scope (they only see `BodyWorldState`'s cached dims + a body-params read), +/// so this takes the primitive `w`/`h`/`body_radius_km` rather than the +/// wrapper structs — the same primitives [`pixel_to_world_m`] itself takes. +pub fn survey_cell_centre_world_m( + pos: SurveyCellPos, + gcpr: usize, + w: usize, + h: usize, + body_radius_km: Option, +) -> (f64, f64) { + let SurveyCellPos(rx, ry) = pos; + let row_start = (ry as usize).saturating_mul(gcpr).min(h); + let row_end = row_start.saturating_add(gcpr).min(h); + let col_start = (rx as usize).saturating_mul(gcpr).min(w); + let col_end = col_start.saturating_add(gcpr).min(w); + let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0; + let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0; + pixel_to_world_m(px, py, w, h, body_radius_km) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -2136,10 +2202,16 @@ mod tests { assert_eq!(districts.len(), 32, "district count mismatch"); } - /// T-1047: the `Some(basin_dirs)` threading path — supplied per-district D8 - /// directions propagate to `DistrictProfile.basin_direction`, and districts - /// not in the map fall back to the default (North). Guards the path that the - /// production cascade actually uses (the existing tests only exercise `None`). + /// T-1047/D-256: the `Some(basin_dirs)` threading path — supplied + /// per-SURVEY-CELL D8 directions propagate to + /// `DistrictProfile.basin_direction` by IDENTITY lookup, and survey cells + /// not in the map fall back to the default (North). `basin_dirs` mirrors + /// the SHAPE the real producer (`Layer1Output::survey_basin_dirs`, + /// `layer1::aggregate_survey_basin_dirs`) actually emits — keyed by + /// [`SurveyCellPos`], the same key space `derive_all_districts` iterates + /// — so this test exercises the production seam (survey-keyed producer → + /// identity-lookup consumer), not a map the test invents to match its own + /// lookup logic. #[test] fn derive_all_districts_threads_supplied_basin_directions() { use crate::atlas::scale::BasinDirection; @@ -2147,16 +2219,16 @@ mod tests { let ta = test_ta(&hm); let params = BodyParams::default(); - // Real DistrictPos keys from a baseline (None) run. + // Real SurveyCellPos keys from a baseline (None) run. let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); let mut keys = baseline.keys().copied(); - let pos_east = keys.next().expect("at least one district"); - let pos_south = keys.next().expect("at least two districts"); - let pos_unmapped = keys.next().expect("at least three districts"); + let cell_east = keys.next().expect("at least one survey cell"); + let cell_south = keys.next().expect("at least two survey cells"); + let cell_unmapped = keys.next().expect("at least three survey cells"); - let mut basin_dirs: BTreeMap = BTreeMap::new(); - basin_dirs.insert(pos_east, BasinDirection::East); - basin_dirs.insert(pos_south, BasinDirection::South); + let mut basin_dirs: BTreeMap = BTreeMap::new(); + basin_dirs.insert(cell_east, BasinDirection::East); + basin_dirs.insert(cell_south, BasinDirection::South); let districts = derive_all_districts( test_seed(), @@ -2168,11 +2240,18 @@ mod tests { None, ); - assert_eq!(districts[&pos_east].basin_direction, BasinDirection::East); - assert_eq!(districts[&pos_south].basin_direction, BasinDirection::South); - // Unmapped districts fall back to the default direction (North). assert_eq!( - districts[&pos_unmapped].basin_direction, + districts[&cell_east].basin_direction, + BasinDirection::East + ); + assert_eq!( + districts[&cell_south].basin_direction, + BasinDirection::South + ); + // A survey cell not present in basin_dirs falls back to the default + // direction (North). + assert_eq!( + districts[&cell_unmapped].basin_direction, BasinDirection::North ); } @@ -2946,7 +3025,7 @@ mod tests { planet_class: Some("temperate".into()), ..Default::default() }; - let pos = (2, 1); + let pos = SurveyCellPos(2, 1); let climate = ClimateConstants::default(); let p1 = derive_district_profile( test_seed(), @@ -3068,7 +3147,7 @@ mod tests { let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, 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"); + assert_eq!(*first, SurveyCellPos(0, 0), "first district must be at origin"); } #[test] diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index 09d381438..7e76745db 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -71,9 +71,10 @@ pub struct DrainageResult { /// (edge, flat peak, or ocean). Row-major, `w × h`. /// /// **Transient — used within the Layer-1 pass only.** The caller aggregates a - /// per-district dominant direction from this grid (T-1047) and carries that - /// compact result on `Layer1Output.district_basin_dirs`; the full 131 KB - /// grid is NOT persisted on `BodyWorldState` or the LRU cache (D-203). + /// per-survey-cell dominant direction from this grid (T-1047, D-256(b)) and + /// carries that compact result on `Layer1Output.survey_basin_dirs`; the + /// full 131 KB grid is NOT persisted on `BodyWorldState` or the LRU cache + /// (D-203). pub fdir: Vec, } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 688a8b924..485dee5c2 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -97,7 +97,8 @@ pub enum GenWorkItem { /// `context` is the D-199 economic read-set pre-resolved at dispatch time. /// All 6 required fields must be populated before this item is submitted /// (D-199: "Missing fields abort the task … generation does not proceed with - /// partial context"). + /// partial context") — EXCEPT `morphology_zone`, which is resolved during + /// execution (see below, D-256(d)). /// /// `body_id` routes the resulting `SkeletonGenerated` completion into the /// correct `BodyWorldState` cache entry (D-230). @@ -112,6 +113,11 @@ pub enum GenWorkItem { city_id: u64, body_id: String, /// D-199 economic read-set + all other context fields. + /// + /// `context.morphology_zone` still starts life as + /// `context_from_read_set`'s `AlluvialPlain` stub at dispatch time — it + /// is overwritten during execution (D-256(d), see `settlement_world_m` + /// below), not by `build_skeleton_work_item` as it was pre-D-256. context: Box, /// Stable content-addressable quarter id (D-194/D-230). quarter_id: u64, @@ -130,6 +136,33 @@ pub enum GenWorkItem { /// regardless of its contents' size (same reasoning already documented /// for `FillChunk.block_tags` below), so this needs no `Box`. exterior_catalog: ExteriorCatalog, + /// D-256(d) exact-position `morphology_zone` judgment inputs — the + /// settlement's own world metres (NOT the survey cell's centre, which + /// can be hundreds of km off for a settlement near a survey-cell edge) + /// and body params, resolved during `run_work_item` execution (where + /// the terrain cache is reachable, unlike `BodyWorldState`'s + /// D-203/T-1048 dropped `TerrainAnalysis`) via `derive_at_metres`. + /// `None` body_params (no DB row for this body) skips the resolution + /// and leaves `context.morphology_zone` at its `AlluvialPlain` stub — + /// the same fallback the pre-D-256 dispatch-time lookup used for an + /// empty district grid. + settlement_world_m: (f64, f64), + body_params: Option>, + /// `SeedChain::for_body(world_seed, body_id)` — the SAME body-scoped + /// chain `derive_all_districts`/`derive_district_profile` use, distinct + /// from `chain` (the quarter-level chain derived further for skeleton + /// RNG). Needed because `derive_at_metres`'s domain-separated warp + /// fields must key on the same seed the rest of the cascade uses for + /// this body, not a chain re-derived from the quarter seed. + body_seed: SeedChain, + /// Shared per-body working-grid heightmap (D-256(d)) — an `Arc` so + /// every settlement dispatched for the same `BodyAnalyzed` completion + /// clones a pointer, not the multi-hundred-KB `Vec`. Reconstructed + /// once at dispatch time from `BodyWorldState.heightmap`/dims/`sea_level` + /// (already in memory — no disk re-read) so `run_work_item` can feed + /// [`TerrainAnalysisCache::get_or_derive`] without touching the + /// filesystem on the Rayon worker thread. + heightmap: std::sync::Arc, }, /// Derive the building shell for one 64 m chunk of an existing quarter /// (D-230 derive phase, T-987). @@ -617,8 +650,12 @@ impl Default for GenerationQueue { /// itself is small (a `RiverNetwork` + basin list + attractor list, not the /// full grid) relative to `TerrainAnalysis`'s ~1.5–2 MB of dense per-cell /// Vecs. +/// `pub(crate)` (D-256(d), T-1174): `plugin.rs`'s tests construct one +/// directly to unit-test `resolve_settlement_morphology_zone` without +/// spinning up a full `GenerationQueue`. Otherwise entirely internal to this +/// module's Rayon-thread execution path. #[derive(Debug)] -struct TerrainAnalysisCache { +pub(crate) struct TerrainAnalysisCache { entries: std::collections::BTreeMap, /// Monotonic access counter (substitutes for `BodyWorldStateCache`'s /// `SimTick` — there is no tick concept on a background Rayon thread). @@ -639,6 +676,13 @@ impl TerrainAnalysisCache { } } + /// Test-only constructor alias (D-256(d)) — `new` stays private-module + /// idiomatic; this is the `pub(crate)` door for `plugin.rs`'s tests. + #[cfg(test)] + pub(crate) fn new_for_test(capacity: usize) -> Self { + Self::new(capacity) + } + /// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`, /// re-deriving via `run_layer1` on a miss and inserting the result /// (evicting the LRU entry first if at capacity). Bumps the access clock @@ -688,6 +732,46 @@ impl TerrainAnalysisCache { } } +/// D-256(d): resolve a settlement's `MorphologyZone` at its EXACT world +/// position via `derive_at_metres`, using the terrain cache to reach a +/// `TerrainAnalysis` without a disk re-read (`heightmap` is already in +/// memory, reconstructed once at dispatch time from `BodyWorldState`'s +/// cached working-grid data). +/// +/// Returns `None` when `body_params` is absent (no DB row for this body) — +/// the caller then leaves `context.morphology_zone` at its dispatch-time +/// `AlluvialPlain` stub, the same fallback the pre-D-256 dispatch-time +/// district-grid lookup used for an empty grid. +/// +/// Extracted from `run_work_item`'s `GenerateSkeleton` arm so the resolution +/// itself is unit-testable without going through the full skeleton-generation +/// pipeline (`QuarterSkeleton` does not expose `morphology_zone` directly — +/// it only affects derived fields like `layout_mode`/corridors). +pub(crate) fn resolve_settlement_morphology_zone( + terrain_cache: &Arc>, + body_id: &str, + body_params: Option<&BodyParams>, + settlement_world_m: (f64, f64), + body_seed: SeedChain, + heightmap: &crate::atlas::heightmap::BodyHeightmap, +) -> Option { + let params = body_params?; + let (_l1, ta) = terrain_cache.lock().unwrap().get_or_derive(body_id, heightmap); + let climate = ClimateConstants::default(); + let profile = crate::atlas::district_profile::derive_at_metres( + body_seed, + body_id, + params, + &ta, + settlement_world_m.0, + settlement_world_m.1, + &climate, + 0.0, + &[], + ); + Some(profile.morphology_zone) +} + // --------------------------------------------------------------------------- // Work execution stub // --------------------------------------------------------------------------- @@ -761,13 +845,36 @@ fn run_work_item( population, founding_age_years, exterior_catalog, + settlement_world_m, + body_params, + body_seed, + heightmap, } => { + // D-256(d): resolve morphology_zone at the settlement's EXACT world + // position, here at execution time — this is where TerrainAnalysis + // is reachable (BodyWorldState drops it, D-203/T-1048). A survey + // cell's centre can be hundreds of km from a settlement near its + // edge; the exact-position derive closes that annotation-vs-canvas + // disagreement class before T-1181/T-1182 draw batch-judged + // annotations onto window-derived canvases. + let mut resolved_context = (**context).clone(); + if let Some(zone) = resolve_settlement_morphology_zone( + terrain_cache, + body_id, + body_params.as_deref(), + *settlement_world_m, + *body_seed, + heightmap, + ) { + resolved_context.morphology_zone = zone; + } + // Build the Phase 1 skeleton from the pre-resolved D-199 context. // `economic_role`, `population`, and `founding_age_years` are the // D-199 raw fields carried alongside the context because // `generate_quarter_skeleton` accepts them as separate parameters. let skeleton = generate_quarter_skeleton( - context, + &resolved_context, *population, economic_role, *quarter_id, @@ -780,7 +887,7 @@ fn run_work_item( // same pass. let block_tags = assign_all_block_tags( &skeleton, - context, + &resolved_context, economic_role, *founding_age_years, *chain, @@ -824,9 +931,13 @@ fn run_work_item( Ok(hm) => { // Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the // window derive must run on the SAME working-grid resolution the - // whole-body cascade uses, or district positions between the two - // views would disagree (derive_district maps DistrictPos through - // ta.w/ta.h, T-1137 decision note). + // whole-body cascade uses, or the TRUE DistrictPos → world-metres + // mapping between the two views would disagree (derive_district + // maps DistrictPos through ta.w/ta.h, T-1137 decision note). This + // is resolution consistency (ta.w/ta.h must match), a DIFFERENT + // concern from D-256's survey-raster/true-district namespace + // collision — both views here already address the true D-243 + // grid, so D-256 doesn't touch this comment's claim. let working = if hm.width > GRID_W || hm.height > GRID_H { hm.downsample(GRID_W, GRID_H) } else { @@ -990,6 +1101,20 @@ mod tests { population: 500_000, founding_age_years: 200, exterior_catalog: ExteriorCatalog::default(), + // These tests exercise queue mechanics, not the D-256(d) + // exact-position resolution — `body_params: None` skips it + // entirely (the same fallback path an empty district grid used + // pre-D-256), so the position/heightmap values below are inert. + settlement_world_m: (0.0, 0.0), + body_params: None, + body_seed: SeedChain::for_body(42 + city_id, &format!("TestBody{city_id}")), + heightmap: std::sync::Arc::new(crate::atlas::heightmap::BodyHeightmap { + body_id: format!("TestBody{city_id}"), + width: 1, + height: 1, + data: vec![0.5], + sea_level: 0.3, + }), } } diff --git a/server/src/atlas/layer1.rs b/server/src/atlas/layer1.rs index e32d4950e..e7b898cef 100644 --- a/server/src/atlas/layer1.rs +++ b/server/src/atlas/layer1.rs @@ -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, + pub survey_basin_dirs: BTreeMap, } /// 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 { +) -> BTreeMap { 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 = BTreeMap::new(); + // Per-survey-cell vote counts: [N, S, E, W]. + let mut votes: BTreeMap = 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" ); } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 5cff36a08..e1ec34356 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -667,19 +667,33 @@ pub struct AtlasLayerResponse { /// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046). /// Returns `None` when the DistrictProfile layer has not run (empty `districts`). /// The grid is dense `[0, cols) × [0, rows)` (the cascade tiles the full -/// heightmap), so the extent comes from the maximum `DistrictPos`. +/// heightmap), so the extent comes from the maximum `SurveyCellPos` (D-256(b) +/// — this is the coarse survey raster, not the true district grid). pub fn build_district_grid( state: &crate::atlas::body_world_state::BodyWorldState, ) -> Option { if state.districts.is_empty() { return None; } - let cols = state.districts.keys().map(|(x, _)| *x).max().unwrap_or(0) as u32 + 1; - let rows = state.districts.keys().map(|(_, y)| *y).max().unwrap_or(0) as u32 + 1; + let cols = state + .districts + .keys() + .map(|p| p.0) + .max() + .unwrap_or(0) as u32 + + 1; + let rows = state + .districts + .keys() + .map(|p| p.1) + .max() + .unwrap_or(0) as u32 + + 1; let n = (cols * rows) as usize; let mut morphology = vec![0u8; n]; let mut elev_q = vec![0u8; n]; - for (&(x, y), profile) in &state.districts { + for (pos, profile) in &state.districts { + let (x, y) = (pos.0, pos.1); if x < 0 || y < 0 { continue; } @@ -2286,13 +2300,13 @@ pub fn handle_atlas_request( // working grid all Layer-1 positions are expressed in (#960). grid_w: state.heightmap_width, grid_h: state.heightmap_height, - // district_basin_dirs is transient — it is aggregated during run_layer1 + // survey_basin_dirs is transient — it is aggregated during run_layer1 // and consumed by derive_all_districts before being stored on // BodyWorldState. When reconstructing Layer1Output from the cache for - // the client response, the per-district direction is already encoded in - // DistrictProfile.basin_direction (BodyWorldState.districts) and is not - // needed again here. Supply an empty map. - district_basin_dirs: std::collections::BTreeMap::new(), + // the client response, the per-survey-cell direction is already encoded + // in DistrictProfile.basin_direction (BodyWorldState.districts) and is + // not needed again here. Supply an empty map. + survey_basin_dirs: std::collections::BTreeMap::new(), }; let district_grid = build_district_grid(state); let road_graph = build_road_graph_layer(state); @@ -2450,6 +2464,7 @@ mod tests { heightmap: vec![], heightmap_width: 16, heightmap_height: 8, + sea_level: 0.3, river_network: RiverNetwork::default(), drainage_basins: vec![], attractors: vec![], @@ -2461,12 +2476,14 @@ mod tests { last_accessed: 0, }; // 3×2 grid with two distinct zones at the corners. - state - .districts - .insert((0, 0), dp(MorphologyZone::AlluvialPlain, 10)); - state - .districts - .insert((2, 1), dp(MorphologyZone::Alpine, 90)); + state.districts.insert( + crate::atlas::scale::SurveyCellPos(0, 0), + dp(MorphologyZone::AlluvialPlain, 10), + ); + state.districts.insert( + crate::atlas::scale::SurveyCellPos(2, 1), + dp(MorphologyZone::Alpine, 90), + ); let grid = build_district_grid(&state).expect("districts present → Some grid"); assert_eq!((grid.cols, grid.rows), (3, 2)); @@ -2661,7 +2678,7 @@ mod tests { assert_eq!(layer.glaciation[0], prof.glaciation_grade as u8); } - /// T-1170/T-1168 A5 integration: the batch path + /// T-1170/T-1168 A5 integration, strengthened by D-256: the batch path /// (`derive_district_profile`, sourcing courses via `near_perennial_water_at` /// on demand) and the window path (`build_district_window_layer`, /// sourcing courses via the pre-invented `Vec`) must @@ -2670,17 +2687,21 @@ mod tests { /// binding requirement, checked end to end (not just at the /// `near_perennial_water`/`near_perennial_water_at` unit level). /// - /// **Design note:** this test deliberately does NOT compare the batch - /// and window paths' full `DistrictProfile` output for "the same - /// district" — `derive_district_profile`'s cell-aggregate-centre - /// sampling and the window path's district-origin sampling are - /// legitimate, PRE-EXISTING different world positions for the same - /// `DistrictPos` (a real quirk of the two derivation strategies, - /// unrelated to T-1168/T-1170), so `morphology_zone`/`elev_q`/etc. - /// routinely differ between them even before this batch's riparian work. - /// Instead this test isolates the ONE signal this batch actually wires - /// (`near_perennial_water`) at a SHARED, EXACT world position, proving - /// the two paths' independent riparian derivations agree there. + /// **Design note (D-256):** pre-D-256 this test deliberately did NOT + /// compare the batch and window paths' full `DistrictProfile` output for + /// "the same district" — `derive_district_profile`'s cell-aggregate-centre + /// sampling and the window path's district-origin sampling resolved + /// genuinely different world positions for a shared nominal `DistrictPos` + /// (the batch namespace collision D-256 rules on). With sampling unified + /// (one derive core, D-256(c)), that framing is now FALSE: batch and + /// window positions agree BY CONSTRUCTION — `derive_district_profile` is + /// a thin wrapper over the exact same [`crate::atlas::district_profile::derive_at_metres`] + /// family the window path calls, at the survey cell's own D-256(b) centre + /// world metres. `full_district_profile_matches_derive_at_metres_at_shared_survey_cell_centre` + /// below is the strengthened full-profile bit-identical check this design + /// note used to explicitly rule out; this test keeps the riparian-signal + /// check as a focused shared-exact-position regression (the ONE hand-wired + /// signal T-1168 added, worth its own targeted assertion). #[test] fn window_and_batch_paths_agree_on_riparian_signal_near_a_real_river_edge() { use crate::atlas::drainage; @@ -2763,6 +2784,249 @@ mod tests { ); } + /// D-256(c): the batch survey-cell profile == `derive_at_metres` at the + /// SAME survey-cell-centre world metres — bit-identical, field for field + /// (basin overridden on the expectation since the core always returns the + /// default and the wrapper post-call-overrides it; riparian threaded + /// equivalently through both paths' own mechanism). This is the + /// strengthened replacement for the pre-D-256 "legitimate different + /// positions" design note on the riparian-only test above — positions now + /// agree by construction, so the FULL profile must too. + #[test] + fn full_district_profile_matches_derive_at_metres_at_shared_survey_cell_centre() { + use crate::atlas::district_profile::{self, ClimateConstants}; + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::scale::{self, BasinDirection, SurveyCellPos}; + use std::collections::BTreeMap; + + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let climate = ClimateConstants::default(); + let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT; + + // An interior survey cell (not clamped at the grid edge — that case + // is covered by the dedicated edge-truncation test below). + let cell = SurveyCellPos(10, 6); + let basin = BasinDirection::East; + + let batch_profile = district_profile::derive_district_profile( + seed, + ¶ms, + &ta, + cell, + gcpr, + &climate, + "GJ1c", + &BTreeMap::new(), + basin, + Some(rn), + ); + + // The SAME survey-cell-centre world metres, resolved via the SAME + // D-256(b) bridge function derive_district_profile uses internally. + let (world_x_m, world_y_m) = + district_profile::survey_cell_centre_world_m(cell, gcpr, ta.w, ta.h, params.body_radius_km); + + // Riparian equivalent: near_perennial_water_at against the same + // RiverNetwork, same station spacing/cutoff derive_district_profile + // uses internally — the SAME on-demand course invention, not the + // window path's pre-invented slice (this is the batch-vs-on-demand + // core identity, not the batch-vs-window riparian check above). + // + // derive_at_metres's PUBLIC signature only accepts a pre-invented + // `nearby_courses` slice (never a raw bool — Ruling 4a), so to force + // the SAME riparian verdict through the public path a single-point + // synthetic course exactly at (world_x_m, world_y_m) is threaded when + // the on-demand signal is true; an empty slice when false. Either way + // `near_perennial_water((wx,wy), courses)` evaluates to the identical + // bool the batch core read. + let near_perennial_water = river_course::near_perennial_water_at( + seed, + &ta, + ¶ms, + rn, + (world_x_m, world_y_m), + DISTRICT_M as f64, + 0.0, + ); + let riparian_equivalent: Vec = if near_perennial_water { + vec![InventedCourse { + edge_id: 0, + class: 0, + terminus: EdgeTerminusKind::Interior, + points: vec![(world_x_m, world_y_m)], + bbox: (world_x_m, world_y_m, world_x_m, world_y_m), + }] + } else { + Vec::new() + }; + let mut expected = district_profile::derive_at_metres( + seed, + "GJ1c", + ¶ms, + &ta, + world_x_m, + world_y_m, + &climate, + 0.0, + &riparian_equivalent, + ); + // basin_direction (inert field, D-256(c) proof) — the core always + // returns the default; the wrapper post-call-overrides it. + expected.basin_direction = basin; + + assert_eq!(batch_profile.morphology_zone as u8, expected.morphology_zone as u8); + assert_eq!(batch_profile.tectonic_class as u8, expected.tectonic_class as u8); + assert_eq!(batch_profile.glaciation_grade as u8, expected.glaciation_grade as u8); + assert_eq!( + batch_profile.precipitation_class as u8, + expected.precipitation_class as u8 + ); + assert_eq!(batch_profile.slope_q, expected.slope_q); + assert_eq!(batch_profile.elev_q, expected.elev_q); + assert_eq!(batch_profile.ocean_fraction_q, expected.ocean_fraction_q); + assert_eq!(batch_profile.river_threshold, expected.river_threshold); + assert_eq!(batch_profile.temperature_c, expected.temperature_c); + assert_eq!(batch_profile.moisture_q, expected.moisture_q); + assert_eq!( + batch_profile.vegetation_class as u8, + expected.vegetation_class as u8 + ); + assert_eq!(batch_profile.basin_direction as u8, expected.basin_direction as u8); + } + + /// D-256(c) invariant: the wrapper (`derive_district_profile`) ≡ core + /// (`derive_at_metres`) identity holds at survey-grid edge cases — the + /// anti-meridian column (`px` near the wrap boundary), the pole rows + /// (latitude clamp), and edge-truncated cells (the covering pixel block + /// itself clamped at `w`/`h`, not just the resulting position). No + /// riparian signal threaded here (both sides get `river_network: None` / + /// `nearby_courses: &[]` — the riparian equivalence is the DEDICATED + /// concern of the test above; this one isolates the geometric position + /// mapping across the grid's hard edges). + #[test] + fn wrapper_matches_core_at_survey_grid_edge_cases() { + use crate::atlas::district_profile::{self, ClimateConstants}; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::scale::{self, BasinDirection, SurveyCellPos}; + use std::collections::BTreeMap; + + // The committed GJ1c heightmap (same fixture the other integration + // tests in this module use), downsampled to the standard 256×128 + // working grid so the survey raster is a real body's shape. + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let hm = heightmap.downsample(256, 128); + let dr = crate::atlas::drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&hm, &dr); + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 2); + let climate = ClimateConstants::default(); + let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT; + let survey_cols = (hm.width as usize).div_ceil(gcpr) as i32; // 32 + let survey_rows = (hm.height as usize).div_ceil(gcpr) as i32; // 16 + + let cases: &[(&str, SurveyCellPos)] = &[ + ("west edge / row 0 corner", SurveyCellPos(0, 0)), + ( + "anti-meridian column (max col)", + SurveyCellPos(survey_cols - 1, survey_rows / 2), + ), + ("north pole row", SurveyCellPos(survey_cols / 2, 0)), + ( + "south pole row", + SurveyCellPos(survey_cols / 2, survey_rows - 1), + ), + ( + "edge-truncated SE corner", + SurveyCellPos(survey_cols - 1, survey_rows - 1), + ), + ]; + + for &(label, cell) in cases { + let batch_profile = district_profile::derive_district_profile( + seed, + ¶ms, + &ta, + cell, + gcpr, + &climate, + "GJ1c", + &BTreeMap::new(), + BasinDirection::North, // default — no override under test here + None, + ); + let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m( + cell, + gcpr, + ta.w, + ta.h, + params.body_radius_km, + ); + let expected = district_profile::derive_at_metres( + seed, + "GJ1c", + ¶ms, + &ta, + world_x_m, + world_y_m, + &climate, + 0.0, + &[], + ); + + assert_eq!( + batch_profile.morphology_zone as u8, expected.morphology_zone as u8, + "[{label}] morphology_zone mismatch at {cell:?}" + ); + assert_eq!( + batch_profile.elev_q, expected.elev_q, + "[{label}] elev_q mismatch at {cell:?}" + ); + assert_eq!( + batch_profile.slope_q, expected.slope_q, + "[{label}] slope_q mismatch at {cell:?}" + ); + assert_eq!( + batch_profile.ocean_fraction_q, expected.ocean_fraction_q, + "[{label}] ocean_fraction_q mismatch at {cell:?}" + ); + assert_eq!( + batch_profile.temperature_c, expected.temperature_c, + "[{label}] temperature_c mismatch at {cell:?}" + ); + assert_eq!( + batch_profile.moisture_q, expected.moisture_q, + "[{label}] moisture_q mismatch at {cell:?}" + ); + } + } + /// Discipline item 3(a), mandatory: two overlapping windows sharing a /// stretch of the same edge must produce BYTE-IDENTICAL course points /// for that shared stretch (Ruling 1e, the window-independence @@ -5226,6 +5490,7 @@ mod tests { heightmap: vec![], heightmap_width: 16, heightmap_height: 8, + sea_level: 0.3, river_network: RiverNetwork::default(), drainage_basins: vec![], attractors: vec![], @@ -5572,6 +5837,7 @@ mod tests { heightmap: vec![0.0; 4], heightmap_width: 2, heightmap_height: 2, + sea_level: 0.3, river_network: RiverNetwork::default(), drainage_basins: vec![], attractors: vec![], diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index a16412904..e8131b5a6 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -12,6 +12,7 @@ use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; use std::collections::BTreeMap; +use std::sync::Arc; use crate::atlas::atlas_data_proxy::{ handle_city_names_request, handle_star_map_request, StarMapDataPath, @@ -25,7 +26,7 @@ use crate::atlas::city_context_reader::{ context_from_read_set, CityContextReaderResource, CityEconomicReadSet, }; use crate::atlas::district_mix::{compute_district_mix, population_tier}; -use crate::atlas::district_profile::{DistrictPos, DistrictProfile}; +use crate::atlas::district_profile::{self, BodyParams, DistrictPos}; use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer_proxy::{ handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus, DistrictWindowCache, @@ -51,7 +52,7 @@ use crate::bridge::{ }; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{ - BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier, + BulkClass, DistrictType, MaintenanceAuthority, ProductionUbiquity, WorldTier, }; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; @@ -227,6 +228,7 @@ fn drain_generation_completions( mut window_cache: ResMut, city_reader: Option>, trait_catalog: Option>, + body_params_reader: Option>, rng: Option>, ) { for completion in queue.drain_completions() { @@ -244,6 +246,47 @@ fn drain_generation_completions( let world_seed = rng.seed(); let body_id = state.body_id.clone(); + // D-256(d): body physical params, re-read here (same pattern + // as `serve_atlas_requests`'s `params_reader` — a cheap DB + // row read on the main thread, mirroring the `AnalyzeBody` + // dispatch-time precedent at T-1023's original call site) so + // `run_work_item`'s exact-position morphology_zone derive + // has the settlement's body radius. `None` on a read + // failure or absent reader — the exact-position resolution + // then skips and `context.morphology_zone` stays at its + // `AlluvialPlain` stub (same fallback as an empty district + // grid pre-D-256). + let dispatch_body_params: Option> = match body_params_reader + .as_ref() + { + Some(reader) => reader + .0 + .read_body_params(&body_id) + .map(Box::new) + .map(Some) + .unwrap_or_else(|e| { + tracing::warn!( + body_id = %body_id, + error = %e, + "L3→L4 dispatch: body_params read failed — morphology_zone stays AlluvialPlain" + ); + None + }), + None => None, + }; + // D-256(d): shared per-body heightmap for `run_work_item`'s + // `TerrainAnalysisCache::get_or_derive` — built once from + // data already in memory (no disk re-read), `Arc`'d so every + // settlement dispatched below clones a pointer. + let dispatch_heightmap = + std::sync::Arc::new(crate::atlas::heightmap::BodyHeightmap { + body_id: body_id.clone(), + width: state.heightmap_width, + height: state.heightmap_height, + data: state.heightmap.clone(), + sea_level: state.sea_level, + }); + // ── T-994 (D-232): body-level aggregation for the phase-1 // trait-vocabulary K-draw ─────────────────────────────────── // Read each placement's D-199 read-set once — reused both to @@ -351,9 +394,12 @@ fn drain_generation_completions( world_seed, placement, read_set, - &state.districts, + state.heightmap_width, + state.heightmap_height, + dispatch_body_params.as_deref(), &state.road_graph, &vocab, + Arc::clone(&dispatch_heightmap), ), GenPriority::Low, ); @@ -549,9 +595,14 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 { /// - `founding_orientation` — from the attractor-matched placement (D-213). /// - `political_archetype` — from the attractor-matched placement (D-214, T-1039), /// replacing the `Commission` stub in `context_from_read_set`. -/// - `morphology_zone` — from the `DistrictProfile` covering this placement's -/// heightmap-grid pixel, via `state.districts` (D-239 §6, T-1039). Falls back -/// to `AlluvialPlain` when the district grid is empty (unit tests, early cascade). +/// - `morphology_zone` — **NOT resolved here** (D-256(d), T-1174). It stays at +/// `context_from_read_set`'s `AlluvialPlain` stub through this function; the +/// work item instead carries `settlement_world_m`/`body_params`/`body_seed`/ +/// `heightmap` so `run_work_item` can resolve it via an exact-position +/// `derive_at_metres` call during execution, where `TerrainAnalysis` is +/// reachable (`BodyWorldState` drops it, D-203/T-1048) — a survey cell's +/// centre (the pre-D-256 lookup key) can be hundreds of km from a +/// settlement near the cell's edge. /// - `road_entry_directions` — derived from `state.road_graph`: for each road edge /// incident on this city, the compass octant (0=N…7=NW) of the bearing from the /// city toward the far endpoint, de-duplicated per octant and ordered by descending @@ -568,6 +619,15 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 { /// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body, /// city)` — not the `city_id * 10` placeholder. /// +/// `heightmap_width`/`heightmap_height` are the body's working-grid dims +/// (`BodyWorldState.heightmap_width`/`heightmap_height`) — used to convert the +/// placement's pixel position to world metres via `pixel_to_world_m` (D-256(b)'s +/// bridge function), for both `settlement_world_m` and the true `DistrictPos` +/// used by `settlement_district_pos`/`pick_district_dominant_by_type` (D-256(a): +/// `DistrictPos` canonically means the true D-243 grid — the pre-D-256 +/// `heightmap_pixel_to_district` conversion actually returned a survey-raster +/// position mislabeled as a district). +/// /// `vocab` carries the D-232 three-phase draw's body-level outputs (T-994): /// `trait_selection` (phase 1, computed once per body by the caller) and the /// `catalog` needed to resolve phase 2 (`district_dominant_by_type`) for this @@ -575,14 +635,18 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 { /// function's argument count under the clippy `too_many_arguments` threshold. /// /// Pure (no queue/cache access) so it unit-tests without a `systems.db`. +#[allow(clippy::too_many_arguments)] fn build_skeleton_work_item( body_id: &str, world_seed: u64, placement: &CityPlacement, read_set: CityEconomicReadSet, - districts: &BTreeMap, + heightmap_width: u32, + heightmap_height: u32, + body_params: Option<&BodyParams>, road_graph: &RoadGraph, vocab: &BodyVocabularyContext, + heightmap: Arc, ) -> GenWorkItem { // The D-199 raw fields ride alongside the context (generate_quarter_skeleton // takes them separately), so capture them before context_from_read_set consumes @@ -602,33 +666,24 @@ fn build_skeleton_work_item( // Replaces the `Commission` stub that `context_from_read_set` leaves. context.political_archetype = placement.political_archetype; - // ── T-1039 / D-239 §6: morphology_zone from covering DistrictProfile ─────── - // Convert the placement's working-grid pixel position to a DistrictPos using - // the canonical scale constant — no hardcoded magic numbers here. - let district_pos = scale::heightmap_pixel_to_district(placement.position); - context.morphology_zone = match districts.get(&district_pos) { - Some(d) => d.morphology_zone, - None => { - // An empty grid is the expected params-missing / early-cascade case - // (debug); a miss against a *populated* grid means the pixel→DistrictPos - // conversion is off — a real bug worth a warning, not a silent wrong - // topology. - if districts.is_empty() { - tracing::debug!( - city_id = placement.city_id, - ?district_pos, - "morphology_zone fallback to AlluvialPlain: district grid not built for this body" - ); - } else { - tracing::warn!( - city_id = placement.city_id, - ?district_pos, - "morphology_zone fallback to AlluvialPlain: pos not in populated district grid — check pixel→district convention" - ); - } - MorphologyZone::AlluvialPlain - } - }; + // ── D-256(d): settlement world metres + true DistrictPos ──────────────────── + // `pixel_to_world_m` is the SAME bridge function the survey raster uses + // (D-256(b)) — converting the placement's working-grid pixel (row, col) to + // world metres, then floor-dividing by DISTRICT_M for the true D-243 cell. + // `morphology_zone` itself is NOT resolved here — see this function's doc + // and `run_work_item`'s `GenerateSkeleton` arm (D-256(d)); it stays at the + // `context_from_read_set` `AlluvialPlain` stub through this function. + let (world_x_m, world_y_m) = district_profile::pixel_to_world_m( + placement.position.1 as f64, + placement.position.0 as f64, + heightmap_width as usize, + heightmap_height as usize, + body_params.and_then(|p| p.body_radius_km), + ); + let district_pos: DistrictPos = ( + (world_x_m / scale::DISTRICT_M as f64).floor() as i32, + (world_y_m / scale::DISTRICT_M as f64).floor() as i32, + ); // ── T-994 / D-232: three-phase trait-template draw ────────────────────────── // Phase 1 (trait_selection) and its inputs (body_district_type_mix) were @@ -677,8 +732,8 @@ fn build_skeleton_work_item( // ── Canonical quarter id (D-194/D-230) ──────────────────────────────────── // Deterministic + namespace-isolated per (world_seed, body, city). // SeedChain is Copy, so `chain.seed()` leaves `chain` usable for the work item. - let chain = SeedChain::for_body(world_seed, body_id) - .derive(SeedDomain::Layer4Quarter, placement.city_id); + let body_seed = SeedChain::for_body(world_seed, body_id); + let chain = body_seed.derive(SeedDomain::Layer4Quarter, placement.city_id); let quarter_id = chain.seed(); GenWorkItem::GenerateSkeleton { @@ -691,6 +746,10 @@ fn build_skeleton_work_item( population, founding_age_years, exterior_catalog: vocab.exterior_catalog.clone(), + settlement_world_m: (world_x_m, world_y_m), + body_params: body_params.cloned().map(Box::new), + body_seed, + heightmap, } } @@ -1064,6 +1123,23 @@ mod tests { } } + /// D-256(d) test fixture: a minimal 64×32 working-grid heightmap + /// (matching `district_profile::tests::test_hm`'s shape) wrapped in the + /// `Arc` `build_skeleton_work_item`/`GenWorkItem::GenerateSkeleton` carry + /// for the exact-position `morphology_zone` resolution. `body_params: + /// None` (the common case in these queue-mechanics-focused tests) skips + /// that resolution entirely, so the heightmap content is inert — flat + /// data is enough to satisfy the type. + fn sample_heightmap() -> Arc { + Arc::new(crate::atlas::heightmap::BodyHeightmap { + body_id: "test".into(), + width: 64, + height: 32, + data: vec![0.5; 64 * 32], + sea_level: 0.3, + }) + } + fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement { CityPlacement { city_id, @@ -1252,9 +1328,12 @@ mod tests { 42, &sample_placement(city_id, FoundingOrientation::Cardinal), read_set_with(6_000, 500_000, founding_age), - &BTreeMap::new(), + 64, + 32, + None, &road_graph, &vocab, + sample_heightmap(), ) else { panic!("expected GenerateSkeleton"); }; @@ -1303,9 +1382,12 @@ mod tests { 42, &placement, sample_read_set(), - &BTreeMap::new(), + 64, + 32, + None, &RoadGraph::default(), &empty_vocab(), + sample_heightmap(), ) else { panic!("expected GenerateSkeleton"); @@ -1342,9 +1424,12 @@ mod tests { 99, &placement, sample_read_set(), - &BTreeMap::new(), + 64, + 32, + None, &RoadGraph::default(), &empty_vocab(), + sample_heightmap(), ) else { unreachable!() }; @@ -1355,57 +1440,36 @@ mod tests { assert_ne!(qid(3), qid(4)); } - // ── T-1039: political_archetype + morphology_zone threading ─────────────── + // ── T-1039 / D-256(d): political_archetype + morphology_zone threading ──── /// Verify that `build_skeleton_work_item` threads the placement's - /// `political_archetype` (replacing the `Commission` stub) and looks up - /// `morphology_zone` from the district grid. + /// `political_archetype` (replacing the `Commission` stub). `morphology_zone` + /// is NOT resolved at this dispatch-time function any more (D-256(d)) — it + /// stays at the `context_from_read_set` `AlluvialPlain` stub here; see + /// `run_work_item_resolves_morphology_zone_at_exact_settlement_position` + /// below for the execution-time resolution this ticket moved it to. #[test] - fn threads_political_archetype_and_morphology_zone() { - use crate::atlas::district_profile::{ - DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass, - }; - use crate::atlas::scale::BasinDirection; + fn threads_political_archetype() { use crate::simulation::generator::MorphologyZone; - // A Corporate archetype placement in a Fjord district. let placement = sample_placement_with_archetype( 42, FoundingOrientation::Coastal { facing_degrees: 90 }, PoliticalArchetype::Corporate, ArrangementPattern::CampusGrid, ); - // CityPlacement.position = (10, 20) → district_pos = (col/8, row/8) = (20/8, 10/8) = (2, 1) - let district_pos = scale::heightmap_pixel_to_district(placement.position); - assert_eq!(district_pos, (2, 1)); - - let mut districts: BTreeMap = BTreeMap::new(); - districts.insert( - district_pos, - DistrictProfile { - morphology_zone: MorphologyZone::Fjord, - tectonic_class: crate::atlas::district_profile::TectonicClass::Stable, - glaciation_grade: GlaciationGrade::Moderate, - precipitation_class: PrecipitationClass::Temperate, - slope_q: 60, - elev_q: 50, - ocean_fraction_q: 10, - river_threshold: 200, - temperature_c: Some(8.0), - moisture_q: 55, - vegetation_class: VegetationClass::Scrub, - basin_direction: BasinDirection::North, - }, - ); let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item( "TestBody", 1, &placement, sample_read_set(), - &districts, + 64, + 32, + None, &RoadGraph::default(), &empty_vocab(), + sample_heightmap(), ) else { panic!("expected GenerateSkeleton"); }; @@ -1416,36 +1480,97 @@ mod tests { PoliticalArchetype::Corporate, "political_archetype must be threaded from placement (T-1039)" ); - // Morphology zone must come from the DistrictProfile. - assert_eq!( - context.morphology_zone, - MorphologyZone::Fjord, - "morphology_zone must be looked up from DistrictProfile (T-1039)" - ); + // morphology_zone is untouched by build_skeleton_work_item post-D-256(d). + assert_eq!(context.morphology_zone, MorphologyZone::AlluvialPlain); } - /// When no district grid is available (empty districts map), morphology_zone - /// falls back to AlluvialPlain (the safe mesh-topology default). + /// D-256(d): `resolve_settlement_morphology_zone` (the function + /// `run_work_item`'s `GenerateSkeleton` arm calls) returns `None` when no + /// `body_params` is supplied (no DB row for this body — the same + /// condition the pre-D-256 "empty district grid" fallback covered), so + /// the caller leaves `context.morphology_zone` at its `AlluvialPlain` + /// stub. #[test] - fn morphology_zone_fallback_when_district_missing() { - use crate::simulation::generator::MorphologyZone; + fn resolve_settlement_morphology_zone_none_when_body_params_absent() { + use crate::atlas::gen_queue::resolve_settlement_morphology_zone; + use std::sync::Mutex; - let placement = sample_placement(1, FoundingOrientation::Cardinal); - let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item( + let heightmap = sample_heightmap(); + let cache = Arc::new(Mutex::new( + crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4), + )); + let zone = resolve_settlement_morphology_zone( + &cache, "BodyX", - 0, - &placement, - sample_read_set(), - &BTreeMap::new(), - &RoadGraph::default(), - &empty_vocab(), - ) else { - panic!("expected GenerateSkeleton") + None, + (0.0, 0.0), + SeedChain::for_body(0, "BodyX"), + &heightmap, + ); + assert_eq!(zone, None, "no body_params → no resolution, caller keeps the stub"); + } + + /// D-256(d): with real `body_params`, `resolve_settlement_morphology_zone` + /// resolves at the settlement's EXACT world position via `derive_at_metres` + /// — not from a survey-cell lookup. Ground truth: an independent + /// `derive_at_metres` call at the same position must agree bit-for-bit. + #[test] + fn resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position() { + use crate::atlas::district_profile::{self, BodyParams, ClimateConstants}; + use crate::atlas::gen_queue::resolve_settlement_morphology_zone; + use std::sync::Mutex; + + let heightmap = sample_heightmap(); + let body_params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() }; + let placement = sample_placement(1, FoundingOrientation::Cardinal); + let (world_x_m, world_y_m) = district_profile::pixel_to_world_m( + placement.position.1 as f64, + placement.position.0 as f64, + heightmap.width as usize, + heightmap.height as usize, + body_params.body_radius_km, + ); + let body_seed = SeedChain::for_body(0, "TestBody"); + + let cache = Arc::new(Mutex::new( + crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4), + )); + let zone = resolve_settlement_morphology_zone( + &cache, + "TestBody", + Some(&body_params), + (world_x_m, world_y_m), + body_seed, + &heightmap, + ); + + // Ground truth: derive_at_metres at the SAME settlement world metres, + // via the terrain cache's own re-derive path (run_layer1) so the + // TerrainAnalysis is byte-identical to what the resolver used. + let (_l1, ta) = crate::atlas::layer1::run_layer1(&heightmap); + let expected = district_profile::derive_at_metres( + body_seed, + "TestBody", + &body_params, + &ta, + world_x_m, + world_y_m, + &ClimateConstants::default(), + 0.0, + &[], + ); + assert_eq!( - context.morphology_zone, - MorphologyZone::AlluvialPlain, - "should fall back to AlluvialPlain when district grid empty" + zone, + Some(expected.morphology_zone), + "resolve_settlement_morphology_zone must match derive_at_metres at the \ + settlement's exact world position (D-256(d))" ); } @@ -1577,14 +1702,20 @@ mod tests { /// Acceptance test for T-1039: a Corporate coastal placement in a Fjord district /// dispatches a work item whose context uses Ribbon topology (Fjord) and /// Corporate (CampusGrid) layout — not mesh+Commission. + /// + /// D-256(d): `morphology_zone` is no longer resolved by + /// `build_skeleton_work_item` (that now happens at execution time via + /// `resolve_settlement_morphology_zone`, tested directly elsewhere against + /// real terrain — see + /// `resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position`). + /// This test's actual subject is `generate_quarter_skeleton`'s downstream + /// Fjord+Corporate behavior, so it overrides `context.morphology_zone` + /// directly on the built context — exactly what `run_work_item` does in + /// production once the exact-position resolution completes. #[test] fn corporate_fjord_placement_uses_ribbon_topology_not_mesh_commission() { - use crate::atlas::district_profile::{ - DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass, - }; - use crate::atlas::scale::BasinDirection; use crate::atlas::skeleton_gen::generate_quarter_skeleton; - use crate::simulation::generator::AccessKind; + use crate::simulation::generator::{AccessKind, MorphologyZone}; // CorpTerritory → Corporate archetype; CoastalAccess attractor. let placement = sample_placement_with_archetype( @@ -1595,25 +1726,6 @@ mod tests { PoliticalArchetype::Corporate, ArrangementPattern::CampusGrid, ); - let district_pos = scale::heightmap_pixel_to_district(placement.position); - let mut districts: BTreeMap = BTreeMap::new(); - districts.insert( - district_pos, - DistrictProfile { - morphology_zone: MorphologyZone::Fjord, - tectonic_class: crate::atlas::district_profile::TectonicClass::Stable, - glaciation_grade: GlaciationGrade::Moderate, - precipitation_class: PrecipitationClass::SemiArid, - slope_q: 70, - elev_q: 30, - ocean_fraction_q: 20, - river_threshold: 200, - temperature_c: Some(5.0), - moisture_q: 35, - vegetation_class: VegetationClass::Barren, - basin_direction: BasinDirection::North, - }, - ); let GenWorkItem::GenerateSkeleton { context, @@ -1628,13 +1740,18 @@ mod tests { 7, &placement, sample_read_set(), - &districts, + 64, + 32, + None, &RoadGraph::default(), &empty_vocab(), + sample_heightmap(), ) else { panic!("expected GenerateSkeleton"); }; + let mut context = context; + context.morphology_zone = MorphologyZone::Fjord; // Verify the context is correctly wired before skeleton generation. assert_eq!(context.political_archetype, PoliticalArchetype::Corporate); @@ -1913,9 +2030,12 @@ mod tests { 42, &placement, sample_read_set(), - &BTreeMap::new(), + 64, + 32, + None, &road_graph, &empty_vocab(), + sample_heightmap(), ) else { panic!("expected GenerateSkeleton"); @@ -1977,9 +2097,12 @@ mod tests { 42, &placement, sample_read_set(), - &BTreeMap::new(), + 64, + 32, + None, &RoadGraph::default(), &empty_vocab(), + sample_heightmap(), ) else { panic!("expected GenerateSkeleton"); diff --git a/server/src/atlas/scale.rs b/server/src/atlas/scale.rs index 76fb70236..6b1697113 100644 --- a/server/src/atlas/scale.rs +++ b/server/src/atlas/scale.rs @@ -72,6 +72,10 @@ const _: () = assert!(DISTRICTS_PER_REGION == 100); /// Position on the 64 m chunk grid (chunk-units, not metres). `BTreeMap` key (D-010). pub type ChunkPos = (i32, i32); /// Position on the 2 km district grid (district-units) — the terrain/climate carrier cell. +/// Canonically the true D-243 grid, origin-corner quantized (`dp * DISTRICT_M`, +/// equator/lon-0 anchor) — the [D-256](../../../governance/decisions/architecture.md) +/// convention every position-pinning test assumes. Not to be confused with +/// [`SurveyCellPos`], a different, coarser grid entirely. pub type DistrictPos = (i32, i32); /// Position on the ~205 km region grid (region-units) — the top hard block. pub type RegionPos = (i32, i32); @@ -100,40 +104,62 @@ pub fn chunk_to_region(c: ChunkPos) -> RegionPos { } // --------------------------------------------------------------------------- -// Working-heightmap pixel ↔ district (cascade.rs working grid) +// Survey raster — coarse planning grid (D-256), NOT a D-243 spatial rung // --------------------------------------------------------------------------- -/// Number of working-heightmap-grid pixels per district side on the standard -/// cascade working resolution (512×256 working grid, `heightmap::GRID_W` × -/// `heightmap::GRID_H`; D-203, D-239 §1, T-1023). (T-1170 audit nit: this -/// comment previously read "~128×64" — stale since the working grid was -/// widened; the actual grid-shape source of truth is `heightmap::GRID_W`/ -/// `GRID_H`, not a number restated here.) +/// Position on the coarse 8×8-working-pixel **survey raster** (D-256(b)) — the +/// batch planning pass used for L2/L3 inputs (settlement placement context, +/// believability sampling, overlays, skeleton dispatch context). +/// +/// **This is a role name for the coarse planning raster, NOT a [D-243](../../../governance/decisions/architecture.md) +/// spatial rung.** The D-243 ladder gains no level from this type — it exists +/// solely so the compiler rejects cross-namespace mixing with the true +/// [`DistrictPos`] grid (D-256's root-cause fix: two semantically different +/// `(i32, i32)` grids used to share one bare tuple type and could be passed to +/// each other by accident). A real newtype, not a type alias. +/// +/// `Ord` is required (not just for convenience): survey positions are +/// `BTreeMap` keys (D-010 determinism). +/// +/// The only sanctioned bridge from a survey cell to the true metric grid is +/// [`crate::atlas::district_profile::pixel_to_world_m`] applied to the cell's +/// centre pixel (the `8·rx + 3.5` pixel-index midpoint — D-256(b): the correct +/// centre of the 8-point bilinear sample lattice, not an error) — never direct +/// arithmetic against [`DISTRICT_M`]/[`REGION_M`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct SurveyCellPos(pub i32, pub i32); + +/// Number of working-heightmap-grid pixels per survey-cell side on the +/// standard cascade working resolution (512×256 working grid, +/// `heightmap::GRID_W` × `heightmap::GRID_H`; D-203, D-239 §1, T-1023). +/// (T-1170 audit nit: this comment previously read "~128×64" — stale since the +/// working grid was widened; the actual grid-shape source of truth is +/// `heightmap::GRID_W`/`GRID_H`, not a number restated here.) /// /// This is NOT a metre-scale constant — it is the `grid_cells_per_district` /// parameter passed to [`crate::atlas::district_profile::derive_all_districts`]. /// Centralised here so plugin.rs and cascade.rs share one definition and neither /// hard-codes `8` independently. /// -/// `DistrictPos = (col / HEIGHTMAP_CELLS_PER_DISTRICT, row / HEIGHTMAP_CELLS_PER_DISTRICT)` -/// for a working-grid pixel `(row, col)` — see [`heightmap_pixel_to_district`]. +/// `SurveyCellPos = (col / HEIGHTMAP_CELLS_PER_DISTRICT, row / HEIGHTMAP_CELLS_PER_DISTRICT)` +/// for a working-grid pixel `(row, col)` — see [`heightmap_pixel_to_survey_cell`]. pub const HEIGHTMAP_CELLS_PER_DISTRICT: usize = 8; /// Convert a working-heightmap-grid pixel coordinate `(row, col)` to the -/// [`DistrictPos`] that covers it. +/// [`SurveyCellPos`] that covers it. /// /// `CityPlacement.position` and `RoadNode.position` are both stored in /// working-heightmap-grid coordinates (row-major, `(row, col)` order), and the -/// district grid is built with the same pixel grid by +/// survey raster is built with the same pixel grid by /// [`crate::atlas::district_profile::derive_all_districts`]. Integer division /// floors toward zero, which matches the `BTreeMap` keys inserted by `derive_all_districts`. #[inline] -pub fn heightmap_pixel_to_district(pixel: (u16, u16)) -> DistrictPos { +pub fn heightmap_pixel_to_survey_cell(pixel: (u16, u16)) -> SurveyCellPos { let cpd = HEIGHTMAP_CELLS_PER_DISTRICT as i32; - // pixel = (row, col); DistrictPos convention is (dx=col_district, dy=row_district). + // pixel = (row, col); SurveyCellPos convention is (dx=col_cell, dy=row_cell). let dx = pixel.1 as i32 / cpd; let dy = pixel.0 as i32 / cpd; - (dx, dy) + SurveyCellPos(dx, dy) } // --------------------------------------------------------------------------- diff --git a/server/src/bin/aliveness_probe.rs b/server/src/bin/aliveness_probe.rs index d073364c5..3f418773e 100644 --- a/server/src/bin/aliveness_probe.rs +++ b/server/src/bin/aliveness_probe.rs @@ -6,11 +6,15 @@ //! `atlas::believability` so this binary and `tests/believability_harness` measure the //! exact same thing. //! -//! ## Why the *district* tier +//! ## Why the *survey-cell* tier (D-256(b)) //! //! No production code maps a world chunk → its covering `DistrictProfile` yet (the voxel -//! layer is a walking skeleton), so analysis is addressed in district space; the voxel -//! sample inside each district is illustrative ground-truth of what its 1 m tiles derive to. +//! layer is a walking skeleton), so the body-level report is addressed in survey-cell +//! space — a coarse planning raster, NOT the true D-243 district grid (see +//! `settled_reach_server::atlas::scale::SurveyCellPos`); the voxel sample inside each +//! survey cell (at its own D-256(b) centre world metres) is illustrative ground-truth of +//! what its 1 m tiles derive to. The `--render` mode below is the true-district +//! counterpart, at real 2 km fidelity via the on-demand `derive_district` path. //! //! ```sh //! cargo run --bin aliveness_probe -- --body GJ338Bd --seed yolo --probes 5 @@ -37,13 +41,11 @@ use settled_reach_server::atlas::believability::{ use settled_reach_server::atlas::body_world_state::BodyWorldState; use settled_reach_server::atlas::chunk_context::derive_chunk_context; use settled_reach_server::atlas::district_profile::{ - derive_district, BodyParams, ClimateConstants, DistrictProfile, GlaciationGrade, + self, derive_district, BodyParams, ClimateConstants, DistrictProfile, GlaciationGrade, VegetationClass, }; use settled_reach_server::atlas::features::TerrainAnalysis; -use settled_reach_server::atlas::scale::{ - self, ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M, -}; +use settled_reach_server::atlas::scale::{self, ChunkPos, DistrictPos, SurveyCellPos, CHUNK_M}; use settled_reach_server::atlas::voxel::derive_voxel_column; use settled_reach_server::seed::SeedChain; use settled_reach_server::simulation::generator::MorphologyZone; @@ -89,9 +91,9 @@ fn main() { eprintln!("cascade produced no districts — cannot probe (body params missing?). Aborting."); std::process::exit(1); } - let keys: Vec = districts.keys().copied().collect(); + let keys: Vec = districts.keys().copied().collect(); eprintln!( - "cascade: {} districts, {} settlement placements", + "cascade: {} survey cells, {} settlement placements", districts.len(), bws.placements.len() ); @@ -102,38 +104,79 @@ fn main() { ); // ── Body-level believability report — the D-245 enforcer verdict ──────── - let report = analyze(world_seed, &args.body, districts); + let report = analyze( + world_seed, + &args.body, + districts, + bws.heightmap_width, + bws.heightmap_height, + body_params.body_radius_km, + ); print_report(&report); // ── Anchored probe: ≈150 chunks west of the principal settlement ──────── - // (highest placement score; per-city population is 0 in the DB). - let anchor_district = bws.placements.iter().max_by_key(|p| p.score).map(|p| { - let dp = scale::heightmap_pixel_to_district(p.position); + // (highest placement score; per-city population is 0 in the DB). D-256(b): + // `heightmap_pixel_to_survey_cell` — a survey-raster position, NOT a true + // district — so "west" is walked in survey-cell units (each cell spans + // HEIGHTMAP_CELLS_PER_DISTRICT working pixels), never true DISTRICT_M. + let anchor_cell = bws.placements.iter().max_by_key(|p| p.score).map(|p| { + let cell = scale::heightmap_pixel_to_survey_cell(p.position); println!( - "\nprincipal settlement: city {} @ pixel {:?} → district {:?} (placement score {})", - p.city_id, p.position, dp, p.score + "\nprincipal settlement: city {} @ pixel {:?} → survey cell {:?} (placement score {})", + p.city_id, p.position, cell, p.score ); - let west_districts = (ANCHOR_OFFSET_CHUNKS * CHUNK_M) / scale::DISTRICT_M; - (dp.0 - west_districts.max(1) - 1, dp.1) + // 150 chunks (9 600 m) ÷ one survey cell's ACTUAL metric width at this + // row — measured directly via two adjacent cells' D-256(b) centre + // world metres (the same bridge derive_district_profile uses), NOT + // `HEIGHTMAP_CELLS_PER_DISTRICT * DISTRICT_M` (that formula silently + // treats a working pixel as if it were DISTRICT_M metres wide, which + // is only true in the no-radius fallback — on a real body a working + // pixel's metric width is `circumference / heightmap_width`, unrelated + // to DISTRICT_M; the earlier version was the SAME pseudo-grid/true-grid + // conflation this ticket's basin-dirs fix corrected). Diagnostic-only + // "west" nudge — exact enough to land roughly the requested distance + // away, never used for anything position-authoritative. + let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT; + let (x0, _) = district_profile::survey_cell_centre_world_m( + cell, + gcpr, + bws.heightmap_width as usize, + bws.heightmap_height as usize, + body_params.body_radius_km, + ); + let (x1, _) = district_profile::survey_cell_centre_world_m( + SurveyCellPos(cell.0 + 1, cell.1), + gcpr, + bws.heightmap_width as usize, + bws.heightmap_height as usize, + body_params.body_radius_km, + ); + let cell_width_m = (x1 - x0).abs().max(1.0); + let cell_width_chunks = (cell_width_m / CHUNK_M as f64).round() as i32; + let west_cells = (ANCHOR_OFFSET_CHUNKS / cell_width_chunks.max(1)).max(1); + SurveyCellPos(cell.0 - west_cells - 1, cell.1) }); - if let Some(adp) = anchor_district { + if let Some(acell) = anchor_cell { println!( "\n---- ANCHORED PROBE: ≈{ANCHOR_OFFSET_CHUNKS} chunks (9.6 km) west of the principal city ----" ); - match nearest_present(districts, adp) { - Some((dp, prof)) => probe( + match nearest_present(districts, acell) { + Some((cell, prof)) => probe( &args.body, world_seed, - dp, + cell, + bws.heightmap_width, + bws.heightmap_height, + body_params.body_radius_km, prof, - if dp == adp { + if cell == acell { "" } else { - "(nearest land district — target is off-map / open ocean)" + "(nearest land survey cell — target is off-map / open ocean)" }, ), - None => println!(" target {adp:?} and neighbours are off-map; nothing to sample."), + None => println!(" target {acell:?} and neighbours are off-map; nothing to sample."), } } @@ -141,11 +184,20 @@ fn main() { println!("\n---- {} RANDOM PROBES ----", args.probes); for i in 0..args.probes { let pick = (seed_to_u64(&format!("{}/{i}", args.seed)) % keys.len() as u64) as usize; - let dp = keys[pick]; - probe(&args.body, world_seed, dp, &districts[&dp], ""); + let cell = keys[pick]; + probe( + &args.body, + world_seed, + cell, + bws.heightmap_width, + bws.heightmap_height, + body_params.body_radius_km, + &districts[&cell], + "", + ); } - println!("\n(district tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth for a representative chunk of each district.)"); + println!("\n(survey-cell tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth for a representative chunk at each cell's centre.)"); // ── --render: district-window PNG maps (T-1123) ────────────────────────── if let Some(out_dir) = &args.render { @@ -201,12 +253,32 @@ fn print_report(r: &BelievabilityReport) { println!(" → {}/{} criteria pass", passes, crit.len()); } -/// Probe one district: print its profile + a derived voxel-column sample for colour. -fn probe(body: &str, world_seed: u64, dp: DistrictPos, prof: &DistrictProfile, note: &str) { - // Representative chunk at the district centre (32 chunks / district). +/// Probe one survey cell: print its profile + a derived voxel-column sample for +/// colour, at the TRUE chunk covering the cell's own D-256(b) centre world +/// metres (never the pseudo-grid coordinates scaled by `CHUNKS_PER_DISTRICT` +/// directly — that arithmetic silently assumed the survey grid WAS the true +/// district grid, exactly the D-256 namespace collision). +#[allow(clippy::too_many_arguments)] +fn probe( + body: &str, + world_seed: u64, + dp: SurveyCellPos, + heightmap_width: u32, + heightmap_height: u32, + body_radius_km: Option, + prof: &DistrictProfile, + note: &str, +) { + let (world_x_m, world_y_m) = district_profile::survey_cell_centre_world_m( + dp, + scale::HEIGHTMAP_CELLS_PER_DISTRICT, + heightmap_width as usize, + heightmap_height as usize, + body_radius_km, + ); let chunk: ChunkPos = ( - dp.0 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2, - dp.1 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2, + (world_x_m / CHUNK_M as f64).floor() as i32, + (world_y_m / CHUNK_M as f64).floor() as i32, ); let ctx = derive_chunk_context(world_seed, body, prof, chunk, None); @@ -232,7 +304,7 @@ fn probe(body: &str, world_seed: u64, dp: DistrictPos, prof: &DistrictProfile, n } } - println!("\n• district {dp:?} {note}"); + println!("\n• survey cell {dp:?} {note}"); println!( " zone={:?} elev_q={} slope_q={} ocean%q={} moisture_q={} temp={} precip={:?} veg_class={:?} glaciation={:?} basin={:?}", prof.morphology_zone, @@ -279,18 +351,18 @@ fn fmt_hist(h: &BTreeMap, n: i64) -> String { .join(", ") } -/// Find the district at `dp`, else the nearest present district within a small ring. +/// Find the survey cell at `dp`, else the nearest present cell within a small ring. fn nearest_present( - districts: &BTreeMap, - dp: DistrictPos, -) -> Option<(DistrictPos, &DistrictProfile)> { + districts: &BTreeMap, + dp: SurveyCellPos, +) -> Option<(SurveyCellPos, &DistrictProfile)> { if let Some(p) = districts.get(&dp) { return Some((dp, p)); } for r in 1..=8 { for dx in -r..=r { for dy in -r..=r { - let q = (dp.0 + dx, dp.1 + dy); + let q = SurveyCellPos(dp.0 + dx, dp.1 + dy); if let Some(p) = districts.get(&q) { return Some((q, p)); } @@ -493,8 +565,9 @@ fn render_window_panels( /// Map a working-grid settlement pixel `(row, col)` to the TRUE metric 2 km /// district containing it — the inverse of `derive_district`'s forward mapping /// (district → fractional working-grid position via the body radius, the D-204 -/// elastic seam). NOT `scale::heightmap_pixel_to_district`: that addresses the -/// eager gcpr = 8 pseudo-grid, whose cells span tens of km. +/// elastic seam). NOT `scale::heightmap_pixel_to_survey_cell` (D-256(b) +/// rename): that addresses the eager gcpr = 8 survey raster, whose cells span +/// tens of km — a coarse planning grid, not the true district. fn true_district_of_pixel( pixel: (u16, u16), ta_w: usize, diff --git a/server/tests/believability_harness.rs b/server/tests/believability_harness.rs index 5cbe93477..141d1c067 100644 --- a/server/tests/believability_harness.rs +++ b/server/tests/believability_harness.rs @@ -26,7 +26,7 @@ //! `cascade_golden`). use settled_reach_server::atlas::believability::{ - analyze, cascade_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport, + analyze, cascade_snapshot_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport, }; const GOLDEN_FILE: &str = "tests/golden/believability.json"; @@ -42,8 +42,18 @@ const VALIDATION_BODIES: &[(&str, &str, &str)] = &[ /// Run the cascade + analyze for one body, or `None` if its committed data is absent. fn report_for(body_id: &str, seed: &str) -> Option { let world_seed = seed_to_u64(seed); - match cascade_for_body(world_seed, body_id) { - Ok(bws) => Some(analyze(world_seed, body_id, &bws.districts)), + match cascade_snapshot_for_body(world_seed, body_id) { + Ok((snapshot, params)) => { + let bws = snapshot.into_body_world_state(); + Some(analyze( + world_seed, + body_id, + &bws.districts, + bws.heightmap_width, + bws.heightmap_height, + params.body_radius_km, + )) + } Err(e) => { eprintln!("[believability] SKIP {body_id}: {e}"); None diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index dfee26f32..d52873993 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -1927,7 +1927,7 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile { seed, params, &ta, - (0, 0), + scale::SurveyCellPos(0, 0), 8, &climate, "test_body", diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 0bb622068..9e5862d78 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -578,7 +578,7 @@ fn generate_atlas_layer_response_fixtures() { }], grid_w: 512, grid_h: 256, - district_basin_dirs: std::collections::BTreeMap::new(), + survey_basin_dirs: std::collections::BTreeMap::new(), }; // T-960 §1/§2: a small populated RoadGraphLayer + SettlementLayer, one // settlement (a capital) connected to one waypoint-free short edge. diff --git a/server/tests/golden/believability.json b/server/tests/golden/believability.json index b76329edc..f88451727 100644 --- a/server/tests/golden/believability.json +++ b/server/tests/golden/believability.json @@ -6,7 +6,7 @@ "voxel_sampled_districts": 64, "contrast": { "moisture_q": { - "min": 16, + "min": 17, "max": 97, "distinct": 74 }, @@ -23,7 +23,7 @@ "ocean_fraction_q": { "min": 0, "max": 100, - "distinct": 42 + "distinct": 44 }, "morphology_zones": 9, "vegetation_classes": 4, @@ -37,7 +37,7 @@ "drainage_samples": 0, "drainage_monotonic": 0, "vegetation_samples": 64, - "vegetated_districts": 5 + "vegetated_districts": 8 } }, { @@ -69,7 +69,7 @@ "morphology_zones": 6, "vegetation_classes": 2, "terrain_materials": 2, - "voxel_relief_m": 21, + "voxel_relief_m": 22, "micro_habitat_distinct": 0 }, "coherence": {