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) <noreply@anthropic.com>
1504 lines
51 KiB
Rust
1504 lines
51 KiB
Rust
//! 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<GoldenEntry> = 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<GoldenEntry> = 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).
|
||
|
||
/// 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 false; // 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
|
||
);
|
||
return true;
|
||
}
|
||
false
|
||
}
|
||
|
||
#[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,
|
||
);
|
||
let mut checked = false;
|
||
for (cx, cy) in [(0, 0), (1, 0), (0, 1), (4, 4), (8, 3)] {
|
||
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]
|
||
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,
|
||
);
|
||
let mut checked = false;
|
||
for (cx, cy) in [(0, 0), (2, 1), (5, 5)] {
|
||
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]
|
||
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<i32> = vec![];
|
||
let mut dry_elevs: Vec<i32> = vec![];
|
||
|
||
// 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),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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]
|
||
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,
|
||
);
|
||
let mut checked = false;
|
||
for (cx, cy) in [(0, 0), (1, 1)] {
|
||
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 ──────────────────────────────────────────
|
||
//
|
||
// 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<VegetationClass> = 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<VegetationClass> = 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<Option<f32>> = [
|
||
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,
|
||
]
|
||
.to_vec();
|
||
|
||
for moisture_q in (0i32..=100).step_by(10) {
|
||
for elev_q in (0i32..=100).step_by(10) {
|
||
let mut last: Option<VegetationClass> = 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 | 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()),
|
||
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 | 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()),
|
||
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 | 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()),
|
||
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 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 > -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 \
|
||
(oceanic class-envelope temp at equator per D-240)",
|
||
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<f32>,
|
||
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 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<f32> = 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)
|
||
}
|