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
+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:?}"
);
}