fix(simulation): address PR #163 review (freeze/snow model)
Hoshe found a CRITICAL coherence defect (Tyre APPROVE): - CRITICAL: cluster_scatter received the per-voxel sub_chunk_seed as its cluster seed, so voxels in the same 6m cell got different scatter -> salt-and-pepper, not the §3-mandated clustered patches. Re-key on (world, body, cluster-cell): add SeedDomain::Cover=10 (pinned); cluster_scatter(world_seed, body_id, x, y) derives for_body(world_seed,body_id).derive(Cover, cluster_pair_id). derive_cover now takes (world_seed, body_id, ...). (Hoshe #1) - The coherence test was tautological (constant seed bypassed the broken path). Rewritten to the production derive_cover signature in a cold-lake scatter band; it FAILED at 20% on the old code, passes 100% now. (Hoshe #2) - Zigzag-encode cluster (cx,cy) before Cantor pairing (negatives were cast as u64 directly); fix the misleading 'no collision' comment. (Hoshe #3, Tyre) - Sea-ice band was inverted (~85% ice at the -2 onset). Now monotonic: ice grows ~8% (onset) -> ~92% (cold edge); leads (open gaps) layered only in the cold half via a 3x super-cell, passing the super-cell index directly. Dead depth==0 guard removed. (Hoshe #4, Tyre #1/#3) cargo test passes, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+179
-108
@@ -392,7 +392,7 @@ pub fn derive_voxel_column(
|
||||
// axes (terrain/water/vegetation/elevation); cover is a separate orthogonal
|
||||
// axis derived from the region's mean temperature + water/terrain + coherent
|
||||
// cluster scatter. One site, set here — no family generator needs changing.
|
||||
column.cover = derive_cover(region, &column, voxel_pos, sub_chunk_seed);
|
||||
column.cover = derive_cover(world_seed, body_id, region, &column, voxel_pos);
|
||||
|
||||
column
|
||||
}
|
||||
@@ -1309,27 +1309,52 @@ fn classify_surface(zone: &MorphologyZone, water: Water) -> SurfaceClass {
|
||||
|
||||
/// Per-cluster scatter offset (D-239 §3).
|
||||
///
|
||||
/// Hash a coarse cluster cell `(cx, cy)` into a u64 scatter offset. All voxels
|
||||
/// that share the same cluster cell get the same offset, producing spatially-
|
||||
/// coherent ragged patches rather than per-tile salt-and-pepper noise.
|
||||
/// Hash a coarse cluster cell `(cx, cy)` — derived from `(voxel_x, voxel_y)`
|
||||
/// by `div_euclid(CLUSTER_M)` — into a body-scoped u64 scatter value via
|
||||
/// `SeedChain`. All voxels that share the same cluster cell get the **same**
|
||||
/// scatter value (coherent patches). Different world seeds and body ids yield
|
||||
/// different freeze patterns.
|
||||
///
|
||||
/// Uses the project's canonical `splitmix64` mixer (D-010; same as SeedChain).
|
||||
/// The world seed is mixed in so different worlds have different freeze patterns.
|
||||
/// ## Key correctness properties
|
||||
///
|
||||
/// - The cluster-cell coordinates may be negative (voxels at negative positions).
|
||||
/// We zigzag-encode each signed `cx`/`cy` to a non-negative `u64` before
|
||||
/// Cantor-pairing, giving a collision-free bijection for the full signed range.
|
||||
/// (The old cast-to-u64 was wrong for negative inputs — huge two's-complement
|
||||
/// values broke the "no collision" claim.)
|
||||
/// - The seed is derived through `SeedChain::for_body(world_seed, body_id)
|
||||
/// .derive(SeedDomain::Cover, cluster_pair_id)` — a body-scoped, domain-
|
||||
/// separated derivation, not a raw `splitmix64` on the per-voxel sub_chunk_seed.
|
||||
/// This is the root fix for the coherence defect: `sub_chunk_seed` is per-voxel,
|
||||
/// so passing it here gave each voxel its own scatter value → salt-and-pepper.
|
||||
/// `SeedChain::for_body` is body-scoped and the cluster id is a cluster-cell
|
||||
/// property — all voxels in the same cell derive the same seed.
|
||||
#[inline]
|
||||
fn cluster_scatter(world_seed: u64, voxel_x: i32, voxel_y: i32) -> u64 {
|
||||
use crate::seed::splitmix64;
|
||||
// Coarse cluster cell: integer division (Euclidean, so negative coords map
|
||||
// correctly). Two voxels 1 m apart that share a cluster cell get the same hash.
|
||||
let cx = voxel_x.div_euclid(CLUSTER_M) as u64;
|
||||
let cy = voxel_y.div_euclid(CLUSTER_M) as u64;
|
||||
// Cantor-style pairing to fold (cx, cy) into a single u64 without collision.
|
||||
// We use zigzag encode + additive mixing to keep the avalanche properties.
|
||||
fn cluster_scatter(world_seed: u64, body_id: &str, voxel_x: i32, voxel_y: i32) -> u64 {
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
// Coarse cluster cell index (Euclidean div so negatives map cleanly).
|
||||
let cx_i = voxel_x.div_euclid(CLUSTER_M);
|
||||
let cy_i = voxel_y.div_euclid(CLUSTER_M);
|
||||
// Zigzag encode signed → unsigned (same pattern as voxel_pos_to_id and
|
||||
// domain_warp::pos_to_id — bijective over the full i32 range, no collisions
|
||||
// between positive and negative cluster coords).
|
||||
let zz = |v: i32| -> u64 {
|
||||
let v = v as i64;
|
||||
((v << 1) ^ (v >> 63)) as u64
|
||||
};
|
||||
let cx = zz(cx_i);
|
||||
let cy = zz(cy_i);
|
||||
// Cantor-pairing (now collision-free because both inputs are non-negative u64).
|
||||
let s = cx.wrapping_add(cy);
|
||||
let paired = s
|
||||
let cluster_pair_id = s
|
||||
.wrapping_mul(s.wrapping_add(1))
|
||||
.wrapping_div(2)
|
||||
.wrapping_add(cy);
|
||||
splitmix64(world_seed ^ splitmix64(paired))
|
||||
// Body-scoped, domain-separated derivation via SeedChain (D-224 / D-010).
|
||||
// All voxels sharing this cluster cell → same cluster_pair_id → same seed.
|
||||
SeedChain::for_body(world_seed, body_id)
|
||||
.derive(SeedDomain::Cover, cluster_pair_id)
|
||||
.seed()
|
||||
}
|
||||
|
||||
/// Derive the `SeasonalCover` overlay for a single voxel (D-239 §3, T-1030).
|
||||
@@ -1383,10 +1408,11 @@ fn cluster_scatter(world_seed: u64, voxel_x: i32, voxel_y: i32) -> u64 {
|
||||
/// All decisions are integer. `temperature_c` is cast to `i32` once (positional
|
||||
/// quantisation, not a structural float gate). All scatter is hash/integer only.
|
||||
pub fn derive_cover(
|
||||
world_seed: u64,
|
||||
body_id: &str,
|
||||
region: &RegionProfile,
|
||||
column: &VoxelColumn,
|
||||
voxel_pos: VoxelPos,
|
||||
sub_chunk_seed: u64,
|
||||
) -> SeasonalCover {
|
||||
// ── Airless body (D-239 §2) ───────────────────────────────────────────
|
||||
// No atmosphere → no climate branch → no seasonal cover.
|
||||
@@ -1400,11 +1426,10 @@ pub fn derive_cover(
|
||||
|
||||
let (voxel_x, voxel_y) = voxel_pos;
|
||||
|
||||
// Derive the world seed from sub_chunk_seed for cluster coherence. We use
|
||||
// the sub_chunk_seed itself (already body-scoped and position-keyed by
|
||||
// SeedChain) as the basis, avoiding a separate parameter.
|
||||
// cluster_scatter extracts a shared per-cluster value from this.
|
||||
let scatter = cluster_scatter(sub_chunk_seed, voxel_x, voxel_y);
|
||||
// Per-cluster scatter: keyed ONLY on (world, body, cluster-cell) — never
|
||||
// on the per-voxel sub_chunk_seed. All voxels in the same 6×6 m cell share
|
||||
// this value, producing ragged clustered patches (D-239 §3).
|
||||
let scatter = cluster_scatter(world_seed, body_id, voxel_x, voxel_y);
|
||||
|
||||
let surface = classify_surface(®ion.morphology_zone, column.water);
|
||||
|
||||
@@ -1419,13 +1444,12 @@ pub fn derive_cover(
|
||||
SeasonalCover::Ice
|
||||
} else {
|
||||
// Within the scatter band [FRESH_BAND_LOW_C, FRESH_BAND_HIGH_C).
|
||||
// Map temp into [0, band_width]: 0 = warm edge (rare freeze),
|
||||
// band_width = cold edge (almost always frozen).
|
||||
// Threshold: scatter % band_width < depth_into_band → Ice.
|
||||
// depth 1 (just below +5°C) ≈ 1/15 ≈ 7% Ice.
|
||||
// depth 14 (just above −10°C) ≈ 14/15 ≈ 93% Ice.
|
||||
// Ice-fraction is monotonically increasing as temp drops.
|
||||
let band_width = (FRESH_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 15
|
||||
let depth_into_band = (FRESH_BAND_HIGH_C - temp_i) as u64; // 0..=15
|
||||
// Threshold: voxels with scatter % band_width < depth_into_band → Ice.
|
||||
// At depth 1 (just below +5°C) ≈ 1/15 ≈ 7% frozen.
|
||||
// At depth 14 (just above −10°C) ≈ 14/15 ≈ 93% frozen.
|
||||
let depth_into_band = (FRESH_BAND_HIGH_C - temp_i) as u64; // 1..=14
|
||||
if scatter % band_width < depth_into_band {
|
||||
SeasonalCover::Ice
|
||||
} else {
|
||||
@@ -1444,27 +1468,55 @@ pub fn derive_cover(
|
||||
// Below band — permanent pack ice.
|
||||
SeasonalCover::Ice
|
||||
} else {
|
||||
// Within the sea-ice scatter band.
|
||||
// Pack-ice pattern: larger coherent sheets + open lead gaps.
|
||||
// We use a 3× coarser cluster for the sheet-scale coherence,
|
||||
// giving km-scale ice sheets rather than the CLUSTER_M metre patches.
|
||||
let lead_scatter = cluster_scatter(
|
||||
sub_chunk_seed,
|
||||
voxel_x.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3,
|
||||
voxel_y.div_euclid(CLUSTER_M * 3) * CLUSTER_M * 3,
|
||||
);
|
||||
// Depth into the band (0 = warm edge, SALT_BAND_WIDTH = cold edge).
|
||||
let depth_into_band = (SALT_ONSET_C - temp_i) as u64;
|
||||
// Within the sea-ice scatter band [−14, −2).
|
||||
//
|
||||
// Ice-fraction is monotonically increasing as temp drops
|
||||
// (same linear model as freshwater: scatter % band_width <
|
||||
// depth_into_band). At onset (depth 1) ≈ 8% ice; at depth 11
|
||||
// (just above −14°C) ≈ 92% ice.
|
||||
//
|
||||
// Pack-ice sheets + leads structure: on the cold, mostly-frozen
|
||||
// end we apply a 3× coarser cluster (super-cell = 3×CLUSTER_M)
|
||||
// to carve occasional open "lead" gaps within what would otherwise
|
||||
// be solid ice. A lead forms when the super-cell scatter indicates
|
||||
// a gap AND the voxel is in the cold zone where ice would normally
|
||||
// be solid. This gives sheet-scale coherence (large contiguous ice
|
||||
// panels) with a small fraction of narrow persistent leads.
|
||||
let depth_into_band = (SALT_ONSET_C - temp_i) as u64; // 1..=11
|
||||
let band_width = SALT_BAND_WIDTH as u64; // 12
|
||||
// Ice unless this is an open "lead" gap (rare, ~15% probability).
|
||||
// The deeper into the band, the smaller the lead probability.
|
||||
let lead_probability =
|
||||
(band_width.saturating_sub(depth_into_band) * 15) / band_width.max(1); // 0..=15 (percent)
|
||||
let is_lead = lead_scatter % 100 < lead_probability;
|
||||
if depth_into_band == 0 || is_lead {
|
||||
SeasonalCover::None
|
||||
|
||||
// Primary ice decision (monotonic, same logic as freshwater).
|
||||
let is_ice = scatter % band_width < depth_into_band;
|
||||
|
||||
if is_ice {
|
||||
// In the cold half of the band (depth > band_width/2), apply
|
||||
// the pack-ice lead pattern using a coarser super-cell cluster.
|
||||
// The lead cluster is keyed on the super-cell index directly,
|
||||
// not on pre-quantized coordinates, so the intent is clear.
|
||||
let cold_threshold = band_width / 2;
|
||||
if depth_into_band > cold_threshold {
|
||||
// Super-cell index: one step per 3×CLUSTER_M metres.
|
||||
let super_cell_size = CLUSTER_M * 3;
|
||||
let scx = voxel_x.div_euclid(super_cell_size);
|
||||
let scy = voxel_y.div_euclid(super_cell_size);
|
||||
let sheet_scatter = cluster_scatter(
|
||||
world_seed,
|
||||
body_id,
|
||||
scx * super_cell_size,
|
||||
scy * super_cell_size,
|
||||
);
|
||||
// ~8% open leads in the cold zone (persistent gaps in pack ice).
|
||||
let is_lead = sheet_scatter % 100 < 8;
|
||||
if is_lead {
|
||||
SeasonalCover::None
|
||||
} else {
|
||||
SeasonalCover::Ice
|
||||
}
|
||||
} else {
|
||||
SeasonalCover::Ice
|
||||
}
|
||||
} else {
|
||||
SeasonalCover::Ice
|
||||
SeasonalCover::None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1483,8 +1535,9 @@ pub fn derive_cover(
|
||||
SeasonalCover::Snow
|
||||
} else {
|
||||
// Within the scatter band [FRESH_BAND_LOW_C, SNOW_BAND_HIGH_C).
|
||||
// Same monotonic model: ice-fraction increases as temp drops.
|
||||
let band_width = (SNOW_BAND_HIGH_C - FRESH_BAND_LOW_C) as u64; // 12
|
||||
let depth_into_band = (SNOW_BAND_HIGH_C - temp_i) as u64; // 0..=12
|
||||
let depth_into_band = (SNOW_BAND_HIGH_C - temp_i) as u64; // 1..=11
|
||||
if scatter % band_width < depth_into_band {
|
||||
SeasonalCover::Snow
|
||||
} else {
|
||||
@@ -3126,7 +3179,7 @@ mod tests {
|
||||
let mut region = alluvial_region();
|
||||
region.temperature_c = None;
|
||||
let col = make_column(Water::Dry);
|
||||
let cover = derive_cover(®ion, &col, (0, 0), 12345);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (0, 0));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::None,
|
||||
@@ -3139,7 +3192,7 @@ mod tests {
|
||||
let mut region = cover_region(MorphologyZone::Lake, 15.0, 80);
|
||||
region.temperature_c = None;
|
||||
let col = make_column(Water::Deep);
|
||||
let cover = derive_cover(®ion, &col, (50, 50), 99999);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (50, 50));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::None,
|
||||
@@ -3156,7 +3209,7 @@ mod tests {
|
||||
let col = make_column(Water::Deep);
|
||||
// Sample many positions — all must be None above the band.
|
||||
for i in 0..50i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 7, i * 3), i as u64 * 17 + 1);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 7, i * 3));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::None,
|
||||
@@ -3173,7 +3226,7 @@ mod tests {
|
||||
let region = cover_region(MorphologyZone::Lake, -11.0, 60);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..50i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 11, i * 5), i as u64 * 31 + 7);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 11, i * 5));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::Ice,
|
||||
@@ -3195,7 +3248,7 @@ mod tests {
|
||||
for i in 0..200i32 {
|
||||
// Spread across a large area to span many cluster cells.
|
||||
let pos = (i * CLUSTER_M * 2, i * CLUSTER_M);
|
||||
let cover = derive_cover(®ion, &col, pos, 42);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, pos);
|
||||
match cover {
|
||||
SeasonalCover::Ice => ice_count += 1,
|
||||
SeasonalCover::None => none_count += 1,
|
||||
@@ -3220,7 +3273,7 @@ mod tests {
|
||||
let region = cover_region(MorphologyZone::OpenOcean, 0.0, 50);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 13, i * 7), i as u64 * 23);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 13, i * 7));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::None,
|
||||
@@ -3235,7 +3288,7 @@ mod tests {
|
||||
let region = cover_region(MorphologyZone::OpenOcean, -15.0, 50);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 17, i * 9), i as u64 * 41);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 17, i * 9));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::Ice,
|
||||
@@ -3251,7 +3304,7 @@ mod tests {
|
||||
let region = cover_region(MorphologyZone::OpenOcean, 3.0, 50);
|
||||
let col = make_column(Water::Deep);
|
||||
for i in 0..20i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 7, i * 5), i as u64 + 1);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 7, i * 5));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::None,
|
||||
@@ -3268,7 +3321,7 @@ mod tests {
|
||||
let region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 60);
|
||||
let col = make_column(Water::Dry);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 9, i * 3), i as u64 * 11);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 9, i * 3));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::Snow,
|
||||
@@ -3283,7 +3336,7 @@ mod tests {
|
||||
let region = cover_region(MorphologyZone::AlluvialPlain, -12.0, 20);
|
||||
let col = make_column(Water::Dry);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 9, i * 3), i as u64 * 11);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 9, i * 3));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::None,
|
||||
@@ -3298,7 +3351,7 @@ mod tests {
|
||||
let region = cover_region(MorphologyZone::AlluvialPlain, 10.0, 80);
|
||||
let col = make_column(Water::Dry);
|
||||
for i in 0..30i32 {
|
||||
let cover = derive_cover(®ion, &col, (i * 5, i * 2), i as u64 * 7);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, (i * 5, i * 2));
|
||||
assert_eq!(
|
||||
cover,
|
||||
SeasonalCover::None,
|
||||
@@ -3316,7 +3369,7 @@ mod tests {
|
||||
let mut none_count = 0;
|
||||
for i in 0..200i32 {
|
||||
let pos = (i * CLUSTER_M * 2, i * CLUSTER_M);
|
||||
let cover = derive_cover(®ion, &col, pos, 42);
|
||||
let cover = derive_cover(42, "test_body", ®ion, &col, pos);
|
||||
match cover {
|
||||
SeasonalCover::Snow => snow_count += 1,
|
||||
SeasonalCover::None => none_count += 1,
|
||||
@@ -3343,8 +3396,8 @@ mod tests {
|
||||
let dry_col = make_column(Water::Dry);
|
||||
let wet_col = make_column(Water::Shallow);
|
||||
|
||||
let dry_cover = derive_cover(®ion, &dry_col, (0, 0), 12345);
|
||||
let wet_cover = derive_cover(®ion, &wet_col, (0, 0), 12345);
|
||||
let dry_cover = derive_cover(42, "test_body", ®ion, &dry_col, (0, 0));
|
||||
let wet_cover = derive_cover(42, "test_body", ®ion, &wet_col, (0, 0));
|
||||
|
||||
assert_eq!(
|
||||
dry_cover,
|
||||
@@ -3360,70 +3413,89 @@ mod tests {
|
||||
|
||||
// ── Spatial coherence ────────────────────────────────────────────────────
|
||||
|
||||
/// Coherence test: exercises the REAL `derive_cover` production signature
|
||||
/// (with `world_seed` + `body_id` — the fixed API) to catch the defect from
|
||||
/// PR #163.
|
||||
///
|
||||
/// The defect: in the broken code, `cluster_scatter` received `sub_chunk_seed`
|
||||
/// (a per-voxel value), so every voxel had a distinct scatter hash → salt-and-
|
||||
/// pepper noise, NOT clustered patches. This test would FAIL against that code
|
||||
/// because intra-cluster agreement would be ~50% (random coin flip) instead of
|
||||
/// 100%. It PASSES only with the fix: `cluster_scatter` uses
|
||||
/// `SeedChain::for_body(world_seed, body_id).derive(Cover, cluster_cell_id)`,
|
||||
/// keyed on (world, body, cluster-cell) so every voxel in the same cell gets
|
||||
/// the same hash.
|
||||
///
|
||||
/// ## Why we test `derive_cover` directly rather than via `derive_voxel_column`
|
||||
///
|
||||
/// `derive_voxel_column` applies domain warp (±8 m) before computing the voxel
|
||||
/// address. Two logically-adjacent tiles (1 m apart) may warp to positions that
|
||||
/// fall in *different* 6 m cluster cells — which is correct behavior, not a
|
||||
/// coherence failure. Testing coherence at the `derive_voxel_column` level would
|
||||
/// require knowing the post-warp cluster-cell boundaries, making the test
|
||||
/// fragile. Testing `derive_cover` directly with positions that are explicitly
|
||||
/// within the same cluster cell is the clean contract-level test.
|
||||
#[test]
|
||||
fn cover_cluster_coherence_not_per_tile() {
|
||||
// Neighbours within the same cluster cell must agree more than random.
|
||||
// We check that adjacent voxels within a CLUSTER_M block have the SAME
|
||||
// cover value at least 90% of the time — because they share the cluster hash.
|
||||
//
|
||||
// Strategy: for each cluster origin (cx*CLUSTER_M, cy*CLUSTER_M), sample
|
||||
// 4 voxels inside it and verify they all agree.
|
||||
// Use a cold lake region so cover is in the scatter band (mix of Ice/None)
|
||||
// — solid-frozen would pass trivially regardless of coherence.
|
||||
let region = cover_region(MorphologyZone::Lake, -3.0, 60);
|
||||
let col = make_column(Water::Shallow);
|
||||
let world_seed: u64 = 42;
|
||||
let body_id = "coherence_body";
|
||||
|
||||
let mut cluster_agreements = 0;
|
||||
let mut cluster_total = 0;
|
||||
for cx in 0..20i32 {
|
||||
for cy in 0..20i32 {
|
||||
|
||||
// Walk a grid of cluster cells. For each cell, sample 4 voxels that are
|
||||
// explicitly inside that cell: offsets (0,0), (1,0), (0,1), (CLUSTER_M-1, CLUSTER_M-1).
|
||||
// All 4 must produce identical cover — same cluster-cell → same SeedChain hash.
|
||||
for cx in -10..10i32 {
|
||||
for cy in -10..10i32 {
|
||||
let base_x = cx * CLUSTER_M;
|
||||
let base_y = cy * CLUSTER_M;
|
||||
// Sample 4 voxels inside the cluster cell.
|
||||
let covers: Vec<SeasonalCover> = (0..4)
|
||||
.map(|offset| {
|
||||
let px = base_x + offset % 2;
|
||||
let py = base_y + offset / 2;
|
||||
derive_cover(®ion, &col, (px, py), 42)
|
||||
let offsets = [(0, 0), (1, 0), (0, 1), (CLUSTER_M - 1, CLUSTER_M - 1)];
|
||||
let covers: Vec<SeasonalCover> = offsets
|
||||
.iter()
|
||||
.map(|&(dx, dy)| {
|
||||
derive_cover(
|
||||
world_seed,
|
||||
body_id,
|
||||
®ion,
|
||||
&col,
|
||||
(base_x + dx, base_y + dy),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// All 4 should agree (same cluster cell → same scatter hash).
|
||||
// All 4 must agree.
|
||||
if covers.windows(2).all(|w| w[0] == w[1]) {
|
||||
cluster_agreements += 1;
|
||||
}
|
||||
cluster_total += 1;
|
||||
}
|
||||
}
|
||||
let agreement_rate = cluster_agreements * 100 / cluster_total.max(1);
|
||||
assert!(
|
||||
agreement_rate >= 90,
|
||||
"voxels within the same cluster cell must agree ≥90% of the time; got {}% ({}/{})",
|
||||
agreement_rate,
|
||||
|
||||
assert_eq!(
|
||||
cluster_agreements,
|
||||
cluster_total,
|
||||
"ALL voxels within the same cluster cell must agree (same cluster-cell hash); \
|
||||
{}/{} cells disagreed — this means cluster_scatter is NOT keyed solely on the \
|
||||
cluster-cell coordinates (salt-and-pepper bug)",
|
||||
cluster_total - cluster_agreements,
|
||||
cluster_total
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_different_cluster_cells_produce_variety() {
|
||||
// Different cluster cells should produce different outcomes — confirming
|
||||
// the spatial pattern is clustered (not solid or per-tile random).
|
||||
// Sample one representative from each of many distinct cluster cells.
|
||||
let region = cover_region(MorphologyZone::Lake, -3.0, 60);
|
||||
let col = make_column(Water::Shallow);
|
||||
let mut ice_cells = 0;
|
||||
let mut none_cells = 0;
|
||||
// Walk across many cluster cells, one sample per cell.
|
||||
for cx in 0..50i32 {
|
||||
let px = cx * CLUSTER_M; // one canonical voxel per cluster
|
||||
let cover = derive_cover(®ion, &col, (px, 0), 42);
|
||||
match cover {
|
||||
SeasonalCover::Ice => ice_cells += 1,
|
||||
SeasonalCover::None => none_cells += 1,
|
||||
SeasonalCover::Snow => {}
|
||||
}
|
||||
}
|
||||
// Also verify that cover varies across different cluster cells in the scatter
|
||||
// band — confirming the hash is not degenerate (not all-Ice or all-None).
|
||||
let cell_covers: Vec<SeasonalCover> = (-25..25i32)
|
||||
.map(|cx| derive_cover(world_seed, body_id, ®ion, &col, (cx * CLUSTER_M, 0)))
|
||||
.collect();
|
||||
let has_ice = cell_covers.contains(&SeasonalCover::Ice);
|
||||
let has_none = cell_covers.contains(&SeasonalCover::None);
|
||||
assert!(
|
||||
ice_cells > 0 && none_cells > 0,
|
||||
"different cluster cells must produce both Ice and None in the scatter band; \
|
||||
got Ice:{ice_cells} None:{none_cells}"
|
||||
has_ice && has_none,
|
||||
"cover must vary across cluster cells (both Ice and None expected in scatter \
|
||||
band −3°C); got has_ice={has_ice} has_none={has_none} — hash may be degenerate"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3436,9 +3508,8 @@ mod tests {
|
||||
let col = make_column(Water::Shallow);
|
||||
for i in 0..50i32 {
|
||||
let pos = (i * CLUSTER_M, i * 3);
|
||||
let seed = (i as u64 * 1234567) ^ 0xdeadbeef;
|
||||
let a = derive_cover(®ion, &col, pos, seed);
|
||||
let b = derive_cover(®ion, &col, pos, seed);
|
||||
let a = derive_cover(42, "test_body", ®ion, &col, pos);
|
||||
let b = derive_cover(42, "test_body", ®ion, &col, pos);
|
||||
assert_eq!(
|
||||
a,
|
||||
b,
|
||||
|
||||
@@ -105,6 +105,11 @@ pub enum SeedDomain {
|
||||
/// voxel stream can never collide with the region-scale meander seed (D-224
|
||||
/// domain separation).
|
||||
Voxel = 9,
|
||||
/// Seasonal cover cluster scatter (D-239 §3, T-1030). Keyed by the coarse
|
||||
/// cluster-cell id (zigzag-encoded and Cantor-paired). Distinct from `Voxel`
|
||||
/// so the per-cluster cover hash can never collide with the per-voxel terrain
|
||||
/// stream — domain separation guarantees no freeze-to-terrain correlation.
|
||||
Cover = 10,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
@@ -263,6 +268,7 @@ mod tests {
|
||||
assert_eq!(SeedDomain::DomainWarp as u64, 7);
|
||||
assert_eq!(SeedDomain::ChunkContext as u64, 8);
|
||||
assert_eq!(SeedDomain::Voxel as u64, 9);
|
||||
assert_eq!(SeedDomain::Cover as u64, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user