//! DistrictProfile carrier — the coarsest derivation stage of the D-239 refinement //! chain (T-1023). //! //! 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 //! //! All gating parameters (`GlaciationGrade`, `TectonicClass`, `river_threshold`) are //! derived from integer body params via integer arithmetic. No `HashMap` or //! floating-point comparison in the derivation path. //! //! ## MorphologyZone (T-1027, D-239 §6 freeze point) //! //! The `morphology_zone` field uses the D-239 §6 **frozen 17-zone** `MorphologyZone` //! 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, BTreeSet}; use serde::{Deserialize, Serialize}; use crate::atlas::body_world_state::RiverNetwork; 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, SurveyCellPos}; use crate::seed::SeedChain; use crate::simulation::generator::MorphologyZone; // --------------------------------------------------------------------------- // Supporting enums // --------------------------------------------------------------------------- /// Tectonic activity class — integer-discriminant, append-only (D-010). /// /// Used in morphology gates (D-239 §5): LavaField requires `Volcanic`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] #[repr(u8)] pub enum TectonicClass { /// Stable craton — minimal volcanism, low earthquake risk. #[default] Stable = 0, /// Active rifting / orogenesis — mountains, earthquakes, some volcanism. Active = 1, /// Dominant volcanic activity — lava fields, shield volcanoes. Volcanic = 2, /// Tidally-stressed body — volcanism driven by gravitational flexing. TidallyForced = 3, } /// Glaciation grade (0–4) — integer-discriminant, append-only (D-010). /// /// D-239 §5 gates: fjord ≥ 2; U-valleys ≥ 1; moraines ≥ 1; cirques ≥ 2; /// grade 0 = V-ridges, never glacial U. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] #[repr(u8)] pub enum GlaciationGrade { /// No glaciation — V-ridges only. #[default] None = 0, /// Glacial erosion signatures — U-valleys, moraines. Light = 1, /// Moderate glaciation — fjords possible, cirques. Moderate = 2, /// Heavy glaciation — ice sheets, extensive fjord systems. Heavy = 3, /// Full ice-cap or snowball body. IceCap = 4, } /// Precipitation class derived from temperature + moisture primitives (D-239 §2). /// /// Integer-discriminant, D-010 compliant. Used to derive `river_threshold`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] #[repr(u8)] pub enum PrecipitationClass { /// Arid — very low precipitation. Arid = 0, /// Semi-arid — moderate precipitation. SemiArid = 1, /// Temperate — normal precipitation. #[default] Temperate = 2, /// Humid — high precipitation. Humid = 3, /// Super-humid / oceanic world. SuperHumid = 4, } /// Vegetation cover class (D-239 §2, §8 climate→vegetation law). /// /// Ordered by density: `Forest` > `Scrub` > `Barren`. The D-239 §8 binding law /// states "Forest→Scrub→Barren, no skip" — a district cannot jump from Forest to /// Barren. Integer-discriminant, D-010 compliant. /// /// `None` variant: airless body (`temperature_c == None`) — entire /// climate/vegetation branch is absent (D-239 §2). /// /// Riparian zones (`RiparianThicket`, `RiparianScrub`) are sub-variants of the /// 1–3 tile band along perennial waterways (D-239 §8). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] #[repr(u8)] pub enum VegetationClass { /// Airless body — climate/vegetation branch absent (D-239 §2). #[default] Absent = 0, /// Barren — above treeline or hyper-arid; sparse or no plant cover. Barren = 1, /// Scrub — transitional band below treeline; shrubs, dwarf plants. Scrub = 2, /// Forest — below treeline with sufficient moisture; closed-canopy. Forest = 3, /// Riparian Scrub — 1–3 tile band along perennial waterways in Scrub/Barren zones. /// Takes precedence over the base class when adjacent to a perennial river. RiparianScrub = 4, /// Riparian Thicket — 1–3 tile band along perennial waterways in Forest zones. /// Takes precedence over Forest when adjacent to a perennial river. RiparianThicket = 5, /// Open water (T-1126): the district's morphology verdict is OpenOcean/Lake — /// no land vegetation. Set from the already-derived `MorphologyZone`, never /// from a water threshold of its own (vegetation and morphology can never /// disagree by construction). NOTE: the `Ord` position is NON-SEMANTIC — /// the D-239 §8 Forest>Scrub>Barren density-ladder reading does not extend /// to `Marine`; the discriminant is append-only serialisation order. Marine = 6, } // --------------------------------------------------------------------------- // Body parameters (input to derivation) // --------------------------------------------------------------------------- /// Body-level physical parameters needed to derive `DistrictProfile`. /// /// Modelled on the `BodyRow` reader at `bin/atlas/common.rs`. Source columns /// live on the `bodies` table: `hydrosphere`, `atmosphere`, `planet_class`. /// All are optional (may be NULL in the DB). /// /// **D-240:** orbit/star fields (`orbital_period_days`, `axial_tilt_deg`, /// `spectral_class`, `star_type`) are non-canonical placeholder data and are /// NOT read into this struct. Temperature derives from `planet_class` envelope /// only — see `derive_temperature_c`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BodyParams { /// `bodies.hydrosphere` — the real vocabulary in systems.db: "ice", /// "liquid_water", "none", "rivers", "moderate", "ocean", "minimal", "trace", /// "subsurface_liquid", "rivers-lakes", "extensive", "subsurface_ice", /// "subsurface", "ocean-coastal" | NULL. The `[hydrosphere_maritime]` table /// keys on these for temperature moderation (D-240). pub hydrosphere: Option, /// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL pub atmosphere: Option, /// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL pub planet_class: Option, /// `bodies.tectonic_activity` — optional authored tectonic override. /// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived /// from `planet_class`. pub tectonic_activity: Option, /// 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. /// /// 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 /// body-specific input to the D-243 elastic seam (`scale::regions_per_equator`). /// `None` if the body has no recorded radius. pub body_radius_km: Option, } // --------------------------------------------------------------------------- // DistrictPos — position on the ~1 km district grid // --------------------------------------------------------------------------- /// Position on the 2 km district grid — re-exported from the canonical ladder /// ([`crate::atlas::scale`], D-243) so the carrier shares one `DistrictPos` with /// the addressing layer. `BTreeMap` key — `Ord` for D-010 determinism. pub use crate::atlas::scale::DistrictPos; // --------------------------------------------------------------------------- // DistrictProfile // --------------------------------------------------------------------------- /// Per-district (~1 km) terrain classification derived from body params + heightmap. /// /// Pure derivation — no per-body authoring. All gating parameters come from /// `BodyParams`; lore-anchored bodies honour their params, not override hooks. /// Stored in `BodyWorldState.districts` (D-203, D-239 §10). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DistrictProfile { /// District morphology zone (D-239 §6). Uses the **existing** `MorphologyZone` /// enum — reconciliation to the D-239 frozen 17-zone vocabulary is T-1027. pub morphology_zone: MorphologyZone, /// Tectonic activity class (D-239 §5). pub tectonic_class: TectonicClass, /// Glaciation grade 0–4 (D-239 §5 gates). pub glaciation_grade: GlaciationGrade, /// Precipitation class, derived from hydrosphere + body params. pub precipitation_class: PrecipitationClass, /// Integer-quantized slope summary for the district (0–100 scale, units: 1 = ~0.45 °). /// Derived from the `TerrainAnalysis.slope_deg` cells that fall in this district. pub slope_q: i32, /// Integer-quantized elevation percentile summary (0–100 scale). /// Average of `TerrainAnalysis.elev_pct` × 100 across district cells. pub elev_q: i32, /// Ocean fraction for this district (0–100 scale, integer). pub ocean_fraction_q: i32, /// Settled-hydrology lake-margin depth band (T-1188, D-227 amendment (4) /// continued): `0` at/near the shoreline (the `filled == elevation` /// crossing `lake_from_hydrology_at` gates on), ramping toward `100` as /// the settled water surface sits deeper above the original bedrock, /// relative to THIS BASIN's own maximum depth — quantized /// `((filled - elevation) / basin_max_depth * 100)`, clamped, `0` when /// the basin's own max depth is degenerate (see /// `LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`). `0` for every non-lake cell /// (never negative — a cell with no settled water above it has no /// margin to shade). This is the lake counterpart to `ocean_fraction_q`'s /// coastal transition-zone gradient: `ocean_fraction_q` is always `0` /// inside a lake basin (lakes sit ABOVE sea level; `ta.ocean_mask` never /// fires there), so the existing TidalFlat/DuneStrand/CliffCoast/ /// Estuarine morphology gates — every one keyed on `ocean_fraction_q` — /// are structurally unreachable at a lake edge. `lake_margin_q` gives /// the client a continuous tone source for lake shorelines without /// inventing a second morphology-classification path; see /// `lake_from_hydrology_at`'s doc for the full per-basin-normalization /// rationale (PR #206 eyeball finding: a fixed absolute ceiling read /// visually flat on real lakes). #[serde(default)] pub lake_margin_q: i32, /// Per-district river threshold (D-239 §1). Replaces the global /// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant /// itself is unchanged — this value is what `DistrictProfile` carries downstream. pub river_threshold: i32, /// Nullable mean-annual temperature in °C (T-1024 forward declaration). /// `None` if no atmosphere (airless; see D-239 §2, D-227). pub temperature_c: Option, /// Moisture primitive (T-1024 forward declaration). /// Integer 0–100; 0 = arid, 100 = saturated. pub moisture_q: i32, /// Vegetation cover class (T-1025, D-239 §2 §8). /// /// `VegetationClass::Absent` when `temperature_c == None` (airless body). /// Forest→Scrub→Barren ordering; no-skip invariant enforced by `derive_vegetation`. /// Riparian variants override the base class in the 1–3 tile band along /// perennial waterways (D-239 §8 climate→vegetation law). pub vegetation_class: VegetationClass, /// Dominant D8 thalweg direction for this district (T-1047, D-239 §8). /// /// 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.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). #[serde(default)] pub basin_direction: BasinDirection, } // --------------------------------------------------------------------------- // Derivation helpers // --------------------------------------------------------------------------- /// Derive `TectonicClass` from body params. /// /// Respects the optional `tectonic_activity` authored override; falls back to /// `planet_class` derivation. Integer / string comparison only (D-010). fn derive_tectonic_class(params: &BodyParams) -> TectonicClass { // Authored override takes precedence. if let Some(ta) = ¶ms.tectonic_activity { return match ta.as_str() { "volcanic" => TectonicClass::Volcanic, "active" => TectonicClass::Active, "tidally_forced" => TectonicClass::TidallyForced, _ => TectonicClass::Stable, }; } // Derive from planet_class. match params.planet_class.as_deref().unwrap_or("") { "volcanic" => TectonicClass::Volcanic, "oceanic" | "ocean_world" => TectonicClass::Active, _ => TectonicClass::Stable, } } /// Derive `PrecipitationClass` from temperature + moisture primitives (D-239 §2). /// /// Precipitation is f(temperature, moisture). `None` temperature (airless body) /// → always `Arid` (no precipitation without atmosphere; D-239 §2). /// All comparisons are on integer `moisture_q` (0–100) and integer °C cast /// from temperature_c — D-010 compliant (no float gate). pub fn derive_precipitation_class_from_climate( temperature_c: Option, moisture_q: i32, ) -> PrecipitationClass { // Airless: no atmosphere → no precipitation cycle. let Some(temp_c) = temperature_c else { return PrecipitationClass::Arid; }; // Integer cast: all gating uses i32 (D-010; f32→i32 cast is a positional // quantisation, not a structural comparison). let temp_i = temp_c as i32; // Very cold + any moisture → frozen world with low effective precipitation. // Below −40°C the atmosphere holds very little moisture regardless. if temp_i < -40 { return if moisture_q >= 20 { PrecipitationClass::SemiArid } else { PrecipitationClass::Arid }; } // Main precipitation ladder: moisture_q drives class; temperature modulates. match moisture_q { 0..=10 => PrecipitationClass::Arid, 11..=25 => PrecipitationClass::SemiArid, 26..=55 => { // Cold-dry worlds drop one class (thin air → less precipitation cycle). if temp_i < 0 { PrecipitationClass::SemiArid } else { PrecipitationClass::Temperate } } 56..=75 => { if temp_i < 0 { PrecipitationClass::Temperate } else { PrecipitationClass::Humid } } _ => { // moisture_q > 75 if temp_i < -10 { PrecipitationClass::Humid } else { PrecipitationClass::SuperHumid } } } } /// Derive `GlaciationGrade` from temperature + moisture primitives (D-239 §2, §8). /// /// D-239 §2: "long-term / seasonal-minimum temperature → GlaciationGrade". /// D-239 §8 law: "grade 0 = V-ridges, never glacial U". /// Airless bodies (`temperature_c == None`) → grade 0 (ice is geology, D-227). /// All gating uses integer °C (D-010). pub fn derive_glaciation_grade_from_climate( temperature_c: Option, moisture_q: i32, ) -> GlaciationGrade { // Airless: ice is geology (D-227), no glacial morphology. let Some(temp_c) = temperature_c else { return GlaciationGrade::None; }; let temp_i = temp_c as i32; // Glaciation needs both cold temperature AND some moisture (D-239 §3: snow // gated on moisture; cold + dry → bare frozen ground, not glaciers). // No moisture → no ice accumulation regardless of temperature. if moisture_q < 10 { return GlaciationGrade::None; } // Temperature bands → glaciation grade. // These thresholds represent mean-annual temperature; seasonal-minimum // is always colder, so glaciation forms even with modestly sub-freezing means. match temp_i { i32::MIN..=-30 => GlaciationGrade::IceCap, // Perennial ice cover -29..=-15 => GlaciationGrade::Heavy, // Extensive fjord systems -14..=-5 => GlaciationGrade::Moderate, // Fjords possible (≥ grade 2 gate) -4..=5 => GlaciationGrade::Light, // U-valleys, moraines _ => GlaciationGrade::None, // Temperate/warm: V-ridges only } } /// Derive vegetation class from temperature, moisture, and elevation (D-239 §2, §8). /// /// Climate→vegetation law (D-239 §8): /// - treeline band: Forest → Scrub → Barren with NO skipping /// - riparian: Thicket/Scrub 1–3 tile band along perennial waterways /// /// Airless body (`temperature_c == None`) → `VegetationClass::Absent`. /// All comparisons use integer °C + integer elev_q / moisture_q (D-010). /// /// `near_perennial_water` signals whether this district is within the riparian /// band (i.e. the district or an adjacent district carries a perennial waterway). /// When true the riparian sub-variant is returned: `RiparianThicket` over /// Forest, `RiparianScrub` over Scrub/Barren. It is a RIVER-proximity signal — /// unrelated to `open_water`, which is the ocean/lake verdict. /// /// `open_water` (T-1126) is the **morphology-derived** water verdict — `true` /// when the district's already-derived `MorphologyZone` is OpenOcean/Lake. /// Callers pass the verdict in; this function must NEVER re-derive it from an /// ocean-fraction threshold of its own (no fourth threshold — vegetation and /// morphology can never disagree by construction). Airless takes precedence: /// an airless body has no climate/vegetation branch at all (D-239 §2), so its /// seas read `Absent`, not `Marine`. pub fn derive_vegetation( temperature_c: Option, moisture_q: i32, elev_q: i32, near_perennial_water: bool, open_water: bool, ) -> VegetationClass { // Airless: entire climate/vegetation branch absent. let Some(temp_c) = temperature_c else { return VegetationClass::Absent; }; // Open water (T-1126): the morphology verdict, passed in — see doc above. if open_water { return VegetationClass::Marine; } let temp_i = temp_c as i32; // Extreme cold → Barren regardless of water proximity: below ~−50 °C mean // there is no liquid surface water, so a perennial-waterway micro-oasis is // physically impossible (surface ice is geology per D-227, not vegetation). if temp_i < -50 { return VegetationClass::Barren; } // Hyper-arid → Barren, but a perennial waterway sustains a riparian micro-oasis. if moisture_q < 5 { return if near_perennial_water { VegetationClass::RiparianScrub } else { VegetationClass::Barren }; } // Treeline derivation: elevation × temperature interaction. // High elevation or cold temperature pushes toward Barren → Scrub → Forest. // D-239 §8: no-skip guaranteed by ladder structure below. // // Treeline elevation (elev_q threshold above which forest cannot form): // - Warm (temp_i > 10): treeline at elev_q ≈ 75 (alpine zone above) // - Cool (0–10): treeline at elev_q ≈ 55 (trees don't grow as high) // - Cold (−15 to 0): treeline at elev_q ≈ 35 (subarctic treeline is low) // - Very cold (< −15): no forest possible let base_class = if temp_i < -15 { // Tundra/ice: Scrub or Barren only. if moisture_q >= 15 { VegetationClass::Scrub } else { VegetationClass::Barren } } else if temp_i < 0 { // Cold but not ice-cap: Forest possible only at low elevation. let treeline_q = 35_i32; if elev_q > treeline_q { VegetationClass::Barren } else if elev_q > treeline_q - 15 || moisture_q < 20 { VegetationClass::Scrub } else { VegetationClass::Forest } } else if temp_i <= 10 { // Cool temperate. let treeline_q = 55_i32; if elev_q > treeline_q { VegetationClass::Barren } else if elev_q > treeline_q - 20 || moisture_q < 20 { VegetationClass::Scrub } else { VegetationClass::Forest } } else { // Warm temperate to tropical. let treeline_q = 75_i32; if elev_q > treeline_q { VegetationClass::Barren } else if elev_q > treeline_q - 20 || moisture_q < 15 { VegetationClass::Scrub } else { VegetationClass::Forest } }; // Riparian override: 1–3 tile band along perennial waterways. // Upgrades Scrub/Barren → RiparianScrub; upgrades Forest → RiparianThicket. if near_perennial_water { match base_class { VegetationClass::Forest => VegetationClass::RiparianThicket, VegetationClass::Scrub | VegetationClass::Barren => VegetationClass::RiparianScrub, // Absent already returned above. other => other, } } else { base_class } } /// Derive per-district river threshold from tectonic class + precipitation (D-239 §1). /// /// Replaces the global `RIVER_THRESHOLD = 200` for tile-layer consumers. /// Higher precipitation → lower threshold (more rivers). /// All arithmetic is integer (D-010). pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClass) -> i32 { let tectonic_bonus: i32 = match tectonic { TectonicClass::Active => -30, TectonicClass::Volcanic => -20, TectonicClass::TidallyForced => -10, TectonicClass::Stable => 0, }; let precip_factor: i32 = match precip { PrecipitationClass::Arid => 100, PrecipitationClass::SemiArid => 50, PrecipitationClass::Temperate => 0, PrecipitationClass::Humid => -50, PrecipitationClass::SuperHumid => -80, }; // Base of 200 + body-level adjustments, clamped to [20, 500]. (200 + precip_factor + tectonic_bonus).clamp(20, 500) } /// Derive `MorphologyZone` from `DistrictProfile` gating params (D-239 §5, §6, §7). /// /// Implements the 8-family decision tree over integer inputs, in the gate order /// specified by D-239 §5. Hard boolean gates are pre-selection (§5); output is /// then refined to one of the 17 frozen zone labels (§6) by sub-classification /// from family + elevation/water-height (§6: four zones are sub-classifications). /// /// Gate order (D-239 §5, most-constrained first per §7): /// 1. Water bodies (submerged fraction) /// 2. LavaField — gate: tectonic == Volcanic (§5, §8 lava law) /// 3. FjordWall — gate: GlaciationGrade ≥ 2 + slope + coastal (§5) /// 4. CliffCoast — gate: high slope + coastal + NOT fjord (§8 Rock→vertical) /// 5. BraidedDelta — gate: very low slope + low elev + coastal (§8 Gravel→braided) /// 6. DuneStrand — gate: low slope + coastal + moderate elev (§8 Sand→dunes) /// 7. IncisedGorge — gate: high slope + inland + high elev (→MountainPass label) /// 8. MeanderReach — gate: gentle slope + some water presence /// 9. AlluvialPlain — fallback (§5) /// /// Sub-classifications (§6): after family selection, apply. NOTE: the §6 "parent /// family" names are descriptive of the typical source, not exhaustive — these /// zones derive from morphological conditions (low elev + tidal signal; flat + /// moist) that legitimately overlap several families, so a zone can arise from /// more than one family context. The actual emission sites: /// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD. /// - Wetland: flat (slope_q ≤ 5) + moisture ≥ 60; emitted from the AlluvialPlain fallback AND within the MeanderReach gate (§8 Wetland ≤5° flats). /// - TidalFlat: very low elev + ocean signal; emitted from the BraidedDelta and DuneStrand gates AND a standalone low-coast gate after Family 5. /// - Estuarine: Delta family + strong ocean signal (brackish tidal zone, river mouth). /// - ValleyFloor and RiverBank are their own gates (ValleyFloor between IncisedGorge and MeanderReach; RiverBank within the MeanderReach gate). /// - BraidedPlain (§6) is NOT emitted at district scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) DistrictProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note). /// /// D-010: all gates are integer comparisons. No float arithmetic in this function. /// /// `lake_from_hydrology` (T-1184, D-227 amendment (4) / D-255(f) mechanism B): /// the caller-computed settled-hydrology basin verdict at this exact /// position — `true` when a bilinear sample of `HydrologyResult.filled_scaled` /// exceeds a bilinear sample of the original elevation at the SAME position /// (the continuous filled-surface comparison; never a discrete basin-cell /// membership lookup, which would give a blocky, non-refining lake edge). /// `false` both when hydrology genuinely found no lake here AND when no /// `HydrologyResult` is available at all (`TerrainAnalysis.hydrology == /// None`) — both cases fall through to the pre-existing `ocean_fraction_q` /// heuristic below unchanged, so a caller with no hydrology data reproduces /// today's behaviour byte-for-byte. This is a MORE AUTHORITATIVE trigger /// checked AHEAD OF the heuristic (per the araminta-round2.md §(e) ruling: /// "Sea vs. Lake stays exactly as today... the `Lake` emission site gains a /// second, more-authoritative trigger ahead of the existing heuristic /// fallback") — it never touches the `ocean_fraction_q >= 80` open-ocean /// tier, which stays exactly as before. pub fn derive_morphology_zone( tectonic: TectonicClass, glaciation: GlaciationGrade, slope_q: i32, elev_q: i32, ocean_fraction_q: i32, moisture_q: i32, lake_from_hydrology: bool, ) -> MorphologyZone { // ── Tier 0: fully submerged ────────────────────────────────────────────── if ocean_fraction_q >= 80 { // Very high ocean fraction: open ocean or lake depending on context. // No body-scale salinity signal at district level yet; treat all as OpenOcean. // Lake differentiation lives at ChunkContext (D-239 §10). // // Unchanged by T-1184: a settled-hydrology lake basin can never // reclassify a cell the raw heightmap already reads as ≥80% below // sea level back OUT of OpenOcean — hydrology only ever ADDS Lake // coverage the heuristic was missing, never removes the open-ocean // floor. (Also structurally moot: `HydrologyResult`'s priority-flood // seeds from below-sea-level cells, so a genuine open-ocean cell's // `filled == original` there — no lake basin ever covers it.) return MorphologyZone::OpenOcean; } if lake_from_hydrology { return MorphologyZone::Lake; } if ocean_fraction_q >= 60 { return MorphologyZone::Lake; } // ── Family 1: LavaField ───────────────────────────────────────────────── // Hard gate: tectonic == Volcanic (D-239 §5; §8 Lava→sheets/shield slopes). if tectonic == TectonicClass::Volcanic { return MorphologyZone::Volcanic; } // ── Family 2: FjordWall ───────────────────────────────────────────────── // Hard gate: GlaciationGrade ≥ 2 (D-239 §5) + steep slope + coastal. // §8: glaciation→form law: fjord ≥ 2. if glaciation >= GlaciationGrade::Moderate && slope_q >= 40 && ocean_fraction_q >= 20 { return MorphologyZone::Fjord; } // ── Family 3: CliffCoast ──────────────────────────────────────────────── // §8 lithology law: Rock → vertical faces. High slope + coastal + not fjord. if slope_q >= 55 && ocean_fraction_q >= 15 && elev_q >= 20 { return MorphologyZone::CliffCoast; } // ── Family 4: BraidedDelta ────────────────────────────────────────────── // §8 lithology law: Gravel → braided channels/fans, not single-thread meander. // Very flat + low elevation + coastal/water presence. if slope_q <= 5 && elev_q < 20 && ocean_fraction_q >= 10 { // Sub-classification: Estuarine if strong ocean signal (brackish tidal zone). if ocean_fraction_q >= 30 { return MorphologyZone::Estuarine; } // Sub-classification: TidalFlat if very low elev (regularly inundated zone). if elev_q < 10 && ocean_fraction_q >= 15 { return MorphologyZone::TidalFlat; } return MorphologyZone::Delta; } // ── Family 5: DuneStrand ──────────────────────────────────────────────── // §8 lithology law: Sand → ≤~32° angle of repose, dunes not cliffs. // Low slope + coastal + arid/semi-arid (low moisture → loose sand). if slope_q <= 20 && ocean_fraction_q >= 15 && elev_q < 30 && moisture_q <= 30 { // Sub-classification: TidalFlat if very low elev + tidal signal (ocean_fraction_q). if elev_q < 10 && ocean_fraction_q >= 20 { return MorphologyZone::TidalFlat; } return MorphologyZone::DuneStrand; } // Tidal flat also reachable from non-arid low coasts. if ocean_fraction_q >= 20 && elev_q < 8 && slope_q <= 8 { return MorphologyZone::TidalFlat; } // ── Family 6: IncisedGorge → MountainPass / Alpine ────────────────────── // High slope + high elevation + inland (non-coastal). // §5: MountainPass is a zone label sharing IncisedGorge geometry. // §6: Alpine is a sub-classification from IncisedGorge family + very high elevation. if slope_q >= 40 && elev_q >= 50 && ocean_fraction_q < 20 { // Sub-classification: Alpine above treeline elevation threshold. // elev_q ≥ 75 → alpine zone (no forest, exposed rock/ice). if elev_q >= 75 { return MorphologyZone::Alpine; } return MorphologyZone::MountainPass; } // ValleyFloor: moderate slope + high-ish elevation + enclosed. if (15..40).contains(&slope_q) && elev_q >= 40 && ocean_fraction_q < 20 { return MorphologyZone::ValleyFloor; } // ── Family 7: MeanderReach ────────────────────────────────────────────── // §8 lithology law: Soil → rolling/floodplain; single-thread meander. // Gentle slope + some water presence. if slope_q <= 20 && ocean_fraction_q >= 5 { // Sub-classification: Wetland if very flat + high moisture (§8 Wetland ≤5° flats). if slope_q <= 5 && moisture_q >= 60 { return MorphologyZone::Wetland; } // RiverBank if moderate water presence but not delta/braided. if (10..30).contains(&ocean_fraction_q) { return MorphologyZone::RiverBank; } return MorphologyZone::MeanderReach; } // ── Family 8: AlluvialPlain — fallback ────────────────────────────────── // §5: fallback for all remaining cases. // Sub-classification: Wetland if very flat + high moisture. if slope_q <= 5 && moisture_q >= 60 { return MorphologyZone::Wetland; } MorphologyZone::AlluvialPlain } // --------------------------------------------------------------------------- // Climate constants (T-1024, D-239 §2) // --------------------------------------------------------------------------- /// Maximum per-district elevation (km), used to scale a district'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. /// /// D-240: temperature derives from `planet_class` envelope, not orbit/star data. #[derive(Debug, Clone)] pub struct ClimateConstants { /// Per-`planet_class` temperature envelope: `(cold_end_c, warm_end_c)` °C. /// A body can never derive outside its class band (D-240). /// Keys: "temperate", "arid", "frozen", "tropical", "hot_arid", "volcanic", … /// Prefix rules applied in `envelope()`: "cold_*" → −10°C shift; "hot_*" → +10°C; /// "warm_*" → +5°C. Unknown class falls back to "temperate" band. pub planet_class_temperature: std::collections::BTreeMap, /// Greenhouse warming fraction per atmosphere class (0.0–1.0 of band width). /// Keys: "thin", "standard", "breathable", "toxic", "dense". /// "none" / absent = 0.0 (airless bodies return `None` before reaching this). pub greenhouse_offset_c: std::collections::BTreeMap, /// Day/night swing amplitude per atmosphere class (°C). pub diurnal_amplitude_c: std::collections::BTreeMap, /// Maritime-moderation factor per `hydrosphere` (D-240): scales the /// equator→pole gradient. `1.0` = full gradient (dry world swings the whole /// band); `< 1.0` compresses toward the band midpoint (water-rich worlds are /// milder at both ends). Absent/unknown = `1.0`. pub hydrosphere_maritime: std::collections::BTreeMap, // ── Moisture gradient (T-1080, D-239 §2) ───────────────────────────────── // Per-district moisture spatial-gradient penalties — integer points subtracted // from the body's hydrosphere moisture *ceiling*. A living world is wet at the // coast / lowland / tropics and drier toward the interior / highland / poles; // without this gradient the climate field is a single body-constant (T-1080). // Provisional magnitudes — Q-123 calibrates. Mirror `[moisture_gradient]` in // `climate_constants.toml`. /// Moisture lost equator→pole (× |latitude|/90). pub moisture_lat_penalty: i32, /// Moisture lost low→high elevation (× elev_q/100) — orographic / rain-shadow. pub moisture_elev_penalty: i32, /// Moisture lost coast→interior (× (100 − ocean_fraction_q)/100) — continentality. pub moisture_continental_penalty: i32, } impl Default for ClimateConstants { /// Embedded fallback — matches `server/data/climate_constants.toml`. /// Used when the TOML file is not available (tests, embedded contexts). fn default() -> Self { let mut pct = std::collections::BTreeMap::new(); pct.insert("frozen".into(), (-90.0f32, -25.0f32)); pct.insert("ice".into(), (-90.0f32, -25.0f32)); pct.insert("boreal".into(), (-35.0f32, 12.0f32)); pct.insert("cold_arid".into(), (-40.0f32, 20.0f32)); pct.insert("temperate".into(), (-12.0f32, 28.0f32)); pct.insert("oceanic".into(), (-12.0f32, 28.0f32)); pct.insert("subtropical".into(), (2.0f32, 34.0f32)); pct.insert("warm_ocean".into(), (2.0f32, 34.0f32)); pct.insert("tropical".into(), (16.0f32, 40.0f32)); pct.insert("arid".into(), (-5.0f32, 45.0f32)); pct.insert("hot_arid".into(), (20.0f32, 58.0f32)); pct.insert("volcanic".into(), (30.0f32, 90.0f32)); pct.insert("geothermal".into(), (30.0f32, 90.0f32)); let mut gh = std::collections::BTreeMap::new(); gh.insert("thin".into(), 0.10f32); gh.insert("standard".into(), 0.25f32); gh.insert("breathable".into(), 0.25f32); gh.insert("toxic".into(), 0.25f32); gh.insert("dense".into(), 0.55f32); let mut da = std::collections::BTreeMap::new(); da.insert("thin".into(), 50.0f32); da.insert("standard".into(), 15.0f32); da.insert("breathable".into(), 15.0f32); da.insert("toxic".into(), 20.0f32); da.insert("dense".into(), 3.0f32); // Maritime moderation: water-rich worlds compress the equator–pole gradient. // Keys are the actual `bodies.hydrosphere` vocabulary in systems.db. let mut hm = std::collections::BTreeMap::new(); // Large surface liquid — strong moderation. hm.insert("liquid_water".into(), 0.6f32); hm.insert("ocean".into(), 0.6f32); hm.insert("ocean-coastal".into(), 0.6f32); hm.insert("extensive".into(), 0.6f32); // Partial surface water — mild moderation. hm.insert("rivers".into(), 0.8f32); hm.insert("rivers-lakes".into(), 0.8f32); hm.insert("moderate".into(), 0.8f32); // Frozen / subsurface — slight moderation. hm.insert("ice".into(), 0.85f32); hm.insert("subsurface_liquid".into(), 0.9f32); hm.insert("subsurface".into(), 0.95f32); hm.insert("subsurface_ice".into(), 0.95f32); // "minimal" / "trace" / "none" / NULL → 1.0 (full gradient) via fallback. ClimateConstants { planet_class_temperature: pct, greenhouse_offset_c: gh, diurnal_amplitude_c: da, hydrosphere_maritime: hm, // Moisture gradient (T-1080) — provisional; Q-123 calibrates. Tuned so a // wet body (high ceiling) stays mostly vegetated with drier patches rather // than cratering to near-desert; a dry/frozen body (low ceiling) still // clamps mostly barren. Total max penalty 55 < an ocean ceiling of ~80. moisture_lat_penalty: 20, moisture_elev_penalty: 15, moisture_continental_penalty: 20, } } } impl ClimateConstants { /// Return the temperature envelope `(cold_c, warm_c)` for a planet class. /// /// Looks up the class directly first. On a miss, applies prefix rules against /// the "temperate" band: `cold_*` shifts both ends −10 °C, `hot_*` +10 °C, /// `warm_*` +5 °C. Completely unknown classes fall back to the "temperate" band. pub fn envelope(&self, planet_class: &str) -> (f32, f32) { // Direct lookup first. if let Some(&band) = self.planet_class_temperature.get(planet_class) { return band; } // Prefix rules: shift the temperate fallback band. let default_band = self .planet_class_temperature .get("temperate") .copied() .unwrap_or((-12.0, 28.0)); if planet_class.starts_with("cold_") { (default_band.0 - 10.0, default_band.1 - 10.0) } else if planet_class.starts_with("hot_") { (default_band.0 + 10.0, default_band.1 + 10.0) } else if planet_class.starts_with("warm_") { (default_band.0 + 5.0, default_band.1 + 5.0) } else { default_band } } /// Greenhouse fraction for a given atmosphere class (0.0–1.0), defaulting to 0.0. pub fn greenhouse(&self, atmosphere: &str) -> f32 { self.greenhouse_offset_c .get(atmosphere) .copied() .unwrap_or(0.0) } /// Maritime-moderation factor for a given `hydrosphere` (D-240): `1.0` = full /// equator→pole gradient (dry/unknown); `< 1.0` compresses toward the band /// midpoint (ocean ≈ 0.6 → milder at both ends). pub fn maritime_factor(&self, hydrosphere: &str) -> f32 { self.hydrosphere_maritime .get(hydrosphere) .copied() .unwrap_or(1.0) } /// Diurnal amplitude for a given atmosphere class (°C), defaulting to 30. pub fn diurnal_amplitude(&self, atmosphere: &str) -> f32 { self.diurnal_amplitude_c .get(atmosphere) .copied() .unwrap_or(30.0) } } /// Derive mean-annual district temperature in °C (nullable). /// /// Implements D-240: temperature derives from `planet_class` envelope only. /// Orbit/star fields (`orbital_period_days`, `spectral_class`, `star_type`, /// `axial_tilt_deg`) are non-canonical placeholder data and are NOT inputs. /// /// Returns `None` if no atmosphere (airless; D-227). /// /// ## Algorithm (D-240) /// /// 1. Look up the `(cold, warm)` envelope for `planet_class` via /// `ClimateConstants::envelope()`. Unknown class falls back to "temperate". /// 2. **Latitude lerp**: `t_lat = warm - (warm - cold) * (|lat| / 90)` — /// equator → warm end, pole → cold end. /// 3. **Atmosphere modulation**: greenhouse fraction nudges `t_lat` toward the /// warm end by `greenhouse_frac × (warm - cold)`. /// 4. **Elevation lapse**: subtract `lapse_rate × elevation_km` (thin atmo: /// 3.5 °C/km, else 6.5 °C/km). /// 5. **Seed nudge**: deterministic ±~3 °C per-body variation from `body_seed`. /// 6. **Clamp to `[cold, warm]`** — class band is a hard invariant (D-240). /// /// Uses f32 throughout (adequate for ~2 km district-mean temperature). /// D-010: all downstream gating uses `temperature_c as i32`; the float here /// is positional physics, not a structural comparison. pub fn derive_temperature_c( params: &BodyParams, constants: &ClimateConstants, body_seed: u64, ) -> Option { let atmosphere = params.atmosphere.as_deref().unwrap_or("none"); // No atmosphere → airless body; temperature is None (D-227). if atmosphere == "none" { return None; } // Step 1: planet_class → (cold, warm) envelope (D-240). let planet_class = params.planet_class.as_deref().unwrap_or("temperate"); let (cold, warm) = constants.envelope(planet_class); let band_width = warm - cold; // Step 2: latitude lerp across the maritime-moderated band (D-240). Water-rich // worlds compress the equator→pole gradient toward the band midpoint — an ocean // world is milder at both ends; a dry world swings the full band. let hydrosphere = params.hydrosphere.as_deref().unwrap_or("none"); let maritime = constants.maritime_factor(hydrosphere); let mid = (cold + warm) * 0.5; let half = band_width * 0.5 * maritime; 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; // Step 3: atmosphere greenhouse nudge — fraction of band_width toward warm end. let gh_frac = constants.greenhouse(atmosphere); let t_atmo = t_lat + gh_frac * band_width; // Step 4: elevation lapse rate (°C/km). let lapse = if atmosphere == "thin" { 3.5_f32 } else { 6.5_f32 }; let t_lapse = t_atmo - lapse * (params.elevation_km as f32).max(0.0); // Step 5: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240). // Hash body_seed with a mixing constant to produce a per-body offset. // The nudge is a fraction of the band width, bounded to ±3 °C maximum. let nudge = { // Deterministic hash: splitmix64-style single round. let h = body_seed .wrapping_add(0x9e37_79b9_7f4a_7c15) .wrapping_mul(0x6c62_272e_07bb_0142); // Map to [-1.0, 1.0] and scale to ±3°C. let unit = (h as i64 as f64 / i64::MAX as f64) as f32; unit * 3.0_f32 }; let t_nudged = t_lapse + nudge; // Step 6: clamp to [cold, warm] — class band is a hard invariant (D-240). Some(t_nudged.clamp(cold, warm)) } /// Derive the district-level temperature as a **modulation** of a region baseline /// (D-243 §3 / D-239 §2 split, T-1078). /// /// This is **step (b)** of the two-phase temperature derivation: /// - **Step (a)** is the region baseline (`region_profile::derive_region_baseline_c`): /// latitude + greenhouse nudge + seed nudge, clamped to the class band. /// - **Step (b)** is here: apply elevation lapse + slope aspect ON TOP of the /// baseline, re-clamp to the class band. /// /// When a region baseline is available (produced by the edge-fuzz blend in /// `region_profile::region_baseline_at_district`), callers should prefer this /// function over [`derive_temperature_c`]. The two-phase split ensures the /// temperature gradient is a continuous, warp-perturbed scalar field (D-243 §4) /// rather than independent per-district derivations. /// /// ## Inputs /// /// - `region_baseline_c` — the edge-fuzz-blended region mean temperature (from /// `region_profile::region_baseline_at_district`). `None` means airless. /// - `params` — `BodyParams` with the district's own `elevation_km` and /// `atmosphere` (for lapse rate selection). The latitude/hydrosphere fields /// are **not re-used here** — they were consumed by the region baseline. /// - `constants` — climate constants (for the class-band clamp). /// /// ## Returns /// /// `None` if `region_baseline_c` is `None` (airless body). Otherwise the /// district temperature in °C, clamped to the planet class band. /// /// ## Slope aspect modulation (D-243 §3) /// /// Slope aspect is a future input that will modulate temperature based on /// sun-facing vs. shaded slopes. It is not yet available at the district tier /// (no per-district aspect data). The parameter is reserved; pass `0.0`. /// /// ## D-010 compliance /// /// All structural gating downstream uses `temperature_c as i32`. The f32 /// arithmetic here is positional physics. pub fn derive_district_temperature_c( region_baseline_c: Option, params: &BodyParams, constants: &ClimateConstants, _slope_aspect_deg: f32, // reserved for Q-105 / future per-district aspect ) -> Option { // Airless: region baseline is None → no district temperature. let baseline = region_baseline_c?; let atmosphere = params.atmosphere.as_deref().unwrap_or("none"); // Double-check: if atmosphere is "none" the region baseline should already // be None, but guard defensively. if atmosphere == "none" { return None; } // Elevation lapse rate (°C/km). Same as in derive_temperature_c. let lapse = if atmosphere == "thin" { 3.5_f32 } else { 6.5_f32 }; let elev_km = (params.elevation_km as f32).max(0.0); let t_lapse = baseline - lapse * elev_km; // Slope aspect modulation: reserved for future Q-105 / per-district aspect data. // _slope_aspect_deg is currently unused; the `let _ = …` suppresses the lint. let _ = _slope_aspect_deg; // Clamp to the class band (D-240 hard invariant). let planet_class = params.planet_class.as_deref().unwrap_or("temperate"); let (cold, warm) = constants.envelope(planet_class); Some(t_lapse.clamp(cold, warm)) } /// Derive the district moisture primitive (0–100 integer; 0 = arid, 100 = saturated). /// /// D-239 §2 / T-1080: moisture is a **body ceiling × per-district spatial gradient**. /// The body ceiling comes from `hydrosphere` + `atmosphere` (an ocean world is wetter /// on average); the gradient then varies it across the body so the climate field is /// not a single constant (the T-1080 bug: `moisture_q = 80` for all 2048 districts, /// which left vegetation/terrain/ecotones uniform). A living world is wetter at the /// **coast / lowland / tropics** and drier toward the **interior / highland / poles**: /// - **latitude** — equator wet → pole dry (`params.latitude_deg`, set per-district). /// - **elevation** — high ground drier (orographic / rain-shadow, `elev_q`). /// - **continentality** — interior drier than coast (`100 − ocean_fraction_q`). /// /// Penalties are subtractive points from the ceiling, magnitudes tuned in /// [`ClimateConstants`] (`moisture_*_penalty`, provisional pending Q-123). Integer /// arithmetic throughout (D-010); the only float is the deterministic `latitude_deg` /// truncation at the decision boundary, mirroring the `slope_q`/`elev_q` aggregation. /// /// The `hydro` arms use the **actual `bodies.hydrosphere` vocabulary in systems.db** /// — same set the `[hydrosphere_maritime]` table (D-240) keys on — grouped by available /// surface moisture (T-1034). /// Body-wide moisture ceiling — the wettest a district on this body can be, /// from `hydrosphere` + `atmosphere` alone (T-1080's `ceiling` term, before /// the per-district latitude/elevation/continentality gradient). Extracted /// (T-1184) so a body-level-only consumer — [`crate::atlas::hydrology_equilibrium`]'s /// `ClimateInputs::moisture_q`, which needs exactly this single scalar and /// nothing position-specific — can share the vocabulary table with /// [`derive_moisture_q`] instead of re-deriving a parallel one that could /// silently drift from it. Byte-identical to the `ceiling` local this /// function's caller computed inline before the extraction. pub fn derive_moisture_ceiling_q(params: &BodyParams) -> i32 { let hydro = params.hydrosphere.as_deref().unwrap_or("none"); let atmo = params.atmosphere.as_deref().unwrap_or("none"); let base: i32 = match hydro { // Large surface liquid — saturated. "liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 80, // Partial surface water (rivers / lakes / seasonal) — moderate. "rivers" | "rivers-lakes" | "moderate" => 55, // Frozen surface water — low available moisture. "ice" => 20, // Buried water — minimal surface effect. "subsurface_liquid" => 15, "subsurface" | "subsurface_ice" => 10, // Effectively dry. "minimal" | "trace" => 5, "none" => 0, // Unknown vocab — conservative mid-low default. _ => 30, }; let atmo_boost: i32 = match atmo { "none" => -20, "thin" => -10, "standard" | "breathable" => 0, "toxic" => 5, "dense" => 15, _ => 0, }; (base + atmo_boost).clamp(0, 100) } pub fn derive_moisture_q( params: &BodyParams, elev_q: i32, ocean_fraction_q: i32, climate: &ClimateConstants, ) -> i32 { // Body moisture ceiling — the wettest a district on this body can be. let ceiling = derive_moisture_ceiling_q(params); // ── Per-district spatial gradient (T-1080) ──────────────────────────────── // Latitude: equator (0) wet → pole (90) dry. `latitude_deg` is per-district. let lat_q = (params.latitude_deg.abs() as i32).clamp(0, 90) * 100 / 90; // 0..100 let lat_penalty = lat_q * climate.moisture_lat_penalty / 100; // Elevation: high ground is drier (orographic uplift / rain-shadow / less retention). let elev_penalty = elev_q.clamp(0, 100) * climate.moisture_elev_penalty / 100; // Continentality: interior (low ocean fraction) is drier than coast / open water. let interiorness = (100 - ocean_fraction_q.clamp(0, 100)).max(0); let cont_penalty = interiorness * climate.moisture_continental_penalty / 100; (ceiling - lat_penalty - elev_penalty - cont_penalty).clamp(0, 100) } // --------------------------------------------------------------------------- // T-1125 — invented primitives (shared by both derivation paths) // --------------------------------------------------------------------------- /// Distinct hash-path salt for the T-1162 sub-district relief stream (part /// b) — keeps `voxel_relief`'s noise uncorrelated with the district-band /// `terrain_detail` scatter sampled at the same position. Promoted to a /// named const (Tyre, PR #194 forward-contract for T-1156) matching /// `vegetation_invention::VEGETATION_MASSIF_SALT`/`VEGETATION_TEXTURE_SALT`'s /// named-and-greppable convention, ahead of T-1156 adding a fourth /// isolated stream (rivers) — a fourth inline hex literal here would have /// made the pattern harder to audit at a glance. const WINDOW_RELIEF_SALT: u64 = 0x5EED_C0DE; /// The invented `(slope_q, elev_q, ocean_fraction_q)` triple (T-1125, D-227). struct InventedPrimitives { slope_q: i32, elev_q: i32, ocean_fraction_q: i32, } /// Compose the invented district primitives at one position — the shared front /// half of BOTH derivation paths ([`derive_district`] on-demand and /// [`derive_district_profile`] batch), so the D-227 invention can never diverge /// between them again (the pre-T-1125 failure: the batch path was scatter-free). /// /// Steps: /// 1. **Driver tier (one-step-stale, T-1125 circularity ruling):** the raw /// *unwarped* bilinear envelope feeds a provisional moisture/temperature → /// [`derive_glaciation_grade_from_climate`] — the coast character reads /// these stale drivers; it never reads the warped values it produces. The /// raw inputs are the coarse ~40–160 km/pixel envelope anyway, so one step /// of staleness is far below the signal's own resolution. /// 2. **Character:** tier-1 body envelope ([`coast_invention::body_coast_envelope`]; /// `derive_tectonic_class` hoisted here — it needs only `BodyParams`) + /// tier-2 position character ([`coast_invention::coast_character_at`]). /// 3. **Invented coastline:** every envelope field (elevation, slope, ocean /// mask) is sampled at the SAME warped position, so terrain moves coherently /// — a warped-in bay carries its sea-level elevation with it. /// 4. **Slope-independent scatter:** the character's `scatter_floor` breaks the /// old amplitude-slaved-to-coarse-slope collapse (invention ≈ 0 exactly on /// low-relief coasts); `ridge` carries fjord/tectonic sharpness. /// /// `body_params` must already carry the district's `latitude_deg`. /// /// `min_wavelength_m` (T-1149, zoom ladder §2): threaded straight to the /// `terrain_detail` scatter call — octaves finer than this are truncated. /// `0.0` = no cutoff = today's behavior. #[allow(clippy::too_many_arguments)] fn invent_primitives( seed: SeedChain, body_params: &BodyParams, climate: &ClimateConstants, ta: &TerrainAnalysis, px: f64, py: f64, world_x_m: f64, world_y_m: f64, region_baseline_c: Option, min_wavelength_m: f64, ) -> InventedPrimitives { // ── 1. Driver tier: UNWARPED raw-bilinear climate (one-step-stale). ───── let raw_elev_q = ((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100); let raw_ocean_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32) .clamp(0, 100); let driver_params = BodyParams { elevation_km: (raw_elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM, ..body_params.clone() }; let driver_temp = match region_baseline_c { Some(b) => derive_district_temperature_c(Some(b), &driver_params, climate, 0.0), None => derive_temperature_c(&driver_params, climate, seed.seed()), }; let driver_moisture = derive_moisture_q(&driver_params, raw_elev_q, raw_ocean_q, climate); let driver_glaciation = derive_glaciation_grade_from_climate(driver_temp, driver_moisture); // ── 2. Character: body personality envelope + position modulation. ────── let tectonic = derive_tectonic_class(body_params); let envelope = coast_invention::body_coast_envelope(body_params, tectonic); let ch = coast_invention::coast_character_at( &envelope, seed.seed(), world_x_m, world_y_m, body_params.latitude_deg, driver_glaciation, driver_moisture, ); // ── 3. Invented coastline: warp the whole envelope sampling. ──────────── // T-1162: the coast warp gets the SAME min_wavelength_m cutoff as the // terrain-detail scatter below — one rung, one cutoff, applied to both // continuous fields that compose the invented coastline. let (wdx, wdy) = coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch, min_wavelength_m); let (spx, spy) = (px + wdx, py + wdy); let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, spx, spy) as f64; let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, spx, spy) as f64; let ocean_frac = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, spx, spy) as f64; // ── 4. Slope-independent scatter (floor + ridge character). ───────────── let local_slope = (slope_deg / 45.0).clamp(0.0, 1.0); let env_amp = (local_slope + ch.scatter_floor).clamp(0.0, 1.0); let ruggedness = (0.6 * local_slope + 0.5 * ch.ridge).clamp(0.0, 1.0); let scatter = crate::atlas::detail_scatter::terrain_detail( seed.seed(), world_x_m, world_y_m, env_amp, ruggedness, min_wavelength_m, ); // T-1162 part (b): sub-district relief band (VOXEL_OCTAVE_WAVELENGTHS_M, // 1,024–128 m — "the rolling hills a walking character navigates by", // T-1081) fed into window classification for the first time. Same // envelope/ruggedness inputs as the district-band `scatter` above (one // amplitude ceiling, two wavelength bands composing additively — never a // second independently-tuned amplitude rule) and the SAME // `min_wavelength_m` cutoff. At District's real Nyquist floor (4,096 m, // `layer_proxy::MIN_WL_BANDS_M`) every VOXEL_OCTAVE_WAVELENGTHS_M entry // (all ≤1,024 m) is truncated, so `relief` is always exactly 0.0 there — // District's output is unchanged byte-for-byte. At Quarter's cutoff // (1,024 m) only the two coarsest voxel-band entries (1,024, 512) // survive; the two finest (256, 128) stay truncated even at Quarter // (below Quarter's own 512 m spacing's Nyquist floor of 1,024 m) — this // is correct and expected per the D-226 T-1150 wire-contract note and // T-1162 refinement resolution (2): a contributing wavelength is never // capped by the rung's sample-density floor alone (the coast warp // already crosses scales the other way), it is simply that only 2 of // the 4 voxel octaves are coarse enough to matter at Quarter's own // sample density; the other two are reserved for a future // finer-than-Quarter rung. Distinct seed salt // ([`WINDOW_RELIEF_SALT`]) keeps this stream uncorrelated with // `scatter`'s stream at the same position (same isolation discipline as // `coast_invention`'s warp salt). let relief = crate::atlas::detail_scatter::voxel_relief( seed.seed() ^ WINDOW_RELIEF_SALT, world_x_m, world_y_m, env_amp, ruggedness, min_wavelength_m, ); // Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut // steep — fjord walls and cliff coasts. The gentle scatter floor alone can // never voice the D-239 steep coastal families (Fjord gates at slope_q ≥ 40, // CliffCoast at ≥ 55), so where the invented position is coastal // (`shoreline` peaks at the ocean-fraction midpoint) and the character is // ridged (fjord/tectonic), the normalized scatter contributes real slope — // patchy by construction (it reuses the scatter noise), so a glaciated // coast grows fjord SEGMENTS, not a uniform fjord stripe. let shoreline = (ocean_frac * (1.0 - ocean_frac) * 4.0).clamp(0.0, 1.0); let carve = 0.55 * ch.ridge * shoreline * (scatter.abs() / env_amp.max(0.05)).clamp(0.0, 1.0); // Both bands add into the same elevation/slope quantization — `relief` is // 0.0 at every cutoff ≥ 2,048 m (District and coarser), so this sum is // byte-identical to the pre-T-1162 `elev_pct + scatter` wherever the // cutoff discipline says it must be. InventedPrimitives { elev_q: (((elev_pct + scatter + relief) * 100.0).round() as i32).clamp(0, 100), slope_q: (((local_slope + ruggedness * (scatter.abs() + relief.abs()) + carve) * 100.0) .round() as i32) .clamp(0, 100), ocean_fraction_q: ((ocean_frac * 100.0).round() as i32).clamp(0, 100), } } /// Fractional working-grid position → absolute world metres — the inverse of /// [`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 /// (`river_course::cell_world_m`) to resolve a river cell's pixel position to /// its world-metre anchor — the SAME mapping, reused rather than duplicated. pub(crate) fn pixel_to_world_m( px: f64, py: f64, w: usize, h: usize, radius_km: Option, ) -> (f64, f64) { match radius_km { Some(r) if r > 0.0 => { let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0); let lat_frac = if h > 1 { py / (h - 1) as f64 - 0.5 } else { 0.0 }; (wx, lat_frac * (std::f64::consts::PI * r * 1000.0)) } _ => (px * scale::DISTRICT_M as f64, py * scale::DISTRICT_M as f64), } } /// Absolute world metres → fractional working-grid pixel — the inverse of /// [`pixel_to_world_m`], and the SAME mapping [`derive_at_metres`]/ /// [`derive_orbital_at_metres`] compute inline for their own `(px, py)` /// derivation (T-1170: extracted as a standalone `pub(crate)` helper rather /// than duplicated a third time, for the river course inventor's Stage A /// valley-seeking control path, which needs bilinear `elev_pct` reads at /// arbitrary world positions without paying for a full `DistrictProfile` /// derive per candidate — Ruling 3b, binding: "NOT `derive_at_metres` per /// candidate"). Returns `(px, py)` only — callers that also need latitude /// (temperature-sensitive derivation) still compute it themselves; the course /// inventor's Stage A elevation proxy has no use for latitude. pub(crate) fn world_m_to_pixel( wx: f64, wy: f64, w: usize, h: usize, radius_km: Option, ) -> (f64, f64) { match radius_km { Some(r_km) if r_km > 0.0 => { let circumference_m = std::f64::consts::TAU * r_km * 1000.0; let meridian_m = std::f64::consts::PI * r_km * 1000.0; let px = (wx / circumference_m).rem_euclid(1.0) * w as f64; let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); let py = (0.5 + lat_frac) * h.saturating_sub(1) as f64; (px, py) } _ => { let dm = scale::DISTRICT_M as f64; let px = (wx / dm).clamp(0.0, w.saturating_sub(1) as f64); let py = (wy / dm).clamp(0.0, h.saturating_sub(1) as f64); (px, py) } } } // --------------------------------------------------------------------------- // Public derivation function // --------------------------------------------------------------------------- /// 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 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. /// /// ## 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 /// /// - `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. /// - `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 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 /// degrades to `false` in that case, matching the pre-T-1168 hardcoded /// default exactly, never a panic or an error. pub fn derive_district_profile( seed: SeedChain, body_params: &BodyParams, ta: &TerrainAnalysis, pos: SurveyCellPos, grid_cells_per_district: usize, climate: &ClimateConstants, body_id: &str, region_cache: &BTreeMap, basin_direction: BasinDirection, river_network: Option<&RiverNetwork>, ) -> DistrictProfile { let SurveyCellPos(rx, ry) = pos; let w = ta.w; let h = ta.h; let gcpr = grid_cells_per_district.max(1); // 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); 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; let (world_x_m, world_y_m) = pixel_to_world_m(px, py, w, h, body_params.body_radius_km); // T-1168 Ruling 4b: batch-path riparian signal — edges near this // 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 .map(|rn| { river_course::near_perennial_water_at( seed, ta, body_params, rn, (world_x_m, world_y_m), scale::DISTRICT_M as f64, 0.0, // batch path — no octave cutoff, matches this function's own default ) }) .unwrap_or(false); // 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, 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 /// 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) /// /// `region_baseline_c` is the edge-fuzz-blended region mean temperature from /// [`crate::atlas::region_profile::region_baseline_at_district`]. /// /// - When `Some(baseline)`: district temperature is derived as a **modulation** /// of the baseline via [`derive_district_temperature_c`] — elevation lapse /// only (latitude/greenhouse/nudge already in the baseline). This is the /// D-243 §3 correct two-phase path. /// - When `None`: falls back to the legacy single-phase [`derive_temperature_c`] /// for backward compatibility (used by unit tests and paths where no region /// layer has run yet). /// /// The distinction is important for edge fuzz: only the two-phase path produces /// a continuous, warp-perturbed temperature gradient. The single-phase path /// still satisfies D-240 but without edge fuzz. /// /// ## Vegetation patchiness (T-1162) /// /// `world_x_m`/`world_y_m`/`min_wavelength_m` feed /// [`crate::atlas::vegetation_invention::moisture_perturb_q`] — the nature-layer /// patchiness field that perturbs `moisture_q` (see that module's docs for the /// full design rationale) before precipitation/glaciation/vegetation are /// derived from it, so the three stay in lockstep. `world_x_m == 0.0 && /// world_y_m == 0.0 && min_wavelength_m == 0.0` is NOT a special "disabled" /// case — the origin is a legal world position — vegetation patchiness is /// always active wherever `VegetationEnvelope::ceiling_q > 0`, mirroring the /// coast invention's own always-on posture (the ceiling being zero, not a /// separate flag, is what turns it off on airless/dry bodies). /// /// ## Riparian signal (T-1168, Ruling 4a-4d) /// /// `near_perennial_water` is the T-1168 riparian point test result for /// `(world_x_m, world_y_m)` — a separate boolean signal into /// [`derive_vegetation`], computed by the caller (window path: distance to /// the retained `Layer1Output`'s invented courses; batch path: distance to /// on-demand-invented courses for nearby edges — both via the SAME pure /// [`crate::atlas::river_course::near_perennial_water`] function). **This /// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it /// is threaded straight through to `derive_vegetation` unchanged, after every /// moisture/temperature/morphology field above it has already been resolved. /// /// ## Lake sourcing (T-1184, D-227 amendment (4); T-1188 depth band) /// /// `lake_from_hydrology` is the caller-computed [`lake_from_hydrology_at`] /// verdict for this position — threaded straight into /// [`derive_morphology_zone`]'s new gate, ahead of its pre-existing /// `ocean_fraction_q >= 60` heuristic. `lake_margin_q` is the SAME call's /// depth-band quantization, threaded straight onto the output profile /// (T-1188 — no further derivation needed; see `lake_from_hydrology_at`'s /// doc for what it represents and why lakes need it where oceans don't). /// Computed by the caller (not here) for the same reason `near_perennial_water` /// is: this function stays free of `TerrainAnalysis`/pixel-position concerns, /// taking only the already-reduced per-position signals every other field /// here consumes. #[allow(clippy::too_many_arguments)] fn build_district_profile( seed: SeedChain, body_params: &BodyParams, climate: &ClimateConstants, slope_q: i32, elev_q: i32, ocean_fraction_q: i32, region_baseline_c: Option, basin_direction: BasinDirection, world_x_m: f64, world_y_m: f64, min_wavelength_m: f64, near_perennial_water: bool, lake_from_hydrology: bool, lake_margin_q: i32, ) -> DistrictProfile { 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). 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, ..body_params.clone() }; // Temperature derivation: two-phase (D-243 §3) when a region baseline is // available; single-phase legacy fallback otherwise. let temperature_c = match region_baseline_c { Some(baseline) => { // Two-phase path: apply only elevation lapse on top of the // edge-fuzz-blended region baseline (D-243 §3 / D-239 §2 split). // slope_aspect_deg = 0.0: reserved, not yet available (Q-105). derive_district_temperature_c(Some(baseline), &district_climate_params, climate, 0.0) } None => { // Legacy single-phase path: derive temperature from scratch. // D-240: body-scoped seed for the deterministic per-body nudge. let body_seed = seed.seed(); derive_temperature_c(&district_climate_params, climate, body_seed) } }; let base_moisture_q = derive_moisture_q(body_params, elev_q, ocean_fraction_q, climate); // T-1162: vegetation-patchiness field perturbs the moisture INPUT (see // `vegetation_invention` module docs for the full design rationale) — // this is what turns a uniform per-district class tint into massifs at // Region scale resolving to distinct woods/copses/clearings at // District/Quarter. Applied uniformly at every derivation path // (on-demand, batch, orbital) since all three route through this shared // classification tail. let veg_envelope = crate::atlas::vegetation_invention::vegetation_envelope(body_params); let moisture_perturb = crate::atlas::vegetation_invention::moisture_perturb_q( &veg_envelope, seed.seed(), world_x_m, world_y_m, min_wavelength_m, ); let moisture_q = (base_moisture_q + moisture_perturb).clamp(0, 100); // Climate-derived fields: computed from temperature + moisture primitives // (D-239 §2). This is the correct call order — temperature must be resolved // before precipitation and glaciation are derived from it. let precipitation_class = derive_precipitation_class_from_climate(temperature_c, moisture_q); let glaciation_grade = derive_glaciation_grade_from_climate(temperature_c, moisture_q); let river_threshold = derive_river_threshold(tectonic_class, precipitation_class); // Morphology precedes vegetation (T-1126): the vegetation call consumes the // morphology water verdict, so the two can never disagree by construction. let morphology_zone = derive_morphology_zone( tectonic_class, glaciation_grade, slope_q, elev_q, ocean_fraction_q, moisture_q, lake_from_hydrology, ); // Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is // the caller-computed riparian point test result — see this function's // doc for the full threading contract (Ruling 4a-4d). open_water = the // morphology verdict (T-1126) — never a threshold of its own. let open_water = matches!( morphology_zone, MorphologyZone::OpenOcean | MorphologyZone::Lake ); let vegetation_class = derive_vegetation( temperature_c, moisture_q, elev_q, near_perennial_water, open_water, ); DistrictProfile { morphology_zone, tectonic_class, glaciation_grade, precipitation_class, slope_q, elev_q, ocean_fraction_q, lake_margin_q, river_threshold, temperature_c, moisture_q, vegetation_class, basin_direction, } } /// On-demand 2 km district profile (D-243 §2, T-1077) — the corrected carrier. /// /// Maps the district to a fractional heightmap position via the body radius (the /// elastic seam, D-204), bilinearly interpolates the Layer-1 terrain (the /// continental *envelope*), then composes the adaptive detail-scatter /// ([`crate::atlas::detail_scatter`]) for the mid-scale relief the heightmap is /// too coarse to carry. `body_radius_km = None` (e.g. tiny test bodies) falls back /// to direct heightmap indexing. /// /// Pure and deterministic (D-227/D-010): a function of /// `(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, climate: &ClimateConstants, ) -> DistrictProfile { let (dx, dy) = district_pos; let dm = scale::DISTRICT_M as f64; // Thin wrapper (T-1149): quantize DistrictPos -> world metres, then hand off // to the metres-addressable interior. `min_wavelength_m = 0.0` = no octave // cutoff, preserving this function's output byte-for-byte. derive_at_metres( seed, body_id, body_params, ta, dx as f64 * dm, dy as f64 * dm, climate, 0.0, &[], ) } /// The metres-addressable derivation interior (T-1149, zoom ladder keystone, /// design doc §2/§8 step 1) — `derive_district`'s former inline body, extracted /// so a fractional-metres position (not just an integer [`DistrictPos`]) can be /// classified. This is what makes the quarter rung (512 m spacing, T-1150) /// possible without a second derivation pipeline: same function, finer step. /// /// `wx`/`wy` are absolute world metres — NOT required to fall on a district-grid /// multiple of [`scale::DISTRICT_M`]; any fractional position is legal. /// /// `min_wavelength_m` (§2): forwarded to the `terrain_detail` octave sum inside /// [`invent_primitives`] — octaves finer than this cutoff are truncated. `0.0` /// = no cutoff = [`derive_district`]'s existing behavior. /// /// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation. /// /// `nearby_courses` (T-1168, Ruling 4b/4c): pre-invented river courses /// (already culled to the caller's neighbourhood — the window path's own /// bbox cull, `layer_proxy::build_courses_for_window`) consulted for the /// riparian point test via [`river_course::near_perennial_water`]. Passing /// `&[]` (the common case for a position far from any river, and every /// pre-T-1168 caller via [`derive_district`]) is exactly the old hardcoded /// `false` default — byte-identical output for every caller that doesn't /// thread real course geometry through. This is a PRE-INVENTED slice, not a /// `RiverNetwork` — this function is called once per window CELL (thousands /// of times per window), so re-inventing courses on every call here (rather /// than once per window) would be the exact per-candidate-derive cost this /// whole batch's Ruling 3b was written to avoid. #[allow(clippy::too_many_arguments)] pub fn derive_at_metres( seed: SeedChain, body_id: &str, body_params: &BodyParams, ta: &TerrainAnalysis, wx: f64, wy: f64, climate: &ClimateConstants, min_wavelength_m: f64, nearby_courses: &[river_course::InventedCourse], ) -> DistrictProfile { // T-1168 Ruling 4a: the riparian point test against the caller-supplied // (already-culled) course slice — the SAME pure predicate the batch path // uses via `near_perennial_water_at`. Computed here, BEFORE the core call, // so the core itself never touches course geometry (D-256(c) binding // requirement 2: a naive wrapper passing `&[]` internally would silently // regress every riverside cell to `false` — this public signature and its // byte-behavior are unchanged by the D-256 extraction). let near_perennial_water = river_course::near_perennial_water((wx, wy), nearby_courses); derive_at_metres_with_riparian( seed, body_id, body_params, ta, wx, wy, climate, min_wavelength_m, near_perennial_water, None, // no pre-built region cache — on-demand on-the-fly derivation, exactly as before extraction ) } /// The D-256(c) shared derive core — `derive_at_metres`'s former inline body, /// extracted so [`derive_district_profile`] can become a thin wrapper over the /// SAME metres-addressable derivation instead of re-implementing it. Private: /// the only two sanctioned callers are `derive_at_metres` (which computes /// `near_perennial_water` from its public `nearby_courses` slice exactly as /// before, and passes `None` for `region_cache` — an on-the-fly region /// baseline derivation, matching its pre-extraction behavior byte-for-byte) /// and `derive_district_profile` (which computes `near_perennial_water` via /// `near_perennial_water_at`, the on-demand course inventor, and passes its /// pre-built per-body `region_cache` for the same performance reason the /// batch path built one in the first place — thousands of district calls /// sharing one derived region set rather than each re-deriving up to 4 /// baselines). /// /// `basin_direction` on the returned profile is always [`BasinDirection::default`] /// (North) here — the caller-supplied true L1 D8 value, when available, is /// applied as a post-call field override (D-256(c) binding requirement 1: the /// field is inert to every other field's derivation, proven in the D-256 /// ruling, so an override after the fact is exactly equivalent to threading it /// through). #[allow(clippy::too_many_arguments)] fn derive_at_metres_with_riparian( seed: SeedChain, body_id: &str, body_params: &BodyParams, ta: &TerrainAnalysis, wx: f64, wy: f64, climate: &ClimateConstants, min_wavelength_m: f64, near_perennial_water: bool, region_cache: Option<&BTreeMap>, ) -> DistrictProfile { // World metres -> fractional heightmap pixel + latitude. Mirrors // `derive_district`'s former inline mapping exactly, just keyed on // fractional (wx, wy) instead of an integer DistrictPos scaled up first. let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km { Some(r_km) if r_km > 0.0 => { let circumference_m = std::f64::consts::TAU * r_km * 1000.0; let meridian_m = std::f64::consts::PI * r_km * 1000.0; // Longitude wraps; (0,0) sits at lon 0 / the equator. let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64; // Latitude: equator at py = h/2, clamped at the poles. let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); // −0.5 = N pole, +0.5 = S let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64; (px, py, wx, wy, -lat_frac * 180.0) } _ => { // No radius: the working grid IS the metre grid (tiny test bodies), // 1 DISTRICT_M = 1 heightmap pixel — the inverse of // `pixel_to_world_m`'s own no-radius convention. let dm = scale::DISTRICT_M as f64; let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64); let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64); let lat_deg = if ta.h > 1 { 90.0 - (py / (ta.h - 1) as f64) * 180.0 } else { 0.0 }; (px, py, px * dm, py * dm, lat_deg) } }; let params = BodyParams { latitude_deg: lat_deg, ..body_params.clone() }; // 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. // // `region_baseline_at_district` keys on the CONTAINING DistrictPos (via // `rem_euclid` inside `region_profile.rs`), not on fractional metres — so a // sub-district sample (e.g. a quarter, T-1150) floor-divides down to its // containing district here. This is D-243's design intent (climate is a // district-tier field, R2/zoom-ladder-design-doc §9): temperature is a hard // 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, ); let region_baseline_c = region_profile::region_baseline_at_district( seed.seed(), body_id, district_pos, ¶ms, climate, seed, region_cache, ); // T-1125: invented primitives — warped coastline (invented bays/capes) + // slope-independent character-driven scatter. Shared with the batch path // (`derive_district_profile`) via `invent_primitives`. let prims = invent_primitives( seed, ¶ms, climate, ta, px, py, world_x_m, world_y_m, region_baseline_c, min_wavelength_m, ); // T-1184: the settled-hydrology lake test, sampled at the SAME (px, py) // fractional working-grid position every other envelope field here reads // — the continuous filled-surface comparison (D-227 amendment (4)). // T-1188: the same sample also yields the depth-band tone source // (`lake_margin_q`) lake shorelines were missing. let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py); build_district_profile( seed, ¶ms, climate, prims.slope_q, prims.elev_q, prims.ocean_fraction_q, region_baseline_c, BasinDirection::default(), world_x_m, world_y_m, min_wavelength_m, near_perennial_water, lake_from_hydrology, lake_margin_q, ) } /// The orbital-rung derivation (T-1152, zoom ladder design doc §2/§4): the /// coarse-granularity twin of [`derive_at_metres`] that skips [`invent_primitives`] /// entirely — **no coastline warp, no detail-scatter octave sum, no classification /// noise call of any kind**. Per the design doc's orbital row: "`region_baseline_at_district` /// only — bilinear blend of 4 region baselines, no `invent_primitives`, no /// classification [driver]." Orbital sample spacing (≥205 km, D-243's region rung /// and coarser) sits below `detail_scatter`'s own octave floor /// (`OCTAVE_WAVELENGTHS_M`'s coarsest entry is 32,768 m ≈ 32.8 km — an order of /// magnitude finer than a region), so the invented terrain has nothing left to /// contribute at this spacing; calling it would burn cycles synthesizing detail /// no orbital pixel can resolve. What DOES vary at orbital spacing is the /// **envelope** the heightmap itself carries (the `TerrainAnalysis` continental /// shape) and the **region climate baseline** — this function samples exactly /// those two, nothing else. /// /// **Cost model (design doc R1 — measure first):** one `bilinear` (elevation), /// one `bilinear_bool` (ocean mask), one `region_baseline_at_district` call (its /// own cost is 4×`derive_region_baseline_c` on a cache miss, O(1) on a cache hit) /// — no octave sum, no coast-warp trig, no character/envelope computation. See /// `server/tests/zoom_ladder_bench.rs`'s `bench_derive_orbital_at_metres` for the /// measured per-cell figure this claim rests on. /// /// Produces the SAME six-field tail every other rung produces (`morphology_zone`, /// `elev_q`, `temperature_c`, `moisture_q`, `vegetation_class`, `glaciation_grade`) /// by routing the bilinear-only primitives through the same /// [`build_district_profile`] classification tail every other rung uses — one /// classification pipeline, never a second orbital-only decision tree (D-227: /// classification thresholds don't get a coarse-rung variant any more than the /// quarter rung got its own "quarter mode" thresholds, design doc §6). /// /// **R2 (stepped fields):** `moisture_q`/`temperature_c`/`morphology_zone`/etc. /// are exactly as stepped here as at every other rung — `region_baseline_at_district` /// floor-divides to the containing `DistrictPos` regardless of caller spacing (see /// [`derive_at_metres`]'s own doc on this), so this function does not make /// temperature MORE continuous at orbital scale; it inherits the same /// district-tier step the design doc documents as permanent, by construction. /// /// **`slope_q` is fixed at 0`** — the bilinear-only envelope carries no /// per-cell slope signal at orbital spacing (`ta.slope_deg` is a district-scale /// proxy; sampling it here would imply a precision the coarse envelope doesn't /// have). `slope_q` only affects morphology gates 3–6 (FjordWall/CliffCoast/ /// BraidedDelta/DuneStrand) and the invented-primitives `carve` term this /// function never runs — passing 0 means those gates fall through to their /// low-slope alternatives, which is the correct behavior for a coastline sampled /// at coarser-than-detail-scatter resolution (no invented ruggedness to report). pub fn derive_orbital_at_metres( seed: SeedChain, body_id: &str, body_params: &BodyParams, ta: &TerrainAnalysis, wx: f64, wy: f64, climate: &ClimateConstants, ) -> DistrictProfile { // Same world-metres -> fractional heightmap pixel + latitude mapping // derive_at_metres uses — the envelope is the SAME TerrainAnalysis grid at // every rung, only the sampling density differs. let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km { Some(r_km) if r_km > 0.0 => { let circumference_m = std::f64::consts::TAU * r_km * 1000.0; let meridian_m = std::f64::consts::PI * r_km * 1000.0; let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64; let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64; (px, py, wx, wy, -lat_frac * 180.0) } _ => { let dm = scale::DISTRICT_M as f64; let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64); let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64); let lat_deg = if ta.h > 1 { 90.0 - (py / (ta.h - 1) as f64) * 180.0 } else { 0.0 }; (px, py, px * dm, py * dm, lat_deg) } }; let params = BodyParams { latitude_deg: lat_deg, ..body_params.clone() }; // The envelope only — no coast-warp, no detail-scatter. This is exactly // `invent_primitives`' step-1 "driver tier" raw bilinear reads, promoted to // be the FINAL primitives instead of a one-step-stale input to invention. let elev_q = ((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100); let ocean_fraction_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0) .round() as i32) .clamp(0, 100); // No invented ruggedness at orbital spacing (see the function doc's note // on slope_q) — the envelope carries no per-cell slope signal this coarse. let slope_q = 0; let district_pos: DistrictPos = ( (wx / scale::DISTRICT_M as f64).floor() as i32, (wy / scale::DISTRICT_M as f64).floor() as i32, ); 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, same posture as derive_at_metres ); // T-1184: same continuous filled-surface comparison every rung samples, // at the orbital rung's own (px, py) — lake edges refine at Region // spacing exactly as they do at every finer rung (D-227 amendment (4)). // T-1188: the same sample also yields the depth-band tone source. let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py); build_district_profile( seed, ¶ms, climate, slope_q, elev_q, ocean_fraction_q, region_baseline_c, BasinDirection::default(), world_x_m, world_y_m, // T-1162: vegetation patchiness's massif tier is NEVER cutoff-gated // (see vegetation_invention module docs) and is cheap (two small fBm // sums, not the invent_primitives bilinear+warp+scatter pipeline this // function deliberately skips) — passing 0.0 here means the orbital // path samples the SAME uncut massif+texture field derive_at_metres // would at min_wavelength_m=0.0, preserving cross-rung coherence for // the vegetation verdict even though slope/elevation stay // envelope-only at this rung. 0.0, // T-1168 Ruling 4e/5a: NO windowed course invention at Region // granularity (`layer_proxy::build_courses_for_window` early-returns // for `WindowGranularity::Region` — the whole-body skeleton path // draws Region-rung rivers instead, Ruling 5a). Always `false` here // — honest, not a gap: even if courses existed at Region, the 1-3 m // riparian band is many orders of magnitude below Region's ~205 km // spacing and could never fire (Ruling 4e). false, lake_from_hydrology, lake_margin_q, ) } /// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`. /// Columns wrap (equirectangular); rows clamp at the poles. /// /// `pub(crate)` (T-1170): also the elevation-proxy read the river course /// inventor's Stage A valley-seeking control path uses /// (`river_course::score_candidate`) — the SAME bilinear-`elev_pct` tradeoff /// the coast warp already makes (`invent_primitives`'s step 3), reused rather /// than re-implemented so the two invention fields can never silently drift /// on interpolation semantics. pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 { if w == 0 || h == 0 { return 0.0; } let x0 = px.floor(); let y0 = py.floor().clamp(0.0, (h - 1) as f64); let tx = (px - x0) as f32; let ty = (py - y0) as f32; let ix0 = (x0 as i64).rem_euclid(w as i64) as usize; let ix1 = (ix0 + 1) % w; let iy0 = (y0 as usize).min(h - 1); let iy1 = (iy0 + 1).min(h - 1); let v00 = field[iy0 * w + ix0]; let v10 = field[iy0 * w + ix1]; let v01 = field[iy1 * w + ix0]; let v11 = field[iy1 * w + ix1]; let a = v00 + (v10 - v00) * tx; let b = v01 + (v11 - v01) * tx; a + (b - a) * ty } /// Degenerate-basin guard for [`lake_from_hydrology_at`]'s per-basin /// normalization: a basin whose own max depth is at or below this floor /// (in the same `[0.0, 1.0]` normalized elevation-fraction units as /// `HydrologySample`) is treated as uniformly shallow — `lake_margin_q` /// reads `0` everywhere in it rather than dividing by a near-zero /// denominator (which would amplify heightmap sampling noise into an /// artificial, meaningless gradient). `1e-5` is ~17× smaller than the /// smallest genuinely-flooded per-cell depth observed in the T-1188 /// calibration survey (below), well inside "this basin has no real depth /// signal at this heightmap resolution" territory. const LAKE_MARGIN_DEGENERATE_BASIN_EPSILON: f32 = 1e-5; /// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f) /// mechanism B) PLUS its T-1188 depth-band extension. Returns /// `(is_lake, lake_margin_q)`: /// /// - `is_lake` — `true` when a bilinear sample of the settled filled-surface /// field strictly exceeds a bilinear sample of the original elevation at /// the SAME fractional working-grid position — the continuous comparison /// that makes lake edges refine with rung exactly like coastlines, rather /// than projecting `HydrologyResult.basins[*].cells` membership as a /// discrete, non-refining lookup (explicitly rejected, see this function's /// callers' docs). `false` when `ta.hydrology` is `None` (no solve /// available for this analysis — every caller must already treat `false` /// here as "fall through to the `ocean_fraction_q` heuristic", never as an /// error). /// - `lake_margin_q` — `0` when `!is_lake` (a non-lake cell has no margin to /// shade); otherwise the settled depth `(filled - original)` at this /// position, normalized against THIS BASIN's own maximum depth /// (`HydrologySample.basin_max_depth`, bilinear-sampled at the SAME /// position — see that field's doc), then quantized to `[0, 100]`. /// /// **Per-basin, not a fixed absolute ceiling (PR #206 eyeball finding, /// T-1188 round 2):** the original design used one fixed absolute-depth /// ceiling calibrated against a single body's p90 depth. Two compounding /// effects made that read visually flat on real lakes: (1) a linear /// absolute scale compresses the bulk of any MORE-skewed basin's depth /// distribution into single-digit values; (2) heightmap resolution /// (~40–78 km/px) means within-basin absolute-depth variation is often /// sub-texel-tiny (GJ1c's test basin measured a full-basin depth spread /// under 0.0003 normalized units — genuinely below what a fixed ceiling /// calibrated for a DIFFERENT body's deeper lakes could ever resolve). /// Normalizing against each basin's own max depth fixes both: every /// non-degenerate basin uses the full 0–100 range on ITS OWN terms, /// independent of the body's absolute elevation scale or any other /// basin's depth. A basin at or below /// [`LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`] max depth reads `0` /// everywhere (a genuinely uniform shallow pond shades flat — honest, /// not forced) rather than dividing by ~zero. /// /// This is the continuous tone source lake shorelines were missing — /// `ocean_fraction_q` is definitionally `0` throughout a lake basin (lakes /// sit above sea level; `ta.ocean_mask` never fires there), so every /// coastal-transition morphology gate (TidalFlat, DuneStrand, CliffCoast, /// Estuarine — all keyed on `ocean_fraction_q >= N`) is structurally /// unreachable at a lake edge even though the underlying position /// sampling refines correctly with rung (verified: a shoreline-crossing /// sweep at district/quarter/block spacing lands on the exact same /// continuous world-metres crossing at every rung, and a 10 m fine sweep /// confirms sub-block precision — the T-1188 hypothesis (a) positional /// check). `lake_margin_q` fixes the PRESENTATION gap (hypothesis (b)) /// without touching that already-correct positional refinement. /// /// All three fields (`elevation`, `filled`, `basin_max_depth`) are sampled /// via the SAME `bilinear` helper `ocean_fraction_q`'s own /// `ta.elev_pct`/`ta.ocean_mask` reads already use at every derive-core call /// site (T-1178/T-1154's per-cell rate numbers already include equivalent- /// cost sampling in the measured per-rung budget — one more bilinear sample /// is not a new cost category, per the workshop's own pipeline-slot ruling). fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) { let Some(h) = ta.hydrology.as_ref() else { return (false, 0); }; let filled = bilinear(&h.filled, ta.w, ta.h, px, py); let original = bilinear(&h.elevation, ta.w, ta.h, px, py); let is_lake = filled > original; let lake_margin_q = if is_lake { let basin_max_depth = bilinear(&h.basin_max_depth, ta.w, ta.h, px, py); if basin_max_depth <= LAKE_MARGIN_DEGENERATE_BASIN_EPSILON { 0 } else { let depth = (filled - original).max(0.0); ((depth / basin_max_depth) * 100.0).round().clamp(0.0, 100.0) as i32 } } else { 0 }; (is_lake, lake_margin_q) } /// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage). fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 { if w == 0 || h == 0 { return 0.0; } let x0 = px.floor(); let y0 = py.floor().clamp(0.0, (h - 1) as f64); let tx = (px - x0) as f32; let ty = (py - y0) as f32; let ix0 = (x0 as i64).rem_euclid(w as i64) as usize; let ix1 = (ix0 + 1) % w; let iy0 = (y0 as usize).min(h - 1); let iy1 = (iy0 + 1).min(h - 1); let f = |r: usize, c: usize| mask[r * w + c] as i32 as f32; let a = f(iy0, ix0) + (f(iy0, ix1) - f(iy0, ix0)) * tx; let b = f(iy1, ix0) + (f(iy1, ix1) - f(iy1, ix0)) * tx; a + (b - a) * ty } /// Eagerly derive a coarse profile grid covering the body, by direct heightmap /// tiling (`grid_cells_per_district` cells per cell). /// /// **Scale note (D-256(b)):** this is the *survey raster* — the coarse eager /// grid (one cell per `gcpr` heightmap pixels, tens-to-hundreds of km) kept as /// the Atlas zone overlay source and the L2/L3 planning input (settlement /// placement context, believability sampling, skeleton dispatch context). The /// **corrected 2 km carrier** the voxel chain consumes is the on-demand /// [`derive_district`] (heightmap interpolation + detail-scatter via the /// elastic seam). Both now route through the SAME [`derive_at_metres_with_riparian`] /// core (D-256(c)) — one derive core, two position sets. /// /// Returns a `BTreeMap` covering the full /// heightmap at the given survey-grid resolution. /// /// `grid_cells_per_district = 8` means each survey cell is 8×8 heightmap cells. /// /// ## Region baseline (D-256(c), cache pre-build per PR #199 review) /// /// 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. /// /// `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 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 /// `road_graph` precedent (`cascade.rs`'s already-unpacked /// `layer1.river_network`, same source, same threading pattern). pub fn derive_all_districts( seed: SeedChain, body_params: &BodyParams, ta: &TerrainAnalysis, grid_cells_per_district: usize, body_id: &str, basin_dirs: Option<&BTreeMap>, river_network: Option<&RiverNetwork>, ) -> BTreeMap { let climate = ClimateConstants::default(); let gcpr = grid_cells_per_district.max(1); let survey_cols = ta.w.div_ceil(gcpr) as i32; let survey_rows = ta.h.div_ceil(gcpr) as i32; // 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 { 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, body_params, ta, pos, gcpr, &climate, body_id, ®ion_cache, basin_direction, river_network, ); out.insert(pos, profile); } } out } /// The survey cell's centre pixel → world metres, per [`derive_district_profile`]'s /// own D-256(b) `8·rx + 3.5` (clamped) convention — factored out so any /// caller resolving a `SurveyCellPos` to a world position (e.g. /// [`derive_all_districts`]'s `basin_dirs` lookup, or `believability::analyze`'s /// voxel-sample chunk resolution) reaches the SAME world position the profile /// itself is centred on, rather than a second, possibly-disagreeing mapping. /// /// `pub` (D-256): `believability.rs` and `bin/aliveness_probe.rs` (a separate /// crate) both need this bridge and have no `TerrainAnalysis`/`BodyParams` in /// scope (they only see `BodyWorldState`'s cached dims + a body-params read), /// so this takes the primitive `w`/`h`/`body_radius_km` rather than the /// wrapper structs — the same primitives [`pixel_to_world_m`] itself takes. pub fn survey_cell_centre_world_m( pos: SurveyCellPos, gcpr: usize, w: usize, h: usize, body_radius_km: Option, ) -> (f64, f64) { let SurveyCellPos(rx, ry) = pos; let row_start = (ry as usize).saturating_mul(gcpr).min(h); let row_end = row_start.saturating_add(gcpr).min(h); let col_start = (rx as usize).saturating_mul(gcpr).min(w); let col_end = col_start.saturating_add(gcpr).min(w); let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0; let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0; pixel_to_world_m(px, py, w, h, body_radius_km) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::atlas::drainage; use crate::atlas::heightmap::BodyHeightmap; use crate::seed::SeedDomain; fn test_hm() -> BodyHeightmap { let (w, h) = (64u32, 32u32); let n = (w * h) as usize; let data = (0..n) .map(|i| { let r = (i / w as usize) as f32 / h as f32; let c = (i % w as usize) as f32 / w as f32; (r * 0.6 + c * 0.4).min(1.0) }) .collect(); BodyHeightmap { body_id: "test".into(), width: w, height: h, data, sea_level: 0.3, } } fn test_seed() -> SeedChain { SeedChain::root(42).derive(SeedDomain::Body, 1) } fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis { let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); TerrainAnalysis::analyze(hm, &dr) } #[test] fn derive_all_districts_covers_full_heightmap() { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); // Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts. assert_eq!(districts.len(), 32, "district count mismatch"); } /// 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; let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); // Real SurveyCellPos keys from a baseline (None) run. let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); let mut keys = baseline.keys().copied(); let cell_east = keys.next().expect("at least one survey cell"); let cell_south = keys.next().expect("at least two survey cells"); let cell_unmapped = keys.next().expect("at least three survey cells"); let mut basin_dirs: BTreeMap = BTreeMap::new(); basin_dirs.insert(cell_east, BasinDirection::East); basin_dirs.insert(cell_south, BasinDirection::South); let districts = derive_all_districts( test_seed(), ¶ms, &ta, 8, "test_body", Some(&basin_dirs), None, ); assert_eq!(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 ); } // --- derive_district (on-demand 2 km, interpolation + detail-scatter) ----- fn earth_params() -> BodyParams { BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), body_radius_km: Some(6371.0), ..Default::default() } } #[test] fn derive_district_is_deterministic() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); 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); assert_eq!(a.temperature_c, b.temperature_c); } #[test] fn derive_district_radius_maps_to_latitude_climate() { // With a body radius, equatorial vs near-polar districts get different // temperature (the seam maps district_y → latitude). Pole = colder. // // T-1162: `derive_district` calls with `min_wavelength_m = 0.0` (no // cutoff), so it now admits the extended coast-warp + sub-district // relief octaves this ticket adds — real per-position elevation noise // that a SINGLE probe district at each latitude is no longer immune // to (elevation lapse feeds temperature; a single unlucky relief // sample can swing one probe point by ~1°C, enough to flip a // single-pair comparison at these specific hand-picked positions). // Average temperature over several districts spanning a few hundred // metres at each latitude band — the same zero-mean-cancellation // technique `derivation_harness.rs`'s cross-district blend test uses // — so the assertion tests the LATITUDE law, not one noise sample. let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); // 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 avg_temp_c = |dy: i32| -> f32 { let mut sum = 0.0f32; let mut n = 0; for dx in 0..8 { let prof = derive_district(test_seed(), "test_body", &p, &ta, (dx, dy), &climate); sum += prof .temperature_c .expect("breathable body must have a temperature"); n += 1; } sum / n as f32 }; let eq = avg_temp_c(0); let hi = avg_temp_c(merid_districts / 2 - 2); assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})"); } #[test] fn derive_district_flat_envelope_bounded_invention() { // Pre-T-1125 this pinned "flat heightmap → ZERO invented relief" — the // envelope-as-amplitude reading that collapsed the D-227 invention on // exactly the low-relief coasts where coastlines live. T-1125 (Jeroen's // crinkle-varies ruling) replaces the zero with a bounded FLOOR: a flat // world gains gentle invented relief that varies from place to place, // never mountains, and the invention cannot conjure water that is not // in the ocean mask. let (w, h) = (64u32, 32u32); let flat = BodyHeightmap { body_id: "flat".into(), width: w, height: h, data: vec![0.6; (w * h) as usize], sea_level: 0.3, }; let ta = test_ta(&flat); let climate = ClimateConstants::default(); let p = earth_params(); let a = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate); let b = derive_district(test_seed(), "flat", &p, &ta, (900, 40), &climate); // No ocean can be invented on a fully-landlocked flat world: the coast // warp displaces sampling, it never fabricates mask content. assert_eq!(a.ocean_fraction_q, 0, "above sea level → no ocean"); assert_eq!(b.ocean_fraction_q, 0, "above sea level → no ocean"); // Bounded invention: gentle relief only — an authored plain must never // sprout mountain-grade slopes (the envelope rule survives as a ceiling). assert!(a.slope_q <= 25, "invented slope too steep: {}", a.slope_q); assert!(b.slope_q <= 25, "invented slope too steep: {}", b.slope_q); // And the invention must actually vary between distant districts — the // old zero rule left every flat district identical (the T-1125 finding). assert!( a.elev_q != b.elev_q || a.slope_q != b.slope_q, "flat-world districts must differ under the invention floor \ (a: elev_q={} slope_q={}, b: elev_q={} slope_q={})", a.elev_q, a.slope_q, b.elev_q, b.slope_q ); } #[test] fn derive_district_no_radius_falls_back_to_direct_indexing() { // body_radius_km = None (tiny test bodies): the district grid is the // heightmap grid; derivation still succeeds and is deterministic. let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = BodyParams { planet_class: Some("temperate".into()), atmosphere: Some("breathable".into()), ..Default::default() // body_radius_km: None }; 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)); } // --- derive_at_metres (T-1149 keystone extraction) ------------------------- /// `derive_district` is a thin wrapper: at an exact district-aligned metre /// position, with `min_wavelength_m = 0.0`, it must be BIT-IDENTICAL to /// calling `derive_at_metres` directly (the acceptance criterion the /// ticket names explicitly — existing callers see byte-identical output). #[test] fn derive_at_metres_matches_derive_district_at_aligned_position_zero_cutoff() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dp = (1234, -567); let dm = scale::DISTRICT_M as f64; let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate); let via_metres = derive_at_metres( test_seed(), "test_body", &p, &ta, dp.0 as f64 * dm, dp.1 as f64 * dm, &climate, 0.0, &[], ); assert_district_profiles_eq(&via_wrapper, &via_metres); } /// Same equivalence check on the no-radius (tiny test body) branch — the /// two derivation paths diverge internally (fractional-pixel clamp vs. /// direct district indexing) and must be checked independently. #[test] fn derive_at_metres_matches_derive_district_no_radius() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = BodyParams { planet_class: Some("temperate".into()), atmosphere: Some("breathable".into()), ..Default::default() // body_radius_km: None }; let dp = (20, 10); let dm = scale::DISTRICT_M as f64; let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate); let via_metres = derive_at_metres( test_seed(), "test_body", &p, &ta, dp.0 as f64 * dm, dp.1 as f64 * dm, &climate, 0.0, &[], ); assert_district_profiles_eq(&via_wrapper, &via_metres); } /// Field-by-field `DistrictProfile` equality — the struct has no /// `PartialEq` derive (production type, not test-only), so the /// bit-identical acceptance checks above compare fields directly instead /// of adding a derive to non-test code for test convenience. fn assert_district_profiles_eq(a: &DistrictProfile, b: &DistrictProfile) { assert_eq!(a.morphology_zone as u8, b.morphology_zone as u8); assert_eq!(a.tectonic_class as u8, b.tectonic_class as u8); assert_eq!(a.glaciation_grade as u8, b.glaciation_grade as u8); assert_eq!(a.precipitation_class as u8, b.precipitation_class as u8); assert_eq!(a.slope_q, b.slope_q); assert_eq!(a.elev_q, b.elev_q); assert_eq!(a.ocean_fraction_q, b.ocean_fraction_q); assert_eq!(a.river_threshold, b.river_threshold); assert_eq!(a.temperature_c, b.temperature_c); assert_eq!(a.moisture_q, b.moisture_q); assert_eq!(a.vegetation_class as u8, b.vegetation_class as u8); assert_eq!(a.basin_direction as u8, b.basin_direction as u8); } // ------------------------------------------------------------------- // T-1184 — lake sourcing from settled hydrology (D-227 amendment (4)) // ------------------------------------------------------------------- /// Bowl-shaped heightmap (high rim, low centre) — same fixture shape as /// `hydrology_equilibrium.rs`'s own `bowl_grid` and `layer1.rs`'s /// `bowl_hm`, reproduced locally (both are `#[cfg(test)]`-private to /// their own modules) so this module's tests can build a /// `TerrainAnalysis` with real hydrology attached via /// `with_hydrology` without depending on solver-internal or /// layer1-internal test helpers. `sea_level: 0.0` keeps the ENTIRE grid /// dry land except the filled basin, so `ocean_fraction_q` can never /// independently trigger the pre-existing `>= 60` heuristic — any /// `Lake` verdict this test observes can only come from the hydrology /// gate. fn bowl_hm_no_ocean() -> BodyHeightmap { let (w, h) = (64u32, 32u32); let n = (w * h) as usize; let cx = w as f32 / 2.0; let cy = h as f32 / 2.0; let max_r = cx.min(cy).max(1.0); let data = (0..n) .map(|i| { let r = (i / w as usize) as f32; let c = (i % w as usize) as f32; let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0); 0.1 + d * 0.8 }) .collect(); BodyHeightmap { body_id: "bowl_test".into(), width: w, height: h, data, sea_level: 0.0, } } /// Real end-to-end wiring: solve hydrology on the bowl fixture, attach it /// via `with_hydrology` (the same call `layer1::run_layer1` makes in /// production), and confirm `derive_at_metres` classifies the bowl /// CENTRE as `Lake` — sourced from the hydrology gate, not the /// `ocean_fraction_q` heuristic (impossible here: `sea_level == 0.0` /// means `ocean_fraction_q` is always 0 on this fixture). #[test] fn derive_at_metres_sources_lake_from_hydrology_at_bowl_centre() { let hm = bowl_hm_no_ocean(); let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); let base_ta = TerrainAnalysis::analyze(&hm, &dr); let hydrology = crate::atlas::hydrology_equilibrium::solve( &hm.data, hm.width, hm.height, hm.sea_level, crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 }, ); let ta = base_ta.with_hydrology(&hm.data, &hydrology); let climate = ClimateConstants::default(); let p = BodyParams { planet_class: Some("temperate".into()), atmosphere: Some("breathable".into()), ..Default::default() // body_radius_km: None -> 1 DISTRICT_M = 1 px }; let dm = scale::DISTRICT_M as f64; // Bowl centre in pixel space is (32, 16); no-radius mode maps // DistrictPos 1:1 onto heightmap pixels. let prof = derive_at_metres( test_seed(), "test_body", &p, &ta, 32.0 * dm, 16.0 * dm, &climate, 0.0, &[], ); assert_eq!( prof.morphology_zone, MorphologyZone::Lake, "bowl centre must classify Lake via the hydrology-sourced gate; \ ocean_fraction_q is always 0 on this fixture (sea_level=0.0), so \ this cannot be the pre-existing heuristic" ); assert_eq!( prof.ocean_fraction_q, 0, "sanity: heuristic gate never fires here" ); } /// The same bowl centre, sampled via `derive_orbital_at_metres` (Region /// rung) — confirms the hydrology gate is wired into BOTH derive paths /// through the shared `build_district_profile` tail, not just the /// district/quarter/chunk path. #[test] fn derive_orbital_at_metres_sources_lake_from_hydrology_at_bowl_centre() { let hm = bowl_hm_no_ocean(); let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); let base_ta = TerrainAnalysis::analyze(&hm, &dr); let hydrology = crate::atlas::hydrology_equilibrium::solve( &hm.data, hm.width, hm.height, hm.sea_level, crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 }, ); let ta = base_ta.with_hydrology(&hm.data, &hydrology); let climate = ClimateConstants::default(); let p = BodyParams { planet_class: Some("temperate".into()), atmosphere: Some("breathable".into()), ..Default::default() }; let dm = scale::DISTRICT_M as f64; let prof = derive_orbital_at_metres( test_seed(), "test_body", &p, &ta, 32.0 * dm, 16.0 * dm, &climate, ); assert_eq!( prof.morphology_zone, MorphologyZone::Lake, "orbital rung must also source Lake from hydrology at the bowl centre" ); } /// No hydrology attached (`ta.hydrology == None`, the state every /// pre-T-1184 caller and every OTHER test in this module is already in) /// must fall through to the pre-existing `ocean_fraction_q` heuristic /// byte-identically — the whole point of making `with_hydrology` an /// opt-in builder rather than changing `analyze`'s default output. #[test] fn derive_at_metres_without_hydrology_falls_back_to_heuristic() { let hm = bowl_hm_no_ocean(); let ta = test_ta(&hm); // no with_hydrology call — ta.hydrology stays None assert!(ta.hydrology.is_none()); let climate = ClimateConstants::default(); let p = BodyParams { planet_class: Some("temperate".into()), atmosphere: Some("breathable".into()), ..Default::default() }; let dm = scale::DISTRICT_M as f64; let prof = derive_at_metres( test_seed(), "test_body", &p, &ta, 32.0 * dm, 16.0 * dm, &climate, 0.0, &[], ); // sea_level=0.0 on this fixture means ocean_fraction_q is always 0, // so without hydrology the bowl centre must NOT classify Lake (no // trigger available at all) — proving the fallback path is inert, // not silently finding a lake some other way. assert_ne!( prof.morphology_zone, MorphologyZone::Lake, "without hydrology data, the bowl centre must not classify Lake — \ confirms with_hydrology is what supplies the signal, not some \ other implicit path" ); } /// The D-255(f) mandatory cache-hit == cache-miss determinism gate, /// applied to lake classification specifically: deriving the SAME /// position through the SAME `HydrologyResult` (as if reading a resident /// coarser canvas) must be byte-identical to solving hydrology fresh a /// second time and deriving again (as if the cache had been evicted and /// hydrology re-solved) — D-227's "evict -> recompute -> byte-identical" /// test, instantiated for the hydrology-sourced `morphology_zone` gate /// this ticket adds. #[test] fn lake_classification_cache_hit_equals_cache_miss() { let hm = bowl_hm_no_ocean(); let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); let climate_inputs = crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 }; // "Cache hit" path: solve once, reuse the SAME HydrologyResult for // every sample (mirrors a resident coarser canvas served from cache). let hydrology_cached = crate::atlas::hydrology_equilibrium::solve( &hm.data, hm.width, hm.height, hm.sea_level, climate_inputs, ); let ta_hit = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_cached); let climate = ClimateConstants::default(); let p = BodyParams { planet_class: Some("temperate".into()), atmosphere: Some("breathable".into()), ..Default::default() }; let dm = scale::DISTRICT_M as f64; // Sample several positions (centre, rim, corner) through the "hit" path. let positions = [(32.0, 16.0), (5.0, 5.0), (60.0, 28.0), (32.0, 4.0)]; let hit_zones: Vec = positions .iter() .map(|&(px, py)| { derive_at_metres( test_seed(), "test_body", &p, &ta_hit, px * dm, py * dm, &climate, 0.0, &[], ) .morphology_zone }) .collect(); // "Cache miss" path: re-solve hydrology fresh (a second, independent // solve() call — D-227's eviction/recompute case) and re-derive the // SAME positions. let hydrology_fresh = crate::atlas::hydrology_equilibrium::solve( &hm.data, hm.width, hm.height, hm.sea_level, climate_inputs, ); let ta_miss = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_fresh); let miss_zones: Vec = positions .iter() .map(|&(px, py)| { derive_at_metres( test_seed(), "test_body", &p, &ta_miss, px * dm, py * dm, &climate, 0.0, &[], ) .morphology_zone }) .collect(); assert_eq!( hit_zones, miss_zones, "cache-hit path (reused HydrologyResult) and cache-miss path \ (freshly re-solved HydrologyResult) must classify byte-identically \ at every sampled position (D-227 / D-255(f))" ); // Non-vacuous: at least the centre position must actually be a lake, // so this test is exercising the gate, not trivially passing because // nothing ever classified Lake. assert!( hit_zones.contains(&MorphologyZone::Lake), "sanity: the position sweep must include at least one Lake cell" ); } /// A non-district-aligned fractional metre position (e.g. a quarter-grid /// sample, T-1150) must derive without panicking and stay within the same /// value ranges as the district-aligned case — the whole point of the /// extraction is that ANY fractional world position is now legal input, /// not just integer DistrictPos multiples. #[test] fn derive_at_metres_accepts_fractional_sub_district_position() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::DISTRICT_M as f64; // A quarter-grid offset (512 m, D-243) inside district (1234, -567). let prof = derive_at_metres( test_seed(), "test_body", &p, &ta, 1234.0 * dm + 512.0, -567.0 * dm + 512.0, &climate, 512.0, &[], ); assert!((0..=100).contains(&prof.elev_q)); assert!((0..=100).contains(&prof.slope_q)); } /// A `min_wavelength_m` cutoff must actually change the invented terrain /// primitives relative to the uncut (0.0) derive at the SAME position — /// otherwise the parameter would be silently inert at this layer (the /// enveloped_fbm-level test already covers the raw scatter function; this /// confirms the wiring survives through invent_primitives/derive_at_metres). #[test] fn derive_at_metres_cutoff_changes_invented_primitives() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::DISTRICT_M as f64; let mut any_differs = false; for i in 0..20 { let wx = (100 + i * 37) as f64 * dm; let wy = (100 + i * 53) as f64 * dm; let uncut = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0, &[], ); let cut = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 8_193.0, // above the two finest OCTAVE_WAVELENGTHS_M entries &[], ); if uncut.elev_q != cut.elev_q || uncut.slope_q != cut.slope_q { any_differs = true; } } assert!( any_differs, "a mid-band min_wavelength_m cutoff must change invented terrain \ at at least one sampled position" ); } // ------------------------------------------------------------------- // T-1162 — coast crinkle / sub-district relief / vegetation patchiness // ------------------------------------------------------------------- /// Determinism of the T-1162 fields specifically: two independent /// `derive_at_metres` calls at the SAME cutoff-bearing position (Quarter /// spacing, admitting the new coast-warp + relief + vegetation content) /// must be bit-identical (D-010/D-227) — the new machinery is pure, same /// as everything else in this module. #[test] fn t1162_new_fields_are_deterministic_at_quarter_cutoff() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::DISTRICT_M as f64; for i in 0..12 { let wx = (300 + i * 41) as f64 * dm * 0.1; let wy = (300 + i * 29) as f64 * dm * 0.1; let a = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0, &[], ); let b = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0, &[], ); assert_district_profiles_eq(&a, &b); } } /// Cutoff-exclusion discipline (T-1162 parts a/b): District's REAL /// quantized band is `4,096` m (`MIN_WL_BANDS_M`'s pre-existing finest /// entry — matches `terrain_detail`'s own finest octave, i.e. District's /// Nyquist floor). At that exact cutoff, `derive_at_metres` must produce /// output IDENTICAL to a second call at the same cutoff — this is the /// "byte-identical if the cutoff excludes the new octaves" guarantee for /// the sub-district relief band (all four `VOXEL_OCTAVE_WAVELENGTHS_M` /// entries are ≤1,024 m, strictly below 4,096) and the two FINEST /// coast-warp additions (2,048/1,024 m, also below 4,096). The coast /// warp's 8,192/4,096 m additions are legitimately ADMITTED at District's /// own floor (4,096 IS District's Nyquist limit, not "too fine for /// District") — that is intended enrichment, not a leak, and is /// deliberately NOT asserted away here (see the companion /// `quarter_cutoff_admits_more_than_district` test for the positive /// case). This test instead pins that AT THE SAME NOMINAL CUTOFF VALUE, /// repeated derivation is stable — the determinism half of the contract. #[test] fn district_floor_cutoff_is_stable_and_deterministic() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::DISTRICT_M as f64; for i in 0..20 { let wx = (150 + i * 47) as f64 * dm; let wy = (150 + i * 31) as f64 * dm; let a = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0, &[], ); let b = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0, &[], ); assert_district_profiles_eq(&a, &b); } } /// The sub-district relief band specifically (part b) is fully excluded /// at District's floor (4,096 m — every `VOXEL_OCTAVE_WAVELENGTHS_M` /// entry is ≤1,024 m, strictly below 4,096). Isolated directly against /// `detail_scatter::voxel_relief` (rather than through the full /// `derive_at_metres` stack, where the coast warp's OWN 8,192/4,096/2,048 /// additions would confound a two-cutoff comparison — see the module doc /// on `MIN_WL_BANDS_M` for why 4,096 vs any value in `(1_024, 4_096)` /// legitimately differs on the coast-warp side alone): at cutoff 4,096 /// the relief contribution is exactly zero, matching the /// `flat_envelope_invents_nothing`-style empty-sum guard. #[test] fn voxel_relief_band_fully_excluded_at_district_floor() { for i in 0..20 { let wx = (150 + i * 91) as f64 * 137.0; let wy = (150 + i * 67) as f64 * -211.0; let relief = crate::atlas::detail_scatter::voxel_relief( test_seed().seed(), wx, wy, 0.8, 0.6, 4_096.0, ); assert_eq!( relief, 0.0, "voxel_relief must contribute exactly zero at District's 4,096 m floor \ (every VOXEL_OCTAVE_WAVELENGTHS_M entry is ≤1,024 m)" ); } } /// The companion positive case: Quarter's real band (1,024 m) admits /// content District's real band (4,096 m) excludes — the extension must /// not be inert. Sweeps several positions and requires at least one to /// diverge (a single unlucky zero-crossing position would otherwise /// false-fail). #[test] fn quarter_cutoff_admits_more_than_district() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::DISTRICT_M as f64; let mut any_differs = false; for i in 0..20 { let wx = (150 + i * 47) as f64 * dm; let wy = (150 + i * 31) as f64 * dm; let district = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0, &[], ); let quarter = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0, &[], ); if district.elev_q != quarter.elev_q || district.slope_q != quarter.slope_q || district.moisture_q != quarter.moisture_q { any_differs = true; } } assert!( any_differs, "Quarter's finer cutoff must admit SOME content District's cutoff excludes \ at at least one sampled position — the T-1162 extension must not be inert" ); } /// Unknown/coarser cutoffs never admit finer octaves: a cutoff ABOVE /// every extended band (coast warp's coarsest is 262,144 m) must produce /// IDENTICAL output to the pre-extension "everything truncated" case — /// confirms the extension didn't accidentally widen what a coarse cutoff /// admits, only what a fine one does. #[test] fn coarse_cutoff_admits_nothing_from_t1162_extension_either() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::DISTRICT_M as f64; for i in 0..10 { let wx = (200 + i * 61) as f64 * dm; let wy = (200 + i * 43) as f64 * dm; // Above the coastal warp's own coarsest octave (262,144 m) — every // octave in every band (coast, terrain, voxel relief) is excluded. let far_above = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 300_000.0, &[], ); // An even more extreme cutoff must produce the SAME result — once // every octave is truncated, going coarser still changes nothing. let even_further = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 10_000_000.0, &[], ); assert_district_profiles_eq(&far_above, &even_further); } } /// Vegetation cross-rung coherence (the ticket's hard requirement): the /// MAJORITY vegetation class over a sampled patch at Region-equivalent /// (uncut massif-only) scale must be preserved when the SAME patch is /// refined to Quarter spacing — Quarter punches clearings/copses (some /// cells legitimately differ), but it must not flip the patch's dominant /// class wholesale. Uses a wet, warm, low-elevation body so Forest is the /// achievable majority class and the massif field has genuine amplitude /// to work with (see `vegetation_envelope`'s wetness-product ceiling). #[test] fn vegetation_majority_class_preserved_under_quarter_refinement() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), planet_class: Some("tropical".into()), body_radius_km: Some(6371.0), latitude_deg: 5.0, // near-equator: warm, wet, low treeline pressure ..Default::default() }; let dm = scale::DISTRICT_M as f64; // A patch of 8x8 quarter-cells (one district's worth) around a fixed // low-elevation coastal-adjacent-but-inland district. let base_wx = 40.0 * dm; let base_wy = 15.0 * dm; let district_class = derive_at_metres( test_seed(), "test_body", &p, &ta, base_wx, base_wy, &climate, 2_048.0, &[], ) .vegetation_class; // Sample the surrounding quarter grid (512 m spacing) and tally class // frequency — the majority must match the district-rung verdict at // the patch centre if cross-rung coherence holds. Skip Marine (open // water) tallies since the ticket's coherence claim is about land // vegetation classes refining, not the ocean/land boundary itself. use std::collections::BTreeMap; let mut tally: BTreeMap = BTreeMap::new(); let qm = scale::QUARTER_M as f64; for dy in -2..2 { for dx in -2..2 { let wx = base_wx + dx as f64 * qm; let wy = base_wy + dy as f64 * qm; let prof = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0, &[], ); if prof.vegetation_class != VegetationClass::Marine { *tally.entry(prof.vegetation_class as u8).or_insert(0) += 1; } } } if district_class == VegetationClass::Marine { // The centre itself is open water — nothing to assert about land // majority at this probe point; the test still ran the refinement // sweep above without panicking, which is the structural check. return; } let majority = tally .iter() .max_by_key(|&(_, count)| count) .map(|(&class, _)| class); assert_eq!( majority, Some(district_class as u8), "Quarter-refined majority vegetation class must match the District-rung \ verdict at the patch centre (tally: {tally:?}, district: {district_class:?})" ); } // ------------------------------------------------------------------- // derive_orbital_at_metres (T-1152, design doc §2/§4 orbital row) // ------------------------------------------------------------------- /// Determinism (D-010/D-227): two independent orbital derives at the same /// position produce a bit-identical `DistrictProfile`, mirroring /// `derive_district_is_deterministic`'s pattern for the finer rungs. #[test] fn derive_orbital_at_metres_is_deterministic() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::REGION_M as f64; let a = derive_orbital_at_metres( test_seed(), "test_body", &p, &ta, 3.0 * dm, 2.0 * dm, &climate, ); let b = derive_orbital_at_metres( test_seed(), "test_body", &p, &ta, 3.0 * dm, 2.0 * dm, &climate, ); assert_district_profiles_eq(&a, &b); } /// The orbital path must NOT run `invent_primitives` — the design doc's /// central constraint (§2: "no invent_primitives at orbital wavelengths"). /// Direct proof: `slope_q` is always exactly 0 (invention is the only /// source of nonzero slope_q at this call depth — see /// `derive_orbital_at_metres`'s doc on why slope_q is fixed), sampled /// across enough distinct positions that a nonzero value appearing even /// once would falsify the claim. #[test] fn derive_orbital_at_metres_never_invents_slope() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::REGION_M as f64; for i in 0..25 { let wx = (i * 7) as f64 * dm * 0.37; let wy = (i * 11) as f64 * dm * 0.29; let prof = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate); assert_eq!( prof.slope_q, 0, "orbital derive must never report invented slope (position {i})" ); } } /// The orbital derive's `elev_q`/`temperature_c` must come from the SAME /// envelope + region-baseline sources `derive_at_metres` reads — not an /// independent/divergent computation. At a position where the invented /// scatter happens to contribute exactly zero (impossible to guarantee by /// construction, so this test instead checks the WEAKER, always-true /// property: both paths' `elev_q` derive from the same underlying /// bilinear envelope, so they must be close — within the invented /// scatter's own bounded contribution range, not arbitrarily different). /// This guards against the orbital path silently reading a different /// terrain field entirely (a copy-paste bug this refactor is exactly the /// kind of change that could introduce). #[test] fn derive_orbital_at_metres_elevation_tracks_the_same_envelope() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::DISTRICT_M as f64; // Sample at a DISTRICT-aligned position (within the orbital function's // legal domain — it accepts any world position, this just makes the // district-mode comparison call meaningful) so both paths read the // exact same fractional heightmap pixel. let wx = 40.0 * dm; let wy = 20.0 * dm; let orbital = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate); let full = derive_at_metres( test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0, &[], ); // The invented scatter is a bounded perturbation on top of the raw // envelope (detail_scatter's amplitude is capped well under 100 elev_q // points) — the two must be in the same ballpark, not exactly equal // (that would defeat the point of invention existing at all at the // finer rung) and not wildly different (that would mean the orbital // path is reading a different field). let elev_diff = (orbital.elev_q - full.elev_q).abs(); assert!( elev_diff <= 50, "orbital elev_q ({}) and full-derive elev_q ({}) must come from the \ same envelope, not diverge arbitrarily", orbital.elev_q, full.elev_q ); } /// Orbital-scale windows must still fill all six dense wire arrays the /// client's colorizer family reads (T-1152: "the orbital cells must fill /// the same six dense arrays the DistrictWindowLayer carries") — this is /// checked at the `DistrictProfile` level (the pre-packing source of /// those six fields): every field the packer reads /// (`morphology_zone`/`elev_q`/`temperature_c`/`moisture_q`/ /// `vegetation_class`/`glaciation_grade`) must be populated the same way /// regardless of rung — this test asserts the orbital output is a /// legitimate `DistrictProfile`, not a partially-filled stand-in. #[test] fn derive_orbital_at_metres_populates_all_six_wire_fields() { let hm = test_hm(); let ta = test_ta(&hm); let climate = ClimateConstants::default(); let p = earth_params(); let dm = scale::REGION_M as f64; let prof = derive_orbital_at_metres( test_seed(), "test_body", &p, &ta, 5.0 * dm, 3.0 * dm, &climate, ); assert!((0..=100).contains(&prof.elev_q)); assert!((0..=100).contains(&prof.moisture_q)); // temperature_c is Some for a breathable-atmosphere body (earth_params). assert!(prof.temperature_c.is_some()); // morphology_zone/vegetation_class/glaciation_grade are enums with no // "unset" state — successfully constructing the DistrictProfile at // all (no panic) is the actual assertion; the field reads below just // confirm they're reachable typed values, matching the discipline // `derive_district_is_deterministic` and neighbours already use. let _ = prof.morphology_zone; let _ = prof.vegetation_class; let _ = prof.glaciation_grade; } #[test] fn derive_district_profile_is_deterministic() { let hm = test_hm(); let ta = test_ta(&hm); // D-240: no orbit/star fields in BodyParams — only class/atmo/hydro. let params = BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), ..Default::default() }; let pos = SurveyCellPos(2, 1); let climate = ClimateConstants::default(); let p1 = derive_district_profile( test_seed(), ¶ms, &ta, pos, 8, &climate, "test_body", &BTreeMap::new(), BasinDirection::North, None, ); let p2 = derive_district_profile( test_seed(), ¶ms, &ta, pos, 8, &climate, "test_body", &BTreeMap::new(), BasinDirection::North, None, ); // Equality via serialized fields (no PartialEq on MorphologyZone — compare by name). assert_eq!( format!("{:?}", p1.morphology_zone), format!("{:?}", p2.morphology_zone), "morphology_zone must be deterministic" ); assert_eq!(p1.tectonic_class, p2.tectonic_class); assert_eq!(p1.glaciation_grade, p2.glaciation_grade); assert_eq!(p1.river_threshold, p2.river_threshold); assert_eq!(p1.slope_q, p2.slope_q); assert_eq!(p1.elev_q, p2.elev_q); } #[test] fn volcanic_body_gets_volcanic_tectonic() { let params = BodyParams { planet_class: Some("volcanic".into()), ..Default::default() }; assert_eq!(derive_tectonic_class(¶ms), TectonicClass::Volcanic); } #[test] fn authored_tectonic_override_wins() { let params = BodyParams { planet_class: Some("temperate".into()), tectonic_activity: Some("volcanic".into()), ..Default::default() }; assert_eq!(derive_tectonic_class(¶ms), TectonicClass::Volcanic); } #[test] fn airless_body_gets_no_glaciation() { // Airless: temperature == None → ice is geology, not climate (D-227). assert_eq!( derive_glaciation_grade_from_climate(None, 80), GlaciationGrade::None ); } #[test] fn frozen_body_with_atmo_gets_heavy_glaciation() { // −20°C mean-annual + adequate moisture → Heavy glaciation. assert_eq!( derive_glaciation_grade_from_climate(Some(-20.0), 50), GlaciationGrade::Heavy ); } #[test] fn arid_body_has_higher_river_threshold() { // Arid (low precip) → higher river threshold. let t_arid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::Arid); let t_humid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::SuperHumid); assert!( t_arid > t_humid, "arid body should have higher river threshold than humid body" ); } #[test] fn tectonic_class_discriminants_pinned() { // Load-bearing: renumbering breaks the D-010 integer derivation contract. assert_eq!(TectonicClass::Stable as u8, 0); assert_eq!(TectonicClass::Active as u8, 1); assert_eq!(TectonicClass::Volcanic as u8, 2); assert_eq!(TectonicClass::TidallyForced as u8, 3); } #[test] fn glaciation_grade_discriminants_pinned() { assert_eq!(GlaciationGrade::None as u8, 0); assert_eq!(GlaciationGrade::Light as u8, 1); assert_eq!(GlaciationGrade::Moderate as u8, 2); assert_eq!(GlaciationGrade::Heavy as u8, 3); assert_eq!(GlaciationGrade::IceCap as u8, 4); } #[test] fn precipitation_class_discriminants_pinned() { assert_eq!(PrecipitationClass::Arid as u8, 0); assert_eq!(PrecipitationClass::SemiArid as u8, 1); assert_eq!(PrecipitationClass::Temperate as u8, 2); assert_eq!(PrecipitationClass::Humid as u8, 3); assert_eq!(PrecipitationClass::SuperHumid as u8, 4); } #[test] fn regions_use_btreemap_order() { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); // BTreeMap iterates in sorted key order — verify the first key is (0,0). let first = districts.keys().next().expect("at least one district"); assert_eq!( *first, SurveyCellPos(0, 0), "first district must be at origin" ); } #[test] fn river_threshold_clamped_to_range() { // Even with extreme params, threshold stays in [20, 500]. let t_humid = derive_river_threshold(TectonicClass::Active, PrecipitationClass::SuperHumid); let t_arid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::Arid); assert!( (20..=500).contains(&t_humid), "humid threshold {t_humid} out of range" ); assert!( (20..=500).contains(&t_arid), "arid threshold {t_arid} out of range" ); } // ----------------------------------------------------------------------- // T-1024 / D-240 climate derivation tests — planet_class envelope model // ----------------------------------------------------------------------- /// Convenience: derive temperature with a fixed seed (0) — deterministic, but /// note seed 0 still applies its (constant, non-zero) nudge like any other seed. fn temp(planet_class: &str, atmosphere: &str, lat: f64, elev_km: f64) -> Option { let params = BodyParams { planet_class: Some(planet_class.into()), atmosphere: Some(atmosphere.into()), latitude_deg: lat, elevation_km: elev_km, ..Default::default() }; derive_temperature_c(¶ms, &ClimateConstants::default(), 0) } #[test] fn airless_body_has_no_temperature() { let params = BodyParams { atmosphere: Some("none".into()), ..Default::default() }; let climate = ClimateConstants::default(); assert_eq!( derive_temperature_c(¶ms, &climate, 0), None, "airless body must return None temperature (D-227)" ); } #[test] fn polar_region_is_colder_than_equatorial() { // seed=0 has nudge; compare equatorial vs polar for same body → ordering holds. let t_eq = temp("temperate", "breathable", 0.0, 0.0).unwrap(); let t_pol = temp("temperate", "breathable", 90.0, 0.0).unwrap(); assert!( t_pol < t_eq, "polar temperature {t_pol}°C must be less than equatorial {t_eq}°C" ); } #[test] fn high_elevation_is_colder() { let t_low = temp("temperate", "breathable", 0.0, 0.0).unwrap(); let t_high = temp("temperate", "breathable", 0.0, 5.0).unwrap(); // Lapse rate 6.5°C/km × 5 km = 32.5°C; nudge same for same seed. assert!( t_high < t_low, "mountain temperature {t_high}°C must be less than sea level {t_low}°C" ); let delta = t_low - t_high; // The clamping to [cold, warm] may reduce the apparent delta at the band edge, // but 5 km lapse should still register at least 5°C. assert!( delta >= 5.0, "elevation delta {delta}°C too small for 5 km lapse" ); } #[test] fn tropical_body_temperature_plausible() { // Tropical band is [16, 40]°C. Equatorial, sea-level, seed=0. let t = temp("tropical", "breathable", 0.0, 0.0).unwrap(); assert!( (16.0..=40.0).contains(&t), "tropical equatorial temperature {t}°C out of class band [16, 40]" ); } #[test] fn frozen_body_temperature_plausible() { // Frozen band is [-90, -25]°C. let t = temp("frozen", "thin", 45.0, 0.0).unwrap(); assert!( (-90.0..=-25.0).contains(&t), "frozen temperature {t}°C out of class band [-90, -25]" ); } #[test] fn temperature_is_deterministic() { // Same inputs → same output, always. let params = BodyParams { atmosphere: Some("thin".into()), planet_class: Some("arid".into()), latitude_deg: 45.0, elevation_km: 1.5, ..Default::default() }; let climate = ClimateConstants::default(); let t1 = derive_temperature_c(¶ms, &climate, 12345); let t2 = derive_temperature_c(¶ms, &climate, 12345); assert_eq!(t1, t2, "temperature derivation must be deterministic"); } #[test] fn different_seeds_produce_different_nudges() { // Two different body seeds should produce slightly different temperatures // for the same class/lat/elev. let params = BodyParams { atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), latitude_deg: 30.0, elevation_km: 0.0, ..Default::default() }; let climate = ClimateConstants::default(); let t_a = derive_temperature_c(¶ms, &climate, 1); let t_b = derive_temperature_c(¶ms, &climate, 999_999_999); // Both still within the class band, but not identical. assert_ne!(t_a, t_b, "different seeds should produce different nudges"); let (cold, warm) = climate.envelope("temperate"); assert!(t_a.unwrap() >= cold && t_a.unwrap() <= warm); assert!(t_b.unwrap() >= cold && t_b.unwrap() <= warm); } /// D-240 class-band consistency guard: every planet_class must derive within /// its `[cold, warm]` band across a full latitude sweep, all atmospheres, /// and sea level. This is the key acceptance test for T-1033. #[test] fn every_planet_class_derives_within_its_band() { let climate = ClimateConstants::default(); let planet_classes = [ "frozen", "ice", "boreal", "cold_arid", "temperate", "oceanic", "subtropical", "warm_ocean", "tropical", "arid", "hot_arid", "volcanic", "geothermal", ]; let atmospheres = ["thin", "standard", "breathable", "toxic", "dense"]; // Sweep latitudes 0°–90° in 5° steps; elevation = 0. let latitudes: Vec = (0..=90).step_by(5).map(|d| d as f64).collect(); let seeds: [u64; 4] = [0, 1, u64::MAX / 2, u64::MAX]; for class in &planet_classes { let (cold, warm) = climate.envelope(class); for atmo in &atmospheres { for &lat in &latitudes { // Sweep sea level AND high altitude so the lapse term + clamp // are exercised jointly against the cold end, not just lat alone. for elev in [0.0_f64, 6.0] { // Sweep hydrosphere too, so the maritime-compressed gradient // path is covered by the band invariant (not just None/1.0). for hydro in ["none", "ocean", "liquid_water", "rivers", "ice"] { for seed in seeds { let params = BodyParams { planet_class: Some((*class).into()), atmosphere: Some((*atmo).into()), hydrosphere: Some(hydro.into()), latitude_deg: lat, elevation_km: elev, ..Default::default() }; let t = derive_temperature_c(¶ms, &climate, seed) .expect("non-airless body must have temperature"); assert!( t >= cold && t <= warm, "class={class} atmo={atmo} hydro={hydro} lat={lat} \ elev={elev} seed={seed}: temperature {t}°C outside \ band [{cold}, {warm}]" ); } } } } } } } #[test] fn maritime_hydrosphere_compresses_the_gradient() { // D-240: a water-rich world has a SMALLER equator→pole temperature delta // than a dry world of the same class (maritime moderation). let climate = ClimateConstants::default(); let mk = |hydro: &str, lat: f64| BodyParams { planet_class: Some("temperate".into()), atmosphere: Some("standard".into()), hydrosphere: Some(hydro.into()), latitude_deg: lat, elevation_km: 0.0, ..Default::default() }; // Same seed → only hydrosphere differs. let delta = |hydro: &str| { (derive_temperature_c(&mk(hydro, 0.0), &climate, 7).unwrap() - derive_temperature_c(&mk(hydro, 90.0), &climate, 7).unwrap()) .abs() }; let ocean_delta = delta("ocean"); let dry_delta = delta("none"); // hydrosphere none ≠ airless (atmosphere is "standard") assert!( ocean_delta < dry_delta, "ocean gradient {ocean_delta}°C must be milder than dry {dry_delta}°C" ); } #[test] fn unknown_planet_class_falls_back_to_temperate_band() { // An unrecognised class string must not panic; it falls back to temperate. let climate = ClimateConstants::default(); let (cold, warm) = climate.envelope("temperate"); let params = BodyParams { planet_class: Some("unknown_alien_class".into()), atmosphere: Some("breathable".into()), latitude_deg: 0.0, elevation_km: 0.0, ..Default::default() }; let t = derive_temperature_c(¶ms, &climate, 0) .expect("breathable body must have temperature"); assert!( t >= cold && t <= warm, "unknown class temperature {t}°C outside temperate band [{cold}, {warm}]" ); } #[test] fn prefix_classes_derive_within_shifted_band() { // "cold_weird" is not in the table; prefix rule should shift temperate. let climate = ClimateConstants::default(); let (base_cold, base_warm) = climate.envelope("temperate"); let (cold, warm) = climate.envelope("cold_weird"); assert!( (cold - (base_cold - 10.0)).abs() < f32::EPSILON * 10.0, "cold_ prefix should shift cold_end by -10" ); assert!( (warm - (base_warm - 10.0)).abs() < f32::EPSILON * 10.0, "cold_ prefix should shift warm_end by -10" ); } #[test] fn moisture_q_ocean_breathable_is_high() { let params = BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), ..Default::default() }; let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default()); assert!(q >= 70, "ocean + breathable moisture {q} should be >= 70"); } #[test] fn moisture_q_liquid_water_is_high_not_default() { // T-1034 regression: "liquid_water" is the dominant surface-water vocab in // systems.db (175 bodies). It must map to the high surface-liquid band, not // silently fall to the `_ => 30` default like it did before the fix. let params = BodyParams { hydrosphere: Some("liquid_water".into()), atmosphere: Some("standard".into()), ..Default::default() }; let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default()); assert!( q >= 70, "liquid_water moisture {q} should be >= 70 (surface-liquid band), not the default 30" ); } #[test] fn moisture_q_airless_is_zero() { let params = BodyParams { hydrosphere: Some("none".into()), atmosphere: Some("none".into()), ..Default::default() }; let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default()); assert_eq!(q, 0, "airless no-hydrosphere body moisture must be 0"); } #[test] fn moisture_q_clamped_to_range() { // All combinations must stay in [0, 100]. Sweep the full canonical // `bodies.hydrosphere` vocabulary (T-1034) plus an unknown fallback. let hydros = [ "liquid_water", "ocean", "ocean-coastal", "extensive", "rivers", "rivers-lakes", "moderate", "ice", "subsurface_liquid", "subsurface", "subsurface_ice", "minimal", "trace", "none", "unknown", ]; let atmos = [ "none", "thin", "standard", "breathable", "toxic", "dense", "unknown", ]; for h in &hydros { for a in &atmos { let params = BodyParams { hydrosphere: Some(h.to_string()), atmosphere: Some(a.to_string()), ..Default::default() }; let q = derive_moisture_q(¶ms, 0, 100, &ClimateConstants::default()); assert!( (0..=100).contains(&q), "moisture_q {q} out of range for hydro={h} atmo={a}" ); } } } #[test] fn moisture_q_has_spatial_gradient() { // T-1080: moisture must vary across the body, not be a single body constant. let climate = ClimateConstants::default(); let ocean = |lat: f64| BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("breathable".into()), latitude_deg: lat, ..Default::default() }; // Wettest: equatorial coastal lowland. Driest: polar interior highland. let wet = derive_moisture_q(&ocean(0.0), 0, 100, &climate); let dry = derive_moisture_q(&ocean(90.0), 90, 0, &climate); assert!( wet > dry, "equatorial coast ({wet}) must be wetter than polar interior ({dry})" ); assert!( wet - dry >= 40, "moisture gradient ({} pts) should be substantial", wet - dry ); // Each axis independently lowers moisture from the wet corner. assert!( derive_moisture_q(&ocean(90.0), 0, 100, &climate) < wet, "latitude lowers moisture" ); assert!( derive_moisture_q(&ocean(0.0), 90, 100, &climate) < wet, "elevation lowers moisture" ); assert!( derive_moisture_q(&ocean(0.0), 0, 0, &climate) < wet, "continentality lowers moisture" ); } #[test] fn climate_constants_envelope_direct_lookup() { // Direct lookup returns the exact registered band. let cc = ClimateConstants::default(); let (cold, warm) = cc.envelope("tropical"); assert!( (cold - 16.0).abs() < f32::EPSILON * 10.0, "tropical cold_end should be 16°C" ); assert!( (warm - 40.0).abs() < f32::EPSILON * 10.0, "tropical warm_end should be 40°C" ); } // ----------------------------------------------------------------------- // T-1025: Climate-derived fields — precipitation, glaciation, vegetation // ----------------------------------------------------------------------- #[test] fn precipitation_airless_is_arid() { // D-239 §2: no atmosphere → no precipitation cycle. assert_eq!( derive_precipitation_class_from_climate(None, 80), PrecipitationClass::Arid ); } #[test] fn precipitation_warm_high_moisture_is_superhumid() { // Warm + very high moisture → SuperHumid. assert_eq!( derive_precipitation_class_from_climate(Some(25.0), 90), PrecipitationClass::SuperHumid ); } #[test] fn precipitation_warm_low_moisture_is_arid() { // Warm but very low moisture → Arid. assert_eq!( derive_precipitation_class_from_climate(Some(30.0), 5), PrecipitationClass::Arid ); } #[test] fn precipitation_cold_reduces_class() { // Cold temperature reduces precipitation class relative to moisture alone. // moisture_q=40 is mid-range; warm → Temperate, cold → SemiArid. let warm = derive_precipitation_class_from_climate(Some(15.0), 40); let cold = derive_precipitation_class_from_climate(Some(-5.0), 40); assert!( warm >= cold, "warm precipitation class ({warm:?}) should be >= cold ({cold:?}) at same moisture" ); } #[test] fn glaciation_airless_is_none() { // D-239 §2: airless body → no glacial morphology. assert_eq!( derive_glaciation_grade_from_climate(None, 50), GlaciationGrade::None ); } #[test] fn glaciation_dry_is_none() { // Cold but dry → no glaciation (D-239 §3: snow gated on moisture). assert_eq!( derive_glaciation_grade_from_climate(Some(-25.0), 5), GlaciationGrade::None ); } #[test] fn glaciation_grade_temperature_bands() { // Verify the temperature-band thresholds produce the expected grades. // IceCap: temp ≤ −30 assert_eq!( derive_glaciation_grade_from_climate(Some(-35.0), 50), GlaciationGrade::IceCap ); // Heavy: −29 to −15 assert_eq!( derive_glaciation_grade_from_climate(Some(-20.0), 50), GlaciationGrade::Heavy ); // Moderate (fjord gate ≥ 2): −14 to −5 assert_eq!( derive_glaciation_grade_from_climate(Some(-10.0), 50), GlaciationGrade::Moderate ); // Light: −4 to 5 assert_eq!( derive_glaciation_grade_from_climate(Some(0.0), 50), GlaciationGrade::Light ); // None: warm assert_eq!( derive_glaciation_grade_from_climate(Some(20.0), 50), GlaciationGrade::None ); } #[test] fn glaciation_fjord_gate_at_moderate() { // D-239 §5: fjord requires GlaciationGrade ≥ 2 (Moderate). // Verify that the boundary temperature produces exactly Moderate. let grade = derive_glaciation_grade_from_climate(Some(-10.0), 50); assert!( grade >= GlaciationGrade::Moderate, "−10°C should produce ≥ Moderate glaciation for fjord gate" ); } // ----------------------------------------------------------------------- // T-1025: VegetationClass — derive_vegetation // ----------------------------------------------------------------------- #[test] fn vegetation_airless_is_absent() { // D-239 §2: airless body → vegetation branch absent. assert_eq!( derive_vegetation(None, 50, 20, false, false), VegetationClass::Absent ); } #[test] fn vegetation_warm_moist_low_elevation_is_forest() { // Warm + moist + low elevation → Forest. assert_eq!( derive_vegetation(Some(20.0), 60, 10, false, false), VegetationClass::Forest ); } #[test] fn vegetation_high_elevation_is_barren() { // Warm + moist but very high elevation → Barren (above treeline). assert_eq!( derive_vegetation(Some(20.0), 60, 90, false, false), VegetationClass::Barren ); } #[test] fn vegetation_no_skip_forest_to_barren() { // D-239 §8: Forest→Scrub→Barren, no skip. // Scan a wide range of elevations; verify no jump from Forest directly to Barren. let temp = Some(20.0_f32); // warm enough for forest at low elev let moisture = 60_i32; let mut prev: Option = None; for elev_q in (0..=100).step_by(5) { let v = derive_vegetation(temp, moisture, elev_q, false, false); if let Some(p) = prev { // Absent not reachable here (has atmosphere); skip riparian variants. let is_base = matches!( v, VegetationClass::Forest | VegetationClass::Scrub | VegetationClass::Barren ); let was_base = matches!( p, VegetationClass::Forest | VegetationClass::Scrub | VegetationClass::Barren ); if is_base && was_base { // Can only decrease by one step (Forest→Scrub or Scrub→Barren) // or stay the same. Forest→Barren skip is forbidden. assert!( !(p == VegetationClass::Forest && v == VegetationClass::Barren), "vegetation skipped from Forest to Barren at elev_q={elev_q}" ); } } prev = Some(v); } } #[test] fn vegetation_riparian_upgrades_scrub_to_riparian_scrub() { // A scrub-zone district near perennial water → RiparianScrub. let v = derive_vegetation(Some(20.0), 60, 85, true, false); // high elev = scrub zone assert_eq!(v, VegetationClass::RiparianScrub); } #[test] fn vegetation_riparian_upgrades_forest_to_riparian_thicket() { // A forest-zone district near perennial water → RiparianThicket. let v = derive_vegetation(Some(20.0), 60, 10, true, false); // low elev = forest zone assert_eq!(v, VegetationClass::RiparianThicket); } #[test] fn vegetation_class_discriminants_pinned() { // Load-bearing: renumbering breaks D-010 integer derivation contract. assert_eq!(VegetationClass::Absent as u8, 0); assert_eq!(VegetationClass::Barren as u8, 1); assert_eq!(VegetationClass::Scrub as u8, 2); assert_eq!(VegetationClass::Forest as u8, 3); assert_eq!(VegetationClass::RiparianScrub as u8, 4); assert_eq!(VegetationClass::RiparianThicket as u8, 5); // T-1126: Marine appended (open-water verdict). Ord position is // NON-SEMANTIC — the density ladder does not extend to Marine. assert_eq!(VegetationClass::Marine as u8, 6); } #[test] fn vegetation_open_water_is_marine_and_airless_wins() { // T-1126: the morphology water verdict → Marine… assert_eq!( derive_vegetation(Some(15.0), 80, 5, false, true), VegetationClass::Marine ); // …but airless takes precedence: no climate/vegetation branch at all // (D-239 §2) — an airless sea is Absent, not Marine. assert_eq!( derive_vegetation(None, 80, 5, false, true), VegetationClass::Absent ); // The riparian flag is river-proximity, unrelated to open water: a // riparian river district on land is NOT Marine. assert_eq!( derive_vegetation(Some(15.0), 80, 5, true, false), VegetationClass::RiparianThicket ); } #[test] fn vegetation_hyper_arid_is_barren_not_absent() { // With atmosphere but zero moisture → Barren (not Absent). assert_eq!( derive_vegetation(Some(20.0), 0, 10, false, false), VegetationClass::Barren ); } #[test] fn precipitation_class_keyed_on_temp_and_moisture() { // Verify the derivation is actually f(temperature, moisture), not just moisture. // Same moisture_q=40, cold vs warm → different class. let warm = derive_precipitation_class_from_climate(Some(20.0), 40); let cold = derive_precipitation_class_from_climate(Some(-10.0), 40); // Cold should produce a lower or equal class. assert!( (warm as u8) >= (cold as u8), "warm precip ({warm:?}) should be >= cold ({cold:?}) at moisture_q=40" ); } // ----------------------------------------------------------------------- // T-1027: Morphology classifier — frozen 17-zone vocab + 8-family gates // + seam-matrix compatibility invariant (D-239 §5, §6, §7) // ----------------------------------------------------------------------- /// Helper to call `derive_morphology_zone` with a complete set of defaults, /// overriding only the parameters relevant to the test. `lake_from_hydrology` /// defaults to `false` (T-1184) — no existing caller of this helper tests /// the hydrology-sourced lake gate; see `lake_from_hydrology_true_wins_...` /// below for the dedicated hydrology-path tests. fn zone( tectonic: TectonicClass, glaciation: GlaciationGrade, slope_q: i32, elev_q: i32, ocean_fraction_q: i32, moisture_q: i32, ) -> MorphologyZone { derive_morphology_zone( tectonic, glaciation, slope_q, elev_q, ocean_fraction_q, moisture_q, false, ) } fn zone_defaults() -> MorphologyZone { zone(TectonicClass::Stable, GlaciationGrade::None, 10, 30, 0, 30) } // ── 17-zone vocabulary completeness ────────────────────────────────────── #[test] fn morphology_zone_discriminants_pinned() { // D-239 §6 freeze point: discriminant values must never change. assert_eq!(MorphologyZone::OpenOcean as u8, 0); assert_eq!(MorphologyZone::Lake as u8, 1); assert_eq!(MorphologyZone::TidalFlat as u8, 2); assert_eq!(MorphologyZone::DuneStrand as u8, 3); assert_eq!(MorphologyZone::CliffCoast as u8, 4); assert_eq!(MorphologyZone::Fjord as u8, 5); assert_eq!(MorphologyZone::Delta as u8, 6); assert_eq!(MorphologyZone::Estuarine as u8, 7); assert_eq!(MorphologyZone::AlluvialPlain as u8, 8); assert_eq!(MorphologyZone::RiverBank as u8, 9); assert_eq!(MorphologyZone::MeanderReach as u8, 10); assert_eq!(MorphologyZone::BraidedPlain as u8, 11); assert_eq!(MorphologyZone::ValleyFloor as u8, 12); assert_eq!(MorphologyZone::MountainPass as u8, 13); assert_eq!(MorphologyZone::Alpine as u8, 14); assert_eq!(MorphologyZone::Volcanic as u8, 15); assert_eq!(MorphologyZone::Wetland as u8, 16); } #[test] fn morphology_zone_region_scale_emits_16_of_17() { // Enumerate the zones the district-scale classifier can emit across a // representative input grid. 16 of the 17 frozen zones are reachable here; // BraidedPlain is the lone exception — see the assertion comment below. use std::collections::BTreeSet; let mut seen: BTreeSet = BTreeSet::new(); // All possible combinations of key inputs. let tectonics = [TectonicClass::Stable, TectonicClass::Volcanic]; let glaciations = [ GlaciationGrade::None, GlaciationGrade::Moderate, GlaciationGrade::Heavy, ]; for tec in &tectonics { for gl in &glaciations { for slope in [0, 5, 10, 20, 40, 55, 70] { for elev in [0, 5, 8, 15, 20, 30, 40, 50, 60, 75, 90] { for ocean in [0, 5, 10, 15, 20, 30, 60, 80, 90] { for moist in [0, 10, 30, 60, 80] { let z = zone(*tec, *gl, slope, elev, ocean, moist); seen.insert(z as u8); } } } } } } // BraidedPlain (discriminant 11) is the ONE frozen zone the district-scale // classifier never emits: distinguishing it from Delta needs a lithology // signal (§8 Gravel→braided) that DistrictProfile doesn't carry yet, so it is // deferred to ChunkContext (D-239 §6 implementation note). All other 16 are // reachable. (Lake IS reachable — ocean_fraction 60–79 → Lake, ≥80 → OpenOcean.) assert_eq!( seen.len(), 16, "expected exactly 16/17 district-reachable zones (BraidedPlain deferred), got: {seen:?}" ); } // ── Family hard gates ────────────────────────────────────────────────── #[test] fn volcanic_gate_requires_tectonic_volcanic() { // D-239 §5: LavaField requires tectonic_class == Volcanic. let non_volcanic = zone(TectonicClass::Stable, GlaciationGrade::None, 10, 30, 0, 30); assert_ne!( non_volcanic, MorphologyZone::Volcanic, "non-volcanic tectonic must not produce Volcanic zone" ); let volcanic = zone( TectonicClass::Volcanic, GlaciationGrade::None, 30, 30, 5, 30, ); assert_eq!( volcanic, MorphologyZone::Volcanic, "Volcanic tectonic must produce Volcanic zone" ); } #[test] fn fjord_gate_requires_glaciation_moderate_or_above() { // D-239 §5: fjord requires GlaciationGrade ≥ 2 (Moderate). // Coastal + steep but grade 0 → not Fjord. let no_glaciation = zone( TectonicClass::Stable, GlaciationGrade::None, 50, // high slope 25, 30, // coastal 40, ); assert_ne!( no_glaciation, MorphologyZone::Fjord, "GlaciationGrade::None must not produce Fjord" ); // GlaciationGrade::Light (grade 1) also must not produce Fjord. let light_glaciation = zone( TectonicClass::Stable, GlaciationGrade::Light, 50, 25, 30, 40, ); assert_ne!( light_glaciation, MorphologyZone::Fjord, "GlaciationGrade::Light must not produce Fjord" ); // Moderate (grade 2) + correct slope + coastal → Fjord. let fjord = zone( TectonicClass::Stable, GlaciationGrade::Moderate, 50, // slope_q ≥ 40 25, 30, // ocean_fraction_q ≥ 20 40, ); assert_eq!( fjord, MorphologyZone::Fjord, "Moderate glaciation should produce Fjord" ); } #[test] fn volcanic_zone_overrides_fjord_gate() { // D-239 §5: LavaField gate (volcanic tectonic) is tested before FjordWall. // Even with GlaciationGrade::Heavy + correct slope + coastal, // volcanic tectonic wins. let z = zone( TectonicClass::Volcanic, GlaciationGrade::Heavy, 50, 25, 30, 40, ); assert_eq!( z, MorphologyZone::Volcanic, "Volcanic tectonic must override fjord gate (LavaField before FjordWall)" ); } #[test] fn alpine_subclassification_from_high_elevation() { // §6: Alpine is sub-classification of IncisedGorge family + elev_q ≥ 75. let alpine = zone( TectonicClass::Stable, GlaciationGrade::None, 50, // steep slope 80, // very high elevation 5, // inland 30, ); assert_eq!( alpine, MorphologyZone::Alpine, "high elev + steep slope → Alpine" ); // Same slope but lower elevation → MountainPass, not Alpine. let mountain_pass = zone( TectonicClass::Stable, GlaciationGrade::None, 50, 60, // below Alpine threshold 5, 30, ); assert_eq!( mountain_pass, MorphologyZone::MountainPass, "moderate-high elev + steep slope → MountainPass, not Alpine" ); } #[test] fn wetland_requires_flat_and_moist() { // §8: Wetland ≤5° flats + high moisture. let wetland = zone( TectonicClass::Stable, GlaciationGrade::None, 3, // slope_q ≤ 5 20, 5, 70, // moisture_q ≥ 60 ); assert_eq!(wetland, MorphologyZone::Wetland, "flat + moist → Wetland"); // Flat but dry → not Wetland. let not_wetland = zone( TectonicClass::Stable, GlaciationGrade::None, 3, 20, 5, 20, // moisture < 60 ); assert_ne!( not_wetland, MorphologyZone::Wetland, "flat + dry must not be Wetland" ); } #[test] fn tidal_flat_requires_low_elevation_and_ocean() { // §6: TidalFlat sub-classification from coastal + very low elev. let tidal = zone( TectonicClass::Stable, GlaciationGrade::None, 5, // gentle slope 5, // very low elev (< 10) 25, // ocean signal ≥ 20 30, ); assert_eq!( tidal, MorphologyZone::TidalFlat, "low elev + coastal → TidalFlat" ); } #[test] fn estuarine_requires_delta_plus_ocean() { // §6: Estuarine sub-classification from Delta family + strong ocean signal. let estuarine = zone( TectonicClass::Stable, GlaciationGrade::None, 3, // very flat 15, // low elevation 35, // ocean_fraction_q ≥ 30 → Estuarine 40, ); assert_eq!( estuarine, MorphologyZone::Estuarine, "delta + ocean → Estuarine" ); // Weaker ocean signal stays as Delta. let delta = zone( TectonicClass::Stable, GlaciationGrade::None, 3, 15, 15, // ocean_fraction_q < 30 → Delta 40, ); assert_eq!(delta, MorphologyZone::Delta, "delta + low ocean → Delta"); } #[test] fn alluvial_plain_is_fallback() { // D-239 §5: AlluvialPlain is the fallback when no other gate fires. let z = zone_defaults(); assert_eq!( z, MorphologyZone::AlluvialPlain, "default inputs → AlluvialPlain fallback" ); } // ── Build-time compatibility-matrix invariant (D-239 §7) ───────────────── // // §7: incompatible family pairs cannot be adjacent classifier outputs. // This test encodes the incompatibility matrix and asserts the classifier's // gate ordering structurally cannot emit a forbidden adjacency. // // Valid sharp seams (cliff↔fjord at glaciation threshold, lithology faults) // are PERMITTED. The test validates the gate structure, not runtime adjacency. #[test] fn compatibility_matrix_meander_volcanic_cannot_be_adjacent() { // D-239 §7: MeanderReach ↔ Volcanic is a forbidden pair. // This is structurally impossible because the Volcanic gate (family 1) // fires for ALL tectonic==Volcanic inputs regardless of other params, // and MeanderReach requires tectonic != Volcanic. // Verify: any input producing MeanderReach cannot also produce Volcanic. let produces_meander_with_stable = zone( TectonicClass::Stable, // non-volcanic GlaciationGrade::None, 10, 25, 8, // some water → MeanderReach territory 35, ); // Doesn't matter if this particular call produces MeanderReach, but // any call with TectonicClass::Volcanic must produce Volcanic, not MeanderReach. let volcanic_body_zone = zone( TectonicClass::Volcanic, GlaciationGrade::None, 10, 25, 8, 35, ); assert_eq!( volcanic_body_zone, MorphologyZone::Volcanic, "Volcanic tectonic must always produce Volcanic — never MeanderReach" ); assert_ne!( produces_meander_with_stable, MorphologyZone::Volcanic, "Stable tectonic cannot produce Volcanic" ); } #[test] fn compatibility_matrix_fjord_requires_glaciation_gate() { // §7: Fjord and AlluvialPlain cannot be adjacent without a glaciation // discontinuity. The gate (GlaciationGrade ≥ 2) enforces this: // inputs just below the fjord gate can only produce non-fjord zones. let below_gate = zone( TectonicClass::Stable, GlaciationGrade::Light, // grade 1, below the fjord gate 50, 25, 30, 40, ); assert_ne!( below_gate, MorphologyZone::Fjord, "GlaciationGrade::Light is below fjord gate — must not produce Fjord" ); // One grade above the gate (Moderate = 2) → Fjord. let above_gate = zone( TectonicClass::Stable, GlaciationGrade::Moderate, 50, 25, 30, 40, ); assert_eq!( above_gate, MorphologyZone::Fjord, "GlaciationGrade::Moderate is at fjord gate — must produce Fjord" ); } #[test] fn compatibility_matrix_volcanic_cannot_neighbour_fjord_directly() { // §7: Volcanic ↔ Fjord adjacency is structurally prevented. // Fjord requires non-volcanic tectonic (volcanic gate fires first). // So any Volcanic zone cannot produce Fjord with the same tectonic. // The only way they could neighbour is across a tectonic fault — // which is a valid sharp seam (D-239 §7: permitted). // This test confirms the gate ordering: volcanic check happens BEFORE fjord. let fjord_attempt_with_volcanic = zone( TectonicClass::Volcanic, GlaciationGrade::Heavy, // would qualify for fjord if not volcanic 50, 25, 30, 40, ); assert_eq!( fjord_attempt_with_volcanic, MorphologyZone::Volcanic, "Volcanic tectonic must prevent Fjord production (gate order)" ); } // ----------------------------------------------------------------------- // T-1078 / D-243 §3: derive_district_temperature_c — two-phase split // ----------------------------------------------------------------------- #[test] fn district_modulation_applies_lapse_on_baseline() { // High elevation must produce colder district temperature than sea level, // given the same region baseline. let climate = ClimateConstants::default(); let baseline = Some(15.0f32); // hypothetical region baseline at sea level let sea_level_params = BodyParams { atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), elevation_km: 0.0, ..Default::default() }; let high_params = BodyParams { elevation_km: 4.0, ..sea_level_params.clone() }; let t_sea = derive_district_temperature_c(baseline, &sea_level_params, &climate, 0.0) .expect("breathable body must have temperature"); let t_high = derive_district_temperature_c(baseline, &high_params, &climate, 0.0) .expect("breathable body must have temperature"); assert!( t_high < t_sea, "district at 4 km ({t_high}°C) must be colder than sea level ({t_sea}°C)" ); // 4 km × 6.5 °C/km = 26 °C lapse; clamping may reduce it, but at // least a few degrees should register. let delta = t_sea - t_high; assert!(delta >= 5.0, "4 km elevation delta {delta}°C too small"); } #[test] fn district_modulation_airless_baseline_returns_none() { // None baseline (airless body) → None district temperature. let climate = ClimateConstants::default(); let params = BodyParams { atmosphere: Some("thin".into()), planet_class: Some("frozen".into()), elevation_km: 0.0, ..Default::default() }; let t = derive_district_temperature_c(None, ¶ms, &climate, 0.0); assert_eq!( t, None, "None baseline must propagate as None district temperature" ); } #[test] fn district_modulation_within_class_band() { // Even with high lapse, the clamped output must stay within the class band. let climate = ClimateConstants::default(); let (cold, warm) = climate.envelope("frozen"); // Baseline at the warm end of the frozen band. let baseline = Some(warm); let params = BodyParams { atmosphere: Some("thin".into()), planet_class: Some("frozen".into()), elevation_km: 8.0, // max elevation → would push far below cold end ..Default::default() }; let t = derive_district_temperature_c(baseline, ¶ms, &climate, 0.0) .expect("non-airless body must have temperature"); assert!( t >= cold && t <= warm, "district temperature {t}°C outside frozen band [{cold}, {warm}]" ); } #[test] fn district_modulation_does_not_re_apply_latitude_or_greenhouse() { // The district modulation function must NOT include latitude or greenhouse // effects — those are already in the region baseline. Pass different baselines // (simulating the latitude gradient) and verify the delta is exactly what the // lapse adds, with no additional latitude-induced shift. let climate = ClimateConstants::default(); let params = BodyParams { atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), elevation_km: 2.0, latitude_deg: 0.0, // this should be irrelevant for the modulation ..Default::default() }; // Two different baselines (simulating equatorial vs mid-latitude regions). let t_warm_region = derive_district_temperature_c(Some(20.0), ¶ms, &climate, 0.0); let t_cool_region = derive_district_temperature_c(Some(5.0), ¶ms, &climate, 0.0); // Both get the same lapse (same params), so the delta between them must // equal the delta between the baselines: 15°C. let delta = t_warm_region.unwrap() - t_cool_region.unwrap(); assert!( (delta - 15.0).abs() < 1.0, "district modulation should preserve the baseline delta (got {delta}°C, expected ~15°C)" ); } #[test] fn district_modulation_thin_atmosphere_uses_lower_lapse() { // Thin atmosphere → lapse = 3.5 °C/km (vs 6.5 for standard/breathable). // At 2 km elevation, thin should be ~6 °C warmer than breathable. let climate = ClimateConstants::default(); let baseline = Some(0.0f32); let thin_params = BodyParams { atmosphere: Some("thin".into()), planet_class: Some("frozen".into()), elevation_km: 2.0, ..Default::default() }; let breathable_params = BodyParams { atmosphere: Some("breathable".into()), planet_class: Some("temperate".into()), elevation_km: 2.0, ..Default::default() }; let t_thin = derive_district_temperature_c(baseline, &thin_params, &climate, 0.0).unwrap(); let t_breathable = derive_district_temperature_c(baseline, &breathable_params, &climate, 0.0).unwrap(); // thin lapse: 3.5 × 2 = 7°C; breathable lapse: 6.5 × 2 = 13°C. // t_thin should be ~6°C warmer than t_breathable (both start from 0°C). // Note: clamping to class bands may reduce the difference at band edges. // Just verify the ordering holds. assert!( t_thin > t_breathable || { // If clamping squishes both to the cold end, verify at least thin // didn't produce MORE lapse than breathable. let (frozen_cold, _) = climate.envelope("frozen"); let (_, temperate_warm) = climate.envelope("temperate"); t_thin >= frozen_cold && t_breathable <= temperate_warm }, "thin atmosphere lapse ({t_thin}°C) should be milder than breathable ({t_breathable}°C) at same elevation" ); } }