From 895bb83bad5333ee78c0892b350fd17caa56c2ac Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 5 Jul 2026 19:12:05 +0200 Subject: [PATCH] fix(simulation): unify C1 climate gate across Wetland+Forest+Grassland (PR #172 N1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyre's re-review found the C1 !Barren guard only covered the Grassland arm — the Wetland arm was an unconditional => true. Since derive_morphology_zone has no temperature gate, a frozen + wet + flat district still yields Wetland material, so the wetland palette would paint copse/bog onto frozen marsh: the exact C1 symptom via the material path. Not a regression (the arm was unchanged) and unreachable by the 2 harness bodies, but the D-246 amendment + the voxel.rs comment already claim the guarantee universally, so the gate must match the claim. Gate is now: apply = !Barren && class in {Wetland, Forest, Grassland}. Behavior change is limited to frozen/hyper-arid Wetland districts (a temperate marsh is never Barren-dominant; Forest is Barren-free by construction). believability.json unchanged (neither harness body reaches the frozen-wet case). New regression test mosaic_pass_does_not_revegetate_frozen_wetland; D-246 amendment reworded to the unified gate. Full cargo test green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- governance/decisions/architecture.md | 2 +- server/src/atlas/voxel.rs | 60 ++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index ce24aca99..95ad028a5 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1927,7 +1927,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Amendment (T-1084, 2026-07-05 — v1 implementation scope, PR #172 H2/C4):** the first implementation lands a **narrower pass than item 5 specifies**, recorded here so the record and code agree (the [D-239](#d-239) T-1080/81/82 postscript pattern): - **Only the Soil-derived vegetated/wet classes take the mosaic** in v1 — Wetland, Forest, Grassland (the `apply` gate in `voxel.rs`). The **material-driven** families (Sand, Rock, Gravel, Lava) are gated `_ => false`, so the material-variant rows of the table above — dune crest/**hardpan**, **scree**/talus, dry-wash Gravel, and every Lava row — are **authored but not yet constructed on any reachable path**, and the two new `TerrainMaterial::{Hardpan, Scree}` values are consequently unreachable in v1. A material change on those families must respect the family's own lithology and carved geometry (shape-aware §8 reconciliation), so they are the deferred follow-up. Item 5's promise that the flat families take the *full* (material + micro-relief) mosaic is **deferred, not delivered** at v1. - **No elevation change in v1.** The item-5 micro-relief nudge (dune crest/slack, bog lows) and the non-structural dramatic-family pass are both deferred — v1 modulates **vegetation + water only**, never `elevation_m`. The D-239 §8 **lithology** law therefore holds trivially: material and shape are left exactly as the family generator set them. - - **For the three classes it does touch, the mosaic OWNS the §8 climate→vegetation law** (C4), not merely stays inside it. The **no-skip** half holds — no palette emits `Barren` in a Forest / Wetland / Grassland zone. The **climate-appropriateness** half is enforced by the Grassland apply-gate `!Barren`: a climatically-barren district (frozen `< -50 °C` = surface ice / geology per D-239 §2, or hyper-arid `moisture_q < 5` — both resolved to `VegetationClass::Barren` upstream by `derive_vegetation`) is **skipped**, so the mosaic never re-grants cover the climate withheld (the C1 fix: Edict, a frozen ice world, must not read as vegetated tundra — [D-245](#d-245) §5). The trade: the family generators' *guaranteed* riparian Thicket band is softened to a **probabilistic** wet-habitat clustered near `has_active_channel` chunks (T-1040-gated) — acceptable at v1. + - **For the three classes it does touch, the mosaic OWNS the §8 climate→vegetation law** (C4), not merely stays inside it. The **no-skip** half holds — no palette emits `Barren` in a Forest / Wetland / Grassland zone. The **climate-appropriateness** half is enforced by a **unified `!Barren` apply-gate spanning all three classes** (C1 + N1): a climatically-barren district (frozen `< -50 °C` = surface ice / geology per D-239 §2, or hyper-arid `moisture_q < 5` — both resolved to `VegetationClass::Barren` upstream by `derive_vegetation`) is **skipped**, so the mosaic never re-grants cover the climate withheld. The guard must span **Wetland as well as Grassland**: `derive_morphology_zone` has no temperature gate, so a frozen + wet + flat district still yields Wetland material and would otherwise be painted with wetland copse/bog — the same C1 symptom via the material path (a frozen ice world must read as barren ground, not as vegetated tundra *or* marsh — [D-245](#d-245) §5). Forest is `Barren`-free by construction (`SurfaceClass::Forest` requires `dominant_veg == Forest`), so the guard is a no-op there. The trade: the family generators' *guaranteed* riparian Thicket band is softened to a **probabilistic** wet-habitat clustered near `has_active_channel` chunks (T-1040-gated) — acceptable at v1. - **Cross-reference:** [D-228](#d-228) (the axes this modulates — amended 2026-07-02 to add the five values), [D-227](#d-227) (derive-don't-store — the tier the mosaic lives in), [D-245](#d-245) (believability gate — this is condition 3, intra-class variety), [D-239](#d-239) §8 (binding laws — the mosaic stays inside them) / §10 (sub-chunk budget), [D-243](#d-243) §2 (the detail-scatter tier T-1081 and this extend), [D-247](content.md#d-247) (native-mirror vs introduced-Earth **biosphere register** — palette-key input 3; the mosaic is chirality-blind, but its register labels each niche native or introduced), [D-210](#d-210) / [T-948](../../.pql) (SubBiomeVariant — now **palette-key input 2**: the macro biome realized as the finer intra-class distribution, not a single tag). Tickets: T-1084 (this), T-1079 (epic), T-1081 (relief — a conditioning input + the reused machinery), T-1083 (enforcer — measures the mosaic), [Q-123](../questions/architecture.md#q-123) item 3 (calibrates K). - **Dissent:** None diff --git a/server/src/atlas/voxel.rs b/server/src/atlas/voxel.rs index ac53d9597..36da38804 100644 --- a/server/src/atlas/voxel.rs +++ b/server/src/atlas/voxel.rs @@ -537,19 +537,24 @@ pub fn derive_voxel_column( // boreal / tundra read distinctly (D-246 biome-awareness). let biome = mosaic::ClimateBiome::from_climate(district_eff.temperature_c, district_eff.moisture_q); - let apply = match class { - SurfaceClass::Wetland | SurfaceClass::Forest => true, - // Only where the climate already grants cover (C1 fix). A Barren-dominant - // district is Barren for a reason `derive_vegetation` already resolved — - // frozen (< -50 °C = surface ice/geology, D-239 §2), hyper-arid (moisture < 5), - // or cold-dry marginal ground — so the mosaic must NOT re-grant it (else it - // paints tundra scrub onto frozen ice worlds like Edict). Genuine tundra - // (cold + moisture ≥ 15) is already Scrub upstream, so it reaches the biome's - // tundra palette through this same !Barren path — no special cold clause needed. - SurfaceClass::Grassland => !matches!(dominant_veg, Vegetation::Barren), - // Rock / Sand / Gravel / Lava are material-driven — a §8-aware follow-up. - _ => false, - }; + // Apply within the vegetated / wet land classes — but ONLY where the climate + // already grants cover (C1 + N1). A Barren-dominant district is Barren for a + // reason `derive_vegetation` already resolved — frozen (< -50 °C = surface + // ice/geology, D-239 §2), hyper-arid (moisture < 5), or cold-dry marginal ground + // — so the mosaic must NOT re-grant it, else it paints tundra scrub onto a frozen + // ice world (Edict, via the Grassland arm) or wetland copse onto a frozen marsh + // (via the Wetland arm — `derive_morphology_zone` has no temperature gate, so + // frozen + wet + flat still yields Wetland material). The `!Barren` guard therefore + // spans all three classes, not just Grassland. Genuine tundra (cold + moisture ≥ 15) + // is Scrub upstream (not Barren), so it still reaches the biome's tundra palette. + // Forest is Barren-free by construction (SurfaceClass::Forest requires + // dominant_veg == Forest). Rock / Sand / Gravel / Lava are material-driven — a + // §8-aware follow-up (D-246 amendment). + let apply = !matches!(dominant_veg, Vegetation::Barren) + && matches!( + class, + SurfaceClass::Wetland | SurfaceClass::Forest | SurfaceClass::Grassland + ); if apply { let mosaic_seed = SeedChain::for_body(world_seed, body_id) .derive(SeedDomain::VoxelMosaic, 0) @@ -2629,6 +2634,35 @@ mod tests { } } + /// N1 regression (PR #172 re-review): the C1 climate gate must hold on the Wetland + /// arm too, not only Grassland. `derive_morphology_zone` takes no temperature, so a + /// FROZEN + wet + flat district maps to Wetland material (`SurfaceClass::Wetland`) + /// while `derive_vegetation` returns Barren — the exact C1 symptom via the material + /// path. The unified `!Barren` gate skips it, so the frozen marsh stays Barren rather + /// than being painted with the wetland palette (copse / meadow / bog / marsh). Before + /// the N1 fix the Wetland arm was an unconditional `=> true`. + #[test] + fn mosaic_pass_does_not_revegetate_frozen_wetland() { + let frozen_wet = DistrictProfile { + vegetation_class: VegetationClass::Barren, + temperature_c: Some(-60.0), // frozen: surface ice = geology (D-239 §2) + moisture_q: 70, // ≥ 60 + flat ⇒ Wetland material (SurfaceClass::Wetland) + morphology_zone: MorphologyZone::Wetland, + slope_q: 3, + ..alluvial_district() + }; + let chunk = derive_chunk_context(42, "frozenmarsh", &frozen_wet, (10, 20), None); + for tx in [100, 137, 200, 263, 331] { + let col = derive_voxel_column(42, "frozenmarsh", &frozen_wet, &chunk, tx, 90); + assert_eq!( + col.vegetation, + Vegetation::Barren, + "frozen Wetland district must stay Barren (N1), got {:?} at x={tx}", + col.vegetation, + ); + } + } + #[test] fn elevation_is_non_negative() { let district = alluvial_district();