fix(simulation): address PR #160 review (climate + morphology)

Hoshe REQUEST_CHANGES + Tyre APPROVE:

- Riparian vegetation logic was doubly-wrong: it granted the micro-oasis only
  in the impossible cold case (temp < -50, no liquid water) and DENIED it in the
  intended hyper-arid case. Split: extreme cold -> Barren always; hyper-arid ->
  RiparianScrub iff near perennial water. (Hoshe #2)
- morphology_zone_has_exactly_17_variants was misleading (asserted >=16). Rename
  to morphology_zone_region_scale_emits_16_of_17, tighten to ==16, and document
  that BraidedPlain is the lone region-unreachable zone (needs lithology, deferred
  to ChunkContext) and that Lake IS reachable. Tyre's 'Lake also deferred' was a
  false positive (ocean 60-79 -> Lake). (Hoshe #1, Tyre #1)
- derive_morphology_zone docstring now lists the real multi-path emission sites
  for TidalFlat/Wetland, ValleyFloor/RiverBank as their own gates, and BraidedPlain's
  deferral — the §6 parent-family names are descriptive, not exhaustive. (Hoshe #3, Tyre #2/#3/#4)
- D-239 record: added a T-1025/T-1027 implementation note (enum freeze location,
  16/17 region reachability + BraidedPlain deferral, §7 gate-ordering interpretation).

No vocabulary change. cargo test passes, clippy -D warnings clean, fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:24:13 +02:00
co-authored by Claude Opus 4.8
parent 961855f276
commit 868da28192
2 changed files with 35 additions and 20 deletions
+1
View File
@@ -1771,6 +1771,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Raised by:** tile-derivation-contract workshop (Tyre — refinement chain, warp, determinism; Gestalt — 8 families, game-feel; Troblum — feasibility, warp precision, scale corrections; Miri — believability laws, vocabulary, lore reconciliation), lead-interviewed decisions + an adversarial verification pass, 2026-06-07.
- **Dissent:** Tyre's initial cross-family elevation-blend was resolved against (prevent-at-source). Early-integer-truncation of the warp (raised against Gestalt's `ElevationDelta` ranges and by Tyre) was resolved against in favour of f64-to-voxel.
- **Implementation note (T-1024, 2026-06-07):** §1 per-body `RIVER_THRESHOLD` is now a derived field (`derive_river_threshold`) on `RegionProfile`. §2 district temperature is a nullable `f32` on `RegionProfile` sampled from a per-region climate derivation (`derive_temperature_c`); moisture is a separate integer primitive (`derive_moisture_q`, 0100). Both populate the `RegionProfile` carrier during the L4 cascade run. Climate inputs use a **hybrid strategy**: stellar luminosity and spectral class are sourced from the import pipeline (`bodies.axial_tilt_deg`, `star_systems.spectral_class`); greenhouse offsets and diurnal amplitudes are tunable at runtime via `server/data/climate_constants.toml` (source-canonical TOML, not hardcoded).
- **Implementation note (T-1025/T-1027, 2026-06-08):** §6's frozen 17-zone `MorphologyZone` enum is realised in `server/src/simulation/generator.rs` (`repr(u8)`, discriminant-pinned). The §5 8-family gated classifier + §7 compatibility-matrix invariants live in `derive_morphology_zone` (`region_profile.rs`). **16 of the 17 zones are reachable at RegionProfile scale; `BraidedPlain` is the exception** — distinguishing it from `Delta` needs a lithology signal (§8 Gravel→braided) that `RegionProfile` does not carry, so `BraidedPlain` is **deferred to ChunkContext** sub-classification. The §7 compatibility invariant is enforced as a **classifier-gate-ordering** property (a build-time test that a single region cannot yield a forbidden pair), per §7's "build-time test" language; genuine cross-region sharp seams (cliff↔fjord, lithology faults) remain permitted. §2 climate-derived fields (`precipitation_class`, `glaciation_grade`, `vegetation_class`) all derive from the temperature(+moisture) primitive (T-1025).
- **Cross-reference:** [D-227](#d-227) (derive-don't-store voxel model), [D-228](#d-228) (composite tile axes / cohesion / seasonal state), [D-210](#d-210) (temperature proxy — formalised), [D-203](#d-203) (BodyWorldState cache), [D-206](#d-206) (background analysis pass), [D-208](#d-208) (drainage / D8), [D-010](#d-010) (determinism), [D-234](#d-234) (street/footprint geometry — consumes morphology), [D-142](content.md#d-142) (zone types), [D-217](#d-217) (tile condition), [Q-102](../questions/architecture.md#q-102) (cohesion = the warp), [Q-103](../questions/architecture.md#q-103) (mutator schema — open), [Q-105](../questions/architecture.md#q-105) (seasonal/clock state — temperature/ElevationDelta forward contract)
---
+34 -20
View File
@@ -366,11 +366,16 @@ pub fn derive_vegetation(
};
let temp_i = temp_c as i32;
// Hyper-arid or extreme cold → Barren regardless of elevation.
// These are the absolute limits; no vegetation survives.
if moisture_q < 5 || temp_i < -50 {
return if near_perennial_water && moisture_q >= 5 {
VegetationClass::RiparianScrub // water creates a micro-oasis even in arid extremes
// 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
};
@@ -479,11 +484,17 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas
/// 8. MeanderReach — gate: gentle slope + some water presence
/// 9. AlluvialPlain — fallback (§5)
///
/// Sub-classifications (§6): after family selection, apply:
/// - Alpine: IncisedGorge family + elev_q ≥ ALPINE_ELEV_THRESHOLD
/// - Wetland: AlluvialPlain/Delta family + slope_q ≤ 5 + moisture ≥ 60 (§8 Wetland ≤5°)
/// - TidalFlat: AlluvialPlain/CliffCoast family + ocean_fraction_q ≥ TIDAL_OCEAN_MIN + very low elev
/// - Estuarine: Delta family + ocean signal (brackish tidal zone at river mouth)
/// 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 region scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) RegionProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note).
///
/// D-010: all gates are integer comparisons. No float arithmetic in this function.
fn derive_morphology_zone(
@@ -1609,9 +1620,10 @@ mod tests {
}
#[test]
fn morphology_zone_has_exactly_17_variants() {
// Enumerate all reachable zones through the classifier across a
// representative grid of inputs to verify all 17 are reachable.
fn morphology_zone_region_scale_emits_16_of_17() {
// Enumerate the zones the region-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();
@@ -1636,13 +1648,15 @@ mod tests {
}
}
}
// BraidedPlain is not yet emitted by classifier (it's a future ChunkContext
// sub-classification at finer scale); all others must be reachable.
// Assert at least 16 of 17 zones are reachable via the region-scale classifier.
assert!(
seen.len() >= 16,
"only {}/17 zones reachable by classifier: {seen:?}",
seen.len()
// BraidedPlain (discriminant 11) is the ONE frozen zone the region-scale
// classifier never emits: distinguishing it from Delta needs a lithology
// signal (§8 Gravel→braided) that RegionProfile 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 region-reachable zones (BraidedPlain deferred), got: {seen:?}"
);
}