diff --git a/server/src/atlas/body_params_reader.rs b/server/src/atlas/body_params_reader.rs index 62741faa4..9b1e5aa2a 100644 --- a/server/src/atlas/body_params_reader.rs +++ b/server/src/atlas/body_params_reader.rs @@ -23,7 +23,7 @@ //! //! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity` //! is left `None` so the derivation falls back to `planet_class` as documented -//! on the struct. The per-district fields `district_latitude_deg` and `elevation_km` +//! on the struct. The per-district fields `latitude_deg` and `elevation_km` //! are set by `derive_all_districts` / `derive_district_profile`, not here; they //! remain at their struct defaults (0.0) from this reader. //! @@ -121,7 +121,7 @@ impl BodyParamsReader { tectonic_activity: None, // Per-district fields are set by derive_all_districts / derive_district_profile, // not at the body level. Leave at struct defaults (0.0). - district_latitude_deg: 0.0, + latitude_deg: 0.0, elevation_km: 0.0, body_radius_km, }), @@ -216,7 +216,7 @@ mod tests { assert_eq!(params.planet_class.as_deref(), Some("temperate")); assert_eq!(params.body_radius_km, Some(6371.0)); // Per-district fields always start at 0.0 from the reader. - assert_eq!(params.district_latitude_deg, 0.0); + assert_eq!(params.latitude_deg, 0.0); assert_eq!(params.elevation_km, 0.0); // tectonic_activity not in schema → None. assert!(params.tectonic_activity.is_none()); diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index f542938bc..13e13d5df 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -258,8 +258,15 @@ pub fn run_cascade_from_heightmap( // ~8 cells per district on a 128×64 working grid → ~80×32 = ~2 560 districts; // at full working resolution the budget is ~6 000/body (D-203). const CELLS_PER_REGION: usize = 8; - let districts = - district_profile::derive_all_districts(body_seed, params, &ta, CELLS_PER_REGION); + // body_id is required for the D-243 §4 climate edge-fuzz warp domain + // separation — derive_all_districts builds the region cache internally. + let districts = district_profile::derive_all_districts( + body_seed, + params, + &ta, + CELLS_PER_REGION, + &snapshot.body_id, + ); snapshot.layer_district = Some(LayerDistrictOutput { districts }); } diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index d73e421ed..e9a41fdfd 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -23,7 +23,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::atlas::features::TerrainAnalysis; -use crate::atlas::scale; +use crate::atlas::region_profile::{self, RegionProfile}; +use crate::atlas::scale::{self, RegionPos}; use crate::seed::SeedChain; use crate::simulation::generator::MorphologyZone; @@ -148,9 +149,17 @@ pub struct BodyParams { /// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived /// from `planet_class`. pub tectonic_activity: Option, - /// Latitude of the district's centre in the body's reference frame, in degrees. + /// Latitude of this cell's centre in the body's reference frame, in degrees. /// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient. - pub district_latitude_deg: f64, + /// + /// This field serves at **both** the district (2 km) and region (~205 km) scales: + /// when building a `DistrictProfile` it holds the district centre latitude; when + /// passed to [`crate::atlas::region_profile::derive_region_baseline_c`] it is + /// expected to carry the **region centre latitude** (callers override it via + /// struct-update syntax before passing the params down). The name was changed + /// from `latitude_deg` to `latitude_deg` (T-1078) to remove the + /// misleading scale implication. + pub latitude_deg: f64, /// Mean elevation of this district relative to sea level, in km. Used for lapse rate. pub elevation_km: f64, /// `bodies.body_radius_km` (D-204) — the body's radius in km. The single @@ -813,7 +822,7 @@ pub fn derive_temperature_c( let maritime = constants.maritime_factor(hydrosphere); let mid = (cold + warm) * 0.5; let half = band_width * 0.5 * maritime; - let lat_frac = (params.district_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0); + let lat_frac = (params.latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0); // equator (frac 0) → mid + half; pole (frac 1) → mid − half. let t_lat = (mid + half) - (2.0 * half) * lat_frac; @@ -975,9 +984,20 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 { /// 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). /// -/// Temperature and moisture are derived inline via D-239 §2 / D-240 climate -/// functions (T-1024). Pass a `&ClimateConstants` to control the tuning constants. -/// The seed chain provides the body-scoped seed for the D-240 temperature nudge. +/// 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. +/// +/// ## Parameters +/// +/// - `body_id` — the body's string identifier; required for the D-243 §4 climate +/// edge-fuzz warp domain separation (distinct bodies get distinct warps). +/// - `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. pub fn derive_district_profile( seed: SeedChain, body_params: &BodyParams, @@ -985,6 +1005,8 @@ pub fn derive_district_profile( pos: DistrictPos, grid_cells_per_district: usize, climate: &ClimateConstants, + body_id: &str, + region_cache: &BTreeMap, ) -> DistrictProfile { let (rx, ry) = pos; let w = ta.w; @@ -1025,9 +1047,19 @@ pub fn derive_district_profile( (0, 0, 0) }; - // No region layer in the eager coarse-grid path — use legacy single-phase - // temperature derivation (region baseline threading is wired in derive_district - // for the on-demand 2 km path; T-1078). + // 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. + let region_baseline_c = region_profile::region_baseline_at_district( + seed.seed(), + body_id, + pos, + body_params, + climate, + seed, + Some(region_cache), + ); + build_district_profile( seed, body_params, @@ -1035,7 +1067,7 @@ pub fn derive_district_profile( slope_q, elev_q, ocean_fraction_q, - None, // region_baseline_c: legacy path + region_baseline_c, ) } @@ -1072,7 +1104,7 @@ fn build_district_profile( let tectonic_class = derive_tectonic_class(body_params); // District-local BodyParams: elevation_km comes from the district's own - // elev_q (0–100 scaled to the body's elevation span). district_latitude_deg + // elev_q (0–100 scaled to the body's elevation span). latitude_deg // is already set per-district by the caller. Per-cell refinement at ChunkContext. let district_climate_params = BodyParams { elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM, @@ -1143,10 +1175,22 @@ fn build_district_profile( /// to direct heightmap indexing. /// /// Pure and deterministic (D-227/D-010): a function of -/// `(seed, body_params, terrain, district_pos)`; the f64 scatter is quantised to -/// integer `slope_q`/`elev_q` at the decision boundary. +/// `(seed, body_id, body_params, terrain, district_pos)`; the f64 scatter is +/// quantised to integer `slope_q`/`elev_q` at the decision boundary. +/// +/// ## Region baseline (D-243 §3/§4, T-1078) +/// +/// The district temperature is derived as a **modulation** of the edge-fuzz-blended +/// region baseline ([`region_profile::region_baseline_at_district`]). No pre-built +/// region cache is required here — the on-demand path derives the four surrounding +/// region baselines on-the-fly (pure, deterministic, cheap: four `derive_region_baseline_c` +/// calls). For batch derivation of many districts use [`derive_all_districts`], which +/// builds a region cache once per body. +/// +/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation. pub fn derive_district( seed: SeedChain, + body_id: &str, body_params: &BodyParams, ta: &TerrainAnalysis, district_pos: DistrictPos, @@ -1209,16 +1253,24 @@ pub fn derive_district( let ocean_fraction_q = ((ocean_frac * 100.0).round() as i32).clamp(0, 100); let params = BodyParams { - district_latitude_deg: lat_deg, + latitude_deg: lat_deg, ..body_params.clone() }; - // The on-demand derive_district path does not yet carry a pre-computed region - // baseline (wiring the region cache into the per-district call site is the - // production integration step; the structure is ready in region_profile.rs via - // region_baseline_at_district). Pass None here → legacy single-phase derivation. - // T-1078: production callers that have a region cache should call - // build_district_profile(..., region_baseline_at_district(...)) - // directly instead. + + // D-243 §3/§4: compute the edge-fuzz-blended region baseline on-the-fly for + // this district. No pre-built cache here — the on-demand path derives the four + // surrounding region baselines directly. Pure, deterministic, cheap. + // `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation. + let region_baseline_c = region_profile::region_baseline_at_district( + seed.seed(), + body_id, + district_pos, + ¶ms, + climate, + seed, + None, // no pre-built cache; derive on-the-fly + ); + build_district_profile( seed, ¶ms, @@ -1226,7 +1278,7 @@ pub fn derive_district( slope_q, elev_q, ocean_fraction_q, - None, + region_baseline_c, ) } @@ -1291,17 +1343,53 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 { /// 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 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. +/// +/// `body_id` is the body's string identifier, required for the climate edge-fuzz +/// warp domain separation. pub fn derive_all_districts( seed: SeedChain, body_params: &BodyParams, ta: &TerrainAnalysis, grid_cells_per_district: usize, + body_id: &str, ) -> BTreeMap { let climate = ClimateConstants::default(); let gcpr = grid_cells_per_district.max(1); let district_cols = ta.w.div_ceil(gcpr) as i32; let district_rows = ta.h.div_ceil(gcpr) as i32; + // Build the region cache once for the whole body before district derivation. + // Collect all unique region positions that cover this district grid, plus + // their immediate neighbours (the edge-fuzz blend samples up to one region + // beyond the district's own region). Using a BTreeSet for determinism (D-010). + let region_positions: std::collections::BTreeSet = { + let mut set = std::collections::BTreeSet::new(); + for ry in 0..district_rows { + for rx in 0..district_cols { + let district_pos = (rx, ry); + let rpos = scale::district_to_region(district_pos); + // The edge-fuzz blend samples the base region and one neighbour + // in each axis direction (±1). Pre-populate all 9 candidates so + // cache hits dominate and on-the-fly derivations are rare. + for dy in -1i32..=1 { + for dx in -1i32..=1 { + set.insert((rpos.0 + dx, rpos.1 + dy)); + } + } + } + } + set + }; + let region_cache = + region_profile::derive_regions_for_body(seed, body_params, &climate, region_positions); + let mut out = BTreeMap::new(); for ry in 0..district_rows { // Map ry to latitude: row 0 → +90°, row (rows-1) → -90°. @@ -1318,10 +1406,19 @@ pub fn derive_all_districts( // 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 { - district_latitude_deg: lat_deg, + latitude_deg: lat_deg, ..body_params.clone() }; - let profile = derive_district_profile(seed, &district_params, ta, pos, gcpr, &climate); + let profile = derive_district_profile( + seed, + &district_params, + ta, + pos, + gcpr, + &climate, + body_id, + ®ion_cache, + ); out.insert(pos, profile); } } @@ -1372,7 +1469,7 @@ mod tests { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); - let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8); + let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body"); // Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts. assert_eq!(districts.len(), 32, "district count mismatch"); @@ -1396,8 +1493,8 @@ mod tests { let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); - let a = derive_district(test_seed(), &p, &ta, (1234, -567), &climate); - let b = derive_district(test_seed(), &p, &ta, (1234, -567), &climate); + let a = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate); + let b = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate); assert_eq!(a.elev_q, b.elev_q); assert_eq!(a.slope_q, b.slope_q); assert_eq!(a.morphology_zone, b.morphology_zone); @@ -1415,9 +1512,15 @@ mod tests { // meridian ≈ π·6371·1000 m; a district near the pole is ~quarter-meridian away. let merid_districts = (std::f64::consts::PI * 6371.0 * 1000.0 / scale::DISTRICT_M as f64) as i32; - let equator = derive_district(test_seed(), &p, &ta, (0, 0), &climate); - let high_lat = - derive_district(test_seed(), &p, &ta, (0, merid_districts / 2 - 2), &climate); + let equator = derive_district(test_seed(), "test_body", &p, &ta, (0, 0), &climate); + let high_lat = derive_district( + test_seed(), + "test_body", + &p, + &ta, + (0, merid_districts / 2 - 2), + &climate, + ); match (equator.temperature_c, high_lat.temperature_c) { (Some(eq), Some(hi)) => { assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})") @@ -1441,7 +1544,7 @@ mod tests { let ta = test_ta(&flat); let climate = ClimateConstants::default(); let p = earth_params(); - let d = derive_district(test_seed(), &p, &ta, (500, 100), &climate); + let d = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate); // Flat land everywhere → slope 0 → no invented relief, no ocean. assert_eq!( d.slope_q, 0, @@ -1462,8 +1565,8 @@ mod tests { atmosphere: Some("breathable".into()), ..Default::default() // body_radius_km: None }; - let a = derive_district(test_seed(), &p, &ta, (20, 10), &climate); - let b = derive_district(test_seed(), &p, &ta, (20, 10), &climate); + let a = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate); + let b = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate); assert_eq!(a.elev_q, b.elev_q); assert!((0..=100).contains(&a.elev_q) && (0..=100).contains(&a.slope_q)); } @@ -1481,8 +1584,26 @@ mod tests { }; let pos = (2, 1); let climate = ClimateConstants::default(); - let p1 = derive_district_profile(test_seed(), ¶ms, &ta, pos, 8, &climate); - let p2 = derive_district_profile(test_seed(), ¶ms, &ta, pos, 8, &climate); + let p1 = derive_district_profile( + test_seed(), + ¶ms, + &ta, + pos, + 8, + &climate, + "test_body", + &BTreeMap::new(), + ); + let p2 = derive_district_profile( + test_seed(), + ¶ms, + &ta, + pos, + 8, + &climate, + "test_body", + &BTreeMap::new(), + ); // Equality via serialized fields (no PartialEq on MorphologyZone — compare by name). assert_eq!( format!("{:?}", p1.morphology_zone), @@ -1576,7 +1697,7 @@ mod tests { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); - let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8); + let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body"); // 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"); @@ -1607,7 +1728,7 @@ mod tests { let params = BodyParams { planet_class: Some(planet_class.into()), atmosphere: Some(atmosphere.into()), - district_latitude_deg: lat, + latitude_deg: lat, elevation_km: elev_km, ..Default::default() }; @@ -1683,7 +1804,7 @@ mod tests { let params = BodyParams { atmosphere: Some("thin".into()), planet_class: Some("arid".into()), - district_latitude_deg: 45.0, + latitude_deg: 45.0, elevation_km: 1.5, ..Default::default() }; @@ -1700,7 +1821,7 @@ mod tests { let params = BodyParams { atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), - district_latitude_deg: 30.0, + latitude_deg: 30.0, elevation_km: 0.0, ..Default::default() }; @@ -1755,7 +1876,7 @@ mod tests { planet_class: Some((*class).into()), atmosphere: Some((*atmo).into()), hydrosphere: Some(hydro.into()), - district_latitude_deg: lat, + latitude_deg: lat, elevation_km: elev, ..Default::default() }; @@ -1784,7 +1905,7 @@ mod tests { planet_class: Some("temperate".into()), atmosphere: Some("standard".into()), hydrosphere: Some(hydro.into()), - district_latitude_deg: lat, + latitude_deg: lat, elevation_km: 0.0, ..Default::default() }; @@ -1810,7 +1931,7 @@ mod tests { let params = BodyParams { planet_class: Some("unknown_alien_class".into()), atmosphere: Some("breathable".into()), - district_latitude_deg: 0.0, + latitude_deg: 0.0, elevation_km: 0.0, ..Default::default() }; @@ -2643,7 +2764,7 @@ mod tests { atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), elevation_km: 2.0, - district_latitude_deg: 0.0, // this should be irrelevant for the modulation + latitude_deg: 0.0, // this should be irrelevant for the modulation ..Default::default() }; diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 75a7cddd8..821e1de01 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -188,7 +188,7 @@ pub struct RegionProfile { /// ## Inputs /// /// - `body_params` — `planet_class`, `atmosphere`, `hydrosphere`, and the -/// **region centre latitude** in `district_latitude_deg` (repurposed for +/// **region centre latitude** in `latitude_deg` (repurposed for /// the region's central latitude — the field name is historical). /// - `constants` — tunable climate constants (D-240 table). /// - `body_seed` — the body-scoped seed for the deterministic nudge (D-010). @@ -226,7 +226,7 @@ pub fn derive_region_baseline_c( let maritime = constants.maritime_factor(hydrosphere); let mid = (cold + warm) * 0.5; let half = band_width * 0.5 * maritime; - let lat_frac = (body_params.district_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0); + let lat_frac = (body_params.latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0); // equator (frac 0) → mid + half; pole (frac 1) → mid − half. let t_lat = (mid + half) - (2.0 * half) * lat_frac; @@ -283,7 +283,7 @@ pub fn region_centre_latitude_deg(region_pos: RegionPos, body_radius_km: Option< /// /// Pure and deterministic: `(seed, body_params, region_pos)` → `RegionProfile`. /// -/// `body_params.district_latitude_deg` is **overridden** to the region centre's +/// `body_params.latitude_deg` is **overridden** to the region centre's /// latitude internally — callers do not need to pre-set it. pub fn build_region_profile( seed: SeedChain, @@ -295,7 +295,7 @@ pub fn build_region_profile( // Build region-local params: override latitude to the region centre. let region_params = BodyParams { - district_latitude_deg: lat_deg, + latitude_deg: lat_deg, // Elevation at region level is sea level (baseline only; no lapse here). elevation_km: 0.0, ..body_params.clone() @@ -444,7 +444,7 @@ pub fn region_baseline_at_district( // Derive on the fly (test path / cache miss). let lat = region_centre_latitude_deg(rpos, body_params.body_radius_km); let r_params = BodyParams { - district_latitude_deg: lat, + latitude_deg: lat, elevation_km: 0.0, ..body_params.clone() }; @@ -668,7 +668,7 @@ mod tests { let params = BodyParams { atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), - district_latitude_deg: 30.0, + latitude_deg: 30.0, elevation_km: 5.0, // This must be ignored by the region baseline ..Default::default() }; diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index cdb00ea62..f58f3b788 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -36,6 +36,7 @@ //! Update golden: `UPDATE_GOLDEN=1 cargo test --test derivation_harness` //! Budget gate: `BUDGET_ASSERT=1 cargo test --test derivation_harness -- budget` +use std::collections::BTreeMap; use std::path::PathBuf; use std::time::Instant; @@ -1872,7 +1873,7 @@ fn kallast_body_params() -> BodyParams { atmosphere: Some("standard".into()), planet_class: Some("temperate".into()), tectonic_activity: Some("active".into()), - district_latitude_deg: 0.0, + latitude_deg: 0.0, elevation_km: 0.0, body_radius_km: None, } @@ -1887,7 +1888,7 @@ fn gloedberg_body_params() -> BodyParams { atmosphere: Some("standard".into()), planet_class: Some("volcanic".into()), tectonic_activity: Some("volcanic".into()), - district_latitude_deg: 0.0, + latitude_deg: 0.0, elevation_km: 0.5, body_radius_km: None, } @@ -1903,7 +1904,7 @@ fn marevna_body_params() -> BodyParams { atmosphere: Some("standard".into()), planet_class: Some("oceanic".into()), tectonic_activity: Some("active".into()), - district_latitude_deg: 0.0, + latitude_deg: 0.0, elevation_km: 0.0, body_radius_km: None, } @@ -1920,7 +1921,16 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile { let climate = ClimateConstants::default(); let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); let ta = make_dry_terrain_analysis(); - derive_district_profile(seed, params, &ta, (0, 0), 8, &climate) + derive_district_profile( + seed, + params, + &ta, + (0, 0), + 8, + &climate, + "test_body", + &BTreeMap::new(), + ) } #[test]