Files
settled-reach/server/src/atlas/district_profile.rs
T
jpmschweitzerandClaude Fable 5 39f0fd8c51 fix(simulation): lake_margin_q normalizes per basin — full-range depth gradient (T-1188)
The PR #206 eyeball caught what every numeric gate passed: the depth
signal was visually flat on both test lakes (GJ1c lmq=0 across the whole
basin; GJ338Bd 0-13 of 100). Two compounding causes: a fixed absolute
ceiling (one body's p90 cell depth) compressing skewed depth
distributions into single digits, and heightmap-pitch depth variation
being sub-texel-tiny within most basins. lake_margin_q is now
depth / the basin's own maximum settled depth: HydrologyResult grows
basin_max_depth_scaled (computed in solve() from existing basin_cells
membership, broadcast per basin), threaded through
HydrologySample.basin_max_depth, normalized in lake_from_hydrology_at
with a degenerate-basin epsilon guard (a genuinely uniform pond shades
flat — honest, not forced). Lake EXISTENCE (filled > original) is
untouched — only tone changes. Measured at district spacing:
GJ1c min=0 p50=33 max=84; GJ338Bd min=8 p50=38 max=70 — full-range
shore-to-deep ramps on both. No perceptual curve added: the linear
per-basin ramp is already well-quartiled. project.yaml 0.4.1 -> 0.4.2
(0.4.1-tagged canvases carrying flat-lmq semantics reached real disk
caches during eyeball runs and must miss). Acceptance gates green;
zero golden churn (lake_margin_q not captured by either golden shape).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:23:11 +02:00

4829 lines
206 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 — the coarsest derivation stage of the D-239 refinement
//! chain (T-1023).
//!
//! In the batch pass a `DistrictProfile` summarizes one survey cell (D-256: an
//! 8×8 working-pixel block, 64×32 ≈ 2 048 cells per body), each a pure
//! deterministic function of `(seed, body_params, terrain_analysis, pos)` sampled
//! at the cell's centre world metres through the shared
//! `derive_at_metres_with_riparian` core; the on-demand pass derives the same
//! struct at any absolute world position. Stored in `BodyWorldState.districts`
//! so the Atlas can read zone labels without triggering voxel derivation
//! (D-239 §10, D-203).
//!
//! ## D-010 compliance
//!
//! All gating parameters (`GlaciationGrade`, `TectonicClass`, `river_threshold`) are
//! derived from integer body params via integer arithmetic. No `HashMap` or
//! floating-point comparison in the derivation path.
//!
//! ## MorphologyZone (T-1027, D-239 §6 freeze point)
//!
//! The `morphology_zone` field uses the D-239 §6 **frozen 17-zone** `MorphologyZone`
//! enum from `simulation::generator`. The enum is the canonical freeze; adding,
//! renaming, or removing a zone requires a D-record amendment.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::atlas::body_world_state::RiverNetwork;
use crate::atlas::coast_invention;
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::region_profile::{self, RegionProfile};
use crate::atlas::river_course;
use crate::atlas::scale::{self, BasinDirection, RegionPos, SurveyCellPos};
use crate::seed::SeedChain;
use crate::simulation::generator::MorphologyZone;
// ---------------------------------------------------------------------------
// Supporting enums
// ---------------------------------------------------------------------------
/// Tectonic activity class — integer-discriminant, append-only (D-010).
///
/// Used in morphology gates (D-239 §5): LavaField requires `Volcanic`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum TectonicClass {
/// Stable craton — minimal volcanism, low earthquake risk.
#[default]
Stable = 0,
/// Active rifting / orogenesis — mountains, earthquakes, some volcanism.
Active = 1,
/// Dominant volcanic activity — lava fields, shield volcanoes.
Volcanic = 2,
/// Tidally-stressed body — volcanism driven by gravitational flexing.
TidallyForced = 3,
}
/// Glaciation grade (04) — 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
/// 13 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 — 13 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 — 13 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 04 (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 (0100 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 (0100 scale).
/// Average of `TerrainAnalysis.elev_pct` × 100 across district cells.
pub elev_q: i32,
/// Ocean fraction for this district (0100 scale, integer).
pub ocean_fraction_q: i32,
/// Settled-hydrology lake-margin depth band (T-1188, D-227 amendment (4)
/// continued): `0` at/near the shoreline (the `filled == elevation`
/// crossing `lake_from_hydrology_at` gates on), ramping toward `100` as
/// the settled water surface sits deeper above the original bedrock,
/// relative to THIS BASIN's own maximum depth — quantized
/// `((filled - elevation) / basin_max_depth * 100)`, clamped, `0` when
/// the basin's own max depth is degenerate (see
/// `LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`). `0` for every non-lake cell
/// (never negative — a cell with no settled water above it has no
/// margin to shade). This is the lake counterpart to `ocean_fraction_q`'s
/// coastal transition-zone gradient: `ocean_fraction_q` is always `0`
/// inside a lake basin (lakes sit ABOVE sea level; `ta.ocean_mask` never
/// fires there), so the existing TidalFlat/DuneStrand/CliffCoast/
/// Estuarine morphology gates — every one keyed on `ocean_fraction_q` —
/// are structurally unreachable at a lake edge. `lake_margin_q` gives
/// the client a continuous tone source for lake shorelines without
/// inventing a second morphology-classification path; see
/// `lake_from_hydrology_at`'s doc for the full per-basin-normalization
/// rationale (PR #206 eyeball finding: a fixed absolute ceiling read
/// visually flat on real lakes).
#[serde(default)]
pub lake_margin_q: i32,
/// Per-district river threshold (D-239 §1). Replaces the global
/// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant
/// itself is unchanged — this value is what `DistrictProfile` carries downstream.
pub river_threshold: i32,
/// Nullable mean-annual temperature in °C (T-1024 forward declaration).
/// `None` if no atmosphere (airless; see D-239 §2, D-227).
pub temperature_c: Option<f32>,
/// Moisture primitive (T-1024 forward declaration).
/// Integer 0100; 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 13 tile band along
/// perennial waterways (D-239 §8 climate→vegetation law).
pub vegetation_class: VegetationClass,
/// Dominant D8 thalweg direction for this district (T-1047, D-239 §8).
///
/// The **true** D8-computed dominant flow direction aggregated from the full
/// `fdir` grid in `run_layer1` — not a seed-bit proxy. Threaded here from
/// `Layer1Output.survey_basin_dirs` (D-256(b): a survey-cell-keyed aggregate,
/// looked up by identity in `derive_all_districts` — see that function's
/// doc) so `derive_chunk_context` reads it directly instead of calling the
/// formerly-false `derive_basin_direction`.
///
/// `#[serde(default)]` ensures backward compatibility when deserializing
/// stored profiles that predate this field (T-1047).
#[serde(default)]
pub basin_direction: BasinDirection,
}
// ---------------------------------------------------------------------------
// Derivation helpers
// ---------------------------------------------------------------------------
/// Derive `TectonicClass` from body params.
///
/// Respects the optional `tectonic_activity` authored override; falls back to
/// `planet_class` derivation. Integer / string comparison only (D-010).
fn derive_tectonic_class(params: &BodyParams) -> TectonicClass {
// Authored override takes precedence.
if let Some(ta) = &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` (0100) 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 13 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 (010): 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: 13 tile band along perennial waterways.
// Upgrades Scrub/Barren → RiparianScrub; upgrades Forest → RiparianThicket.
if near_perennial_water {
match base_class {
VegetationClass::Forest => VegetationClass::RiparianThicket,
VegetationClass::Scrub | VegetationClass::Barren => VegetationClass::RiparianScrub,
// Absent already returned above.
other => other,
}
} else {
base_class
}
}
/// Derive per-district river threshold from tectonic class + precipitation (D-239 §1).
///
/// Replaces the global `RIVER_THRESHOLD = 200` for tile-layer consumers.
/// Higher precipitation → lower threshold (more rivers).
/// All arithmetic is integer (D-010).
pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClass) -> i32 {
let tectonic_bonus: i32 = match tectonic {
TectonicClass::Active => -30,
TectonicClass::Volcanic => -20,
TectonicClass::TidallyForced => -10,
TectonicClass::Stable => 0,
};
let precip_factor: i32 = match precip {
PrecipitationClass::Arid => 100,
PrecipitationClass::SemiArid => 50,
PrecipitationClass::Temperate => 0,
PrecipitationClass::Humid => -50,
PrecipitationClass::SuperHumid => -80,
};
// Base of 200 + body-level adjustments, clamped to [20, 500].
(200 + precip_factor + tectonic_bonus).clamp(20, 500)
}
/// Derive `MorphologyZone` from `DistrictProfile` gating params (D-239 §5, §6, §7).
///
/// Implements the 8-family decision tree over integer inputs, in the gate order
/// specified by D-239 §5. Hard boolean gates are pre-selection (§5); output is
/// then refined to one of the 17 frozen zone labels (§6) by sub-classification
/// from family + elevation/water-height (§6: four zones are sub-classifications).
///
/// Gate order (D-239 §5, most-constrained first per §7):
/// 1. Water bodies (submerged fraction)
/// 2. LavaField — gate: tectonic == Volcanic (§5, §8 lava law)
/// 3. FjordWall — gate: GlaciationGrade ≥ 2 + slope + coastal (§5)
/// 4. CliffCoast — gate: high slope + coastal + NOT fjord (§8 Rock→vertical)
/// 5. BraidedDelta — gate: very low slope + low elev + coastal (§8 Gravel→braided)
/// 6. DuneStrand — gate: low slope + coastal + moderate elev (§8 Sand→dunes)
/// 7. IncisedGorge — gate: high slope + inland + high elev (→MountainPass label)
/// 8. MeanderReach — gate: gentle slope + some water presence
/// 9. AlluvialPlain — fallback (§5)
///
/// Sub-classifications (§6): after family selection, apply. NOTE: the §6 "parent
/// family" names are descriptive of the typical source, not exhaustive — these
/// zones derive from morphological conditions (low elev + tidal signal; flat +
/// moist) that legitimately overlap several families, so a zone can arise from
/// more than one family context. The actual emission sites:
/// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD.
/// - Wetland: flat (slope_q ≤ 5) + moisture ≥ 60; emitted from the AlluvialPlain fallback AND within the MeanderReach gate (§8 Wetland ≤5° flats).
/// - TidalFlat: very low elev + ocean signal; emitted from the BraidedDelta and DuneStrand gates AND a standalone low-coast gate after Family 5.
/// - Estuarine: Delta family + strong ocean signal (brackish tidal zone, river mouth).
/// - ValleyFloor and RiverBank are their own gates (ValleyFloor between IncisedGorge and MeanderReach; RiverBank within the MeanderReach gate).
/// - BraidedPlain (§6) is NOT emitted at district scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) DistrictProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note).
///
/// D-010: all gates are integer comparisons. No float arithmetic in this function.
///
/// `lake_from_hydrology` (T-1184, D-227 amendment (4) / D-255(f) mechanism B):
/// the caller-computed settled-hydrology basin verdict at this exact
/// position — `true` when a bilinear sample of `HydrologyResult.filled_scaled`
/// exceeds a bilinear sample of the original elevation at the SAME position
/// (the continuous filled-surface comparison; never a discrete basin-cell
/// membership lookup, which would give a blocky, non-refining lake edge).
/// `false` both when hydrology genuinely found no lake here AND when no
/// `HydrologyResult` is available at all (`TerrainAnalysis.hydrology ==
/// None`) — both cases fall through to the pre-existing `ocean_fraction_q`
/// heuristic below unchanged, so a caller with no hydrology data reproduces
/// today's behaviour byte-for-byte. This is a MORE AUTHORITATIVE trigger
/// checked AHEAD OF the heuristic (per the araminta-round2.md §(e) ruling:
/// "Sea vs. Lake stays exactly as today... the `Lake` emission site gains a
/// second, more-authoritative trigger ahead of the existing heuristic
/// fallback") — it never touches the `ocean_fraction_q >= 80` open-ocean
/// tier, which stays exactly as before.
pub fn derive_morphology_zone(
tectonic: TectonicClass,
glaciation: GlaciationGrade,
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
moisture_q: i32,
lake_from_hydrology: bool,
) -> MorphologyZone {
// ── Tier 0: fully submerged ──────────────────────────────────────────────
if ocean_fraction_q >= 80 {
// Very high ocean fraction: open ocean or lake depending on context.
// No body-scale salinity signal at district level yet; treat all as OpenOcean.
// Lake differentiation lives at ChunkContext (D-239 §10).
//
// Unchanged by T-1184: a settled-hydrology lake basin can never
// reclassify a cell the raw heightmap already reads as ≥80% below
// sea level back OUT of OpenOcean — hydrology only ever ADDS Lake
// coverage the heuristic was missing, never removes the open-ocean
// floor. (Also structurally moot: `HydrologyResult`'s priority-flood
// seeds from below-sea-level cells, so a genuine open-ocean cell's
// `filled == original` there — no lake basin ever covers it.)
return MorphologyZone::OpenOcean;
}
if lake_from_hydrology {
return MorphologyZone::Lake;
}
if ocean_fraction_q >= 60 {
return MorphologyZone::Lake;
}
// ── Family 1: LavaField ─────────────────────────────────────────────────
// Hard gate: tectonic == Volcanic (D-239 §5; §8 Lava→sheets/shield slopes).
if tectonic == TectonicClass::Volcanic {
return MorphologyZone::Volcanic;
}
// ── Family 2: FjordWall ─────────────────────────────────────────────────
// Hard gate: GlaciationGrade ≥ 2 (D-239 §5) + steep slope + coastal.
// §8: glaciation→form law: fjord ≥ 2.
if glaciation >= GlaciationGrade::Moderate && slope_q >= 40 && ocean_fraction_q >= 20 {
return MorphologyZone::Fjord;
}
// ── Family 3: CliffCoast ────────────────────────────────────────────────
// §8 lithology law: Rock → vertical faces. High slope + coastal + not fjord.
if slope_q >= 55 && ocean_fraction_q >= 15 && elev_q >= 20 {
return MorphologyZone::CliffCoast;
}
// ── Family 4: BraidedDelta ──────────────────────────────────────────────
// §8 lithology law: Gravel → braided channels/fans, not single-thread meander.
// Very flat + low elevation + coastal/water presence.
if slope_q <= 5 && elev_q < 20 && ocean_fraction_q >= 10 {
// Sub-classification: Estuarine if strong ocean signal (brackish tidal zone).
if ocean_fraction_q >= 30 {
return MorphologyZone::Estuarine;
}
// Sub-classification: TidalFlat if very low elev (regularly inundated zone).
if elev_q < 10 && ocean_fraction_q >= 15 {
return MorphologyZone::TidalFlat;
}
return MorphologyZone::Delta;
}
// ── Family 5: DuneStrand ────────────────────────────────────────────────
// §8 lithology law: Sand → ≤~32° angle of repose, dunes not cliffs.
// Low slope + coastal + arid/semi-arid (low moisture → loose sand).
if slope_q <= 20 && ocean_fraction_q >= 15 && elev_q < 30 && moisture_q <= 30 {
// Sub-classification: TidalFlat if very low elev + tidal signal (ocean_fraction_q).
if elev_q < 10 && ocean_fraction_q >= 20 {
return MorphologyZone::TidalFlat;
}
return MorphologyZone::DuneStrand;
}
// Tidal flat also reachable from non-arid low coasts.
if ocean_fraction_q >= 20 && elev_q < 8 && slope_q <= 8 {
return MorphologyZone::TidalFlat;
}
// ── Family 6: IncisedGorge → MountainPass / Alpine ──────────────────────
// High slope + high elevation + inland (non-coastal).
// §5: MountainPass is a zone label sharing IncisedGorge geometry.
// §6: Alpine is a sub-classification from IncisedGorge family + very high elevation.
if slope_q >= 40 && elev_q >= 50 && ocean_fraction_q < 20 {
// Sub-classification: Alpine above treeline elevation threshold.
// elev_q ≥ 75 → alpine zone (no forest, exposed rock/ice).
if elev_q >= 75 {
return MorphologyZone::Alpine;
}
return MorphologyZone::MountainPass;
}
// ValleyFloor: moderate slope + high-ish elevation + enclosed.
if (15..40).contains(&slope_q) && elev_q >= 40 && ocean_fraction_q < 20 {
return MorphologyZone::ValleyFloor;
}
// ── Family 7: MeanderReach ──────────────────────────────────────────────
// §8 lithology law: Soil → rolling/floodplain; single-thread meander.
// Gentle slope + some water presence.
if slope_q <= 20 && ocean_fraction_q >= 5 {
// Sub-classification: Wetland if very flat + high moisture (§8 Wetland ≤5° flats).
if slope_q <= 5 && moisture_q >= 60 {
return MorphologyZone::Wetland;
}
// RiverBank if moderate water presence but not delta/braided.
if (10..30).contains(&ocean_fraction_q) {
return MorphologyZone::RiverBank;
}
return MorphologyZone::MeanderReach;
}
// ── Family 8: AlluvialPlain — fallback ──────────────────────────────────
// §5: fallback for all remaining cases.
// Sub-classification: Wetland if very flat + high moisture.
if slope_q <= 5 && moisture_q >= 60 {
return MorphologyZone::Wetland;
}
MorphologyZone::AlluvialPlain
}
// ---------------------------------------------------------------------------
// Climate constants (T-1024, D-239 §2)
// ---------------------------------------------------------------------------
/// Maximum per-district elevation (km), used to scale a district's normalized
/// elevation (`elev_q`, 0100) 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.01.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 equatorpole 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.01.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 (0100 integer; 0 = arid, 100 = saturated).
///
/// D-239 §2 / T-1080: moisture is a **body ceiling × per-district spatial gradient**.
/// The body ceiling comes from `hydrosphere` + `atmosphere` (an ocean world is wetter
/// on average); the gradient then varies it across the body so the climate field is
/// not a single constant (the T-1080 bug: `moisture_q = 80` for all 2048 districts,
/// which left vegetation/terrain/ecotones uniform). A living world is wetter at the
/// **coast / lowland / tropics** and drier toward the **interior / highland / poles**:
/// - **latitude** — equator wet → pole dry (`params.latitude_deg`, set per-district).
/// - **elevation** — high ground drier (orographic / rain-shadow, `elev_q`).
/// - **continentality** — interior drier than coast (`100 ocean_fraction_q`).
///
/// Penalties are subtractive points from the ceiling, magnitudes tuned in
/// [`ClimateConstants`] (`moisture_*_penalty`, provisional pending Q-123). Integer
/// arithmetic throughout (D-010); the only float is the deterministic `latitude_deg`
/// truncation at the decision boundary, mirroring the `slope_q`/`elev_q` aggregation.
///
/// The `hydro` arms use the **actual `bodies.hydrosphere` vocabulary in systems.db**
/// — same set the `[hydrosphere_maritime]` table (D-240) keys on — grouped by available
/// surface moisture (T-1034).
/// Body-wide moisture ceiling — the wettest a district on this body can be,
/// from `hydrosphere` + `atmosphere` alone (T-1080's `ceiling` term, before
/// the per-district latitude/elevation/continentality gradient). Extracted
/// (T-1184) so a body-level-only consumer — [`crate::atlas::hydrology_equilibrium`]'s
/// `ClimateInputs::moisture_q`, which needs exactly this single scalar and
/// nothing position-specific — can share the vocabulary table with
/// [`derive_moisture_q`] instead of re-deriving a parallel one that could
/// silently drift from it. Byte-identical to the `ceiling` local this
/// function's caller computed inline before the extraction.
pub fn derive_moisture_ceiling_q(params: &BodyParams) -> i32 {
let hydro = params.hydrosphere.as_deref().unwrap_or("none");
let atmo = params.atmosphere.as_deref().unwrap_or("none");
let base: i32 = match hydro {
// Large surface liquid — saturated.
"liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 80,
// Partial surface water (rivers / lakes / seasonal) — moderate.
"rivers" | "rivers-lakes" | "moderate" => 55,
// Frozen surface water — low available moisture.
"ice" => 20,
// Buried water — minimal surface effect.
"subsurface_liquid" => 15,
"subsurface" | "subsurface_ice" => 10,
// Effectively dry.
"minimal" | "trace" => 5,
"none" => 0,
// Unknown vocab — conservative mid-low default.
_ => 30,
};
let atmo_boost: i32 = match atmo {
"none" => -20,
"thin" => -10,
"standard" | "breathable" => 0,
"toxic" => 5,
"dense" => 15,
_ => 0,
};
(base + atmo_boost).clamp(0, 100)
}
pub fn derive_moisture_q(
params: &BodyParams,
elev_q: i32,
ocean_fraction_q: i32,
climate: &ClimateConstants,
) -> i32 {
// Body moisture ceiling — the wettest a district on this body can be.
let ceiling = derive_moisture_ceiling_q(params);
// ── Per-district spatial gradient (T-1080) ────────────────────────────────
// Latitude: equator (0) wet → pole (90) dry. `latitude_deg` is per-district.
let lat_q = (params.latitude_deg.abs() as i32).clamp(0, 90) * 100 / 90; // 0..100
let lat_penalty = lat_q * climate.moisture_lat_penalty / 100;
// Elevation: high ground is drier (orographic uplift / rain-shadow / less retention).
let elev_penalty = elev_q.clamp(0, 100) * climate.moisture_elev_penalty / 100;
// Continentality: interior (low ocean fraction) is drier than coast / open water.
let interiorness = (100 - ocean_fraction_q.clamp(0, 100)).max(0);
let cont_penalty = interiorness * climate.moisture_continental_penalty / 100;
(ceiling - lat_penalty - elev_penalty - cont_penalty).clamp(0, 100)
}
// ---------------------------------------------------------------------------
// T-1125 — invented primitives (shared by both derivation paths)
// ---------------------------------------------------------------------------
/// Distinct hash-path salt for the T-1162 sub-district relief stream (part
/// b) — keeps `voxel_relief`'s noise uncorrelated with the district-band
/// `terrain_detail` scatter sampled at the same position. Promoted to a
/// named const (Tyre, PR #194 forward-contract for T-1156) matching
/// `vegetation_invention::VEGETATION_MASSIF_SALT`/`VEGETATION_TEXTURE_SALT`'s
/// named-and-greppable convention, ahead of T-1156 adding a fourth
/// isolated stream (rivers) — a fourth inline hex literal here would have
/// made the pattern harder to audit at a glance.
const WINDOW_RELIEF_SALT: u64 = 0x5EED_C0DE;
/// The invented `(slope_q, elev_q, ocean_fraction_q)` triple (T-1125, D-227).
struct InventedPrimitives {
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
}
/// Compose the invented district primitives at one position — the shared front
/// half of BOTH derivation paths ([`derive_district`] on-demand and
/// [`derive_district_profile`] batch), so the D-227 invention can never diverge
/// between them again (the pre-T-1125 failure: the batch path was scatter-free).
///
/// Steps:
/// 1. **Driver tier (one-step-stale, T-1125 circularity ruling):** the raw
/// *unwarped* bilinear envelope feeds a provisional moisture/temperature →
/// [`derive_glaciation_grade_from_climate`] — the coast character reads
/// these stale drivers; it never reads the warped values it produces. The
/// raw inputs are the coarse ~40160 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,024128 m — "the rolling hills a walking character navigates by",
// T-1081) fed into window classification for the first time. Same
// envelope/ruggedness inputs as the district-band `scatter` above (one
// amplitude ceiling, two wavelength bands composing additively — never a
// second independently-tuned amplitude rule) and the SAME
// `min_wavelength_m` cutoff. At District's real Nyquist floor (4,096 m,
// `layer_proxy::MIN_WL_BANDS_M`) every VOXEL_OCTAVE_WAVELENGTHS_M entry
// (all ≤1,024 m) is truncated, so `relief` is always exactly 0.0 there —
// District's output is unchanged byte-for-byte. At Quarter's cutoff
// (1,024 m) only the two coarsest voxel-band entries (1,024, 512)
// survive; the two finest (256, 128) stay truncated even at Quarter
// (below Quarter's own 512 m spacing's Nyquist floor of 1,024 m) — this
// is correct and expected per the D-226 T-1150 wire-contract note and
// T-1162 refinement resolution (2): a contributing wavelength is never
// capped by the rung's sample-density floor alone (the coast warp
// already crosses scales the other way), it is simply that only 2 of
// the 4 voxel octaves are coarse enough to matter at Quarter's own
// sample density; the other two are reserved for a future
// finer-than-Quarter rung. Distinct seed salt
// ([`WINDOW_RELIEF_SALT`]) keeps this stream uncorrelated with
// `scatter`'s stream at the same position (same isolation discipline as
// `coast_invention`'s warp salt).
let relief = crate::atlas::detail_scatter::voxel_relief(
seed.seed() ^ WINDOW_RELIEF_SALT,
world_x_m,
world_y_m,
env_amp,
ruggedness,
min_wavelength_m,
);
// Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut
// steep — fjord walls and cliff coasts. The gentle scatter floor alone can
// never voice the D-239 steep coastal families (Fjord gates at slope_q ≥ 40,
// CliffCoast at ≥ 55), so where the invented position is coastal
// (`shoreline` peaks at the ocean-fraction midpoint) and the character is
// ridged (fjord/tectonic), the normalized scatter contributes real slope —
// patchy by construction (it reuses the scatter noise), so a glaciated
// coast grows fjord SEGMENTS, not a uniform fjord stripe.
let shoreline = (ocean_frac * (1.0 - ocean_frac) * 4.0).clamp(0.0, 1.0);
let carve = 0.55 * ch.ridge * shoreline * (scatter.abs() / env_amp.max(0.05)).clamp(0.0, 1.0);
// Both bands add into the same elevation/slope quantization — `relief` is
// 0.0 at every cutoff ≥ 2,048 m (District and coarser), so this sum is
// byte-identical to the pre-T-1162 `elev_pct + scatter` wherever the
// cutoff discipline says it must be.
InventedPrimitives {
elev_q: (((elev_pct + scatter + relief) * 100.0).round() as i32).clamp(0, 100),
slope_q: (((local_slope + ruggedness * (scatter.abs() + relief.abs()) + carve) * 100.0)
.round() as i32)
.clamp(0, 100),
ocean_fraction_q: ((ocean_frac * 100.0).round() as i32).clamp(0, 100),
}
}
/// Fractional working-grid position → absolute world metres — the inverse of
/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam). Used by
/// [`derive_district_profile`] (D-256(b)) to convert a survey cell's centre
/// pixel to the world metres it hands to the shared [`derive_at_metres_with_riparian`]
/// core — both derivation paths key their invention noise fields on the same
/// world-metre convention this way. Radius-less bodies fall back to the
/// 1-working-pixel = 1-district convention `derive_district` uses.
///
/// `pub(crate)` (T-1170): also used by the river course inventor
/// (`river_course::cell_world_m`) to resolve a river cell's pixel position to
/// its world-metre anchor — the SAME mapping, reused rather than duplicated.
pub(crate) fn pixel_to_world_m(
px: f64,
py: f64,
w: usize,
h: usize,
radius_km: Option<f64>,
) -> (f64, f64) {
match radius_km {
Some(r) if r > 0.0 => {
let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0);
let lat_frac = if h > 1 {
py / (h - 1) as f64 - 0.5
} else {
0.0
};
(wx, lat_frac * (std::f64::consts::PI * r * 1000.0))
}
_ => (px * scale::DISTRICT_M as f64, py * scale::DISTRICT_M as f64),
}
}
/// Absolute world metres → fractional working-grid pixel — the inverse of
/// [`pixel_to_world_m`], and the SAME mapping [`derive_at_metres`]/
/// [`derive_orbital_at_metres`] compute inline for their own `(px, py)`
/// derivation (T-1170: extracted as a standalone `pub(crate)` helper rather
/// than duplicated a third time, for the river course inventor's Stage A
/// valley-seeking control path, which needs bilinear `elev_pct` reads at
/// arbitrary world positions without paying for a full `DistrictProfile`
/// derive per candidate — Ruling 3b, binding: "NOT `derive_at_metres` per
/// candidate"). Returns `(px, py)` only — callers that also need latitude
/// (temperature-sensitive derivation) still compute it themselves; the course
/// inventor's Stage A elevation proxy has no use for latitude.
pub(crate) fn world_m_to_pixel(
wx: f64,
wy: f64,
w: usize,
h: usize,
radius_km: Option<f64>,
) -> (f64, f64) {
match radius_km {
Some(r_km) if r_km > 0.0 => {
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
let px = (wx / circumference_m).rem_euclid(1.0) * w as f64;
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5);
let py = (0.5 + lat_frac) * h.saturating_sub(1) as f64;
(px, py)
}
_ => {
let dm = scale::DISTRICT_M as f64;
let px = (wx / dm).clamp(0.0, w.saturating_sub(1) as f64);
let py = (wy / dm).clamp(0.0, h.saturating_sub(1) as f64);
(px, py)
}
}
}
// ---------------------------------------------------------------------------
// Public derivation function
// ---------------------------------------------------------------------------
/// Derive a `DistrictProfile` for the survey cell at `pos` on the D-256(b)
/// survey raster — a thin wrapper over the shared [`derive_at_metres_with_riparian`]
/// core at the survey cell's centre world metres (D-256(c): "one derive core,
/// two position sets").
///
/// Pure (no I/O, no side effects). Inputs are the body's params and the
/// pre-computed `TerrainAnalysis` from Layer 1.
///
/// `grid_cells_per_district` controls how many heightmap cells map to one survey
/// cell; default is 8 (`scale::HEIGHTMAP_CELLS_PER_DISTRICT`, at the standard
/// working grid `heightmap::GRID_W` × `heightmap::GRID_H`, T-1170) — several
/// thousand cells/body, within the D-203 budget.
///
/// ## Survey-cell-centre position (D-256(b))
///
/// The centre pixel is the midpoint of the cell's covering pixel block,
/// `8·rx + 3.5` in the interior (the geometrically correct centre of the
/// 8-point bilinear sample lattice — kept exactly as before this ticket, NOT
/// an error) — clamped at the grid edges where the block is truncated
/// (`saturating_add`/`.min(w)`/`.min(h)`), matching this function's
/// pre-D-256 pixel-range math exactly. That pixel is converted to world
/// metres via [`pixel_to_world_m`], then handed to the shared core with
/// `min_wavelength_m = 0.0` (no octave cutoff, matching [`derive_district`]'s
/// own default).
///
/// ## Region baseline + latitude (D-256(c))
///
/// The core derives its own latitude and region-climate baseline from the
/// survey-cell-centre world metres — this is what auto-fixes the two latent
/// same-position divergences the D-256 investigation found: one inverse
/// mapping computed once cannot disagree with itself (the former three
/// inconsistent latitudes collapse to one), and the region baseline now
/// floor-divides the TRUE world metres instead of keying off the pseudo-grid
/// index (the former body-uniform region-(0,0) climate collapse).
///
/// ## Parameters
///
/// - `body_id` — the body's string identifier; required for the D-243 §4 climate
/// edge-fuzz warp domain separation (distinct bodies get distinct warps).
/// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`];
/// if a neighbour region is missing it is derived on the fly. Build with
/// [`region_profile::derive_regions_for_body`] before calling this in a loop.
/// - `basin_direction` — the true L1 D8 thalweg direction for this cell.
/// `basin_direction` is an inert pass-through field (nothing in the
/// derivation reads it — the D-256 ruling's proof), so it is applied via a
/// post-call field override on the core's returned profile rather than
/// threaded through the core itself.
/// - `river_network` — the body's [`RiverNetwork`] (T-1168, Ruling 4b/4c),
/// consulted for the riparian point test via
/// [`river_course::near_perennial_water_at`] (edges near this cell
/// invented on demand, the same pure function the window path uses).
/// `None` when no river network is available (e.g. a body with no Layer-1
/// drainage pass, or a caller that predates T-1168) — the riparian signal
/// degrades to `false` in that case, matching the pre-T-1168 hardcoded
/// default exactly, never a panic or an error.
pub fn derive_district_profile(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
pos: SurveyCellPos,
grid_cells_per_district: usize,
climate: &ClimateConstants,
body_id: &str,
region_cache: &BTreeMap<RegionPos, RegionProfile>,
basin_direction: BasinDirection,
river_network: Option<&RiverNetwork>,
) -> DistrictProfile {
let SurveyCellPos(rx, ry) = pos;
let w = ta.w;
let h = ta.h;
let gcpr = grid_cells_per_district.max(1);
// D-256(b): the survey cell's covering pixel block, clamped at the grid
// edges — UNCHANGED from the pre-D-256 cell-aggregate-centre math (only
// the position TYPE changed, not the arithmetic).
let row_start = (ry as usize).saturating_mul(gcpr).min(h);
let row_end = row_start.saturating_add(gcpr).min(h);
let col_start = (rx as usize).saturating_mul(gcpr).min(w);
let col_end = col_start.saturating_add(gcpr).min(w);
let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0;
let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0;
let (world_x_m, world_y_m) = pixel_to_world_m(px, py, w, h, body_params.body_radius_km);
// T-1168 Ruling 4b: batch-path riparian signal — edges near this
// cell invented on demand via the SAME pure function the window
// path uses. `river_network.is_none()` degrades to `false` (see this
// function's doc), never a panic.
let near_perennial_water = river_network
.map(|rn| {
river_course::near_perennial_water_at(
seed,
ta,
body_params,
rn,
(world_x_m, world_y_m),
scale::DISTRICT_M as f64,
0.0, // batch path — no octave cutoff, matches this function's own default
)
})
.unwrap_or(false);
// D-256(c): the shared core, at the survey-cell-centre world metres, with
// the pre-built region cache (batch performance — thousands of cells
// sharing one derived region set).
let mut profile = derive_at_metres_with_riparian(
seed,
body_id,
body_params,
ta,
world_x_m,
world_y_m,
climate,
0.0, // batch path — no octave cutoff, matches derive_district's default
near_perennial_water,
Some(region_cache),
);
// D-256(c) binding requirement 1: basin_direction is inert to every other
// field's derivation (proven in the D-256 ruling) — a post-call override
// with the true L1 D8 value is exactly equivalent to threading it through
// the core, and keeps the core itself free of a field only the batch
// path can supply.
profile.basin_direction = basin_direction;
profile
}
/// Build the climate + morphology fields of a `DistrictProfile` from its three
/// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail
/// every position-derivation caller reaches (D-256(c): `derive_district_profile`'s
/// survey-cell-centre position and `derive_district`'s exact district position
/// both route through it via [`derive_at_metres_with_riparian`]). Pure (T-1024,
/// D-239 §2 / D-240).
///
/// ## Region baseline parameter (D-243 §3, T-1078)
///
/// `region_baseline_c` is the edge-fuzz-blended region mean temperature from
/// [`crate::atlas::region_profile::region_baseline_at_district`].
///
/// - When `Some(baseline)`: district temperature is derived as a **modulation**
/// of the baseline via [`derive_district_temperature_c`] — elevation lapse
/// only (latitude/greenhouse/nudge already in the baseline). This is the
/// D-243 §3 correct two-phase path.
/// - When `None`: falls back to the legacy single-phase [`derive_temperature_c`]
/// for backward compatibility (used by unit tests and paths where no region
/// layer has run yet).
///
/// The distinction is important for edge fuzz: only the two-phase path produces
/// a continuous, warp-perturbed temperature gradient. The single-phase path
/// still satisfies D-240 but without edge fuzz.
///
/// ## Vegetation patchiness (T-1162)
///
/// `world_x_m`/`world_y_m`/`min_wavelength_m` feed
/// [`crate::atlas::vegetation_invention::moisture_perturb_q`] — the nature-layer
/// patchiness field that perturbs `moisture_q` (see that module's docs for the
/// full design rationale) before precipitation/glaciation/vegetation are
/// derived from it, so the three stay in lockstep. `world_x_m == 0.0 &&
/// world_y_m == 0.0 && min_wavelength_m == 0.0` is NOT a special "disabled"
/// case — the origin is a legal world position — vegetation patchiness is
/// always active wherever `VegetationEnvelope::ceiling_q > 0`, mirroring the
/// coast invention's own always-on posture (the ceiling being zero, not a
/// separate flag, is what turns it off on airless/dry bodies).
///
/// ## Riparian signal (T-1168, Ruling 4a-4d)
///
/// `near_perennial_water` is the T-1168 riparian point test result for
/// `(world_x_m, world_y_m)` — a separate boolean signal into
/// [`derive_vegetation`], computed by the caller (window path: distance to
/// the retained `Layer1Output`'s invented courses; batch path: distance to
/// on-demand-invented courses for nearby edges — both via the SAME pure
/// [`crate::atlas::river_course::near_perennial_water`] function). **This
/// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it
/// is threaded straight through to `derive_vegetation` unchanged, after every
/// moisture/temperature/morphology field above it has already been resolved.
///
/// ## Lake sourcing (T-1184, D-227 amendment (4); T-1188 depth band)
///
/// `lake_from_hydrology` is the caller-computed [`lake_from_hydrology_at`]
/// verdict for this position — threaded straight into
/// [`derive_morphology_zone`]'s new gate, ahead of its pre-existing
/// `ocean_fraction_q >= 60` heuristic. `lake_margin_q` is the SAME call's
/// depth-band quantization, threaded straight onto the output profile
/// (T-1188 — no further derivation needed; see `lake_from_hydrology_at`'s
/// doc for what it represents and why lakes need it where oceans don't).
/// Computed by the caller (not here) for the same reason `near_perennial_water`
/// is: this function stays free of `TerrainAnalysis`/pixel-position concerns,
/// taking only the already-reduced per-position signals every other field
/// here consumes.
#[allow(clippy::too_many_arguments)]
fn build_district_profile(
seed: SeedChain,
body_params: &BodyParams,
climate: &ClimateConstants,
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
region_baseline_c: Option<f32>,
basin_direction: BasinDirection,
world_x_m: f64,
world_y_m: f64,
min_wavelength_m: f64,
near_perennial_water: bool,
lake_from_hydrology: bool,
lake_margin_q: i32,
) -> DistrictProfile {
let tectonic_class = derive_tectonic_class(body_params);
// District-local BodyParams: elevation_km comes from the district's own
// elev_q (0100 scaled to the body's elevation span). latitude_deg
// is already set per-district by the caller. Per-cell refinement at ChunkContext.
let district_climate_params = BodyParams {
elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
..body_params.clone()
};
// Temperature derivation: two-phase (D-243 §3) when a region baseline is
// available; single-phase legacy fallback otherwise.
let temperature_c = match region_baseline_c {
Some(baseline) => {
// Two-phase path: apply only elevation lapse on top of the
// edge-fuzz-blended region baseline (D-243 §3 / D-239 §2 split).
// slope_aspect_deg = 0.0: reserved, not yet available (Q-105).
derive_district_temperature_c(Some(baseline), &district_climate_params, climate, 0.0)
}
None => {
// Legacy single-phase path: derive temperature from scratch.
// D-240: body-scoped seed for the deterministic per-body nudge.
let body_seed = seed.seed();
derive_temperature_c(&district_climate_params, climate, body_seed)
}
};
let base_moisture_q = derive_moisture_q(body_params, elev_q, ocean_fraction_q, climate);
// T-1162: vegetation-patchiness field perturbs the moisture INPUT (see
// `vegetation_invention` module docs for the full design rationale) —
// this is what turns a uniform per-district class tint into massifs at
// Region scale resolving to distinct woods/copses/clearings at
// District/Quarter. Applied uniformly at every derivation path
// (on-demand, batch, orbital) since all three route through this shared
// classification tail.
let veg_envelope = crate::atlas::vegetation_invention::vegetation_envelope(body_params);
let moisture_perturb = crate::atlas::vegetation_invention::moisture_perturb_q(
&veg_envelope,
seed.seed(),
world_x_m,
world_y_m,
min_wavelength_m,
);
let moisture_q = (base_moisture_q + moisture_perturb).clamp(0, 100);
// Climate-derived fields: computed from temperature + moisture primitives
// (D-239 §2). This is the correct call order — temperature must be resolved
// before precipitation and glaciation are derived from it.
let precipitation_class = derive_precipitation_class_from_climate(temperature_c, moisture_q);
let glaciation_grade = derive_glaciation_grade_from_climate(temperature_c, moisture_q);
let river_threshold = derive_river_threshold(tectonic_class, precipitation_class);
// Morphology precedes vegetation (T-1126): the vegetation call consumes the
// morphology water verdict, so the two can never disagree by construction.
let morphology_zone = derive_morphology_zone(
tectonic_class,
glaciation_grade,
slope_q,
elev_q,
ocean_fraction_q,
moisture_q,
lake_from_hydrology,
);
// Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is
// the caller-computed riparian point test result — see this function's
// doc for the full threading contract (Ruling 4a-4d). open_water = the
// morphology verdict (T-1126) — never a threshold of its own.
let open_water = matches!(
morphology_zone,
MorphologyZone::OpenOcean | MorphologyZone::Lake
);
let vegetation_class = derive_vegetation(
temperature_c,
moisture_q,
elev_q,
near_perennial_water,
open_water,
);
DistrictProfile {
morphology_zone,
tectonic_class,
glaciation_grade,
precipitation_class,
slope_q,
elev_q,
ocean_fraction_q,
lake_margin_q,
river_threshold,
temperature_c,
moisture_q,
vegetation_class,
basin_direction,
}
}
/// On-demand 2 km district profile (D-243 §2, T-1077) — the corrected carrier.
///
/// Maps the district to a fractional heightmap position via the body radius (the
/// elastic seam, D-204), bilinearly interpolates the Layer-1 terrain (the
/// continental *envelope*), then composes the adaptive detail-scatter
/// ([`crate::atlas::detail_scatter`]) for the mid-scale relief the heightmap is
/// too coarse to carry. `body_radius_km = None` (e.g. tiny test bodies) falls back
/// to direct heightmap indexing.
///
/// Pure and deterministic (D-227/D-010): a function of
/// `(seed, body_id, body_params, terrain, district_pos)`; the f64 scatter is
/// quantised to integer `slope_q`/`elev_q` at the decision boundary.
///
/// ## Region baseline (D-243 §3/§4, T-1078)
///
/// The district temperature is derived as a **modulation** of the edge-fuzz-blended
/// region baseline ([`region_profile::region_baseline_at_district`]). No pre-built
/// region cache is required here — the on-demand path derives the four surrounding
/// region baselines on-the-fly (pure, deterministic, cheap: four `derive_region_baseline_c`
/// calls). For batch derivation of many districts use [`derive_all_districts`], which
/// builds a region cache once per body.
///
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
pub fn derive_district(
seed: SeedChain,
body_id: &str,
body_params: &BodyParams,
ta: &TerrainAnalysis,
district_pos: DistrictPos,
climate: &ClimateConstants,
) -> DistrictProfile {
let (dx, dy) = district_pos;
let dm = scale::DISTRICT_M as f64;
// Thin wrapper (T-1149): quantize DistrictPos -> world metres, then hand off
// to the metres-addressable interior. `min_wavelength_m = 0.0` = no octave
// cutoff, preserving this function's output byte-for-byte.
derive_at_metres(
seed,
body_id,
body_params,
ta,
dx as f64 * dm,
dy as f64 * dm,
climate,
0.0,
&[],
)
}
/// The metres-addressable derivation interior (T-1149, zoom ladder keystone,
/// design doc §2/§8 step 1) — `derive_district`'s former inline body, extracted
/// so a fractional-metres position (not just an integer [`DistrictPos`]) can be
/// classified. This is what makes the quarter rung (512 m spacing, T-1150)
/// possible without a second derivation pipeline: same function, finer step.
///
/// `wx`/`wy` are absolute world metres — NOT required to fall on a district-grid
/// multiple of [`scale::DISTRICT_M`]; any fractional position is legal.
///
/// `min_wavelength_m` (§2): forwarded to the `terrain_detail` octave sum inside
/// [`invent_primitives`] — octaves finer than this cutoff are truncated. `0.0`
/// = no cutoff = [`derive_district`]'s existing behavior.
///
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
///
/// `nearby_courses` (T-1168, Ruling 4b/4c): pre-invented river courses
/// (already culled to the caller's neighbourhood — the window path's own
/// bbox cull, `layer_proxy::build_courses_for_window`) consulted for the
/// riparian point test via [`river_course::near_perennial_water`]. Passing
/// `&[]` (the common case for a position far from any river, and every
/// pre-T-1168 caller via [`derive_district`]) is exactly the old hardcoded
/// `false` default — byte-identical output for every caller that doesn't
/// thread real course geometry through. This is a PRE-INVENTED slice, not a
/// `RiverNetwork` — this function is called once per window CELL (thousands
/// of times per window), so re-inventing courses on every call here (rather
/// than once per window) would be the exact per-candidate-derive cost this
/// whole batch's Ruling 3b was written to avoid.
#[allow(clippy::too_many_arguments)]
pub fn derive_at_metres(
seed: SeedChain,
body_id: &str,
body_params: &BodyParams,
ta: &TerrainAnalysis,
wx: f64,
wy: f64,
climate: &ClimateConstants,
min_wavelength_m: f64,
nearby_courses: &[river_course::InventedCourse],
) -> DistrictProfile {
// T-1168 Ruling 4a: the riparian point test against the caller-supplied
// (already-culled) course slice — the SAME pure predicate the batch path
// uses via `near_perennial_water_at`. Computed here, BEFORE the core call,
// so the core itself never touches course geometry (D-256(c) binding
// requirement 2: a naive wrapper passing `&[]` internally would silently
// regress every riverside cell to `false` — this public signature and its
// byte-behavior are unchanged by the D-256 extraction).
let near_perennial_water = river_course::near_perennial_water((wx, wy), nearby_courses);
derive_at_metres_with_riparian(
seed,
body_id,
body_params,
ta,
wx,
wy,
climate,
min_wavelength_m,
near_perennial_water,
None, // no pre-built region cache — on-demand on-the-fly derivation, exactly as before extraction
)
}
/// The D-256(c) shared derive core — `derive_at_metres`'s former inline body,
/// extracted so [`derive_district_profile`] can become a thin wrapper over the
/// SAME metres-addressable derivation instead of re-implementing it. Private:
/// the only two sanctioned callers are `derive_at_metres` (which computes
/// `near_perennial_water` from its public `nearby_courses` slice exactly as
/// before, and passes `None` for `region_cache` — an on-the-fly region
/// baseline derivation, matching its pre-extraction behavior byte-for-byte)
/// and `derive_district_profile` (which computes `near_perennial_water` via
/// `near_perennial_water_at`, the on-demand course inventor, and passes its
/// pre-built per-body `region_cache` for the same performance reason the
/// batch path built one in the first place — thousands of district calls
/// sharing one derived region set rather than each re-deriving up to 4
/// baselines).
///
/// `basin_direction` on the returned profile is always [`BasinDirection::default`]
/// (North) here — the caller-supplied true L1 D8 value, when available, is
/// applied as a post-call field override (D-256(c) binding requirement 1: the
/// field is inert to every other field's derivation, proven in the D-256
/// ruling, so an override after the fact is exactly equivalent to threading it
/// through).
#[allow(clippy::too_many_arguments)]
fn derive_at_metres_with_riparian(
seed: SeedChain,
body_id: &str,
body_params: &BodyParams,
ta: &TerrainAnalysis,
wx: f64,
wy: f64,
climate: &ClimateConstants,
min_wavelength_m: f64,
near_perennial_water: bool,
region_cache: Option<&BTreeMap<RegionPos, RegionProfile>>,
) -> DistrictProfile {
// World metres -> fractional heightmap pixel + latitude. Mirrors
// `derive_district`'s former inline mapping exactly, just keyed on
// fractional (wx, wy) instead of an integer DistrictPos scaled up first.
let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km {
Some(r_km) if r_km > 0.0 => {
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
// Longitude wraps; (0,0) sits at lon 0 / the equator.
let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64;
// Latitude: equator at py = h/2, clamped at the poles.
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); // 0.5 = N pole, +0.5 = S
let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64;
(px, py, wx, wy, -lat_frac * 180.0)
}
_ => {
// No radius: the working grid IS the metre grid (tiny test bodies),
// 1 DISTRICT_M = 1 heightmap pixel — the inverse of
// `pixel_to_world_m`'s own no-radius convention.
let dm = scale::DISTRICT_M as f64;
let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64);
let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64);
let lat_deg = if ta.h > 1 {
90.0 - (py / (ta.h - 1) as f64) * 180.0
} else {
0.0
};
(px, py, px * dm, py * dm, lat_deg)
}
};
let params = BodyParams {
latitude_deg: lat_deg,
..body_params.clone()
};
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this
// position. `region_cache` is `None` for the on-demand caller
// (`derive_at_metres`, derives the four surrounding region baselines
// directly — pure, deterministic, cheap) or `Some` for the batch caller
// (`derive_district_profile`, reuses its pre-built per-body cache).
// `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation.
// Hoisted above the primitives (T-1125): the invention's driver tier needs
// the baseline for its one-step-stale climate estimate.
//
// `region_baseline_at_district` keys on the CONTAINING DistrictPos (via
// `rem_euclid` inside `region_profile.rs`), not on fractional metres — so a
// sub-district sample (e.g. a quarter, T-1150) floor-divides down to its
// containing district here. This is D-243's design intent (climate is a
// district-tier field, R2/zoom-ladder-design-doc §9): temperature is a hard
// step at every district boundary at every rung, by construction — it does
// not refine continuously the way elevation/slope do under a finer
// min_wavelength_m.
//
// D-256(c): this is also what auto-fixes the batch path's former
// region-(0,0) collapse — `derive_district_profile` now reaches this same
// floor-divide on its own survey-cell-centre world metres instead of
// keying off a pseudo-grid index.
let district_pos: DistrictPos = (
(wx / scale::DISTRICT_M as f64).floor() as i32,
(wy / scale::DISTRICT_M as f64).floor() as i32,
);
let region_baseline_c = region_profile::region_baseline_at_district(
seed.seed(),
body_id,
district_pos,
&params,
climate,
seed,
region_cache,
);
// T-1125: invented primitives — warped coastline (invented bays/capes) +
// slope-independent character-driven scatter. Shared with the batch path
// (`derive_district_profile`) via `invent_primitives`.
let prims = invent_primitives(
seed,
&params,
climate,
ta,
px,
py,
world_x_m,
world_y_m,
region_baseline_c,
min_wavelength_m,
);
// T-1184: the settled-hydrology lake test, sampled at the SAME (px, py)
// fractional working-grid position every other envelope field here reads
// — the continuous filled-surface comparison (D-227 amendment (4)).
// T-1188: the same sample also yields the depth-band tone source
// (`lake_margin_q`) lake shorelines were missing.
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
build_district_profile(
seed,
&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,
near_perennial_water,
lake_from_hydrology,
lake_margin_q,
)
}
/// The orbital-rung derivation (T-1152, zoom ladder design doc §2/§4): the
/// coarse-granularity twin of [`derive_at_metres`] that skips [`invent_primitives`]
/// entirely — **no coastline warp, no detail-scatter octave sum, no classification
/// noise call of any kind**. Per the design doc's orbital row: "`region_baseline_at_district`
/// only — bilinear blend of 4 region baselines, no `invent_primitives`, no
/// classification [driver]." Orbital sample spacing (≥205 km, D-243's region rung
/// and coarser) sits below `detail_scatter`'s own octave floor
/// (`OCTAVE_WAVELENGTHS_M`'s coarsest entry is 32,768 m ≈ 32.8 km — an order of
/// magnitude finer than a region), so the invented terrain has nothing left to
/// contribute at this spacing; calling it would burn cycles synthesizing detail
/// no orbital pixel can resolve. What DOES vary at orbital spacing is the
/// **envelope** the heightmap itself carries (the `TerrainAnalysis` continental
/// shape) and the **region climate baseline** — this function samples exactly
/// those two, nothing else.
///
/// **Cost model (design doc R1 — measure first):** one `bilinear` (elevation),
/// one `bilinear_bool` (ocean mask), one `region_baseline_at_district` call (its
/// own cost is 4×`derive_region_baseline_c` on a cache miss, O(1) on a cache hit)
/// — no octave sum, no coast-warp trig, no character/envelope computation. See
/// `server/tests/zoom_ladder_bench.rs`'s `bench_derive_orbital_at_metres` for the
/// measured per-cell figure this claim rests on.
///
/// Produces the SAME six-field tail every other rung produces (`morphology_zone`,
/// `elev_q`, `temperature_c`, `moisture_q`, `vegetation_class`, `glaciation_grade`)
/// by routing the bilinear-only primitives through the same
/// [`build_district_profile`] classification tail every other rung uses — one
/// classification pipeline, never a second orbital-only decision tree (D-227:
/// classification thresholds don't get a coarse-rung variant any more than the
/// quarter rung got its own "quarter mode" thresholds, design doc §6).
///
/// **R2 (stepped fields):** `moisture_q`/`temperature_c`/`morphology_zone`/etc.
/// are exactly as stepped here as at every other rung — `region_baseline_at_district`
/// floor-divides to the containing `DistrictPos` regardless of caller spacing (see
/// [`derive_at_metres`]'s own doc on this), so this function does not make
/// temperature MORE continuous at orbital scale; it inherits the same
/// district-tier step the design doc documents as permanent, by construction.
///
/// **`slope_q` is fixed at 0`** — the bilinear-only envelope carries no
/// per-cell slope signal at orbital spacing (`ta.slope_deg` is a district-scale
/// proxy; sampling it here would imply a precision the coarse envelope doesn't
/// have). `slope_q` only affects morphology gates 36 (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
);
// T-1184: same continuous filled-surface comparison every rung samples,
// at the orbital rung's own (px, py) — lake edges refine at Region
// spacing exactly as they do at every finer rung (D-227 amendment (4)).
// T-1188: the same sample also yields the depth-band tone source.
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
build_district_profile(
seed,
&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,
// T-1168 Ruling 4e/5a: NO windowed course invention at Region
// granularity (`layer_proxy::build_courses_for_window` early-returns
// for `WindowGranularity::Region` — the whole-body skeleton path
// draws Region-rung rivers instead, Ruling 5a). Always `false` here
// — honest, not a gap: even if courses existed at Region, the 1-3 m
// riparian band is many orders of magnitude below Region's ~205 km
// spacing and could never fire (Ruling 4e).
false,
lake_from_hydrology,
lake_margin_q,
)
}
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
/// Columns wrap (equirectangular); rows clamp at the poles.
///
/// `pub(crate)` (T-1170): also the elevation-proxy read the river course
/// inventor's Stage A valley-seeking control path uses
/// (`river_course::score_candidate`) — the SAME bilinear-`elev_pct` tradeoff
/// the coast warp already makes (`invent_primitives`'s step 3), reused rather
/// than re-implemented so the two invention fields can never silently drift
/// on interpolation semantics.
pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 {
if w == 0 || h == 0 {
return 0.0;
}
let x0 = px.floor();
let y0 = py.floor().clamp(0.0, (h - 1) as f64);
let tx = (px - x0) as f32;
let ty = (py - y0) as f32;
let ix0 = (x0 as i64).rem_euclid(w as i64) as usize;
let ix1 = (ix0 + 1) % w;
let iy0 = (y0 as usize).min(h - 1);
let iy1 = (iy0 + 1).min(h - 1);
let v00 = field[iy0 * w + ix0];
let v10 = field[iy0 * w + ix1];
let v01 = field[iy1 * w + ix0];
let v11 = field[iy1 * w + ix1];
let a = v00 + (v10 - v00) * tx;
let b = v01 + (v11 - v01) * tx;
a + (b - a) * ty
}
/// Degenerate-basin guard for [`lake_from_hydrology_at`]'s per-basin
/// normalization: a basin whose own max depth is at or below this floor
/// (in the same `[0.0, 1.0]` normalized elevation-fraction units as
/// `HydrologySample`) is treated as uniformly shallow — `lake_margin_q`
/// reads `0` everywhere in it rather than dividing by a near-zero
/// denominator (which would amplify heightmap sampling noise into an
/// artificial, meaningless gradient). `1e-5` is ~17× smaller than the
/// smallest genuinely-flooded per-cell depth observed in the T-1188
/// calibration survey (below), well inside "this basin has no real depth
/// signal at this heightmap resolution" territory.
const LAKE_MARGIN_DEGENERATE_BASIN_EPSILON: f32 = 1e-5;
/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f)
/// mechanism B) PLUS its T-1188 depth-band extension. Returns
/// `(is_lake, lake_margin_q)`:
///
/// - `is_lake` — `true` when a bilinear sample of the settled filled-surface
/// field strictly exceeds a bilinear sample of the original elevation at
/// the SAME fractional working-grid position — the continuous comparison
/// that makes lake edges refine with rung exactly like coastlines, rather
/// than projecting `HydrologyResult.basins[*].cells` membership as a
/// discrete, non-refining lookup (explicitly rejected, see this function's
/// callers' docs). `false` when `ta.hydrology` is `None` (no solve
/// available for this analysis — every caller must already treat `false`
/// here as "fall through to the `ocean_fraction_q` heuristic", never as an
/// error).
/// - `lake_margin_q` — `0` when `!is_lake` (a non-lake cell has no margin to
/// shade); otherwise the settled depth `(filled - original)` at this
/// position, normalized against THIS BASIN's own maximum depth
/// (`HydrologySample.basin_max_depth`, bilinear-sampled at the SAME
/// position — see that field's doc), then quantized to `[0, 100]`.
///
/// **Per-basin, not a fixed absolute ceiling (PR #206 eyeball finding,
/// T-1188 round 2):** the original design used one fixed absolute-depth
/// ceiling calibrated against a single body's p90 depth. Two compounding
/// effects made that read visually flat on real lakes: (1) a linear
/// absolute scale compresses the bulk of any MORE-skewed basin's depth
/// distribution into single-digit values; (2) heightmap resolution
/// (~4078 km/px) means within-basin absolute-depth variation is often
/// sub-texel-tiny (GJ1c's test basin measured a full-basin depth spread
/// under 0.0003 normalized units — genuinely below what a fixed ceiling
/// calibrated for a DIFFERENT body's deeper lakes could ever resolve).
/// Normalizing against each basin's own max depth fixes both: every
/// non-degenerate basin uses the full 0100 range on ITS OWN terms,
/// independent of the body's absolute elevation scale or any other
/// basin's depth. A basin at or below
/// [`LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`] max depth reads `0`
/// everywhere (a genuinely uniform shallow pond shades flat — honest,
/// not forced) rather than dividing by ~zero.
///
/// This is the continuous tone source lake shorelines were missing —
/// `ocean_fraction_q` is definitionally `0` throughout a lake basin (lakes
/// sit above sea level; `ta.ocean_mask` never fires there), so every
/// coastal-transition morphology gate (TidalFlat, DuneStrand, CliffCoast,
/// Estuarine — all keyed on `ocean_fraction_q >= N`) is structurally
/// unreachable at a lake edge even though the underlying position
/// sampling refines correctly with rung (verified: a shoreline-crossing
/// sweep at district/quarter/block spacing lands on the exact same
/// continuous world-metres crossing at every rung, and a 10 m fine sweep
/// confirms sub-block precision — the T-1188 hypothesis (a) positional
/// check). `lake_margin_q` fixes the PRESENTATION gap (hypothesis (b))
/// without touching that already-correct positional refinement.
///
/// All three fields (`elevation`, `filled`, `basin_max_depth`) are sampled
/// via the SAME `bilinear` helper `ocean_fraction_q`'s own
/// `ta.elev_pct`/`ta.ocean_mask` reads already use at every derive-core call
/// site (T-1178/T-1154's per-cell rate numbers already include equivalent-
/// cost sampling in the measured per-rung budget — one more bilinear sample
/// is not a new cost category, per the workshop's own pipeline-slot ruling).
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) {
let Some(h) = ta.hydrology.as_ref() else {
return (false, 0);
};
let filled = bilinear(&h.filled, ta.w, ta.h, px, py);
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
let is_lake = filled > original;
let lake_margin_q = if is_lake {
let basin_max_depth = bilinear(&h.basin_max_depth, ta.w, ta.h, px, py);
if basin_max_depth <= LAKE_MARGIN_DEGENERATE_BASIN_EPSILON {
0
} else {
let depth = (filled - original).max(0.0);
((depth / basin_max_depth) * 100.0).round().clamp(0.0, 100.0) as i32
}
} else {
0
};
(is_lake, lake_margin_q)
}
/// Bilinear interpolation of a boolean mask as a 01 fraction (for ocean coverage).
fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
if w == 0 || h == 0 {
return 0.0;
}
let x0 = px.floor();
let y0 = py.floor().clamp(0.0, (h - 1) as f64);
let tx = (px - x0) as f32;
let ty = (py - y0) as f32;
let ix0 = (x0 as i64).rem_euclid(w as i64) as usize;
let ix1 = (ix0 + 1) % w;
let iy0 = (y0 as usize).min(h - 1);
let iy1 = (iy0 + 1).min(h - 1);
let f = |r: usize, c: usize| mask[r * w + c] as i32 as f32;
let a = f(iy0, ix0) + (f(iy0, ix1) - f(iy0, ix0)) * tx;
let b = f(iy1, ix0) + (f(iy1, ix1) - f(iy1, ix0)) * tx;
a + (b - a) * ty
}
/// Eagerly derive a coarse profile grid covering the body, by direct heightmap
/// tiling (`grid_cells_per_district` cells per cell).
///
/// **Scale note (D-256(b)):** this is the *survey raster* — the coarse eager
/// grid (one cell per `gcpr` heightmap pixels, tens-to-hundreds of km) kept as
/// the Atlas zone overlay source and the L2/L3 planning input (settlement
/// placement context, believability sampling, skeleton dispatch context). The
/// **corrected 2 km carrier** the voxel chain consumes is the on-demand
/// [`derive_district`] (heightmap interpolation + detail-scatter via the
/// elastic seam). Both now route through the SAME [`derive_at_metres_with_riparian`]
/// core (D-256(c)) — one derive core, two position sets.
///
/// Returns a `BTreeMap<SurveyCellPos, DistrictProfile>` covering the full
/// heightmap at the given survey-grid resolution.
///
/// `grid_cells_per_district = 8` means each survey cell is 8×8 heightmap cells.
///
/// ## Region baseline (D-256(c), cache pre-build per PR #199 review)
///
/// A region-baseline cache is pre-built here on the TRUE region keys the
/// shared core looks up: each survey cell's centre world metres floor-divides
/// to its containing district (the same mapping
/// [`derive_at_metres_with_riparian`] applies internally), and that
/// district's region ±1 neighbour ring covers every key
/// [`region_profile::region_baseline_at_district`]'s edge-fuzz blend can
/// read (base + signed x/y/xy neighbours). The pre-D-256 pre-build keyed on
/// the SURVEY grid's own pseudo-coordinates (`district_to_region((rx, ry))`)
/// — the wrong key space entirely, which is what produced the body-uniform
/// region-(0,0) climate collapse; the true-key rebuild can span up to ~2048
/// distinct regions plus ring on a big body (the survey grid covers the
/// whole body surface in metres). Cache-hit and cache-miss are
/// byte-identical ([`region_profile::build_region_profile`] and the miss
/// branch in `region_baseline_at_district` compute `mean_temp_c` with the
/// same expressions — D-227 purity, guarded by the wrapper≡core agreement
/// tests), so the cache is purely the cost model: each covering region
/// derives once per body instead of four misses per survey cell.
///
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
/// warp domain separation.
///
/// `basin_dirs` is the per-SURVEY-CELL dominant D8 thalweg direction computed
/// in `run_layer1` (T-1047) — `Layer1Output::survey_basin_dirs`. It is
/// **honestly a survey-cell aggregate**: each entry votes over exactly the
/// 8×8 working-pixel block one `DistrictProfile` here summarizes, so
/// [`SurveyCellPos`] is its correct key, not merely a convenient one, and the
/// lookup below is identity (`m.get(&pos)`) — NOT a world-metres floor-divide
/// into the true D-243 district grid (that would be the wrong map: the
/// aggregate's own key space is the survey raster, never was the true grid).
/// Missing entries (edge cells with no land cells) default to
/// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs),
/// every cell gets `BasinDirection::North`.
///
/// `river_network` (T-1168, Ruling 4c) is threaded straight through to every
/// [`derive_district_profile`] call for the batch-path riparian signal — the
/// `road_graph` precedent (`cascade.rs`'s already-unpacked
/// `layer1.river_network`, same source, same threading pattern).
pub fn derive_all_districts(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
grid_cells_per_district: usize,
body_id: &str,
basin_dirs: Option<&BTreeMap<SurveyCellPos, BasinDirection>>,
river_network: Option<&RiverNetwork>,
) -> BTreeMap<SurveyCellPos, DistrictProfile> {
let climate = ClimateConstants::default();
let gcpr = grid_cells_per_district.max(1);
let survey_cols = ta.w.div_ceil(gcpr) as i32;
let survey_rows = ta.h.div_ceil(gcpr) as i32;
// Pre-build the region-baseline cache on the TRUE region keys the shared
// core will look up (see this function's doc): each survey cell's centre
// world metres → containing district → region ±1 ring. Deduped via
// BTreeSet (D-010: deterministic iteration), derived once per body.
let mut covering_regions: BTreeSet<RegionPos> = BTreeSet::new();
for ry in 0..survey_rows {
for rx in 0..survey_cols {
let (wx, wy) = survey_cell_centre_world_m(
SurveyCellPos(rx, ry),
gcpr,
ta.w,
ta.h,
body_params.body_radius_km,
);
// The SAME floor-divide derive_at_metres_with_riparian applies to
// reach its region key — one mapping, never a second one.
let district_pos: DistrictPos = (
(wx / scale::DISTRICT_M as f64).floor() as i32,
(wy / scale::DISTRICT_M as f64).floor() as i32,
);
let base = scale::district_to_region(district_pos);
for ndy in -1..=1i32 {
for ndx in -1..=1i32 {
covering_regions.insert((base.0 + ndx, base.1 + ndy));
}
}
}
}
let region_cache =
region_profile::derive_regions_for_body(seed, body_params, &climate, covering_regions);
let mut out = BTreeMap::new();
for ry in 0..survey_rows {
for rx in 0..survey_cols {
let pos = SurveyCellPos(rx, ry);
// Identity lookup: basin_dirs is keyed by SurveyCellPos (see this
// function's doc) — the SAME survey cell this loop is deriving a
// profile for, no position translation needed or correct.
let basin_direction = basin_dirs
.and_then(|m| m.get(&pos).copied())
.unwrap_or_default();
let profile = derive_district_profile(
seed,
body_params,
ta,
pos,
gcpr,
&climate,
body_id,
&region_cache,
basin_direction,
river_network,
);
out.insert(pos, profile);
}
}
out
}
/// The survey cell's centre pixel → world metres, per [`derive_district_profile`]'s
/// own D-256(b) `8·rx + 3.5` (clamped) convention — factored out so any
/// caller resolving a `SurveyCellPos` to a world position (e.g.
/// [`derive_all_districts`]'s `basin_dirs` lookup, or `believability::analyze`'s
/// voxel-sample chunk resolution) reaches the SAME world position the profile
/// itself is centred on, rather than a second, possibly-disagreeing mapping.
///
/// `pub` (D-256): `believability.rs` and `bin/aliveness_probe.rs` (a separate
/// crate) both need this bridge and have no `TerrainAnalysis`/`BodyParams` in
/// scope (they only see `BodyWorldState`'s cached dims + a body-params read),
/// so this takes the primitive `w`/`h`/`body_radius_km` rather than the
/// wrapper structs — the same primitives [`pixel_to_world_m`] itself takes.
pub fn survey_cell_centre_world_m(
pos: SurveyCellPos,
gcpr: usize,
w: usize,
h: usize,
body_radius_km: Option<f64>,
) -> (f64, f64) {
let SurveyCellPos(rx, ry) = pos;
let row_start = (ry as usize).saturating_mul(gcpr).min(h);
let row_end = row_start.saturating_add(gcpr).min(h);
let col_start = (rx as usize).saturating_mul(gcpr).min(w);
let col_end = col_start.saturating_add(gcpr).min(w);
let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0;
let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0;
pixel_to_world_m(px, py, w, h, body_radius_km)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::drainage;
use crate::atlas::heightmap::BodyHeightmap;
use crate::seed::SeedDomain;
fn test_hm() -> BodyHeightmap {
let (w, h) = (64u32, 32u32);
let n = (w * h) as usize;
let data = (0..n)
.map(|i| {
let r = (i / w as usize) as f32 / h as f32;
let c = (i % w as usize) as f32 / w as f32;
(r * 0.6 + c * 0.4).min(1.0)
})
.collect();
BodyHeightmap {
body_id: "test".into(),
width: w,
height: h,
data,
sea_level: 0.3,
}
}
fn test_seed() -> SeedChain {
SeedChain::root(42).derive(SeedDomain::Body, 1)
}
fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
TerrainAnalysis::analyze(hm, &dr)
}
#[test]
fn derive_all_districts_covers_full_heightmap() {
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
let districts = derive_all_districts(test_seed(), &params, &ta, 8, "test_body", None, None);
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
assert_eq!(districts.len(), 32, "district count mismatch");
}
/// T-1047/D-256: the `Some(basin_dirs)` threading path — supplied
/// per-SURVEY-CELL D8 directions propagate to
/// `DistrictProfile.basin_direction` by IDENTITY lookup, and survey cells
/// not in the map fall back to the default (North). `basin_dirs` mirrors
/// the SHAPE the real producer (`Layer1Output::survey_basin_dirs`,
/// `layer1::aggregate_survey_basin_dirs`) actually emits — keyed by
/// [`SurveyCellPos`], the same key space `derive_all_districts` iterates
/// — so this test exercises the production seam (survey-keyed producer →
/// identity-lookup consumer), not a map the test invents to match its own
/// lookup logic.
#[test]
fn derive_all_districts_threads_supplied_basin_directions() {
use crate::atlas::scale::BasinDirection;
let hm = test_hm();
let ta = test_ta(&hm);
let params = BodyParams::default();
// Real SurveyCellPos keys from a baseline (None) run.
let baseline = derive_all_districts(test_seed(), &params, &ta, 8, "test_body", None, None);
let mut keys = baseline.keys().copied();
let cell_east = keys.next().expect("at least one survey cell");
let cell_south = keys.next().expect("at least two survey cells");
let cell_unmapped = keys.next().expect("at least three survey cells");
let mut basin_dirs: BTreeMap<SurveyCellPos, BasinDirection> = BTreeMap::new();
basin_dirs.insert(cell_east, BasinDirection::East);
basin_dirs.insert(cell_south, BasinDirection::South);
let districts = derive_all_districts(
test_seed(),
&params,
&ta,
8,
"test_body",
Some(&basin_dirs),
None,
);
assert_eq!(districts[&cell_east].basin_direction, BasinDirection::East);
assert_eq!(
districts[&cell_south].basin_direction,
BasinDirection::South
);
// A survey cell not present in basin_dirs falls back to the default
// direction (North).
assert_eq!(
districts[&cell_unmapped].basin_direction,
BasinDirection::North
);
}
// --- derive_district (on-demand 2 km, interpolation + detail-scatter) -----
fn earth_params() -> BodyParams {
BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
}
}
#[test]
fn derive_district_is_deterministic() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let a = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate);
let b = derive_district(test_seed(), "test_body", &p, &ta, (1234, -567), &climate);
assert_eq!(a.elev_q, b.elev_q);
assert_eq!(a.slope_q, b.slope_q);
assert_eq!(a.morphology_zone, b.morphology_zone);
assert_eq!(a.temperature_c, b.temperature_c);
}
#[test]
fn derive_district_radius_maps_to_latitude_climate() {
// With a body radius, equatorial vs near-polar districts get different
// temperature (the seam maps district_y → latitude). Pole = colder.
//
// T-1162: `derive_district` calls with `min_wavelength_m = 0.0` (no
// cutoff), so it now admits the extended coast-warp + sub-district
// relief octaves this ticket adds — real per-position elevation noise
// that a SINGLE probe district at each latitude is no longer immune
// to (elevation lapse feeds temperature; a single unlucky relief
// sample can swing one probe point by ~1°C, enough to flip a
// single-pair comparison at these specific hand-picked positions).
// Average temperature over several districts spanning a few hundred
// metres at each latitude band — the same zero-mean-cancellation
// technique `derivation_harness.rs`'s cross-district blend test uses
// — so the assertion tests the LATITUDE law, not one noise sample.
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
// meridian ≈ π·6371·1000 m; a district near the pole is ~quarter-meridian away.
let merid_districts =
(std::f64::consts::PI * 6371.0 * 1000.0 / scale::DISTRICT_M as f64) as i32;
let avg_temp_c = |dy: i32| -> f32 {
let mut sum = 0.0f32;
let mut n = 0;
for dx in 0..8 {
let prof = derive_district(test_seed(), "test_body", &p, &ta, (dx, dy), &climate);
sum += prof
.temperature_c
.expect("breathable body must have a temperature");
n += 1;
}
sum / n as f32
};
let eq = avg_temp_c(0);
let hi = avg_temp_c(merid_districts / 2 - 2);
assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})");
}
#[test]
fn derive_district_flat_envelope_bounded_invention() {
// Pre-T-1125 this pinned "flat heightmap → ZERO invented relief" — the
// envelope-as-amplitude reading that collapsed the D-227 invention on
// exactly the low-relief coasts where coastlines live. T-1125 (Jeroen's
// crinkle-varies ruling) replaces the zero with a bounded FLOOR: a flat
// world gains gentle invented relief that varies from place to place,
// never mountains, and the invention cannot conjure water that is not
// in the ocean mask.
let (w, h) = (64u32, 32u32);
let flat = BodyHeightmap {
body_id: "flat".into(),
width: w,
height: h,
data: vec![0.6; (w * h) as usize],
sea_level: 0.3,
};
let ta = test_ta(&flat);
let climate = ClimateConstants::default();
let p = earth_params();
let a = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate);
let b = derive_district(test_seed(), "flat", &p, &ta, (900, 40), &climate);
// No ocean can be invented on a fully-landlocked flat world: the coast
// warp displaces sampling, it never fabricates mask content.
assert_eq!(a.ocean_fraction_q, 0, "above sea level → no ocean");
assert_eq!(b.ocean_fraction_q, 0, "above sea level → no ocean");
// Bounded invention: gentle relief only — an authored plain must never
// sprout mountain-grade slopes (the envelope rule survives as a ceiling).
assert!(a.slope_q <= 25, "invented slope too steep: {}", a.slope_q);
assert!(b.slope_q <= 25, "invented slope too steep: {}", b.slope_q);
// And the invention must actually vary between distant districts — the
// old zero rule left every flat district identical (the T-1125 finding).
assert!(
a.elev_q != b.elev_q || a.slope_q != b.slope_q,
"flat-world districts must differ under the invention floor \
(a: elev_q={} slope_q={}, b: elev_q={} slope_q={})",
a.elev_q,
a.slope_q,
b.elev_q,
b.slope_q
);
}
#[test]
fn derive_district_no_radius_falls_back_to_direct_indexing() {
// body_radius_km = None (tiny test bodies): the district grid is the
// heightmap grid; derivation still succeeds and is deterministic.
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("breathable".into()),
..Default::default() // body_radius_km: None
};
let a = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate);
let b = derive_district(test_seed(), "test_body", &p, &ta, (20, 10), &climate);
assert_eq!(a.elev_q, b.elev_q);
assert!((0..=100).contains(&a.elev_q) && (0..=100).contains(&a.slope_q));
}
// --- derive_at_metres (T-1149 keystone extraction) -------------------------
/// `derive_district` is a thin wrapper: at an exact district-aligned metre
/// position, with `min_wavelength_m = 0.0`, it must be BIT-IDENTICAL to
/// calling `derive_at_metres` directly (the acceptance criterion the
/// ticket names explicitly — existing callers see byte-identical output).
#[test]
fn derive_at_metres_matches_derive_district_at_aligned_position_zero_cutoff() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let dp = (1234, -567);
let dm = scale::DISTRICT_M as f64;
let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate);
let via_metres = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
dp.0 as f64 * dm,
dp.1 as f64 * dm,
&climate,
0.0,
&[],
);
assert_district_profiles_eq(&via_wrapper, &via_metres);
}
/// Same equivalence check on the no-radius (tiny test body) branch — the
/// two derivation paths diverge internally (fractional-pixel clamp vs.
/// direct district indexing) and must be checked independently.
#[test]
fn derive_at_metres_matches_derive_district_no_radius() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("breathable".into()),
..Default::default() // body_radius_km: None
};
let dp = (20, 10);
let dm = scale::DISTRICT_M as f64;
let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate);
let via_metres = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
dp.0 as f64 * dm,
dp.1 as f64 * dm,
&climate,
0.0,
&[],
);
assert_district_profiles_eq(&via_wrapper, &via_metres);
}
/// Field-by-field `DistrictProfile` equality — the struct has no
/// `PartialEq` derive (production type, not test-only), so the
/// bit-identical acceptance checks above compare fields directly instead
/// of adding a derive to non-test code for test convenience.
fn assert_district_profiles_eq(a: &DistrictProfile, b: &DistrictProfile) {
assert_eq!(a.morphology_zone as u8, b.morphology_zone as u8);
assert_eq!(a.tectonic_class as u8, b.tectonic_class as u8);
assert_eq!(a.glaciation_grade as u8, b.glaciation_grade as u8);
assert_eq!(a.precipitation_class as u8, b.precipitation_class as u8);
assert_eq!(a.slope_q, b.slope_q);
assert_eq!(a.elev_q, b.elev_q);
assert_eq!(a.ocean_fraction_q, b.ocean_fraction_q);
assert_eq!(a.river_threshold, b.river_threshold);
assert_eq!(a.temperature_c, b.temperature_c);
assert_eq!(a.moisture_q, b.moisture_q);
assert_eq!(a.vegetation_class as u8, b.vegetation_class as u8);
assert_eq!(a.basin_direction as u8, b.basin_direction as u8);
}
// -------------------------------------------------------------------
// T-1184 — lake sourcing from settled hydrology (D-227 amendment (4))
// -------------------------------------------------------------------
/// Bowl-shaped heightmap (high rim, low centre) — same fixture shape as
/// `hydrology_equilibrium.rs`'s own `bowl_grid` and `layer1.rs`'s
/// `bowl_hm`, reproduced locally (both are `#[cfg(test)]`-private to
/// their own modules) so this module's tests can build a
/// `TerrainAnalysis` with real hydrology attached via
/// `with_hydrology` without depending on solver-internal or
/// layer1-internal test helpers. `sea_level: 0.0` keeps the ENTIRE grid
/// dry land except the filled basin, so `ocean_fraction_q` can never
/// independently trigger the pre-existing `>= 60` heuristic — any
/// `Lake` verdict this test observes can only come from the hydrology
/// gate.
fn bowl_hm_no_ocean() -> BodyHeightmap {
let (w, h) = (64u32, 32u32);
let n = (w * h) as usize;
let cx = w as f32 / 2.0;
let cy = h as f32 / 2.0;
let max_r = cx.min(cy).max(1.0);
let data = (0..n)
.map(|i| {
let r = (i / w as usize) as f32;
let c = (i % w as usize) as f32;
let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0);
0.1 + d * 0.8
})
.collect();
BodyHeightmap {
body_id: "bowl_test".into(),
width: w,
height: h,
data,
sea_level: 0.0,
}
}
/// Real end-to-end wiring: solve hydrology on the bowl fixture, attach it
/// via `with_hydrology` (the same call `layer1::run_layer1` makes in
/// production), and confirm `derive_at_metres` classifies the bowl
/// CENTRE as `Lake` — sourced from the hydrology gate, not the
/// `ocean_fraction_q` heuristic (impossible here: `sea_level == 0.0`
/// means `ocean_fraction_q` is always 0 on this fixture).
#[test]
fn derive_at_metres_sources_lake_from_hydrology_at_bowl_centre() {
let hm = bowl_hm_no_ocean();
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let base_ta = TerrainAnalysis::analyze(&hm, &dr);
let hydrology = crate::atlas::hydrology_equilibrium::solve(
&hm.data,
hm.width,
hm.height,
hm.sea_level,
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 },
);
let ta = base_ta.with_hydrology(&hm.data, &hydrology);
let climate = ClimateConstants::default();
let p = BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("breathable".into()),
..Default::default() // body_radius_km: None -> 1 DISTRICT_M = 1 px
};
let dm = scale::DISTRICT_M as f64;
// Bowl centre in pixel space is (32, 16); no-radius mode maps
// DistrictPos 1:1 onto heightmap pixels.
let prof = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
32.0 * dm,
16.0 * dm,
&climate,
0.0,
&[],
);
assert_eq!(
prof.morphology_zone,
MorphologyZone::Lake,
"bowl centre must classify Lake via the hydrology-sourced gate; \
ocean_fraction_q is always 0 on this fixture (sea_level=0.0), so \
this cannot be the pre-existing heuristic"
);
assert_eq!(
prof.ocean_fraction_q, 0,
"sanity: heuristic gate never fires here"
);
}
/// The same bowl centre, sampled via `derive_orbital_at_metres` (Region
/// rung) — confirms the hydrology gate is wired into BOTH derive paths
/// through the shared `build_district_profile` tail, not just the
/// district/quarter/chunk path.
#[test]
fn derive_orbital_at_metres_sources_lake_from_hydrology_at_bowl_centre() {
let hm = bowl_hm_no_ocean();
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let base_ta = TerrainAnalysis::analyze(&hm, &dr);
let hydrology = crate::atlas::hydrology_equilibrium::solve(
&hm.data,
hm.width,
hm.height,
hm.sea_level,
crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 },
);
let ta = base_ta.with_hydrology(&hm.data, &hydrology);
let climate = ClimateConstants::default();
let p = BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("breathable".into()),
..Default::default()
};
let dm = scale::DISTRICT_M as f64;
let prof = derive_orbital_at_metres(
test_seed(),
"test_body",
&p,
&ta,
32.0 * dm,
16.0 * dm,
&climate,
);
assert_eq!(
prof.morphology_zone,
MorphologyZone::Lake,
"orbital rung must also source Lake from hydrology at the bowl centre"
);
}
/// No hydrology attached (`ta.hydrology == None`, the state every
/// pre-T-1184 caller and every OTHER test in this module is already in)
/// must fall through to the pre-existing `ocean_fraction_q` heuristic
/// byte-identically — the whole point of making `with_hydrology` an
/// opt-in builder rather than changing `analyze`'s default output.
#[test]
fn derive_at_metres_without_hydrology_falls_back_to_heuristic() {
let hm = bowl_hm_no_ocean();
let ta = test_ta(&hm); // no with_hydrology call — ta.hydrology stays None
assert!(ta.hydrology.is_none());
let climate = ClimateConstants::default();
let p = BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("breathable".into()),
..Default::default()
};
let dm = scale::DISTRICT_M as f64;
let prof = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
32.0 * dm,
16.0 * dm,
&climate,
0.0,
&[],
);
// sea_level=0.0 on this fixture means ocean_fraction_q is always 0,
// so without hydrology the bowl centre must NOT classify Lake (no
// trigger available at all) — proving the fallback path is inert,
// not silently finding a lake some other way.
assert_ne!(
prof.morphology_zone,
MorphologyZone::Lake,
"without hydrology data, the bowl centre must not classify Lake — \
confirms with_hydrology is what supplies the signal, not some \
other implicit path"
);
}
/// The D-255(f) mandatory cache-hit == cache-miss determinism gate,
/// applied to lake classification specifically: deriving the SAME
/// position through the SAME `HydrologyResult` (as if reading a resident
/// coarser canvas) must be byte-identical to solving hydrology fresh a
/// second time and deriving again (as if the cache had been evicted and
/// hydrology re-solved) — D-227's "evict -> recompute -> byte-identical"
/// test, instantiated for the hydrology-sourced `morphology_zone` gate
/// this ticket adds.
#[test]
fn lake_classification_cache_hit_equals_cache_miss() {
let hm = bowl_hm_no_ocean();
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
let climate_inputs = crate::atlas::hydrology_equilibrium::ClimateInputs { moisture_q: 55 };
// "Cache hit" path: solve once, reuse the SAME HydrologyResult for
// every sample (mirrors a resident coarser canvas served from cache).
let hydrology_cached = crate::atlas::hydrology_equilibrium::solve(
&hm.data,
hm.width,
hm.height,
hm.sea_level,
climate_inputs,
);
let ta_hit = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_cached);
let climate = ClimateConstants::default();
let p = BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("breathable".into()),
..Default::default()
};
let dm = scale::DISTRICT_M as f64;
// Sample several positions (centre, rim, corner) through the "hit" path.
let positions = [(32.0, 16.0), (5.0, 5.0), (60.0, 28.0), (32.0, 4.0)];
let hit_zones: Vec<MorphologyZone> = positions
.iter()
.map(|&(px, py)| {
derive_at_metres(
test_seed(),
"test_body",
&p,
&ta_hit,
px * dm,
py * dm,
&climate,
0.0,
&[],
)
.morphology_zone
})
.collect();
// "Cache miss" path: re-solve hydrology fresh (a second, independent
// solve() call — D-227's eviction/recompute case) and re-derive the
// SAME positions.
let hydrology_fresh = crate::atlas::hydrology_equilibrium::solve(
&hm.data,
hm.width,
hm.height,
hm.sea_level,
climate_inputs,
);
let ta_miss = TerrainAnalysis::analyze(&hm, &dr).with_hydrology(&hm.data, &hydrology_fresh);
let miss_zones: Vec<MorphologyZone> = positions
.iter()
.map(|&(px, py)| {
derive_at_metres(
test_seed(),
"test_body",
&p,
&ta_miss,
px * dm,
py * dm,
&climate,
0.0,
&[],
)
.morphology_zone
})
.collect();
assert_eq!(
hit_zones, miss_zones,
"cache-hit path (reused HydrologyResult) and cache-miss path \
(freshly re-solved HydrologyResult) must classify byte-identically \
at every sampled position (D-227 / D-255(f))"
);
// Non-vacuous: at least the centre position must actually be a lake,
// so this test is exercising the gate, not trivially passing because
// nothing ever classified Lake.
assert!(
hit_zones.contains(&MorphologyZone::Lake),
"sanity: the position sweep must include at least one Lake cell"
);
}
/// A non-district-aligned fractional metre position (e.g. a quarter-grid
/// sample, T-1150) must derive without panicking and stay within the same
/// value ranges as the district-aligned case — the whole point of the
/// extraction is that ANY fractional world position is now legal input,
/// not just integer DistrictPos multiples.
#[test]
fn derive_at_metres_accepts_fractional_sub_district_position() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let dm = scale::DISTRICT_M as f64;
// A quarter-grid offset (512 m, D-243) inside district (1234, -567).
let prof = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
1234.0 * dm + 512.0,
-567.0 * dm + 512.0,
&climate,
512.0,
&[],
);
assert!((0..=100).contains(&prof.elev_q));
assert!((0..=100).contains(&prof.slope_q));
}
/// A `min_wavelength_m` cutoff must actually change the invented terrain
/// primitives relative to the uncut (0.0) derive at the SAME position —
/// otherwise the parameter would be silently inert at this layer (the
/// enveloped_fbm-level test already covers the raw scatter function; this
/// confirms the wiring survives through invent_primitives/derive_at_metres).
#[test]
fn derive_at_metres_cutoff_changes_invented_primitives() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let dm = scale::DISTRICT_M as f64;
let mut any_differs = false;
for i in 0..20 {
let wx = (100 + i * 37) as f64 * dm;
let wy = (100 + i * 53) as f64 * dm;
let uncut = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
0.0,
&[],
);
let cut = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
8_193.0, // above the two finest OCTAVE_WAVELENGTHS_M entries
&[],
);
if uncut.elev_q != cut.elev_q || uncut.slope_q != cut.slope_q {
any_differs = true;
}
}
assert!(
any_differs,
"a mid-band min_wavelength_m cutoff must change invented terrain \
at at least one sampled position"
);
}
// -------------------------------------------------------------------
// T-1162 — coast crinkle / sub-district relief / vegetation patchiness
// -------------------------------------------------------------------
/// Determinism of the T-1162 fields specifically: two independent
/// `derive_at_metres` calls at the SAME cutoff-bearing position (Quarter
/// spacing, admitting the new coast-warp + relief + vegetation content)
/// must be bit-identical (D-010/D-227) — the new machinery is pure, same
/// as everything else in this module.
#[test]
fn t1162_new_fields_are_deterministic_at_quarter_cutoff() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let dm = scale::DISTRICT_M as f64;
for i in 0..12 {
let wx = (300 + i * 41) as f64 * dm * 0.1;
let wy = (300 + i * 29) as f64 * dm * 0.1;
let a = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
1_024.0,
&[],
);
let b = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
1_024.0,
&[],
);
assert_district_profiles_eq(&a, &b);
}
}
/// Cutoff-exclusion discipline (T-1162 parts a/b): District's REAL
/// quantized band is `4,096` m (`MIN_WL_BANDS_M`'s pre-existing finest
/// entry — matches `terrain_detail`'s own finest octave, i.e. District's
/// Nyquist floor). At that exact cutoff, `derive_at_metres` must produce
/// output IDENTICAL to a second call at the same cutoff — this is the
/// "byte-identical if the cutoff excludes the new octaves" guarantee for
/// the sub-district relief band (all four `VOXEL_OCTAVE_WAVELENGTHS_M`
/// entries are ≤1,024 m, strictly below 4,096) and the two FINEST
/// coast-warp additions (2,048/1,024 m, also below 4,096). The coast
/// warp's 8,192/4,096 m additions are legitimately ADMITTED at District's
/// own floor (4,096 IS District's Nyquist limit, not "too fine for
/// District") — that is intended enrichment, not a leak, and is
/// deliberately NOT asserted away here (see the companion
/// `quarter_cutoff_admits_more_than_district` test for the positive
/// case). This test instead pins that AT THE SAME NOMINAL CUTOFF VALUE,
/// repeated derivation is stable — the determinism half of the contract.
#[test]
fn district_floor_cutoff_is_stable_and_deterministic() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let dm = scale::DISTRICT_M as f64;
for i in 0..20 {
let wx = (150 + i * 47) as f64 * dm;
let wy = (150 + i * 31) as f64 * dm;
let a = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
4_096.0,
&[],
);
let b = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
4_096.0,
&[],
);
assert_district_profiles_eq(&a, &b);
}
}
/// The sub-district relief band specifically (part b) is fully excluded
/// at District's floor (4,096 m — every `VOXEL_OCTAVE_WAVELENGTHS_M`
/// entry is ≤1,024 m, strictly below 4,096). Isolated directly against
/// `detail_scatter::voxel_relief` (rather than through the full
/// `derive_at_metres` stack, where the coast warp's OWN 8,192/4,096/2,048
/// additions would confound a two-cutoff comparison — see the module doc
/// on `MIN_WL_BANDS_M` for why 4,096 vs any value in `(1_024, 4_096)`
/// legitimately differs on the coast-warp side alone): at cutoff 4,096
/// the relief contribution is exactly zero, matching the
/// `flat_envelope_invents_nothing`-style empty-sum guard.
#[test]
fn voxel_relief_band_fully_excluded_at_district_floor() {
for i in 0..20 {
let wx = (150 + i * 91) as f64 * 137.0;
let wy = (150 + i * 67) as f64 * -211.0;
let relief = crate::atlas::detail_scatter::voxel_relief(
test_seed().seed(),
wx,
wy,
0.8,
0.6,
4_096.0,
);
assert_eq!(
relief, 0.0,
"voxel_relief must contribute exactly zero at District's 4,096 m floor \
(every VOXEL_OCTAVE_WAVELENGTHS_M entry is ≤1,024 m)"
);
}
}
/// The companion positive case: Quarter's real band (1,024 m) admits
/// content District's real band (4,096 m) excludes — the extension must
/// not be inert. Sweeps several positions and requires at least one to
/// diverge (a single unlucky zero-crossing position would otherwise
/// false-fail).
#[test]
fn quarter_cutoff_admits_more_than_district() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let dm = scale::DISTRICT_M as f64;
let mut any_differs = false;
for i in 0..20 {
let wx = (150 + i * 47) as f64 * dm;
let wy = (150 + i * 31) as f64 * dm;
let district = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
4_096.0,
&[],
);
let quarter = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
1_024.0,
&[],
);
if district.elev_q != quarter.elev_q
|| district.slope_q != quarter.slope_q
|| district.moisture_q != quarter.moisture_q
{
any_differs = true;
}
}
assert!(
any_differs,
"Quarter's finer cutoff must admit SOME content District's cutoff excludes \
at at least one sampled position — the T-1162 extension must not be inert"
);
}
/// Unknown/coarser cutoffs never admit finer octaves: a cutoff ABOVE
/// every extended band (coast warp's coarsest is 262,144 m) must produce
/// IDENTICAL output to the pre-extension "everything truncated" case —
/// confirms the extension didn't accidentally widen what a coarse cutoff
/// admits, only what a fine one does.
#[test]
fn coarse_cutoff_admits_nothing_from_t1162_extension_either() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let dm = scale::DISTRICT_M as f64;
for i in 0..10 {
let wx = (200 + i * 61) as f64 * dm;
let wy = (200 + i * 43) as f64 * dm;
// Above the coastal warp's own coarsest octave (262,144 m) — every
// octave in every band (coast, terrain, voxel relief) is excluded.
let far_above = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
300_000.0,
&[],
);
// An even more extreme cutoff must produce the SAME result — once
// every octave is truncated, going coarser still changes nothing.
let even_further = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
wx,
wy,
&climate,
10_000_000.0,
&[],
);
assert_district_profiles_eq(&far_above, &even_further);
}
}
/// Vegetation cross-rung coherence (the ticket's hard requirement): the
/// MAJORITY vegetation class over a sampled patch at Region-equivalent
/// (uncut massif-only) scale must be preserved when the SAME patch is
/// refined to Quarter spacing — Quarter punches clearings/copses (some
/// cells legitimately differ), but it must not flip the patch's dominant
/// class wholesale. Uses a wet, warm, low-elevation body so Forest is the
/// achievable majority class and the massif field has genuine amplitude
/// to work with (see `vegetation_envelope`'s wetness-product ceiling).
#[test]
fn vegetation_majority_class_preserved_under_quarter_refinement() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("tropical".into()),
body_radius_km: Some(6371.0),
latitude_deg: 5.0, // near-equator: warm, wet, low treeline pressure
..Default::default()
};
let dm = scale::DISTRICT_M as f64;
// A patch of 8x8 quarter-cells (one district's worth) around a fixed
// low-elevation coastal-adjacent-but-inland district.
let base_wx = 40.0 * dm;
let base_wy = 15.0 * dm;
let district_class = derive_at_metres(
test_seed(),
"test_body",
&p,
&ta,
base_wx,
base_wy,
&climate,
2_048.0,
&[],
)
.vegetation_class;
// Sample the surrounding quarter grid (512 m spacing) and tally class
// frequency — the majority must match the district-rung verdict at
// the patch centre if cross-rung coherence holds. Skip Marine (open
// water) tallies since the ticket's coherence claim is about land
// vegetation classes refining, not the ocean/land boundary itself.
use std::collections::BTreeMap;
let mut tally: BTreeMap<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 = SurveyCellPos(2, 1);
let climate = ClimateConstants::default();
let p1 = derive_district_profile(
test_seed(),
&params,
&ta,
pos,
8,
&climate,
"test_body",
&BTreeMap::new(),
BasinDirection::North,
None,
);
let p2 = derive_district_profile(
test_seed(),
&params,
&ta,
pos,
8,
&climate,
"test_body",
&BTreeMap::new(),
BasinDirection::North,
None,
);
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
assert_eq!(
format!("{:?}", p1.morphology_zone),
format!("{:?}", p2.morphology_zone),
"morphology_zone must be deterministic"
);
assert_eq!(p1.tectonic_class, p2.tectonic_class);
assert_eq!(p1.glaciation_grade, p2.glaciation_grade);
assert_eq!(p1.river_threshold, p2.river_threshold);
assert_eq!(p1.slope_q, p2.slope_q);
assert_eq!(p1.elev_q, p2.elev_q);
}
#[test]
fn volcanic_body_gets_volcanic_tectonic() {
let params = BodyParams {
planet_class: Some("volcanic".into()),
..Default::default()
};
assert_eq!(derive_tectonic_class(&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, None);
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
let first = districts.keys().next().expect("at least one district");
assert_eq!(
*first,
SurveyCellPos(0, 0),
"first district must be at origin"
);
}
#[test]
fn river_threshold_clamped_to_range() {
// Even with extreme params, threshold stays in [20, 500].
let t_humid = derive_river_threshold(TectonicClass::Active, PrecipitationClass::SuperHumid);
let t_arid = derive_river_threshold(TectonicClass::Stable, PrecipitationClass::Arid);
assert!(
(20..=500).contains(&t_humid),
"humid threshold {t_humid} out of range"
);
assert!(
(20..=500).contains(&t_arid),
"arid threshold {t_arid} out of range"
);
}
// -----------------------------------------------------------------------
// T-1024 / D-240 climate derivation tests — planet_class envelope model
// -----------------------------------------------------------------------
/// Convenience: derive temperature with a fixed seed (0) — deterministic, but
/// note seed 0 still applies its (constant, non-zero) nudge like any other seed.
fn temp(planet_class: &str, atmosphere: &str, lat: f64, elev_km: f64) -> Option<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. `lake_from_hydrology`
/// defaults to `false` (T-1184) — no existing caller of this helper tests
/// the hydrology-sourced lake gate; see `lake_from_hydrology_true_wins_...`
/// below for the dedicated hydrology-path tests.
fn zone(
tectonic: TectonicClass,
glaciation: GlaciationGrade,
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
moisture_q: i32,
) -> MorphologyZone {
derive_morphology_zone(
tectonic,
glaciation,
slope_q,
elev_q,
ocean_fraction_q,
moisture_q,
false,
)
}
fn zone_defaults() -> MorphologyZone {
zone(TectonicClass::Stable, GlaciationGrade::None, 10, 30, 0, 30)
}
// ── 17-zone vocabulary completeness ──────────────────────────────────────
#[test]
fn morphology_zone_discriminants_pinned() {
// D-239 §6 freeze point: discriminant values must never change.
assert_eq!(MorphologyZone::OpenOcean as u8, 0);
assert_eq!(MorphologyZone::Lake as u8, 1);
assert_eq!(MorphologyZone::TidalFlat as u8, 2);
assert_eq!(MorphologyZone::DuneStrand as u8, 3);
assert_eq!(MorphologyZone::CliffCoast as u8, 4);
assert_eq!(MorphologyZone::Fjord as u8, 5);
assert_eq!(MorphologyZone::Delta as u8, 6);
assert_eq!(MorphologyZone::Estuarine as u8, 7);
assert_eq!(MorphologyZone::AlluvialPlain as u8, 8);
assert_eq!(MorphologyZone::RiverBank as u8, 9);
assert_eq!(MorphologyZone::MeanderReach as u8, 10);
assert_eq!(MorphologyZone::BraidedPlain as u8, 11);
assert_eq!(MorphologyZone::ValleyFloor as u8, 12);
assert_eq!(MorphologyZone::MountainPass as u8, 13);
assert_eq!(MorphologyZone::Alpine as u8, 14);
assert_eq!(MorphologyZone::Volcanic as u8, 15);
assert_eq!(MorphologyZone::Wetland as u8, 16);
}
#[test]
fn morphology_zone_region_scale_emits_16_of_17() {
// Enumerate the zones the district-scale classifier can emit across a
// representative input grid. 16 of the 17 frozen zones are reachable here;
// BraidedPlain is the lone exception — see the assertion comment below.
use std::collections::BTreeSet;
let mut seen: BTreeSet<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 6079 → 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"
);
}
}