Files
settled-reach/server/src/atlas/district_profile.rs
T
jpmschweitzerandClaude Fable 5 5cb63b6867 feat(simulation): T-1162 — invention octaves into the Atlas window path (coast crinkle, quarter relief, vegetation patchiness)
Coast: WARP_OCTAVE_WAVELENGTHS_M 5->9 entries (262144m down to 1024m,
Quarter's Nyquist floor) with new min_wavelength_m cutoff plumbing in
coast_warp_px/warp_fbm (the warp previously had no cutoff at all).
Relief: invent_primitives feeds the VOXEL band (1024-128m, salted) into
elev_q/slope_q under the same envelope/cutoff discipline. Vegetation:
new vegetation_invention module — two-tier (BodyParams+latitude envelope
ceiling; single blended fBm field, never-gated 100-400km massif tier at
70% + cutoff-gated district/voxel texture at 30%) perturbing moisture_q
OUTPUT so precipitation/glaciation/vegetation shift as one world-fact;
wire format unchanged. MIN_WL_BANDS_M 5->6 (adds 1024m band).

Mid-implementation correction caught by tests: District's quantized
floor is 4096m (OCTAVE_WAVELENGTHS_M[3]), not 2x DISTRICT_M — the 8192m
and 4096m coast octaves are legitimately District-admitted enrichment;
only 2048/1024m coast + the voxel band are Quarter-exclusive. Docs and
golden cutoffs corrected to match.

New golden: tests/window_derivation_golden.rs + fixture (derivation_
harness pattern, UPDATE_GOLDEN=1 regen) with structural guards pinning
Quarter/District divergence. Bench (zoom_ladder_bench, release): no
regression — all rungs at or below baseline (District c0 3.54->~3.0
us/cell, Quarter 2.10->1.77, orbital 1.48->1.42, n=6400 window
0.86->0.59 ms). Revert-verified voxel_relief cutoff exclusion. Server
lib 1840 passed; new module 11 tests; district_profile 84 (+6, 1
noise-fragile pre-existing test properly fixed via 8-district
latitude-band averaging).

Tickets: T-1162

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:56:34 +02:00

3972 lines
168 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! DistrictProfile carrier — ~1 km scale tier of the D-239 refinement chain (T-1023).
//!
//! A `DistrictProfile` is the coarsest derivation stage: ~1 km² cells (~6 000 per body,
//! roughly 80 × 75), each a pure deterministic function of
//! `(seed, body_params, terrain_analysis, pos)`. Stored in `BodyWorldState.districts`
//! so the Atlas can read zone labels without triggering voxel derivation (D-239 §10,
//! D-203).
//!
//! ## 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;
use serde::{Deserialize, Serialize};
use crate::atlas::coast_invention;
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::region_profile::{self, RegionProfile};
use crate::atlas::scale::{self, BasinDirection, RegionPos};
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<String>,
/// `bodies.atmosphere` — "breathable" | "thin" | "toxic" | "none" | "dense" | NULL
pub atmosphere: Option<String>,
/// `bodies.planet_class` — "temperate" | "arid" | "frozen" | "oceanic" | "volcanic" | … | NULL
pub planet_class: Option<String>,
/// `bodies.tectonic_activity` — optional authored tectonic override.
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
/// from `planet_class`.
pub tectonic_activity: Option<String>,
/// 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<f64>,
}
// ---------------------------------------------------------------------------
// 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,
/// 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<f32>,
/// 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.district_basin_dirs` 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) = &params.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<f32>,
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<f32>,
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<f32>,
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.
pub fn derive_morphology_zone(
tectonic: TectonicClass,
glaciation: GlaciationGrade,
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
moisture_q: i32,
) -> 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).
return MorphologyZone::OpenOcean;
}
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<String, (f32, f32)>,
/// 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<String, f32>,
/// Day/night swing amplitude per atmosphere class (°C).
pub diurnal_amplitude_c: std::collections::BTreeMap<String, f32>,
/// 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<String, f32>,
// ── 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<f32> {
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<f32>,
params: &BodyParams,
constants: &ClimateConstants,
_slope_aspect_deg: f32, // reserved for Q-105 / future per-district aspect
) -> Option<f32> {
// 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).
pub fn derive_moisture_q(
params: &BodyParams,
elev_q: i32,
ocean_fraction_q: i32,
climate: &ClimateConstants,
) -> 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,
};
// Body moisture ceiling — the wettest a district on this body can be.
let ceiling = (base + atmo_boost).clamp(0, 100);
// ── 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)
// ---------------------------------------------------------------------------
/// 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<f32>,
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 cutoff (≥2,048 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 (D-226(d) note): a
// contributing wavelength is never capped BY the request-granularity
// floor ruling itself (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 (`^ 0x5EED_C0DE`)
// 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() ^ 0x5EED_C0DE,
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
/// the batch path so both derivation paths key the invention noise fields on
/// the same world-metre convention. Radius-less bodies fall back to the
/// 1-working-pixel = 1-district convention `derive_district` uses.
fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option<f64>) -> (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),
}
}
// ---------------------------------------------------------------------------
// Public derivation function
// ---------------------------------------------------------------------------
/// Derive a `DistrictProfile` for the district at `pos` on a `~1km` grid.
///
/// Pure (no I/O, no side effects). Inputs are the body's params and the
/// pre-computed `TerrainAnalysis` from Layer 1.
///
/// `grid_cells_per_district` controls how many heightmap cells map to one district
/// cell; default is 8 (at 128×64 working grid, that yields ~80×64 districts ≈
/// ~5 000 districts/body, within the D-203 ~6 000/body budget).
///
/// Temperature and moisture are derived via the D-243 §3/§4 two-phase stack:
/// the edge-fuzz-blended region baseline (from `region_cache` / on-the-fly
/// derivation via [`region_profile::region_baseline_at_district`]) feeds the
/// district modulation ([`derive_district_temperature_c`]). Pass a
/// `&ClimateConstants` to control the tuning constants. The seed chain provides
/// the body-scoped seed.
///
/// ## Parameters
///
/// - `body_id` — the body's string identifier; required for the D-243 §4 climate
/// edge-fuzz warp domain separation (distinct bodies get distinct warps).
/// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`];
/// if a neighbour region is missing it is derived on the fly. Build with
/// [`region_profile::derive_regions_for_body`] before calling this in a loop.
pub fn derive_district_profile(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
pos: DistrictPos,
grid_cells_per_district: usize,
climate: &ClimateConstants,
body_id: &str,
region_cache: &BTreeMap<RegionPos, RegionProfile>,
basin_direction: BasinDirection,
) -> DistrictProfile {
let (rx, ry) = pos;
let w = ta.w;
let h = ta.h;
let gcpr = grid_cells_per_district.max(1);
// T-1125: the batch path runs the SAME invention as the on-demand path — an
// invented centre-point sample of the continuous field (warped coastline +
// slope-independent scatter via `invent_primitives`) instead of the former
// scatter-free cell-aggregate mean. One invention path, shared with
// [`derive_district`], so the two can never silently diverge again; the
// pseudo-grid samples the same truth the 2 km carrier does. (The former mean
// smoothed away exactly the variance the believability contrast metrics
// measure — and carried no invention at all.)
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);
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this district,
// then pass it through build_district_profile so the two-phase derivation path runs.
// The warp uses `seed.seed()` (the body-scoped seed) for domain separation.
// Hoisted above the primitives (T-1125): the invention's driver tier needs
// the baseline for its one-step-stale climate estimate.
let region_baseline_c = region_profile::region_baseline_at_district(
seed.seed(),
body_id,
pos,
body_params,
climate,
seed,
Some(region_cache),
);
let prims = invent_primitives(
seed,
body_params,
climate,
ta,
px,
py,
world_x_m,
world_y_m,
region_baseline_c,
0.0, // batch path — no octave cutoff, matches derive_district's default
);
build_district_profile(
seed,
body_params,
climate,
prims.slope_q,
prims.elev_q,
prims.ocean_fraction_q,
region_baseline_c,
basin_direction,
world_x_m,
world_y_m,
0.0, // batch path — no octave cutoff, matches derive_district's default
)
}
/// Build the climate + morphology fields of a `DistrictProfile` from its three
/// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail
/// of every derivation path (cell-aggregate [`derive_district_profile`] and the
/// interpolation+scatter [`derive_district`]). Pure (T-1024, D-239 §2 / D-240).
///
/// ## 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).
#[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<f32>,
basin_direction: BasinDirection,
world_x_m: f64,
world_y_m: f64,
min_wavelength_m: f64,
) -> 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,
);
// Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet
// (requires perennial waterway map from L2+); default to false for now.
// L2 ChunkContext will override per-tile once drainage data is threaded through.
// 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, false, open_water);
DistrictProfile {
morphology_zone,
tectonic_class,
glaciation_grade,
precipitation_class,
slope_q,
elev_q,
ocean_fraction_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.
#[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,
) -> 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 on-the-fly for
// this position. No pre-built cache here — the on-demand path derives the
// four surrounding region baselines directly. Pure, deterministic, cheap.
// `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.
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,
&params,
climate,
seed,
None, // no pre-built cache; derive on-the-fly
);
// 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,
&params,
climate,
ta,
px,
py,
world_x_m,
world_y_m,
region_baseline_c,
min_wavelength_m,
);
// derive_at_metres is the on-demand path (arbitrary world position, no L1
// working grid). basin_direction is an ACCEPTED LIMITATION here: it
// defaults to North (a fallback, not a computed value) because the D8
// thalweg is only available from the L1 fdir grid the batch path holds.
// Production voxel generation runs through the batch path
// (derive_all_districts), which threads the true D8 direction from L1;
// this on-demand path is the fallback for positions derived outside that
// pass, where a meaningful basin_direction isn't available.
build_district_profile(
seed,
&params,
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,
)
}
/// 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,
&params,
climate,
seed,
None, // no pre-built cache; derive on-the-fly, same posture as derive_at_metres
);
build_district_profile(
seed,
&params,
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,
)
}
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
/// Columns wrap (equirectangular); rows clamp at the poles.
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
}
/// 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-243, T-1077):** this is the *coarse* eager grid — one cell per
/// `gcpr` heightmap pixels (tens-to-hundreds of km) — kept as the Atlas zone
/// overlay source. The **corrected 2 km carrier** the voxel chain consumes is the
/// on-demand [`derive_district`] (heightmap interpolation + detail-scatter via the
/// elastic seam). Swapping production from this eager grid to on-demand
/// district/region surfacing is the chartered job of T-1046; T-1077 provides the
/// correct on-demand derivation, not the eager/Atlas restructure.
///
/// Returns a `BTreeMap<DistrictPos, DistrictProfile>` covering the full
/// heightmap at the given district-grid resolution.
///
/// `grid_cells_per_district = 8` means each district is 8×8 heightmap cells.
///
/// District latitude is derived from the row index: ry=0 maps to the north pole
/// (+90°), ry=district_rows-1 maps to the south pole (-90°). This is a linear
/// mapping across the equirectangular heightmap.
///
/// ## Region cache (D-243 §3/§4, T-1078)
///
/// A [`RegionProfile`] cache is built once per body from all region positions
/// that cover the district grid, then passed to each [`derive_district_profile`]
/// call so the D-243 §4 edge-fuzz blend reads a consistent set of region
/// baselines — all four corners of every blend come from the same derived set.
///
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
/// warp domain separation.
/// `basin_dirs` is the per-district dominant D8 thalweg direction computed in
/// `run_layer1` (T-1047). When `Some`, each district's `basin_direction` is read
/// from the map; missing entries (edge districts with no land cells) default to
/// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs),
/// every district gets `BasinDirection::North`.
pub fn derive_all_districts(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
grid_cells_per_district: usize,
body_id: &str,
basin_dirs: Option<&BTreeMap<DistrictPos, BasinDirection>>,
) -> BTreeMap<DistrictPos, DistrictProfile> {
let climate = ClimateConstants::default();
let gcpr = grid_cells_per_district.max(1);
let district_cols = ta.w.div_ceil(gcpr) as i32;
let district_rows = ta.h.div_ceil(gcpr) as i32;
// Build the region cache once for the whole body before district derivation.
// Collect all unique region positions that cover this district grid, plus
// their immediate neighbours (the edge-fuzz blend samples up to one region
// beyond the district's own region). Using a BTreeSet for determinism (D-010).
let region_positions: std::collections::BTreeSet<RegionPos> = {
let mut set = std::collections::BTreeSet::new();
for ry in 0..district_rows {
for rx in 0..district_cols {
let district_pos = (rx, ry);
let rpos = scale::district_to_region(district_pos);
// The edge-fuzz blend samples the base region and one neighbour
// in each axis direction (±1). Pre-populate all 9 candidates so
// cache hits dominate and on-the-fly derivations are rare.
for dy in -1i32..=1 {
for dx in -1i32..=1 {
set.insert((rpos.0 + dx, rpos.1 + dy));
}
}
}
}
set
};
let region_cache =
region_profile::derive_regions_for_body(seed, body_params, &climate, region_positions);
let mut out = BTreeMap::new();
for ry in 0..district_rows {
// Map ry to latitude: row 0 → +90°, row (rows-1) → -90°.
// For a single-row grid, latitude is 0°.
let lat_deg = if district_rows > 1 {
90.0 - (ry as f64 / (district_rows - 1) as f64) * 180.0
} else {
0.0
};
for rx in 0..district_cols {
let pos = (rx, ry);
// Build per-district params: body-level params + this district's latitude.
// Per-district elevation is NOT set here — derive_district_profile owns it,
// deriving elevation_km from the district's own elev_q (not the caller's
// body_params.elevation_km). Per-cell refinement happens at ChunkContext (D-239).
let district_params = BodyParams {
latitude_deg: lat_deg,
..body_params.clone()
};
let basin_direction = basin_dirs
.and_then(|m| m.get(&pos).copied())
.unwrap_or_default();
let profile = derive_district_profile(
seed,
&district_params,
ta,
pos,
gcpr,
&climate,
body_id,
&region_cache,
basin_direction,
);
out.insert(pos, profile);
}
}
out
}
// ---------------------------------------------------------------------------
// 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(), &params, &ta, 8, "test_body", None);
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
assert_eq!(districts.len(), 32, "district count mismatch");
}
/// T-1047: the `Some(basin_dirs)` threading path — supplied per-district D8
/// directions propagate to `DistrictProfile.basin_direction`, and districts
/// not in the map fall back to the default (North). Guards the path that the
/// production cascade actually uses (the existing tests only exercise `None`).
#[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 DistrictPos keys from a baseline (None) run.
let baseline = derive_all_districts(test_seed(), &params, &ta, 8, "test_body", None);
let mut keys = baseline.keys().copied();
let pos_east = keys.next().expect("at least one district");
let pos_south = keys.next().expect("at least two districts");
let pos_unmapped = keys.next().expect("at least three districts");
let mut basin_dirs: BTreeMap<DistrictPos, BasinDirection> = BTreeMap::new();
basin_dirs.insert(pos_east, BasinDirection::East);
basin_dirs.insert(pos_south, BasinDirection::South);
let districts =
derive_all_districts(test_seed(), &params, &ta, 8, "test_body", Some(&basin_dirs));
assert_eq!(districts[&pos_east].basin_direction, BasinDirection::East);
assert_eq!(districts[&pos_south].basin_direction, BasinDirection::South);
// Unmapped districts fall back to the default direction (North).
assert_eq!(
districts[&pos_unmapped].basin_direction,
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);
}
/// 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<u8, u32> = 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 = (2, 1);
let climate = ClimateConstants::default();
let p1 = derive_district_profile(
test_seed(),
&params,
&ta,
pos,
8,
&climate,
"test_body",
&BTreeMap::new(),
BasinDirection::North,
);
let p2 = derive_district_profile(
test_seed(),
&params,
&ta,
pos,
8,
&climate,
"test_body",
&BTreeMap::new(),
BasinDirection::North,
);
// 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(&params), 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(&params), 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(), &params, &ta, 8, "test_body", None);
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
let first = districts.keys().next().expect("at least one district");
assert_eq!(*first, (0, 0), "first district must be at origin");
}
#[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<f32> {
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(&params, &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(&params, &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(&params, &climate, 12345);
let t2 = derive_temperature_c(&params, &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(&params, &climate, 1);
let t_b = derive_temperature_c(&params, &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<f64> = (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(&params, &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(&params, &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(&params, 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(&params, 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(&params, 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(&params, 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<VegetationClass> = 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.
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,
)
}
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<u8> = 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, &params, &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, &params, &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), &params, &climate, 0.0);
let t_cool_region = derive_district_temperature_c(Some(5.0), &params, &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"
);
}
}