diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 58865d76f..fcc8c74b9 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -211,16 +211,18 @@ pub fn run_cascade_from_heightmap( } // RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params + - // terrain analysis. Requires Layer 1 TerrainAnalysis, so we re-derive it here - // (TerrainAnalysis is cheap relative to drainage; Layer 1 already ran if we - // reached Settlement, but the analysis isn't stored on Layer1Output). + // terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass. + // Layer 1 already ran drainage inside run_layer1, but neither the drainage + // result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both + // here. Pure → determinism preserved, but the drainage re-run is NOT free at + // the ~6 000-regions/body working scale (D-203). + // PERF/TODO(T-1028/T-1032): cache TerrainAnalysis on Layer1Output to drop this + // redundant drainage pass, and validate the combined cost against the D-239 §10 + // ~45 ms/body budget in the T-1031 verification harness. Latent for now — this + // path only runs when body_params is Some, which production defers to T-1032. // If body_params is None, the region layer is skipped (e.g. unit tests without DB). if up_to >= CascadeLayer::RegionProfile { if let Some(params) = body_params { - // Re-derive TerrainAnalysis from the heightmap (no drainage needed — we - // only need slope/elev/ocean which are computed inside TerrainAnalysis). - // The drainage result isn't stored on the snapshot, so rerun it here. - // This is a pure function so determinism is preserved. use crate::atlas::drainage; let dr = drainage::analyze( &snapshot.heightmap.data, diff --git a/server/src/atlas/domain_warp.rs b/server/src/atlas/domain_warp.rs index ce21209d5..9751020a1 100644 --- a/server/src/atlas/domain_warp.rs +++ b/server/src/atlas/domain_warp.rs @@ -112,54 +112,30 @@ mod tests { use crate::seed::fnv1a_64; use std::thread; - /// Golden-vector test: fixed (seed, body_id, pos) → fixed (dx, dy). - /// Mirrors the `splitmix64_known_vector` pattern in seed.rs. - /// Single-platform; the ULP argument (D-239 §4) makes cross-platform - /// golden values unnecessary. + /// Golden-vector regression anchor: a fixed (seed, body_id, pos) maps to + /// fixed (dx, dy). Mirrors `splitmix64_known_vector` in seed.rs. Single- + /// platform pins suffice — the ±8 m bound + downstream `as i32` truncation + /// (D-239 §4) make cross-platform ULP drift irrelevant to the voxel address. + /// + /// Recompute and re-pin these literals ONLY after a deliberate algorithm + /// change (`pos_to_id`, `u64_to_displacement`, or the SeedDomain chain) — + /// such a change re-rolls the warp for every body and needs a D-record + /// amendment. If this test fails unexpectedly, the warp output drifted. #[test] fn golden_vector() { let (dx, dy) = domain_warp(42, "GJ1c", (100, -50)); - // The values are fixed by the deterministic hash chain. Print them once, - // then pin them as the golden vector. Run once without the assert to - // discover the values, then lock them in. - // Computed reference (do NOT change unless the algorithm changes): - assert!( - dx.abs() <= WARP_BOUND, - "dx={dx} out of bounds ±{WARP_BOUND}" + // Pinned literals — the canonical regression anchor for the warp output. + assert_eq!( + dx, -1.8472385880244921, + "domain_warp dx drifted — algorithm changed?" ); - assert!( - dy.abs() <= WARP_BOUND, - "dy={dy} out of bounds ±{WARP_BOUND}" + assert_eq!( + dy, 5.033644613199796, + "domain_warp dy drifted — algorithm changed?" ); - // Pin exact values for the golden-vector test (same platform). - let (dx2, dy2) = domain_warp(42, "GJ1c", (100, -50)); - assert_eq!(dx, dx2, "dx must be deterministic"); - assert_eq!(dy, dy2, "dy must be deterministic"); - - // Store the actual values for the pinned assertion below. - let expected_dx = domain_warp(42, "GJ1c", (100, -50)).0; - let expected_dy = domain_warp(42, "GJ1c", (100, -50)).1; - assert_eq!(dx, expected_dx); - assert_eq!(dy, expected_dy); - } - - /// Pinned golden vector — the canonical reference for regression detection. - /// - /// To discover the values: comment out this test, run with `-- --nocapture`, - /// add a `println!("{dx} {dy}")` call, read the output, then pin here. - /// Do NOT change these values unless the algorithm (pos_to_id, u64_to_displacement, - /// the SeedDomain chain) deliberately changes — such a change re-rolls the - /// warp for every body. - #[test] - fn pinned_golden_vector() { - let (dx, dy) = domain_warp(42, "GJ1c", (100, -50)); - // These values were computed from the implementation; they are the - // canonical regression anchor. If this test fails, the warp changed. - // Recompute and re-anchor ONLY after a deliberate algorithm change with - // a D-record amendment. - let _ = (dx, dy); // values established by the deterministic chain above - // Structural check: two channels are independent. - assert_ne!(dx, dy, "dx and dy must differ (independent channels)"); + // Within the ±8 m bound, and the two channels are independent. + assert!(dx.abs() <= WARP_BOUND && dy.abs() <= WARP_BOUND); + assert_ne!(dx, dy); } /// Truncation-as-cast (not rounding): the downstream consumer uses `as i32`. diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 378bdd922..dd59465aa 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -24,7 +24,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::atlas::features::TerrainAnalysis; -use crate::seed::{SeedChain, SeedDomain}; +use crate::seed::SeedChain; use crate::simulation::generator::MorphologyZone; // --------------------------------------------------------------------------- @@ -321,11 +321,19 @@ fn derive_morphology_zone( // Climate constants (T-1024, D-239 §2) // --------------------------------------------------------------------------- -/// Climate constants loaded from `server/data/climate_constants.toml`. -/// -/// Source-canonical — tunable without recompile. Use `ClimateConstants::load` -/// to read from the TOML file, or `ClimateConstants::default()` for the -/// embedded fallback values (matching the TOML's current content). +/// Maximum per-region elevation (km), used to scale a region's normalized +/// elevation (`elev_q`, 0–100) into the temperature-lapse input. Earth-like +/// span (~Everest); a coarse body-agnostic constant for now — a per-body relief +/// span can replace it when body relief data is carried (T-1032). +const MAX_REGION_ELEVATION_KM: f64 = 8.0; + +/// Climate tuning constants. `ClimateConstants::default()` holds the embedded +/// values and is the authoritative runtime source **today**; the source-canonical +/// `server/data/climate_constants.toml` mirrors those same values and is the file +/// to edit when tuning. Runtime loading of the TOML ("tunable without recompile") +/// lands with the production dispatch wiring (T-1032) — there is intentionally no +/// `load()` method yet, so keep the TOML and the embedded `default()` in sync by +/// hand until then. /// /// Values mirror `tooling/planet-gen/planet_simulation.py`. #[derive(Debug, Clone)] @@ -463,7 +471,7 @@ pub fn derive_temperature_c(params: &BodyParams, constants: &ClimateConstants) - // Latitude gradient. Axial tilt modulates the equator–pole delta. let axial_tilt = params.axial_tilt_deg.unwrap_or(23.4); // Earth-like default - // tilt_factor: 0.0 = no tilt (uniform insolation), 1.0 = 90° tilt (extreme) + // tilt_factor: 1.0 = no tilt (full equator–pole gradient), 0.5 = 90° tilt (reduced gradient) let tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5; let lat_gradient_c = 60.0 * tilt_factor; let lat_frac = params.region_latitude_deg / 90.0; // [-1.0, 1.0] @@ -525,7 +533,9 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 { /// Temperature and moisture are derived inline via D-239 §2 climate functions /// (T-1024). Pass a `&ClimateConstants` to control the tuning constants. pub fn derive_region_profile( - seed: SeedChain, + // Reserved: per-region stochastic derivation (T-1027/T-1028) will derive + // from this under a dedicated `SeedDomain::RegionProfile`. Unused today. + _seed: SeedChain, body_params: &BodyParams, ta: &TerrainAnalysis, pos: RegionPos, @@ -584,20 +594,18 @@ pub fn derive_region_profile( ocean_fraction_q, ); - // Per-region seed for future stochastic derivation within this region. - // Currently only used as a forward-compatible derivation anchor. - let _region_seed = seed.derive(SeedDomain::DomainWarp, region_pos_id(pos)); - - // Climate derivation (T-1024, D-239 §2). Build per-region BodyParams variant - // that carries this region's latitude and elevation for temperature lapse. - // The lat/elevation are set by the caller via body_params, but since BodyParams - // is a body-level struct we derive them from the terrain here for the region. - // elevation_km: derive from elev_q (0–100 normalized) × assumed body max elev 8 km. - // region_latitude_deg: body_params.region_latitude_deg is caller-set; for the - // derive_all_regions path it is set per-region using the region row index. - // Here we use whatever the caller set (single-region path) or what derive_all_regions - // passes in the per-region params. - let temperature_c = derive_temperature_c(body_params, climate); + // Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS + // region's elevation — otherwise every region on a body shares one body-level + // elevation and gets an identical lapse, defeating the per-district temperature + // primitive. Build a region-local BodyParams whose elevation_km comes from the + // region's own normalized elevation (elev_q, 0–100) scaled to the body's + // elevation span. region_latitude_deg is already per-region (set by the caller + // / derive_all_regions). Per-cell refinement happens later at ChunkContext. + let region_climate_params = BodyParams { + elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM, + ..body_params.clone() + }; + let temperature_c = derive_temperature_c(®ion_climate_params, climate); let moisture_q = derive_moisture_q(body_params); RegionProfile { @@ -660,22 +668,6 @@ pub fn derive_all_regions( out } -/// Map a `RegionPos` to a u64 id for seed derivation (zigzag + Cantor pairing). -/// Matches the pattern in `domain_warp::pos_to_id`. -#[inline] -fn region_pos_id(pos: RegionPos) -> u64 { - let zz = |v: i32| -> u64 { - let v = v as i64; - ((v << 1) ^ (v >> 63)) as u64 - }; - let x = zz(pos.0); - let y = zz(pos.1); - let s = x.wrapping_add(y); - s.wrapping_mul(s.wrapping_add(1)) - .wrapping_div(2) - .wrapping_add(y) -} - // --------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------