diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 559c9bf74..6436e01a8 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -306,8 +306,10 @@ pub fn run_cascade_from_heightmap( // shared via scale::HEIGHTMAP_CELLS_PER_DISTRICT so plugin.rs // converts CityPlacement pixel coords with the same constant. // body_id is required for the D-243 §4 climate edge-fuzz warp - // domain separation — derive_all_districts builds the region - // cache internally. + // domain separation — derive_all_districts pre-builds the + // region-baseline cache internally on TRUE region keys (each + // survey cell's centre world metres → containing district → + // region ±1 ring; D-256(c) + PR #199 review). // // Layer1Output.survey_basin_dirs threads the true D8 thalweg // direction into each DistrictProfile.basin_direction (T-1047) diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index 86b807558..6cb3a8932 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -1,10 +1,14 @@ -//! DistrictProfile carrier — ~1 km scale tier of the D-239 refinement chain (T-1023). +//! DistrictProfile carrier — the coarsest derivation stage of the D-239 refinement +//! chain (T-1023). //! -//! A `DistrictProfile` is the coarsest derivation stage: ~1 km² cells (~6 000 per body, -//! roughly 80 × 75), each a pure deterministic function of -//! `(seed, body_params, terrain_analysis, pos)`. Stored in `BodyWorldState.districts` -//! so the Atlas can read zone labels without triggering voxel derivation (D-239 §10, -//! D-203). +//! In the batch pass a `DistrictProfile` summarizes one survey cell (D-256: an +//! 8×8 working-pixel block, 64×32 ≈ 2 048 cells per body), each a pure +//! deterministic function of `(seed, body_params, terrain_analysis, pos)` sampled +//! at the cell's centre world metres through the shared +//! `derive_at_metres_with_riparian` core; the on-demand pass derives the same +//! struct at any absolute world position. Stored in `BodyWorldState.districts` +//! so the Atlas can read zone labels without triggering voxel derivation +//! (D-239 §10, D-203). //! //! ## D-010 compliance //! @@ -18,7 +22,7 @@ //! enum from `simulation::generator`. The enum is the canonical freeze; adding, //! renaming, or removing a zone requires a D-record amendment. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; @@ -2038,23 +2042,25 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 { /// /// `grid_cells_per_district = 8` means each survey cell is 8×8 heightmap cells. /// -/// ## Region baseline (D-256(c)) +/// ## Region baseline (D-256(c), cache pre-build per PR #199 review) /// -/// 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. +/// A region-baseline cache is pre-built here on the TRUE region keys the +/// shared core looks up: each survey cell's centre world metres floor-divides +/// to its containing district (the same mapping +/// [`derive_at_metres_with_riparian`] applies internally), and that +/// district's region ±1 neighbour ring covers every key +/// [`region_profile::region_baseline_at_district`]'s edge-fuzz blend can +/// read (base + signed x/y/xy neighbours). The pre-D-256 pre-build keyed on +/// the SURVEY grid's own pseudo-coordinates (`district_to_region((rx, ry))`) +/// — the wrong key space entirely, which is what produced the body-uniform +/// region-(0,0) climate collapse; the true-key rebuild can span up to ~2048 +/// distinct regions plus ring on a big body (the survey grid covers the +/// whole body surface in metres). Cache-hit and cache-miss are +/// byte-identical ([`region_profile::build_region_profile`] and the miss +/// branch in `region_baseline_at_district` compute `mean_temp_c` with the +/// same expressions — D-227 purity, guarded by the wrapper≡core agreement +/// tests), so the cache is purely the cost model: each covering region +/// derives once per body instead of four misses per survey cell. /// /// `body_id` is the body's string identifier, required for the climate edge-fuzz /// warp domain separation. @@ -2089,11 +2095,36 @@ pub fn derive_all_districts( let survey_cols = ta.w.div_ceil(gcpr) as i32; let survey_rows = ta.h.div_ceil(gcpr) as i32; - // D-256(c): no pre-built region cache (see this function's doc) — every - // survey cell derives its region baseline on-the-fly through the empty - // map below, keyed on the cell's TRUE world-metres region, not a - // pseudo-grid index. - let region_cache: BTreeMap = BTreeMap::new(); + // Pre-build the region-baseline cache on the TRUE region keys the shared + // core will look up (see this function's doc): each survey cell's centre + // world metres → containing district → region ±1 ring. Deduped via + // BTreeSet (D-010: deterministic iteration), derived once per body. + let mut covering_regions: BTreeSet = BTreeSet::new(); + for ry in 0..survey_rows { + for rx in 0..survey_cols { + let (wx, wy) = survey_cell_centre_world_m( + SurveyCellPos(rx, ry), + gcpr, + ta.w, + ta.h, + body_params.body_radius_km, + ); + // The SAME floor-divide derive_at_metres_with_riparian applies to + // reach its region key — one mapping, never a second one. + let district_pos: DistrictPos = ( + (wx / scale::DISTRICT_M as f64).floor() as i32, + (wy / scale::DISTRICT_M as f64).floor() as i32, + ); + let base = scale::district_to_region(district_pos); + for ndy in -1..=1i32 { + for ndx in -1..=1i32 { + covering_regions.insert((base.0 + ndx, base.1 + ndy)); + } + } + } + } + let region_cache = + region_profile::derive_regions_for_body(seed, body_params, &climate, covering_regions); let mut out = BTreeMap::new(); for ry in 0..survey_rows {