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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:40:13 +02:00
co-authored by Claude Fable 5
parent 35a4137304
commit 933e1f4ee4
15 changed files with 1272 additions and 479 deletions
+81 -34
View File
@@ -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<DistrictPos, DistrictProfile>,
districts: &BTreeMap<SurveyCellPos, DistrictProfile>,
heightmap_width: u32,
heightmap_height: u32,
body_radius_km: Option<f64>,
) -> 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<DistrictPos> = districts.keys().copied().collect();
let keys: Vec<SurveyCellPos> = 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<Item = String>) -> usize {
vals.collect::<BTreeSet<String>>().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<DistrictPos, DistrictProfile> = BTreeMap::new();
let mut districts: BTreeMap<SurveyCellPos, DistrictProfile> = 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<DistrictPos, DistrictProfile> = BTreeMap::new();
let mut districts: BTreeMap<SurveyCellPos, DistrictProfile> = 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<DistrictPos, DistrictProfile> = BTreeMap::new();
let mut districts: BTreeMap<SurveyCellPos, DistrictProfile> = 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);
}
+15 -5
View File
@@ -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<f32>,
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<QuarterId, QuarterWorldState>,
/// 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<DistrictPos, DistrictProfile>,
pub districts: BTreeMap<SurveyCellPos, DistrictProfile>,
/// 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![],
+33 -10
View File
@@ -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<TerrainAnalysis>,
}
/// 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<DistrictPos, DistrictProfile>,
pub districts: std::collections::BTreeMap<SurveyCellPos, DistrictProfile>,
}
/// 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<RegionPos, RegionProfile>,
@@ -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);
}
+251 -172
View File
@@ -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<RegionPos, RegionProfile>>,
) -> 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(
&params,
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,
&params,
@@ -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 gridone 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<DistrictPos, DistrictProfile>` covering the full
/// heightmap at the given district-grid resolution.
/// Returns a `BTreeMap<SurveyCellPos, DistrictProfile>` 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<DistrictPos, BasinDirection>>,
basin_dirs: Option<&BTreeMap<SurveyCellPos, BasinDirection>>,
river_network: Option<&RiverNetwork>,
) -> BTreeMap<DistrictPos, DistrictProfile> {
) -> BTreeMap<SurveyCellPos, DistrictProfile> {
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<RegionPos> = {
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<RegionPos, RegionProfile> = 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, 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(), &params, &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<DistrictPos, BasinDirection> = BTreeMap::new();
basin_dirs.insert(pos_east, BasinDirection::East);
basin_dirs.insert(pos_south, BasinDirection::South);
let mut basin_dirs: BTreeMap<SurveyCellPos, BasinDirection> = 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(), &params, &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]
+4 -3
View File
@@ -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<i8>,
}
+132 -7
View File
@@ -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<CityGenerationContext>,
/// 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<Box<BodyParams>>,
/// `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<f32>`. 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<crate::atlas::heightmap::BodyHeightmap>,
},
/// 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.52 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<String, (Layer1Output, TerrainAnalysis, u64)>,
/// 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<Mutex<TerrainAnalysisCache>>,
body_id: &str,
body_params: Option<&BodyParams>,
settlement_world_m: (f64, f64),
body_seed: SeedChain,
heightmap: &crate::atlas::heightmap::BodyHeightmap,
) -> Option<crate::simulation::generator::MorphologyZone> {
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,
}),
}
}
+47 -37
View File
@@ -27,7 +27,7 @@ use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
use crate::atlas::drainage::{self, DrainageResult};
use crate::atlas::features::{self, TerrainAnalysis};
use crate::atlas::heightmap::BodyHeightmap;
use crate::atlas::scale::{BasinDirection, DistrictPos, HEIGHTMAP_CELLS_PER_DISTRICT};
use crate::atlas::scale::{BasinDirection, HEIGHTMAP_CELLS_PER_DISTRICT, SurveyCellPos};
use crate::atlas::subbiome;
use crate::simulation::generator::{AttractorType, GeographicAttractor};
use serde::{Deserialize, Serialize};
@@ -47,23 +47,30 @@ pub struct Layer1Output {
/// so the overlay scale stays correct for any source resolution (mod-safe).
pub grid_w: u32,
pub grid_h: u32,
/// Dominant D8 thalweg direction per district, aggregated from the `fdir`
/// Dominant D8 thalweg direction per SURVEY CELL, aggregated from the `fdir`
/// grid during the Layer-1 drainage pass (T-1047, D-239 §8). Each entry
/// holds the cardinal direction with the most votes among non-ocean cells in
/// that district. Keyed by `DistrictPos` using `HEIGHTMAP_CELLS_PER_DISTRICT`
/// as the grid-to-district mapping.
/// that cell's covering 8×8 working-grid pixel block. Keyed by
/// [`SurveyCellPos`] (D-256(b)) using `HEIGHTMAP_CELLS_PER_DISTRICT` as the
/// grid-to-cell mapping — the SAME survey raster `derive_all_districts`
/// builds `DistrictProfile`s over, not the true D-243 `DistrictPos` grid
/// (D-256: this field predates the newtype and escaped the initial sweep;
/// the aggregate is honestly a survey-cell aggregate — it votes over
/// exactly the pixel block one `DistrictProfile` summarizes — so
/// `SurveyCellPos` is its correct, not just convenient, key).
///
/// This is the **true D8-computed direction** — not a seed-bit proxy — so
/// `DistrictProfile.basin_direction` (and downstream `ChunkContext`) respect
/// drainage monotonicity (D-239 §8: respect the D8 thalweg).
/// The VALUES are the **true D8-computed direction** — not a seed-bit
/// proxy — so `DistrictProfile.basin_direction` (and downstream
/// `ChunkContext`) respect drainage monotonicity (D-239 §8: respect the D8
/// thalweg). Only the KEY space is the coarse survey raster.
///
/// **Transient:** skipped in serialization (`#[serde(skip)]`) — this field is
/// a cascade-internal transport from `run_layer1` to `derive_all_districts`
/// and is re-derived on each `run_layer1` call. The per-district direction is
/// persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
/// and is re-derived on each `run_layer1` call. The per-survey-cell direction
/// is persisted on `DistrictProfile.basin_direction` (`BodyWorldState.districts`)
/// after the cascade consumes it.
#[serde(skip)]
pub district_basin_dirs: BTreeMap<DistrictPos, BasinDirection>,
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
}
/// Run the Layer-1 topography pipeline for a single body.
@@ -95,9 +102,10 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
})
.collect();
// Aggregate per-district dominant D8 direction from the fdir grid (T-1047,
// D-239 §8). fdir is available here before it is discarded — do NOT expose
// the full grid on DrainageResult externally. The compact per-district map
// Aggregate per-survey-cell dominant D8 direction from the fdir grid
// (T-1047, D-239 §8; D-256(b) survey raster — NOT the true D-243 district
// grid). fdir is available here before it is discarded — do NOT expose
// the full grid on DrainageResult externally. The compact per-cell map
// (~6 000 entries) is what propagates into Layer1Output and DistrictProfile.
//
// Mapping fdir index → 4-way cardinal (D-010 integer; matches D8 table):
@@ -107,8 +115,8 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
// 6 SE → S
// 7 SW → S
// -1 → skip (no outflow: edge, flat peak, ocean)
let district_basin_dirs =
aggregate_district_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
let survey_basin_dirs =
aggregate_survey_basin_dirs(&drainage.fdir, hm.width, hm.height, &ta.ocean_mask);
let l1 = Layer1Output {
body_id: hm.body_id.clone(),
@@ -117,32 +125,34 @@ pub fn run_layer1(hm: &BodyHeightmap) -> (Layer1Output, TerrainAnalysis) {
attractors,
grid_w: hm.width,
grid_h: hm.height,
district_basin_dirs,
survey_basin_dirs,
};
(l1, ta)
}
/// Aggregate a per-district dominant D8 flow direction from the full-grid `fdir`
/// (index into the D8 table, -1 = no outflow). Ocean-masked cells are excluded
/// from voting so coastal districts do not skew toward the ocean sink direction.
/// Aggregate a per-survey-cell dominant D8 flow direction from the full-grid
/// `fdir` (index into the D8 table, -1 = no outflow). Ocean-masked cells are
/// excluded from voting so coastal cells do not skew toward the ocean sink
/// direction.
///
/// Each non-ocean, non-sink cell casts one vote for its cardinal direction
/// (diagonals NE/NW fold to N, SE/SW fold to S). Ties broken by cardinal
/// precedence (N > S > E > W). Districts with no valid votes default to `North`.
/// precedence (N > S > E > W). Survey cells with no valid votes default to
/// `North`.
///
/// Integer arithmetic throughout (D-010).
fn aggregate_district_basin_dirs(
fn aggregate_survey_basin_dirs(
fdir: &[i8],
width: u32,
height: u32,
ocean_mask: &[bool],
) -> BTreeMap<DistrictPos, BasinDirection> {
) -> BTreeMap<SurveyCellPos, BasinDirection> {
let w = width as usize;
let h = height as usize;
let gcpd = HEIGHTMAP_CELLS_PER_DISTRICT;
// Per-district vote counts: [N, S, E, W].
let mut votes: BTreeMap<DistrictPos, [i32; 4]> = BTreeMap::new();
// Per-survey-cell vote counts: [N, S, E, W].
let mut votes: BTreeMap<SurveyCellPos, [i32; 4]> = BTreeMap::new();
for r in 0..h {
for c in 0..w {
@@ -163,19 +173,19 @@ fn aggregate_district_basin_dirs(
7 => 1, // SW → S
_ => continue,
};
let district_pos: DistrictPos = ((c / gcpd) as i32, (r / gcpd) as i32);
votes.entry(district_pos).or_insert([0i32; 4])[vote] += 1;
let cell_pos = SurveyCellPos((c / gcpd) as i32, (r / gcpd) as i32);
votes.entry(cell_pos).or_insert([0i32; 4])[vote] += 1;
}
}
// For each district, pick the cardinal with the most votes.
// For each survey cell, pick the cardinal with the most votes.
// Tie-breaking order: N > S > E > W (matches D8 priority).
let district_cols = w.div_ceil(gcpd) as i32;
let district_rows = h.div_ceil(gcpd) as i32;
let survey_cols = w.div_ceil(gcpd) as i32;
let survey_rows = h.div_ceil(gcpd) as i32;
let mut out = BTreeMap::new();
for dy in 0..district_rows {
for dx in 0..district_cols {
let pos: DistrictPos = (dx, dy);
for dy in 0..survey_rows {
for dx in 0..survey_cols {
let pos = SurveyCellPos(dx, dy);
let dir = if let Some(v) = votes.get(&pos) {
// N=0, S=1, E=2, W=3 in descending priority for tie-breaking.
let mut best_votes = -1i32;
@@ -195,7 +205,7 @@ fn aggregate_district_basin_dirs(
}
best_dir
} else {
BasinDirection::North // ocean-only or empty district: default
BasinDirection::North // ocean-only or empty cell: default
};
out.insert(pos, dir);
}
@@ -300,11 +310,11 @@ mod tests {
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
}
assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells);
// district_basin_dirs is deterministic and non-empty on a slope grid.
assert_eq!(o1.district_basin_dirs, o2.district_basin_dirs);
// survey_basin_dirs is deterministic and non-empty on a slope grid.
assert_eq!(o1.survey_basin_dirs, o2.survey_basin_dirs);
assert!(
!o1.district_basin_dirs.is_empty(),
"slope grid must produce district basin directions"
!o1.survey_basin_dirs.is_empty(),
"slope grid must produce survey-cell basin directions"
);
}
+293 -27
View File
@@ -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<DistrictGridLayer> {
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<InventedCourse>`) 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,
&params,
&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,
&params,
rn,
(world_x_m, world_y_m),
DISTRICT_M as f64,
0.0,
);
let riparian_equivalent: Vec<InventedCourse> = 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",
&params,
&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,
&params,
&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",
&params,
&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![],
+244 -121
View File
@@ -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<DistrictWindowCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
) {
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<Box<BodyParams>> = 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<DistrictPos, DistrictProfile>,
heightmap_width: u32,
heightmap_height: u32,
body_params: Option<&BodyParams>,
road_graph: &RoadGraph,
vocab: &BodyVocabularyContext,
heightmap: Arc<crate::atlas::heightmap::BodyHeightmap>,
) -> 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<crate::atlas::heightmap::BodyHeightmap> {
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<DistrictPos, DistrictProfile> = 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<DistrictPos, DistrictProfile> = 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");
+40 -14
View File
@@ -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)
}
// ---------------------------------------------------------------------------
+113 -40
View File
@@ -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<DistrictPos> = districts.keys().copied().collect();
let keys: Vec<SurveyCellPos> = 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<f64>,
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!("\ndistrict {dp:?} {note}");
println!("\nsurvey 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<String, u32>, 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<DistrictPos, DistrictProfile>,
dp: DistrictPos,
) -> Option<(DistrictPos, &DistrictProfile)> {
districts: &BTreeMap<SurveyCellPos, DistrictProfile>,
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,
+13 -3
View File
@@ -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<BelievabilityReport> {
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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
+4 -4
View File
@@ -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": {