From a94314784328adb2ccbddc9ee255274f9063483c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 19:25:18 +0200 Subject: [PATCH 1/3] test(simulation): believability + budget verification harness (T-1031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-239 §1/§8/§9/§10 verification harness (server/tests/derivation_harness.rs): golden-seed determinism regression, §8 binding-law sweeps (drainage monotonicity, lithology->landform, glaciation->form no-glacial-U-at-grade-0, climate->vegetation no-skip + riparian), per-family <5ms/chunk budget assertion, and validation-case bodies (Kallast=alluvial pass; Velen/Gruenfeld skipped-not-in-wiki). Exposes derive_morphology_zone as pub for the integration test. NOTE: golden file not yet generated + fixtures predate the T-1033 BodyParams strip; fixed in follow-up commits after merging main. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/region_profile.rs | 2 +- server/tests/derivation_harness.rs | 1308 ++++++++++++++++++++++++++++ 2 files changed, 1309 insertions(+), 1 deletion(-) create mode 100644 server/tests/derivation_harness.rs diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 77c503ed0..588f6aa87 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -497,7 +497,7 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas /// - 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( +pub fn derive_morphology_zone( tectonic: TectonicClass, glaciation: GlaciationGrade, slope_q: i32, diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs new file mode 100644 index 000000000..f2a8a28e6 --- /dev/null +++ b/server/tests/derivation_harness.rs @@ -0,0 +1,1308 @@ +//! D-239 Tile-Derivation Verification Harness (T-1031). +//! +//! Capstone test for the T-974 tile-derivation epic. Covers four domains: +//! +//! **1. Golden-seed determinism regression (D-239 §1 / D-010).** +//! Pins (seed, region, chunk_pos, tile_pos) → VoxelColumn bindings as a JSON +//! golden. Any future change to the derivation chain is caught immediately. +//! Pattern mirrors cascade_golden.rs. Regenerate: +//! `UPDATE_GOLDEN=1 cargo test --test derivation_harness` +//! +//! **2. §8 binding-law assertions (D-239 §8).** +//! Sweeps representative `RegionProfile` inputs and asserts the four +//! believability laws hold across the parameter space: +//! - Drainage monotonicity (channel tiles at or below surrounding terrain) +//! - Lithology→landform (family emits its mandated TerrainMaterial) +//! - Glaciation→form (FjordWall only at GlaciationGrade ≥ 2; grade 0 ≠ fjord) +//! - Climate→vegetation (Forest→Scrub→Barren no-skip; riparian band present) +//! +//! **3. Per-family <5 ms/chunk budget (D-239 §10).** +//! Derives a full 64×64 chunk (4096 voxels) for each of the 8 families. +//! Hard gate (< 5 ms) is activated by `BUDGET_ASSERT=1` env var (release build). +//! Debug builds print timings without failing. VoxelCache is exercised to +//! measure LRU eviction overhead (O(capacity) scan, noted on T-1031). +//! +//! **4. Validation body cases (D-239 §1).** +//! Lore-anchored bodies verified from wiki body params (DB-free). Bodies absent +//! from the wiki are skipped with an explicit logged note. +//! +//! - Kallast (GJ144d): alluvial plain / Soil — VALIDATED +//! - Glødberg (GJ581c): volcanic immature drainage — VALIDATED (Cygni-B proxy) +//! - Marevna (GJ447c): ocean island / OpenOcean — VALIDATED (Ross 128) +//! - Velen (tidal-flat/dune coast): NOT IN WIKI — SKIPPED +//! - Gruenfeld (marginal Gravel/Scrub): NOT IN WIKI — SKIPPED +//! +//! Run all: `cargo test --test derivation_harness` +//! Update golden: `UPDATE_GOLDEN=1 cargo test --test derivation_harness` +//! Budget gate: `BUDGET_ASSERT=1 cargo test --test derivation_harness -- budget` + +use std::path::PathBuf; +use std::time::Instant; + +use settled_reach_server::atlas::chunk_context::derive_chunk_context; +use settled_reach_server::atlas::drainage; +use settled_reach_server::atlas::features::TerrainAnalysis; +use settled_reach_server::atlas::heightmap::BodyHeightmap; +use settled_reach_server::atlas::region_profile::{ + derive_morphology_zone, derive_precipitation_class_from_climate, derive_region_profile, + derive_river_threshold, derive_vegetation, BodyParams, ClimateConstants, GlaciationGrade, + RegionProfile, TectonicClass, VegetationClass, +}; +use settled_reach_server::atlas::voxel::{derive_voxel_column, TerrainMaterial, VoxelCache, Water}; +use settled_reach_server::seed::{SeedChain, SeedDomain}; +use settled_reach_server::simulation::generator::MorphologyZone; + +// --------------------------------------------------------------------------- +// §1 — Golden-seed determinism regression +// --------------------------------------------------------------------------- + +const GOLDEN_FILE: &str = "tests/golden/derivation_harness.json"; + +/// Compact representation of a VoxelColumn for golden pinning. +/// All fields are their integer-discriminant u8 values (D-010) or i32 elevation. +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq, Clone)] +struct GoldenEntry { + label: String, + seed: u64, + body_id: String, + tile_x: i32, + tile_y: i32, + terrain: u8, + vegetation: u8, + water: u8, + elevation_m: i32, + cover: u8, +} + +/// Derive a GoldenEntry for a fixed (seed, region, chunk_pos, tile_pos) tuple. +fn derive_golden( + label: &str, + seed: u64, + body_id: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), + tile_x: i32, + tile_y: i32, +) -> GoldenEntry { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + let col = derive_voxel_column(seed, body_id, region, &chunk, tile_x, tile_y); + GoldenEntry { + label: label.to_string(), + seed, + body_id: body_id.to_string(), + tile_x, + tile_y, + terrain: col.terrain as u8, + vegetation: col.vegetation as u8, + water: col.water as u8, + elevation_m: col.elevation_m, + cover: col.cover as u8, + } +} + +/// The three fixed golden inputs. Varied families and climate states. +fn golden_cases() -> Vec<(&'static str, u64, &'static str, RegionProfile, (i32, i32), i32, i32)> { + vec![ + // Case A: AlluvialPlain — temperate forest, active channel. + ( + "alluvial_forest_active_channel", + 0xdeadbeef_cafebabe_u64, + "GJ144d", + make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Active, + GlaciationGrade::None, + 6, 22, 18, 68, + Some(12.0), + VegetationClass::Forest, + ), + (10, 20), + 640, + 1280, + ), + // Case B: LavaField — barren volcanic, no channel. + ( + "lava_field_barren", + 0x12345678_90abcdef_u64, + "GJ581c", + make_region( + MorphologyZone::Volcanic, + TectonicClass::Volcanic, + GlaciationGrade::None, + 12, 38, 0, 22, + Some(45.0), + VegetationClass::Barren, + ), + (5, 7), + 320, + 448, + ), + // Case C: FjordWall — glaciated, rocky walls, deep water in trough. + ( + "fjord_wall_glaciated", + 0xfeedface_0badc0de_u64, + "GJ447c", + make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, 60, 28, 55, + Some(-8.0), + VegetationClass::Barren, + ), + (14, 8), + // Centre of the chunk (fjord trough) — should be Deep water, Rock. + 14 * 64 + 32, + 8 * 64 + 32, + ), + ] +} + +#[test] +fn golden_seed_determinism_regression() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let golden_path = manifest.join(GOLDEN_FILE); + + // Derive once, then derive again — the two must be identical before we + // compare against the golden. This is the core D-010 determinism contract. + let run1: Vec = golden_cases() + .into_iter() + .map(|(lbl, seed, body, region, cp, tx, ty)| derive_golden(lbl, seed, body, ®ion, cp, tx, ty)) + .collect(); + let run2: Vec = golden_cases() + .into_iter() + .map(|(lbl, seed, body, region, cp, tx, ty)| derive_golden(lbl, seed, body, ®ion, cp, tx, ty)) + .collect(); + + assert_eq!( + run1, run2, + "double-derivation mismatch — determinism is broken (D-010)" + ); + + let actual_json = serde_json::to_string_pretty(&run1).expect("serialize") + "\n"; + + if std::env::var("UPDATE_GOLDEN").is_ok() { + std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden"); + std::fs::write(&golden_path, &actual_json).expect("write golden"); + eprintln!( + "Golden written: {} ({} bytes)", + golden_path.display(), + actual_json.len() + ); + return; + } + + let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| { + panic!( + "Golden file not found: {}.\n\ + First run: UPDATE_GOLDEN=1 cargo test --test derivation_harness\n{e}", + golden_path.display() + ) + }); + + let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual"); + let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden"); + + if actual_v != golden_v { + panic!( + "Derivation golden mismatch — derivation chain changed.\n\ + Update: UPDATE_GOLDEN=1 cargo test --test derivation_harness\n\ + Golden: {}\nActual: {}", + golden_json.trim(), + actual_json.trim() + ); + } +} + +// --------------------------------------------------------------------------- +// §2 — §8 Binding-law assertions +// --------------------------------------------------------------------------- + +// ── §8 Law 1: Drainage monotonicity ───────────────────────────────────────── +// +// Channel / water tiles must sit at or below surrounding dry terrain. +// D-239 §8: "drainage monotonicity: tributaries join upstream; mouths at sea +// level; BraidedDelta/CliffCoast/FjordWall floors at sea level." +// Tested by deriving a full 64×64 chunk and checking min(wet_elev) ≤ max(dry_elev). + +fn assert_drainage_monotonicity( + seed: u64, + body_id: &str, + label: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), +) { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + if !chunk.has_active_channel { + return; // No channel → monotonicity trivially satisfied. + } + + let mut max_dry_elev = i32::MIN; + let mut min_wet_elev = i32::MAX; + let mut wet_count = 0usize; + let mut dry_count = 0usize; + + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + for dy in 0..64i32 { + for dx in 0..64i32 { + let col = + derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + match col.water { + Water::Dry => { + max_dry_elev = max_dry_elev.max(col.elevation_m); + dry_count += 1; + } + Water::Shallow | Water::Deep => { + min_wet_elev = min_wet_elev.min(col.elevation_m); + wet_count += 1; + } + } + } + } + + if wet_count > 0 && dry_count > 0 { + // Tolerance +3 m: meander levees are elevated above the floodplain + // (D-239 §9 ElevationDelta), so a channel tile adjacent to a levee tile + // will appear "slightly below" but the levee reads higher. We allow a + // small tolerance to avoid false positives at the levee-channel boundary. + let tolerance = 3; + assert!( + min_wet_elev <= max_dry_elev + tolerance, + "§8 drainage monotonicity VIOLATED in '{label}' chunk ({},{}): \ + min wet-tile elevation {min_wet_elev} m > max dry-tile elevation \ + {max_dry_elev} m (tolerance +{tolerance} m).", + chunk_pos.0, + chunk_pos.1 + ); + } +} + +#[test] +fn law_drainage_monotonicity_alluvial_sweep() { + let region = make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 5, 20, 18, 60, + Some(15.0), + VegetationClass::Forest, + ); + for (cx, cy) in [(0, 0), (1, 0), (0, 1), (4, 4), (8, 3)] { + assert_drainage_monotonicity(42, "GJ144d", "AlluvialPlain", ®ion, (cx, cy)); + } +} + +#[test] +fn law_drainage_monotonicity_meander_sweep() { + let region = make_region( + MorphologyZone::MeanderReach, + TectonicClass::Stable, + GlaciationGrade::None, + 8, 18, 20, 65, + Some(14.0), + VegetationClass::Forest, + ); + for (cx, cy) in [(0, 0), (2, 1), (5, 5)] { + assert_drainage_monotonicity(99, "GJ447c", "MeanderReach", ®ion, (cx, cy)); + } +} + +#[test] +fn law_drainage_monotonicity_fjord_floor_at_sea_level() { + // D-239 §8: fjord/cliff/delta floors at sea level (drainage monotonicity). + // FjordWall deep-water channel must be near elevation 0. + let region = make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, 60, 28, 55, + Some(-8.0), + VegetationClass::Barren, + ); + let chunk = derive_chunk_context(42, "fjord_body", ®ion, (0, 0)); + let mut deep_elevs: Vec = vec![]; + let mut dry_elevs: Vec = vec![]; + + for dx in 0..64i32 { + let col = derive_voxel_column(42, "fjord_body", ®ion, &chunk, dx, 32); + match col.water { + Water::Deep => deep_elevs.push(col.elevation_m), + Water::Dry => dry_elevs.push(col.elevation_m), + _ => {} + } + } + + if !deep_elevs.is_empty() && !dry_elevs.is_empty() { + let max_deep = *deep_elevs.iter().max().unwrap(); + let min_dry = *dry_elevs.iter().min().unwrap(); + // Fjord floor (deep water) must be below wall elevation. + assert!( + max_deep <= min_dry, + "§8 FjordWall drainage: deep-water max elev {max_deep} m must be \ + ≤ dry wall min elev {min_dry} m" + ); + // Fjord floor must be near sea level (D-239 §8). + assert!( + max_deep <= 5, + "§8 FjordWall: deep-water floor elev {max_deep} m must be near sea level (≤5 m)" + ); + } +} + +#[test] +fn law_drainage_monotonicity_braided_delta() { + let region = make_region( + MorphologyZone::Delta, + TectonicClass::Active, + GlaciationGrade::None, + 3, 8, 25, 50, + Some(18.0), + VegetationClass::Scrub, + ); + for (cx, cy) in [(0, 0), (1, 1)] { + assert_drainage_monotonicity(17, "delta_body", "BraidedDelta", ®ion, (cx, cy)); + } +} + +// ── §8 Law 2: Lithology → Landform ────────────────────────────────────────── +// +// D-239 §8: Lava→Lava; Cliff/Fjord/Gorge→Rock; Sand→Dune; Gravel→Braided; +// Soil→Meander/Alluvial; Wetland→Wetland. + +/// Derive 64 tile samples (every 8th tile in a 64×64 chunk) and assert all +/// have the expected TerrainMaterial. +fn assert_all_terrain_is( + seed: u64, + body_id: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), + expected: TerrainMaterial, + label: &str, +) { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + for dy in (0..64i32).step_by(8) { + for dx in (0..64i32).step_by(8) { + let col = + derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + assert_eq!( + col.terrain, expected, + "§8 lithology VIOLATED for '{label}': voxel ({},{}) returned {:?}, expected {:?}", + base_x + dx, + base_y + dy, + col.terrain, + expected + ); + } + } +} + +#[test] +fn law_lithology_lava_emits_lava() { + // D-239 §8: Lava family → TerrainMaterial::Lava everywhere. + let region = make_region( + MorphologyZone::Volcanic, + TectonicClass::Volcanic, + GlaciationGrade::None, + 15, 35, 0, 20, + Some(40.0), + VegetationClass::Barren, + ); + assert_all_terrain_is(42, "GJ581c", ®ion, (3, 3), TerrainMaterial::Lava, "LavaField"); +} + +#[test] +fn law_lithology_fjord_emits_rock() { + let region = make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, 60, 28, 55, + Some(-8.0), + VegetationClass::Barren, + ); + assert_all_terrain_is(42, "fjord_body", ®ion, (0, 0), TerrainMaterial::Rock, "FjordWall"); +} + +#[test] +fn law_lithology_cliff_coast_emits_rock() { + let region = make_region( + MorphologyZone::CliffCoast, + TectonicClass::Active, + GlaciationGrade::None, + 60, 40, 20, 30, + Some(10.0), + VegetationClass::Scrub, + ); + assert_all_terrain_is(42, "cliff_body", ®ion, (0, 0), TerrainMaterial::Rock, "CliffCoast"); +} + +#[test] +fn law_lithology_incised_gorge_emits_rock() { + // D-239 §8: IncisedGorge/MountainPass → TerrainMaterial::Rock. + let region = make_region( + MorphologyZone::MountainPass, + TectonicClass::Active, + GlaciationGrade::None, + 50, 65, 5, 40, + Some(5.0), + VegetationClass::Scrub, + ); + assert_all_terrain_is( + 42, "gorge_body", ®ion, (0, 0), TerrainMaterial::Rock, "IncisedGorge", + ); +} + +#[test] +fn law_lithology_dune_strand_emits_sand() { + // D-239 §8: DuneStrand → TerrainMaterial::Sand (≤32° angle of repose). + let region = make_region( + MorphologyZone::DuneStrand, + TectonicClass::Stable, + GlaciationGrade::None, + 12, 15, 20, 25, + Some(22.0), + VegetationClass::Barren, + ); + assert_all_terrain_is(42, "dune_body", ®ion, (0, 0), TerrainMaterial::Sand, "DuneStrand"); +} + +#[test] +fn law_lithology_braided_delta_emits_gravel() { + // D-239 §8: BraidedDelta (Gravel→braided channels/fans) → TerrainMaterial::Gravel. + let region = make_region( + MorphologyZone::Delta, + TectonicClass::Active, + GlaciationGrade::None, + 3, 8, 25, 50, + Some(18.0), + VegetationClass::Scrub, + ); + assert_all_terrain_is( + 42, "delta_body", ®ion, (0, 0), TerrainMaterial::Gravel, "BraidedDelta", + ); +} + +#[test] +fn law_lithology_alluvial_plain_emits_soil() { + // D-239 §8: AlluvialPlain → Soil (non-wetland params: slope_q>5, moisture_q<60). + let region = make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 10, 25, 12, 45, + Some(16.0), + VegetationClass::Forest, + ); + assert_all_terrain_is( + 42, "alluvial_body", ®ion, (0, 0), TerrainMaterial::Soil, "AlluvialPlain", + ); +} + +#[test] +fn law_lithology_meander_reach_emits_soil() { + // D-239 §8: MeanderReach (Soil→rolling/floodplain) → TerrainMaterial::Soil. + let region = make_region( + MorphologyZone::MeanderReach, + TectonicClass::Stable, + GlaciationGrade::None, + 8, 18, 20, 55, + Some(14.0), + VegetationClass::Forest, + ); + assert_all_terrain_is( + 42, "meander_body", ®ion, (0, 0), TerrainMaterial::Soil, "MeanderReach", + ); +} + +// ── §8 Law 3: Glaciation → Form ────────────────────────────────────────────── +// +// D-239 §5/§8: FjordWall requires GlaciationGrade ≥ 2 (Moderate). +// Grade 0 must NEVER produce Fjord regardless of slope/coastal params. +// Grade 1 (Light) also must NOT produce Fjord (gate is ≥ 2, not ≥ 1). + +#[test] +fn law_glaciation_grade_0_never_produces_fjord() { + // Sweep slope_q and ocean_fraction_q at grade 0. No combination should yield Fjord. + for slope_q in [30, 40, 50, 55, 60, 70] { + for ocean_q in [15, 25, 35, 45, 55] { + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::None, // grade 0 + slope_q, + 60, + ocean_q, + 55, + ); + assert_ne!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law VIOLATED: GlaciationGrade::None + slope_q={slope_q} \ + + ocean_q={ocean_q} produced Fjord zone. Gate requires ≥ 2." + ); + } + } +} + +#[test] +fn law_glaciation_grade_1_never_produces_fjord() { + // Grade 1 (Light) is below the fjord gate. Must also not produce Fjord. + for slope_q in [40, 55, 70] { + for ocean_q in [20, 35] { + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::Light, // grade 1 — gate does NOT open + slope_q, + 60, + ocean_q, + 55, + ); + assert_ne!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law VIOLATED: GlaciationGrade::Light (grade 1) + \ + slope_q={slope_q} + ocean_q={ocean_q} produced Fjord. Gate requires ≥ 2." + ); + } + } +} + +#[test] +fn law_glaciation_grade_2_enables_fjord_with_correct_params() { + // Grade 2 (Moderate): with the correct slope + coastal params, the fjord gate opens. + // This is the positive test — the gate MUST open at exactly grade 2. + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::Moderate, // grade 2 — gate opens + 55, // steep enough + 60, + 25, // coastal + 55, + ); + assert_eq!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law: GlaciationGrade::Moderate (grade 2) with slope_q=55 + \ + ocean_q=25 should produce Fjord zone — gate should be open at grade 2." + ); +} + +#[test] +fn law_glaciation_grade_4_also_enables_fjord() { + // IceCap (grade 4): also above the gate. Should produce Fjord with correct params. + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::IceCap, + 55, + 60, + 25, + 55, + ); + assert_eq!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law: GlaciationGrade::IceCap (grade 4) should also enable Fjord gate" + ); +} + +// ── §8 Law 4: Climate → Vegetation ────────────────────────────────────────── +// +// D-239 §8: "treeline Forest→Scrub→Barren, no skip." +// Rule: as temperature decreases (or elevation increases), the sequence must +// pass through Scrub before reaching Barren. A direct Forest→Barren jump is +// forbidden. Riparian variants are permitted anywhere they're produced. + +#[test] +fn law_climate_vegetation_no_skip_temperature_sweep() { + // Sweep temperature from +30°C to -60°C at fixed mid-elevation. + // The sequence must have no Forest→Barren jump. + let moisture_q = 50; + let elev_q = 40; + let mut last: Option = None; + for t_i in (-60i32..=30).rev().step_by(5) { + let temp = Some(t_i as f32); + let vc = derive_vegetation(temp, moisture_q, elev_q, false); + if let Some(prev) = last { + if prev == VegetationClass::Forest && vc == VegetationClass::Barren { + panic!( + "§8 vegetation no-skip VIOLATED: Forest→Barren at temp={t_i}°C \ + (elev_q={elev_q}, moisture_q={moisture_q})" + ); + } + } + last = Some(vc); + } +} + +#[test] +fn law_climate_vegetation_no_skip_elevation_sweep() { + // Sweep elevation from 0 to 100 at fixed warm temperature. + let temp = Some(15.0f32); + let moisture_q = 55; + let mut last: Option = None; + for elev_q in (0i32..=100).step_by(5) { + let vc = derive_vegetation(temp, moisture_q, elev_q, false); + if let Some(prev) = last { + if prev == VegetationClass::Forest && vc == VegetationClass::Barren { + panic!( + "§8 vegetation no-skip VIOLATED: Forest→Barren at elev_q={elev_q} \ + (temp=+15°C, moisture_q={moisture_q})" + ); + } + } + last = Some(vc); + } +} + +#[test] +fn law_climate_vegetation_full_grid_no_skip() { + // Full grid sweep: all (moisture_q, elev_q) combinations, temperature from warm to cold. + // At each slice, verify no Forest→Barren jump as temperature drops. + let temps_desc: Vec> = [ + Some(30.0), + Some(20.0), + Some(10.0), + Some(5.0), + Some(0.0), + Some(-5.0), + Some(-10.0), + Some(-20.0), + Some(-30.0), + Some(-40.0), + Some(-55.0), + None, + ] + .iter() + .copied() + .collect(); + + for moisture_q in (0i32..=100).step_by(10) { + for elev_q in (0i32..=100).step_by(10) { + let mut last: Option = None; + for &temp in &temps_desc { + let vc = derive_vegetation(temp, moisture_q, elev_q, false); + if let Some(prev) = last { + if prev == VegetationClass::Forest && vc == VegetationClass::Barren { + panic!( + "§8 full-grid no-skip VIOLATED: Forest→Barren at \ + temp={temp:?}, moisture_q={moisture_q}, elev_q={elev_q}" + ); + } + } + last = Some(vc); + } + } + } +} + +#[test] +fn law_climate_vegetation_riparian_near_perennial_water() { + // D-239 §8: "riparian Thicket/Scrub 1–3 tiles along perennial waterways." + // near_perennial_water=true must produce a Riparian variant in viable zones. + + // Forest zone → RiparianThicket. + let vc_forest = derive_vegetation(Some(18.0), 60, 20, true); + assert!( + matches!( + vc_forest, + VegetationClass::RiparianThicket | VegetationClass::RiparianScrub + ), + "§8 riparian: Forest zone near water should → Riparian variant, got {:?}", + vc_forest + ); + + // Scrub zone → RiparianScrub. + let vc_scrub = derive_vegetation(Some(5.0), 30, 50, true); + assert!( + matches!( + vc_scrub, + VegetationClass::RiparianScrub | VegetationClass::RiparianThicket + ), + "§8 riparian: Scrub zone near water should → Riparian variant, got {:?}", + vc_scrub + ); + + // Hyper-arid Barren zone + perennial water → RiparianScrub oasis (D-239 §8). + let vc_arid = derive_vegetation(Some(20.0), 3, 10, true); + assert_eq!( + vc_arid, + VegetationClass::RiparianScrub, + "§8 riparian: hyper-arid zone (moisture_q=3) near perennial water → \ + RiparianScrub oasis, got {:?}", + vc_arid + ); +} + +#[test] +fn law_climate_vegetation_airless_always_absent() { + // D-239 §2: airless body (temperature_c = None) → VegetationClass::Absent always. + for moisture_q in [0, 30, 70, 100] { + for elev_q in [0, 50, 100] { + for near_water in [false, true] { + let vc = derive_vegetation(None, moisture_q, elev_q, near_water); + assert_eq!( + vc, + VegetationClass::Absent, + "§8 vegetation: airless body must produce Absent, got {:?} \ + (moisture_q={moisture_q}, elev_q={elev_q})", + vc + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// §3 — Per-family <5 ms/chunk budget (D-239 §10) +// --------------------------------------------------------------------------- + +/// Derive all 4096 voxels in a 64×64 chunk and return (count, elapsed_µs). +fn derive_chunk_timed( + seed: u64, + body_id: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), +) -> (usize, u128) { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + let t0 = Instant::now(); + let mut count = 0usize; + for dy in 0..64i32 { + for dx in 0..64i32 { + let _ = derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + count += 1; + } + } + (count, t0.elapsed().as_micros()) +} + +/// All 8 family inputs for the budget sweep. +fn budget_families() -> Vec<(&'static str, RegionProfile)> { + vec![ + ( + "AlluvialPlain", + make_region(MorphologyZone::AlluvialPlain, TectonicClass::Stable, GlaciationGrade::None, 5, 20, 18, 60, Some(15.0), VegetationClass::Forest), + ), + ( + "LavaField", + make_region(MorphologyZone::Volcanic, TectonicClass::Volcanic, GlaciationGrade::None, 12, 35, 0, 20, Some(40.0), VegetationClass::Barren), + ), + ( + "FjordWall", + make_region(MorphologyZone::Fjord, TectonicClass::Active, GlaciationGrade::Moderate, 55, 60, 28, 55, Some(-8.0), VegetationClass::Barren), + ), + ( + "CliffCoast", + make_region(MorphologyZone::CliffCoast, TectonicClass::Active, GlaciationGrade::None, 60, 40, 20, 30, Some(10.0), VegetationClass::Scrub), + ), + ( + "BraidedDelta", + make_region(MorphologyZone::Delta, TectonicClass::Active, GlaciationGrade::None, 3, 8, 25, 50, Some(18.0), VegetationClass::Scrub), + ), + ( + "DuneStrand", + make_region(MorphologyZone::DuneStrand, TectonicClass::Stable, GlaciationGrade::None, 12, 15, 20, 25, Some(22.0), VegetationClass::Barren), + ), + ( + "IncisedGorge", + make_region(MorphologyZone::MountainPass, TectonicClass::Active, GlaciationGrade::None, 50, 65, 5, 40, Some(5.0), VegetationClass::Scrub), + ), + ( + "MeanderReach", + make_region(MorphologyZone::MeanderReach, TectonicClass::Stable, GlaciationGrade::None, 8, 18, 20, 55, Some(14.0), VegetationClass::Forest), + ), + ] +} + +#[test] +fn budget_per_family_chunk_derivation() { + let seed = 0xdeadbeef_12345678_u64; + let body_id = "budget_test_body"; + let hard_gate = std::env::var("BUDGET_ASSERT").is_ok(); + + // Warm up with the fallback family first (also establishes baseline). + let (_, _) = derive_chunk_timed(seed, body_id, &budget_families()[0].1, (0, 0)); + let (_, baseline_us) = derive_chunk_timed(seed, body_id, &budget_families()[0].1, (0, 0)); + eprintln!("[budget] AlluvialPlain baseline: {} µs / 4096 voxels", baseline_us); + + let mut results: Vec<(&str, u128)> = vec![]; + + for (label, region) in budget_families() { + let (cnt, us) = derive_chunk_timed(seed, body_id, ®ion, (1, 1)); + let ms = us as f64 / 1000.0; + eprintln!("[budget] {label}: {ms:.2} ms / {cnt} voxels"); + + assert_eq!(cnt, 4096, "chunk must produce exactly 4096 voxels"); + + // Hard gate: < 5 ms. Only enforced when BUDGET_ASSERT=1 (release build). + // D-239 §10 target: ~2.2–4.2 ms/chunk. + if hard_gate { + assert!( + ms < 5.0, + "§10 budget EXCEEDED for '{label}': {ms:.2} ms > 5 ms. \ + (Ensure release mode: cargo test --test derivation_harness --release)" + ); + } + + results.push((label, us)); + } + + // Relative assertion: FjordWall/IncisedGorge are noted as costlier than + // AlluvialPlain (D-239 §10), but must not be pathologically worse (>40×). + // In debug builds this catches O(n²) algorithmic defects regardless of + // absolute timing. + for (label, us) in &results { + if *label == "FjordWall" || *label == "IncisedGorge" { + let ratio = *us as f64 / baseline_us.max(1) as f64; + assert!( + ratio < 40.0, + "§10 relative budget: '{label}' is {ratio:.1}× slower than AlluvialPlain \ + ({:.2} ms vs {:.2} ms baseline). Check for algorithmic regression.", + *us as f64 / 1000.0, + baseline_us as f64 / 1000.0 + ); + } + } +} + +// ── VoxelCache LRU eviction scan ───────────────────────────────────────────── +// +// D-239 §10 / T-1031 note: VoxelCache uses an O(capacity) LRU eviction scan +// (linear BTreeMap iteration for min access_gen). This is accepted behaviour +// for Phase 4 (no production render loop yet). We measure the overhead here +// to document it empirically. +// +// T-1031 baseline: ~40–55× overhead in debug builds at capacity=1024, 4096 voxels. +// The gate is 200× (catastrophic regression detection only). +// Phase 5 upgrade: replace the BTreeMap linear scan with a min-heap or +// generation-indexed secondary structure to achieve <5× overhead. + +#[test] +fn budget_voxel_cache_lru_overhead() { + let seed = 0xfeedface_12345678_u64; + let body_id = "cache_budget_body"; + let region = make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 5, 20, 18, 60, + Some(15.0), + VegetationClass::Forest, + ); + let chunk_pos = (2, 2); + let chunk = derive_chunk_context(seed, body_id, ®ion, chunk_pos); + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + + // Adversarial capacity: one-quarter of a chunk (1024) forces frequent + // LRU evictions on every sequential pass through the full 4096 voxels. + let capacity = 1024; + let mut cache = VoxelCache::new(capacity); + + let t0 = Instant::now(); + for dy in 0..64i32 { + for dx in 0..64i32 { + let _ = + cache.get_or_derive(seed, body_id, ®ion, &chunk, base_x + dx, base_y + dy); + } + } + let cache_us = t0.elapsed().as_micros(); + + // Direct derivation for comparison. + let (_, direct_us) = derive_chunk_timed(seed, body_id, ®ion, chunk_pos); + + let overhead_ratio = cache_us as f64 / direct_us.max(1) as f64; + eprintln!( + "[cache_lru] direct: {direct_us} µs | cache (cap={capacity}): {cache_us} µs | \ + overhead: {overhead_ratio:.1}× \ + [T-1031 note: O(capacity) BTreeMap scan; accepted for Phase 4, ~40–55× measured]" + ); + + // Measured overhead in debug builds: ~40–55× (capacity=1024, 4096 voxels). + // Root cause: BTreeMap linear scan for min access_gen on every eviction — O(n) per + // insert when cache is full. Accepted for Phase 4 (no production render loop). + // + // Gate at 200× to catch catastrophic regressions only (e.g. nested scans, + // quadratic growth). The 40–55× figure is intentionally documented here as the + // T-1031 baseline so future maintainers know what "acceptable" looks like. + // When a render loop is wired (Phase 5), upgrade to a min-heap or generation + // bitmap and lower this gate to <5×. + assert!( + overhead_ratio < 200.0, + "VoxelCache LRU overhead {overhead_ratio:.1}× exceeds 200×. \ + The O({capacity}) BTreeMap scan has regressed beyond the T-1031 Phase 4 baseline. \ + Consider upgrading the eviction strategy." + ); +} + +// --------------------------------------------------------------------------- +// §4 — Validation body cases (D-239 §1) +// --------------------------------------------------------------------------- +// +// Body params are sourced directly from wiki frontmatter (DB-free). +// If a body is absent from the wiki, the test SKIPS with a logged note. + +/// Kallast (GJ144d) params — wiki: star-systems/GJ-144/bodies/GJ144d/index.md +fn kallast_body_params() -> BodyParams { + // planet_class: temperate | atmosphere: standard | hydrosphere: ocean + // orbit.period_days: 280.0 | orbit.axial_tilt_deg: 30.2 | tectonics: active + // star.type: K (luminosity_solar: 0.4) + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("standard".into()), + planet_class: Some("temperate".into()), + orbital_period_days: Some(280.0), + axial_tilt_deg: Some(30.2), + spectral_class: Some("K".into()), + star_type: None, + tectonic_activity: Some("active".into()), + region_latitude_deg: 0.0, + elevation_km: 0.0, + } +} + +/// Glødberg (GJ581c) params — wiki: star-systems/GJ-581/bodies/GJ581c/index.md +fn gloedberg_body_params() -> BodyParams { + // planet_class: volcanic | atmosphere: standard | hydrosphere: subsurface_liquid + // orbit.period_days: 12.0 | orbit.axial_tilt_deg: 0.6 | tectonics: extreme + // star.type: M (luminosity_solar: 0.04) + BodyParams { + hydrosphere: Some("subsurface".into()), + atmosphere: Some("standard".into()), + planet_class: Some("volcanic".into()), + orbital_period_days: Some(12.0), + axial_tilt_deg: Some(0.6), + spectral_class: Some("M".into()), + star_type: None, + tectonic_activity: Some("volcanic".into()), + region_latitude_deg: 0.0, + elevation_km: 0.5, + } +} + +/// Marevna (GJ447c) params — wiki: star-systems/GJ-447/bodies/GJ447c/index.md +/// GJ-447 is the real-world identifier for Ross 128. +fn marevna_body_params() -> BodyParams { + // planet_class: oceanic | atmosphere: standard | hydrosphere: ocean + // orbit.period_days: 170.0 | orbit.axial_tilt_deg: 7.0 | tectonics: active + // star.type: M (luminosity_solar: 0.04) + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("standard".into()), + planet_class: Some("oceanic".into()), + orbital_period_days: Some(170.0), + axial_tilt_deg: Some(7.0), + spectral_class: Some("M".into()), + star_type: None, + tectonic_activity: Some("active".into()), + region_latitude_deg: 0.0, + elevation_km: 0.0, + } +} + +/// Derive a RegionProfile for a body at given latitude/elevation, +/// using the full derive_region_profile pipeline over a minimal dry-land heightmap. +/// +/// Uses a heightmap where all cells are above sea level (data = 0.5, sea_level = 0.3), +/// so ocean_fraction_q = 0 at all positions. This ensures the morphology classifier +/// reaches the tectonic/glaciation gates without being short-circuited by the Tier-0 +/// ocean check (which fires at ocean_fraction_q ≥ 60). +fn derive_profile_for_body(params: &BodyParams) -> RegionProfile { + let climate = ClimateConstants::default(); + let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); + let ta = make_dry_terrain_analysis(); + derive_region_profile(seed, params, &ta, (0, 0), 8, &climate) +} + +#[test] +fn validation_kallast_alluvial_plain() { + // D-239 §1: Kallast = alluvial plain / Soil. GJ144d, K-star, temperate. + eprintln!("[validation] Kallast (GJ144d): checking alluvial plain / non-volcanic / non-fjord"); + + let profile = derive_profile_for_body(&kallast_body_params()); + + // Not volcanic — Kallast is a temperate world. + assert_ne!( + profile.tectonic_class, + TectonicClass::Volcanic, + "Kallast: TectonicClass must not be Volcanic (param: active)" + ); + // Not fjord — no glaciation at temperate K-star orbit. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Fjord, + "Kallast: temperate body must not produce Fjord" + ); + // Not volcanic morphology. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Volcanic, + "Kallast: temperate body must not produce Volcanic" + ); + // Plausible terrestrial/coastal zone for a temperate body with ocean hydro. + let plausible = matches!( + profile.morphology_zone, + MorphologyZone::AlluvialPlain + | MorphologyZone::MeanderReach + | MorphologyZone::RiverBank + | MorphologyZone::Delta + | MorphologyZone::Estuarine + | MorphologyZone::TidalFlat + | MorphologyZone::Lake + | MorphologyZone::OpenOcean + | MorphologyZone::Wetland + ); + assert!( + plausible, + "Kallast: expected alluvial/coastal zone, got {:?}. \ + If wrong, fix body params — not the derivation (D-239 §1).", + profile.morphology_zone + ); + // Temperature plausible for K-star temperate world. + if let Some(t) = profile.temperature_c { + assert!( + (-20.0..=50.0).contains(&t), + "Kallast: temperature {t}°C outside plausible range [-20, 50]" + ); + } + + eprintln!( + "[validation] Kallast: zone={:?} temp={:?}°C glaciation={:?} tectonic={:?} — PASS", + profile.morphology_zone, profile.temperature_c, + profile.glaciation_grade, profile.tectonic_class + ); +} + +#[test] +fn validation_gloedberg_volcanic_immature_drainage() { + // D-239 §1: "Cygni B = volcanic immature drainage." + // No body named "Cygni B" exists in the current wiki. GJ581c (Glødberg) is + // the closest volcanic match. NOTE: update this test if a "Cygni B" body is + // added to the wiki in the future. + eprintln!("[validation] Glødberg (GJ581c): volcanic proxy — checking LavaField + immature drainage"); + + let profile = derive_profile_for_body(&gloedberg_body_params()); + + // Assert volcanic tectonic class. + assert_eq!( + profile.tectonic_class, + TectonicClass::Volcanic, + "Glødberg: volcanic body must have TectonicClass::Volcanic" + ); + // Assert Volcanic morphology (LavaField classifier fires first — §5 gate order). + assert_eq!( + profile.morphology_zone, + MorphologyZone::Volcanic, + "Glødberg: volcanic body must produce Volcanic zone (LavaField family)" + ); + // No glaciation on a hot close-orbit volcanic world. + assert_eq!( + profile.glaciation_grade, + GlaciationGrade::None, + "Glødberg: hot volcanic body at 12-day orbit must have no glaciation" + ); + + // Derive a voxel and check TerrainMaterial::Lava (§8 lithology law). + let chunk = derive_chunk_context(42, "GJ581c", &profile, (0, 0)); + let col = derive_voxel_column(42, "GJ581c", &profile, &chunk, 100, 100); + assert_eq!( + col.terrain, + TerrainMaterial::Lava, + "Glødberg: voxel must be Lava (§8 lava law)" + ); + // Immature drainage: no active channels on lava fields (D-239 §8). + // The LavaField generator explicitly ignores has_active_channel. + assert_eq!( + col.water, + Water::Dry, + "Glødberg: volcanic immature drainage — voxel at (100,100) must be Dry \ + (LavaField ignores active channel per §8 immature drainage law)" + ); + + eprintln!( + "[validation] Glødberg: zone={:?} temp={:?}°C terrain={:?} — PASS", + profile.morphology_zone, profile.temperature_c, col.terrain + ); +} + +#[test] +fn validation_marevna_ocean_island() { + // D-239 §1: "Ross 128 = ocean island." GJ-447 is Ross 128; GJ447c = Marevna. + eprintln!("[validation] Marevna (GJ447c / Ross 128): ocean island — checking coastal/ocean zone"); + + let profile = derive_profile_for_body(&marevna_body_params()); + + // Not volcanic. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Volcanic, + "Marevna: oceanic world must not produce Volcanic" + ); + // Not fjord — warm M-star body, no glaciation expected. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Fjord, + "Marevna: warm oceanic world must not produce Fjord (no glaciation)" + ); + // Plausible zone for an oceanic high-moisture world on a flat dry-land test terrain. + // moisture_q = 80 (ocean hydro + standard atmo) + slope_q ≈ 0 → Wetland is the + // dominant output from the derivation (§8 Wetland ≤5° flats). AlluvialPlain and + // coastal zones are also plausible depending on ocean_fraction_q signal. + let plausible = matches!( + profile.morphology_zone, + MorphologyZone::OpenOcean + | MorphologyZone::Lake + | MorphologyZone::Delta + | MorphologyZone::Estuarine + | MorphologyZone::TidalFlat + | MorphologyZone::MeanderReach + | MorphologyZone::AlluvialPlain + | MorphologyZone::RiverBank + | MorphologyZone::Wetland // Expected on flat high-moisture terrain (moisture_q=80, slope≈0) + ); + assert!( + plausible, + "Marevna: oceanic world expected coastal/wetland zone, got {:?}. \ + If wrong, fix body params — not the derivation (D-239 §1).", + profile.morphology_zone + ); + // Temperature is deterministically derived: log it for audit. + // NOTE: At period_days=170 + M-star luminosity=0.04, the equilibrium derivation + // yields approximately -56°C — colder than expected for a "habitable" world. + // Per D-239 §1: if the body reads wrong, fix the body params (e.g. period_days), + // not the derivation. This assertion is intentionally loose — we verify the + // derivation pipeline ran without panic and produced a consistent result. + // A future wiki update to GJ447c orbital params will bring it into the + // habitable range without touching this harness. + if let Some(t) = profile.temperature_c { + assert!( + t > -250.0 && t < 200.0, + "Marevna: temperature {t}°C is outside the physical range [-250, 200]" + ); + } + + eprintln!( + "[validation] Marevna: zone={:?} temp={:?}°C moisture_q={} — PASS \ + (NOTE: temp ~-56°C at period_days=170 M-star; wiki params may need tuning per D-239 §1)", + profile.morphology_zone, profile.temperature_c, profile.moisture_q + ); +} + +#[test] +fn validation_velen_skipped_not_in_wiki() { + // D-239 §1: "Velen = tidal-flat/dune coast." + // D-239 §1 caveat: "tidal flats require a moon param for the D-228 tidal + // term — verify Velen's params before treating its coast as a contract." + // + // STATUS: Body named "Velen" does NOT exist in the current wiki. + // Searched wiki/star-systems/**/*.md for `name: Velen` → NOT FOUND. + // The tidal/moon param cannot be verified (no body to inspect). + // This test is a deliberate skip with documented reason. + // Re-enable when Velen is created in the wiki and systems.db. + eprintln!( + "[validation] SKIPPED Velen (tidal-flat/dune coast):\n \ + Body 'Velen' not found in wiki/star-systems/**/*.md. Lore anchor not\n \ + yet materialised as a named body in systems.db. D-239 §1 caveat:\n \ + moon/tidal param not verifiable. Re-enable when Velen is created." + ); +} + +#[test] +fn validation_gruenfeld_skipped_not_in_wiki() { + // D-239 §1: "Gruenfeld = marginal Gravel/Scrub." + // + // STATUS: Body named "Gruenfeld" does NOT exist in the current wiki. + // Searched wiki/star-systems/**/*.md for `name: Gruenfeld` → NOT FOUND. + // Re-enable when Gruenfeld is created in the wiki and systems.db. + eprintln!( + "[validation] SKIPPED Gruenfeld (marginal Gravel/Scrub):\n \ + Body 'Gruenfeld' not found in wiki/star-systems/**/*.md. Lore anchor\n \ + not yet materialised. Re-enable when Gruenfeld is created." + ); +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Construct a `RegionProfile` directly from parameters, deriving the +/// dependent fields (precipitation_class, river_threshold) consistently. +fn make_region( + zone: MorphologyZone, + tectonic: TectonicClass, + glaciation: GlaciationGrade, + slope_q: i32, + elev_q: i32, + ocean_fraction_q: i32, + moisture_q: i32, + temperature_c: Option, + vegetation_class: VegetationClass, +) -> RegionProfile { + let precip = derive_precipitation_class_from_climate(temperature_c, moisture_q); + RegionProfile { + morphology_zone: zone, + tectonic_class: tectonic, + glaciation_grade: glaciation, + precipitation_class: precip, + slope_q, + elev_q, + ocean_fraction_q, + river_threshold: derive_river_threshold(tectonic, precip), + temperature_c, + moisture_q, + vegetation_class, + } +} + +/// Build a minimal flat `TerrainAnalysis` for use in body-level derive calls. +/// Same geometry as the region_profile.rs unit tests (64×32 grid, sea_level=0.3). +fn make_test_terrain_analysis() -> TerrainAnalysis { + let (w, h) = (64u32, 32u32); + let n = (w * h) as usize; + let data: Vec = (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(); + let hm = BodyHeightmap { + body_id: "test".into(), + width: w, + height: h, + data, + sea_level: 0.3, + }; + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(&hm, &dr) +} + +/// Build a flat all-dry `TerrainAnalysis` for validation body derivation. +/// +/// All cells have elevation = 0.5 with sea_level = 0.3 → ocean_fraction_q = 0 at +/// every region position. This prevents the Tier-0 ocean short-circuit in +/// `derive_morphology_zone` (ocean_fraction_q ≥ 60) from overriding the tectonic +/// and glaciation gates that we're testing. +/// +/// Uses a gentle constant slope (no variation) so slope_q ~ 0 and elev_q ~ 75, +/// which places the morphology classifier in the AlluvialPlain fallback unless the +/// tectonic/glaciation gates fire first. That is the correct pre-condition for +/// lore body tests. +fn make_dry_terrain_analysis() -> TerrainAnalysis { + let (w, h) = (64u32, 32u32); + let n = (w * h) as usize; + // Constant 0.5: all above sea_level=0.3 → zero ocean cells. + let data: Vec = vec![0.5_f32; n]; + let hm = BodyHeightmap { + body_id: "dry_test".into(), + width: w, + height: h, + data, + sea_level: 0.3, + }; + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(&hm, &dr) +} From de5f0f1e58777a0bc02814f42fd92d9d2199f75d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 19:33:00 +0200 Subject: [PATCH 2/3] test(simulation): align T-1031 harness with D-240 class-envelope temps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt the believability/budget verification harness to the T-1033 BodyParams strip: validation-body fixtures (Kallast/Glødberg/Marevna) no longer set the removed orbit/star fields (orbital_period_days, axial_tilt_deg, spectral_class, star_type). Temperature is now derived from the planet_class envelope (D-240), so the Marevna validation note/assertion is rewritten — oceanic band [-12,28]°C + maritime 0.6 + standard-atmosphere greenhouse yields ~+26°C at the equator (was a stale ~-56°C orbit-equilibrium note). Add the seed→tile golden fixture (tests/golden/derivation_harness.json) that the determinism regression locks. Also fmt the harness and fix one clippy lint (iter().copied().collect() -> to_vec()) — the file predated the pre-push gate's cargo-test/clippy coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/tests/derivation_harness.rs | 347 +++++++++++++++----- server/tests/golden/derivation_harness.json | 38 +++ 2 files changed, 302 insertions(+), 83 deletions(-) create mode 100644 server/tests/golden/derivation_harness.json diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index f2a8a28e6..2d75597e6 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -101,7 +101,15 @@ fn derive_golden( } /// The three fixed golden inputs. Varied families and climate states. -fn golden_cases() -> Vec<(&'static str, u64, &'static str, RegionProfile, (i32, i32), i32, i32)> { +fn golden_cases() -> Vec<( + &'static str, + u64, + &'static str, + RegionProfile, + (i32, i32), + i32, + i32, +)> { vec![ // Case A: AlluvialPlain — temperate forest, active channel. ( @@ -112,7 +120,10 @@ fn golden_cases() -> Vec<(&'static str, u64, &'static str, RegionProfile, (i32, MorphologyZone::AlluvialPlain, TectonicClass::Active, GlaciationGrade::None, - 6, 22, 18, 68, + 6, + 22, + 18, + 68, Some(12.0), VegetationClass::Forest, ), @@ -129,7 +140,10 @@ fn golden_cases() -> Vec<(&'static str, u64, &'static str, RegionProfile, (i32, MorphologyZone::Volcanic, TectonicClass::Volcanic, GlaciationGrade::None, - 12, 38, 0, 22, + 12, + 38, + 0, + 22, Some(45.0), VegetationClass::Barren, ), @@ -146,7 +160,10 @@ fn golden_cases() -> Vec<(&'static str, u64, &'static str, RegionProfile, (i32, MorphologyZone::Fjord, TectonicClass::Active, GlaciationGrade::Moderate, - 55, 60, 28, 55, + 55, + 60, + 28, + 55, Some(-8.0), VegetationClass::Barren, ), @@ -167,11 +184,15 @@ fn golden_seed_determinism_regression() { // compare against the golden. This is the core D-010 determinism contract. let run1: Vec = golden_cases() .into_iter() - .map(|(lbl, seed, body, region, cp, tx, ty)| derive_golden(lbl, seed, body, ®ion, cp, tx, ty)) + .map(|(lbl, seed, body, region, cp, tx, ty)| { + derive_golden(lbl, seed, body, ®ion, cp, tx, ty) + }) .collect(); let run2: Vec = golden_cases() .into_iter() - .map(|(lbl, seed, body, region, cp, tx, ty)| derive_golden(lbl, seed, body, ®ion, cp, tx, ty)) + .map(|(lbl, seed, body, region, cp, tx, ty)| { + derive_golden(lbl, seed, body, ®ion, cp, tx, ty) + }) .collect(); assert_eq!( @@ -246,8 +267,7 @@ fn assert_drainage_monotonicity( let base_y = chunk_pos.1 * 64; for dy in 0..64i32 { for dx in 0..64i32 { - let col = - derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + let col = derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); match col.water { Water::Dry => { max_dry_elev = max_dry_elev.max(col.elevation_m); @@ -284,7 +304,10 @@ fn law_drainage_monotonicity_alluvial_sweep() { MorphologyZone::AlluvialPlain, TectonicClass::Stable, GlaciationGrade::None, - 5, 20, 18, 60, + 5, + 20, + 18, + 60, Some(15.0), VegetationClass::Forest, ); @@ -299,7 +322,10 @@ fn law_drainage_monotonicity_meander_sweep() { MorphologyZone::MeanderReach, TectonicClass::Stable, GlaciationGrade::None, - 8, 18, 20, 65, + 8, + 18, + 20, + 65, Some(14.0), VegetationClass::Forest, ); @@ -316,7 +342,10 @@ fn law_drainage_monotonicity_fjord_floor_at_sea_level() { MorphologyZone::Fjord, TectonicClass::Active, GlaciationGrade::Moderate, - 55, 60, 28, 55, + 55, + 60, + 28, + 55, Some(-8.0), VegetationClass::Barren, ); @@ -356,7 +385,10 @@ fn law_drainage_monotonicity_braided_delta() { MorphologyZone::Delta, TectonicClass::Active, GlaciationGrade::None, - 3, 8, 25, 50, + 3, + 8, + 25, + 50, Some(18.0), VegetationClass::Scrub, ); @@ -385,10 +417,10 @@ fn assert_all_terrain_is( let base_y = chunk_pos.1 * 64; for dy in (0..64i32).step_by(8) { for dx in (0..64i32).step_by(8) { - let col = - derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + let col = derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); assert_eq!( - col.terrain, expected, + col.terrain, + expected, "§8 lithology VIOLATED for '{label}': voxel ({},{}) returned {:?}, expected {:?}", base_x + dx, base_y + dy, @@ -406,11 +438,21 @@ fn law_lithology_lava_emits_lava() { MorphologyZone::Volcanic, TectonicClass::Volcanic, GlaciationGrade::None, - 15, 35, 0, 20, + 15, + 35, + 0, + 20, Some(40.0), VegetationClass::Barren, ); - assert_all_terrain_is(42, "GJ581c", ®ion, (3, 3), TerrainMaterial::Lava, "LavaField"); + assert_all_terrain_is( + 42, + "GJ581c", + ®ion, + (3, 3), + TerrainMaterial::Lava, + "LavaField", + ); } #[test] @@ -419,11 +461,21 @@ fn law_lithology_fjord_emits_rock() { MorphologyZone::Fjord, TectonicClass::Active, GlaciationGrade::Moderate, - 55, 60, 28, 55, + 55, + 60, + 28, + 55, Some(-8.0), VegetationClass::Barren, ); - assert_all_terrain_is(42, "fjord_body", ®ion, (0, 0), TerrainMaterial::Rock, "FjordWall"); + assert_all_terrain_is( + 42, + "fjord_body", + ®ion, + (0, 0), + TerrainMaterial::Rock, + "FjordWall", + ); } #[test] @@ -432,11 +484,21 @@ fn law_lithology_cliff_coast_emits_rock() { MorphologyZone::CliffCoast, TectonicClass::Active, GlaciationGrade::None, - 60, 40, 20, 30, + 60, + 40, + 20, + 30, Some(10.0), VegetationClass::Scrub, ); - assert_all_terrain_is(42, "cliff_body", ®ion, (0, 0), TerrainMaterial::Rock, "CliffCoast"); + assert_all_terrain_is( + 42, + "cliff_body", + ®ion, + (0, 0), + TerrainMaterial::Rock, + "CliffCoast", + ); } #[test] @@ -446,12 +508,20 @@ fn law_lithology_incised_gorge_emits_rock() { MorphologyZone::MountainPass, TectonicClass::Active, GlaciationGrade::None, - 50, 65, 5, 40, + 50, + 65, + 5, + 40, Some(5.0), VegetationClass::Scrub, ); assert_all_terrain_is( - 42, "gorge_body", ®ion, (0, 0), TerrainMaterial::Rock, "IncisedGorge", + 42, + "gorge_body", + ®ion, + (0, 0), + TerrainMaterial::Rock, + "IncisedGorge", ); } @@ -462,11 +532,21 @@ fn law_lithology_dune_strand_emits_sand() { MorphologyZone::DuneStrand, TectonicClass::Stable, GlaciationGrade::None, - 12, 15, 20, 25, + 12, + 15, + 20, + 25, Some(22.0), VegetationClass::Barren, ); - assert_all_terrain_is(42, "dune_body", ®ion, (0, 0), TerrainMaterial::Sand, "DuneStrand"); + assert_all_terrain_is( + 42, + "dune_body", + ®ion, + (0, 0), + TerrainMaterial::Sand, + "DuneStrand", + ); } #[test] @@ -476,12 +556,20 @@ fn law_lithology_braided_delta_emits_gravel() { MorphologyZone::Delta, TectonicClass::Active, GlaciationGrade::None, - 3, 8, 25, 50, + 3, + 8, + 25, + 50, Some(18.0), VegetationClass::Scrub, ); assert_all_terrain_is( - 42, "delta_body", ®ion, (0, 0), TerrainMaterial::Gravel, "BraidedDelta", + 42, + "delta_body", + ®ion, + (0, 0), + TerrainMaterial::Gravel, + "BraidedDelta", ); } @@ -492,12 +580,20 @@ fn law_lithology_alluvial_plain_emits_soil() { MorphologyZone::AlluvialPlain, TectonicClass::Stable, GlaciationGrade::None, - 10, 25, 12, 45, + 10, + 25, + 12, + 45, Some(16.0), VegetationClass::Forest, ); assert_all_terrain_is( - 42, "alluvial_body", ®ion, (0, 0), TerrainMaterial::Soil, "AlluvialPlain", + 42, + "alluvial_body", + ®ion, + (0, 0), + TerrainMaterial::Soil, + "AlluvialPlain", ); } @@ -508,12 +604,20 @@ fn law_lithology_meander_reach_emits_soil() { MorphologyZone::MeanderReach, TectonicClass::Stable, GlaciationGrade::None, - 8, 18, 20, 55, + 8, + 18, + 20, + 55, Some(14.0), VegetationClass::Forest, ); assert_all_terrain_is( - 42, "meander_body", ®ion, (0, 0), TerrainMaterial::Soil, "MeanderReach", + 42, + "meander_body", + ®ion, + (0, 0), + TerrainMaterial::Soil, + "MeanderReach", ); } @@ -578,7 +682,7 @@ fn law_glaciation_grade_2_enables_fjord_with_correct_params() { GlaciationGrade::Moderate, // grade 2 — gate opens 55, // steep enough 60, - 25, // coastal + 25, // coastal 55, ); assert_eq!( @@ -674,9 +778,7 @@ fn law_climate_vegetation_full_grid_no_skip() { Some(-55.0), None, ] - .iter() - .copied() - .collect(); + .to_vec(); for moisture_q in (0i32..=100).step_by(10) { for elev_q in (0i32..=100).step_by(10) { @@ -784,35 +886,115 @@ fn budget_families() -> Vec<(&'static str, RegionProfile)> { vec![ ( "AlluvialPlain", - make_region(MorphologyZone::AlluvialPlain, TectonicClass::Stable, GlaciationGrade::None, 5, 20, 18, 60, Some(15.0), VegetationClass::Forest), + make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 5, + 20, + 18, + 60, + Some(15.0), + VegetationClass::Forest, + ), ), ( "LavaField", - make_region(MorphologyZone::Volcanic, TectonicClass::Volcanic, GlaciationGrade::None, 12, 35, 0, 20, Some(40.0), VegetationClass::Barren), + make_region( + MorphologyZone::Volcanic, + TectonicClass::Volcanic, + GlaciationGrade::None, + 12, + 35, + 0, + 20, + Some(40.0), + VegetationClass::Barren, + ), ), ( "FjordWall", - make_region(MorphologyZone::Fjord, TectonicClass::Active, GlaciationGrade::Moderate, 55, 60, 28, 55, Some(-8.0), VegetationClass::Barren), + make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, + 60, + 28, + 55, + Some(-8.0), + VegetationClass::Barren, + ), ), ( "CliffCoast", - make_region(MorphologyZone::CliffCoast, TectonicClass::Active, GlaciationGrade::None, 60, 40, 20, 30, Some(10.0), VegetationClass::Scrub), + make_region( + MorphologyZone::CliffCoast, + TectonicClass::Active, + GlaciationGrade::None, + 60, + 40, + 20, + 30, + Some(10.0), + VegetationClass::Scrub, + ), ), ( "BraidedDelta", - make_region(MorphologyZone::Delta, TectonicClass::Active, GlaciationGrade::None, 3, 8, 25, 50, Some(18.0), VegetationClass::Scrub), + make_region( + MorphologyZone::Delta, + TectonicClass::Active, + GlaciationGrade::None, + 3, + 8, + 25, + 50, + Some(18.0), + VegetationClass::Scrub, + ), ), ( "DuneStrand", - make_region(MorphologyZone::DuneStrand, TectonicClass::Stable, GlaciationGrade::None, 12, 15, 20, 25, Some(22.0), VegetationClass::Barren), + make_region( + MorphologyZone::DuneStrand, + TectonicClass::Stable, + GlaciationGrade::None, + 12, + 15, + 20, + 25, + Some(22.0), + VegetationClass::Barren, + ), ), ( "IncisedGorge", - make_region(MorphologyZone::MountainPass, TectonicClass::Active, GlaciationGrade::None, 50, 65, 5, 40, Some(5.0), VegetationClass::Scrub), + make_region( + MorphologyZone::MountainPass, + TectonicClass::Active, + GlaciationGrade::None, + 50, + 65, + 5, + 40, + Some(5.0), + VegetationClass::Scrub, + ), ), ( "MeanderReach", - make_region(MorphologyZone::MeanderReach, TectonicClass::Stable, GlaciationGrade::None, 8, 18, 20, 55, Some(14.0), VegetationClass::Forest), + make_region( + MorphologyZone::MeanderReach, + TectonicClass::Stable, + GlaciationGrade::None, + 8, + 18, + 20, + 55, + Some(14.0), + VegetationClass::Forest, + ), ), ] } @@ -826,7 +1008,10 @@ fn budget_per_family_chunk_derivation() { // Warm up with the fallback family first (also establishes baseline). let (_, _) = derive_chunk_timed(seed, body_id, &budget_families()[0].1, (0, 0)); let (_, baseline_us) = derive_chunk_timed(seed, body_id, &budget_families()[0].1, (0, 0)); - eprintln!("[budget] AlluvialPlain baseline: {} µs / 4096 voxels", baseline_us); + eprintln!( + "[budget] AlluvialPlain baseline: {} µs / 4096 voxels", + baseline_us + ); let mut results: Vec<(&str, u128)> = vec![]; @@ -888,7 +1073,10 @@ fn budget_voxel_cache_lru_overhead() { MorphologyZone::AlluvialPlain, TectonicClass::Stable, GlaciationGrade::None, - 5, 20, 18, 60, + 5, + 20, + 18, + 60, Some(15.0), VegetationClass::Forest, ); @@ -905,8 +1093,7 @@ fn budget_voxel_cache_lru_overhead() { let t0 = Instant::now(); for dy in 0..64i32 { for dx in 0..64i32 { - let _ = - cache.get_or_derive(seed, body_id, ®ion, &chunk, base_x + dx, base_y + dy); + let _ = cache.get_or_derive(seed, body_id, ®ion, &chunk, base_x + dx, base_y + dy); } } let cache_us = t0.elapsed().as_micros(); @@ -947,17 +1134,12 @@ fn budget_voxel_cache_lru_overhead() { /// Kallast (GJ144d) params — wiki: star-systems/GJ-144/bodies/GJ144d/index.md fn kallast_body_params() -> BodyParams { - // planet_class: temperate | atmosphere: standard | hydrosphere: ocean - // orbit.period_days: 280.0 | orbit.axial_tilt_deg: 30.2 | tectonics: active - // star.type: K (luminosity_solar: 0.4) + // planet_class: temperate | atmosphere: standard | hydrosphere: ocean | tectonics: active + // (orbit/star data is non-canonical per D-240 and no longer feeds temperature) BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("standard".into()), planet_class: Some("temperate".into()), - orbital_period_days: Some(280.0), - axial_tilt_deg: Some(30.2), - spectral_class: Some("K".into()), - star_type: None, tectonic_activity: Some("active".into()), region_latitude_deg: 0.0, elevation_km: 0.0, @@ -966,17 +1148,12 @@ fn kallast_body_params() -> BodyParams { /// Glødberg (GJ581c) params — wiki: star-systems/GJ-581/bodies/GJ581c/index.md fn gloedberg_body_params() -> BodyParams { - // planet_class: volcanic | atmosphere: standard | hydrosphere: subsurface_liquid - // orbit.period_days: 12.0 | orbit.axial_tilt_deg: 0.6 | tectonics: extreme - // star.type: M (luminosity_solar: 0.04) + // planet_class: volcanic | atmosphere: standard | hydrosphere: subsurface_liquid | tectonics: extreme + // (orbit/star data is non-canonical per D-240 and no longer feeds temperature) BodyParams { hydrosphere: Some("subsurface".into()), atmosphere: Some("standard".into()), planet_class: Some("volcanic".into()), - orbital_period_days: Some(12.0), - axial_tilt_deg: Some(0.6), - spectral_class: Some("M".into()), - star_type: None, tectonic_activity: Some("volcanic".into()), region_latitude_deg: 0.0, elevation_km: 0.5, @@ -986,17 +1163,12 @@ fn gloedberg_body_params() -> BodyParams { /// Marevna (GJ447c) params — wiki: star-systems/GJ-447/bodies/GJ447c/index.md /// GJ-447 is the real-world identifier for Ross 128. fn marevna_body_params() -> BodyParams { - // planet_class: oceanic | atmosphere: standard | hydrosphere: ocean - // orbit.period_days: 170.0 | orbit.axial_tilt_deg: 7.0 | tectonics: active - // star.type: M (luminosity_solar: 0.04) + // planet_class: oceanic | atmosphere: standard | hydrosphere: ocean | tectonics: active + // (orbit/star data is non-canonical per D-240 and no longer feeds temperature) BodyParams { hydrosphere: Some("ocean".into()), atmosphere: Some("standard".into()), planet_class: Some("oceanic".into()), - orbital_period_days: Some(170.0), - axial_tilt_deg: Some(7.0), - spectral_class: Some("M".into()), - star_type: None, tectonic_activity: Some("active".into()), region_latitude_deg: 0.0, elevation_km: 0.0, @@ -1071,8 +1243,10 @@ fn validation_kallast_alluvial_plain() { eprintln!( "[validation] Kallast: zone={:?} temp={:?}°C glaciation={:?} tectonic={:?} — PASS", - profile.morphology_zone, profile.temperature_c, - profile.glaciation_grade, profile.tectonic_class + profile.morphology_zone, + profile.temperature_c, + profile.glaciation_grade, + profile.tectonic_class ); } @@ -1082,7 +1256,9 @@ fn validation_gloedberg_volcanic_immature_drainage() { // No body named "Cygni B" exists in the current wiki. GJ581c (Glødberg) is // the closest volcanic match. NOTE: update this test if a "Cygni B" body is // added to the wiki in the future. - eprintln!("[validation] Glødberg (GJ581c): volcanic proxy — checking LavaField + immature drainage"); + eprintln!( + "[validation] Glødberg (GJ581c): volcanic proxy — checking LavaField + immature drainage" + ); let profile = derive_profile_for_body(&gloedberg_body_params()); @@ -1131,7 +1307,9 @@ fn validation_gloedberg_volcanic_immature_drainage() { #[test] fn validation_marevna_ocean_island() { // D-239 §1: "Ross 128 = ocean island." GJ-447 is Ross 128; GJ447c = Marevna. - eprintln!("[validation] Marevna (GJ447c / Ross 128): ocean island — checking coastal/ocean zone"); + eprintln!( + "[validation] Marevna (GJ447c / Ross 128): ocean island — checking coastal/ocean zone" + ); let profile = derive_profile_for_body(&marevna_body_params()); @@ -1169,24 +1347,22 @@ fn validation_marevna_ocean_island() { If wrong, fix body params — not the derivation (D-239 §1).", profile.morphology_zone ); - // Temperature is deterministically derived: log it for audit. - // NOTE: At period_days=170 + M-star luminosity=0.04, the equilibrium derivation - // yields approximately -56°C — colder than expected for a "habitable" world. - // Per D-239 §1: if the body reads wrong, fix the body params (e.g. period_days), - // not the derivation. This assertion is intentionally loose — we verify the - // derivation pipeline ran without panic and produced a consistent result. - // A future wiki update to GJ447c orbital params will bring it into the - // habitable range without touching this harness. + // Temperature is deterministically derived from the planet_class envelope (D-240), + // not orbit/star data: oceanic band [-12, 28]°C, maritime factor 0.6 for an "ocean" + // hydrosphere. At the equator (latitude 0) the latitude lerp sits at the warm sub-band + // edge (~+20°C) and the standard-atmosphere greenhouse fraction lifts it to ~+26°C — + // a habitable result, as expected for an oceanic world. The assertion stays loose: we + // verify the pipeline ran without panic and produced a physically plausible value. if let Some(t) = profile.temperature_c { assert!( - t > -250.0 && t < 200.0, - "Marevna: temperature {t}°C is outside the physical range [-250, 200]" + t > -90.0 && t < 90.0, + "Marevna: temperature {t}°C is outside the oceanic-class plausible range [-90, 90]" ); } eprintln!( "[validation] Marevna: zone={:?} temp={:?}°C moisture_q={} — PASS \ - (NOTE: temp ~-56°C at period_days=170 M-star; wiki params may need tuning per D-239 §1)", + (oceanic class-envelope temp at equator per D-240)", profile.morphology_zone, profile.temperature_c, profile.moisture_q ); } @@ -1259,6 +1435,11 @@ fn make_region( /// Build a minimal flat `TerrainAnalysis` for use in body-level derive calls. /// Same geometry as the region_profile.rs unit tests (64×32 grid, sea_level=0.3). +/// +/// Retained as the gentle-slope counterpart to `make_dry_terrain_analysis` (which +/// the validation bodies currently use); kept for future morphology-gate tests that +/// need a sloped, partially-oceanic grid rather than the flat all-dry one. +#[allow(dead_code)] fn make_test_terrain_analysis() -> TerrainAnalysis { let (w, h) = (64u32, 32u32); let n = (w * h) as usize; diff --git a/server/tests/golden/derivation_harness.json b/server/tests/golden/derivation_harness.json new file mode 100644 index 000000000..97685400c --- /dev/null +++ b/server/tests/golden/derivation_harness.json @@ -0,0 +1,38 @@ +[ + { + "label": "alluvial_forest_active_channel", + "seed": 16045690984503098046, + "body_id": "GJ144d", + "tile_x": 640, + "tile_y": 1280, + "terrain": 0, + "vegetation": 1, + "water": 0, + "elevation_m": 11, + "cover": 0 + }, + { + "label": "lava_field_barren", + "seed": 1311768467294899695, + "body_id": "GJ581c", + "tile_x": 320, + "tile_y": 448, + "terrain": 5, + "vegetation": 0, + "water": 0, + "elevation_m": 21, + "cover": 0 + }, + { + "label": "fjord_wall_glaciated", + "seed": 18369614217980264670, + "body_id": "GJ447c", + "tile_x": 928, + "tile_y": 544, + "terrain": 3, + "vegetation": 0, + "water": 2, + "elevation_m": 1, + "cover": 1 + } +] From 5cc4ff6c0c443e8dbfec9feec5a3d8a7d883c4e0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 22:27:02 +0200 Subject: [PATCH 3/3] test(simulation): address PR #165 review (T-1031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe review findings — close silent-pass gaps in the §8 drainage tests: - assert_drainage_monotonicity now returns whether it actually checked (chunk had both wet and dry tiles); the three sweep tests (Alluvial/Meander/Braided Delta) assert at least one chunk exercised the law, so a regression that zeroes all channels fails loudly instead of passing vacuously. - The fjord-floor test scans the full 64x64 chunk instead of a single Y=32 row and asserts the deep-water trough exists, so the sea-level floor law can no longer be skipped by a probe that missed the trough. - Remove the unreferenced make_test_terrain_analysis helper (was behind #[allow(dead_code)] with a 'future use' comment — the maintenance trap the review flagged); make_dry_terrain_analysis covers the validation bodies. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/tests/derivation_harness.rs | 122 ++++++++++++++++------------- 1 file changed, 68 insertions(+), 54 deletions(-) diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index 2d75597e6..46835c0b9 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -246,16 +246,22 @@ fn golden_seed_determinism_regression() { // level; BraidedDelta/CliffCoast/FjordWall floors at sea level." // Tested by deriving a full 64×64 chunk and checking min(wet_elev) ≤ max(dry_elev). +/// Returns `true` if the monotonicity assertion was actually exercised (the chunk +/// produced both wet and dry tiles). A `false` return means the chunk had no wet +/// tiles, so the law was trivially satisfied without checking anything — callers +/// sweep several chunks and assert at least one returned `true`, so a derivation +/// regression that silently zeroes all channels fails loudly instead of passing. +#[must_use] fn assert_drainage_monotonicity( seed: u64, body_id: &str, label: &str, region: &RegionProfile, chunk_pos: (i32, i32), -) { +) -> bool { let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); if !chunk.has_active_channel { - return; // No channel → monotonicity trivially satisfied. + return false; // No channel → monotonicity trivially satisfied. } let mut max_dry_elev = i32::MIN; @@ -295,7 +301,9 @@ fn assert_drainage_monotonicity( chunk_pos.0, chunk_pos.1 ); + return true; } + false } #[test] @@ -311,9 +319,15 @@ fn law_drainage_monotonicity_alluvial_sweep() { Some(15.0), VegetationClass::Forest, ); + let mut checked = false; for (cx, cy) in [(0, 0), (1, 0), (0, 1), (4, 4), (8, 3)] { - assert_drainage_monotonicity(42, "GJ144d", "AlluvialPlain", ®ion, (cx, cy)); + checked |= assert_drainage_monotonicity(42, "GJ144d", "AlluvialPlain", ®ion, (cx, cy)); } + assert!( + checked, + "AlluvialPlain sweep exercised no wet tiles — the monotonicity law was never \ + actually checked; a derivation regression could silently pass this test." + ); } #[test] @@ -329,9 +343,15 @@ fn law_drainage_monotonicity_meander_sweep() { Some(14.0), VegetationClass::Forest, ); + let mut checked = false; for (cx, cy) in [(0, 0), (2, 1), (5, 5)] { - assert_drainage_monotonicity(99, "GJ447c", "MeanderReach", ®ion, (cx, cy)); + checked |= assert_drainage_monotonicity(99, "GJ447c", "MeanderReach", ®ion, (cx, cy)); } + assert!( + checked, + "MeanderReach sweep exercised no wet tiles — the monotonicity law was never \ + actually checked; a derivation regression could silently pass this test." + ); } #[test] @@ -353,30 +373,45 @@ fn law_drainage_monotonicity_fjord_floor_at_sea_level() { let mut deep_elevs: Vec = vec![]; let mut dry_elevs: Vec = vec![]; - for dx in 0..64i32 { - let col = derive_voxel_column(42, "fjord_body", ®ion, &chunk, dx, 32); - match col.water { - Water::Deep => deep_elevs.push(col.elevation_m), - Water::Dry => dry_elevs.push(col.elevation_m), - _ => {} + // Scan the full 64×64 chunk, not a single row — the deep-water trough axis is + // not guaranteed to intersect any fixed Y, so a single-row probe could miss it + // entirely and silently pass without ever checking the sea-level claim. + for dy in 0..64i32 { + for dx in 0..64i32 { + let col = derive_voxel_column(42, "fjord_body", ®ion, &chunk, dx, dy); + match col.water { + Water::Deep => deep_elevs.push(col.elevation_m), + Water::Dry => dry_elevs.push(col.elevation_m), + _ => {} + } } } - if !deep_elevs.is_empty() && !dry_elevs.is_empty() { - let max_deep = *deep_elevs.iter().max().unwrap(); - let min_dry = *dry_elevs.iter().min().unwrap(); - // Fjord floor (deep water) must be below wall elevation. - assert!( - max_deep <= min_dry, - "§8 FjordWall drainage: deep-water max elev {max_deep} m must be \ - ≤ dry wall min elev {min_dry} m" - ); - // Fjord floor must be near sea level (D-239 §8). - assert!( - max_deep <= 5, - "§8 FjordWall: deep-water floor elev {max_deep} m must be near sea level (≤5 m)" - ); - } + // A glaciated fjord chunk MUST carve a deep-water trough — if it doesn't, the + // derivation regressed and the sea-level law below would never run. Fail loudly. + assert!( + !deep_elevs.is_empty(), + "§8 FjordWall: no deep-water tiles found in the fjord chunk — the trough \ + derivation regressed; the sea-level floor law was never exercised." + ); + assert!( + !dry_elevs.is_empty(), + "§8 FjordWall: no dry wall tiles found in the fjord chunk." + ); + + let max_deep = *deep_elevs.iter().max().unwrap(); + let min_dry = *dry_elevs.iter().min().unwrap(); + // Fjord floor (deep water) must be below wall elevation. + assert!( + max_deep <= min_dry, + "§8 FjordWall drainage: deep-water max elev {max_deep} m must be \ + ≤ dry wall min elev {min_dry} m" + ); + // Fjord floor must be near sea level (D-239 §8). + assert!( + max_deep <= 5, + "§8 FjordWall: deep-water floor elev {max_deep} m must be near sea level (≤5 m)" + ); } #[test] @@ -392,9 +427,16 @@ fn law_drainage_monotonicity_braided_delta() { Some(18.0), VegetationClass::Scrub, ); + let mut checked = false; for (cx, cy) in [(0, 0), (1, 1)] { - assert_drainage_monotonicity(17, "delta_body", "BraidedDelta", ®ion, (cx, cy)); + checked |= + assert_drainage_monotonicity(17, "delta_body", "BraidedDelta", ®ion, (cx, cy)); } + assert!( + checked, + "BraidedDelta sweep exercised no wet tiles — the monotonicity law was never \ + actually checked; a derivation regression could silently pass this test." + ); } // ── §8 Law 2: Lithology → Landform ────────────────────────────────────────── @@ -1433,34 +1475,6 @@ fn make_region( } } -/// Build a minimal flat `TerrainAnalysis` for use in body-level derive calls. -/// Same geometry as the region_profile.rs unit tests (64×32 grid, sea_level=0.3). -/// -/// Retained as the gentle-slope counterpart to `make_dry_terrain_analysis` (which -/// the validation bodies currently use); kept for future morphology-gate tests that -/// need a sloped, partially-oceanic grid rather than the flat all-dry one. -#[allow(dead_code)] -fn make_test_terrain_analysis() -> TerrainAnalysis { - let (w, h) = (64u32, 32u32); - let n = (w * h) as usize; - let data: Vec = (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(); - let hm = BodyHeightmap { - body_id: "test".into(), - width: w, - height: h, - data, - sea_level: 0.3, - }; - let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); - TerrainAnalysis::analyze(&hm, &dr) -} - /// Build a flat all-dry `TerrainAnalysis` for validation body derivation. /// /// All cells have elevation = 0.5 with sea_level = 0.3 → ocean_fraction_q = 0 at