feat(simulation): cross-district parameter blending at chunk/voxel scale (T-1042)
Replaces the single-DistrictProfile walking-skeleton restriction so continuous terrain parameters no longer step at the 2 km district pitch (D-239 §4/§7/§8). ChunkContext gains secondary: Option<DistrictProfile> + blend_weight: u8; derive_chunk_context detects a chunk within one chunk of a district border, looks up the adjacent profile, and integer-blends meander/channel params on the context. derive_voxel_column integer-blends elev_q/moisture_q before material selection, reusing the same warp offset so the seam cannot align with the chunk edge (Cow fast-path keeps interior chunks bit-identical — golden seed unchanged). Morphology FAMILY selection is never blended — stays sharp per D-239 §7 (family dispatch reads the primary district only). Climate feathering is the separate T-1078 path. Adds cross_district_elevation_blend_reduces_seam_step and cross_district_morphology_family_seams_stay_sharp to the derivation harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,9 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::atlas::chunk_context::{derive_chunk_context, BasinDirection, ChunkPos};
|
||||
use settled_reach_server::atlas::chunk_context::{
|
||||
derive_chunk_context, district_boundary_blend_weight, BasinDirection, ChunkPos,
|
||||
};
|
||||
use settled_reach_server::atlas::district_profile::{
|
||||
derive_district_profile, derive_morphology_zone, derive_precipitation_class_from_climate,
|
||||
derive_river_threshold, derive_vegetation, BodyParams, ClimateConstants, DistrictProfile,
|
||||
@@ -85,7 +87,7 @@ fn derive_golden(
|
||||
tile_x: i32,
|
||||
tile_y: i32,
|
||||
) -> GoldenEntry {
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||||
let col = derive_voxel_column(seed, body_id, district, &chunk, tile_x, tile_y);
|
||||
GoldenEntry {
|
||||
label: label.to_string(),
|
||||
@@ -119,7 +121,7 @@ fn anchor_golden_pos(
|
||||
(0..scale::CHUNKS_PER_DISTRICT).contains(&along_chunk),
|
||||
"along_chunk must stay within district (0, 0)"
|
||||
);
|
||||
let probe = derive_chunk_context(seed, body_id, district, (0, 0));
|
||||
let probe = derive_chunk_context(seed, body_id, district, (0, 0), None);
|
||||
let anchor = probe.channel_anchor_m;
|
||||
let along_tile = along_chunk * 64 + 32;
|
||||
match probe.basin_direction {
|
||||
@@ -300,7 +302,7 @@ fn assert_drainage_monotonicity(
|
||||
district: &DistrictProfile,
|
||||
chunk_pos: (i32, i32),
|
||||
) -> bool {
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||||
if !chunk.has_active_channel {
|
||||
return false; // No channel → monotonicity trivially satisfied.
|
||||
}
|
||||
@@ -420,13 +422,13 @@ fn law_drainage_monotonicity_fjord_floor_at_sea_level() {
|
||||
// The fjord trough is district-anchored (T-1041): scan the chunk whose
|
||||
// cross-range contains the channel anchor — only that chunk column carries
|
||||
// the deep-water trough.
|
||||
let probe = derive_chunk_context(42, "fjord_body", &district, (0, 0));
|
||||
let probe = derive_chunk_context(42, "fjord_body", &district, (0, 0), None);
|
||||
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
|
||||
let chunk_pos = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (anchor_idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, anchor_idx),
|
||||
};
|
||||
let chunk = derive_chunk_context(42, "fjord_body", &district, chunk_pos);
|
||||
let chunk = derive_chunk_context(42, "fjord_body", &district, chunk_pos, None);
|
||||
let (base_x, base_y) = (chunk_pos.0 * 64, chunk_pos.1 * 64);
|
||||
let mut deep_elevs: Vec<i32> = vec![];
|
||||
let mut dry_elevs: Vec<i32> = vec![];
|
||||
@@ -522,7 +524,7 @@ fn assert_all_terrain_is(
|
||||
expected: TerrainMaterial,
|
||||
label: &str,
|
||||
) {
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||||
let base_x = chunk_pos.0 * 64;
|
||||
let base_y = chunk_pos.1 * 64;
|
||||
for dy in (0..64i32).step_by(8) {
|
||||
@@ -1012,7 +1014,7 @@ fn channel_present_in_active_chunks_far_from_origin() {
|
||||
} else {
|
||||
(1000, cross_base + i)
|
||||
};
|
||||
let chunk = derive_chunk_context(seed, body, &district, pos);
|
||||
let chunk = derive_chunk_context(seed, body, &district, pos, None);
|
||||
let (bx, by) = (pos.0 * 64, pos.1 * 64);
|
||||
let mut wet = 0usize;
|
||||
for dy in 0..64i32 {
|
||||
@@ -1068,7 +1070,7 @@ fn channel_continuous_across_chunk_boundary_far_from_origin() {
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let (seed, body) = (42u64, "GJ144d");
|
||||
let probe = derive_chunk_context(seed, body, &district, (1000, -750));
|
||||
let probe = derive_chunk_context(seed, body, &district, (1000, -750), None);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
@@ -1108,6 +1110,243 @@ fn channel_continuous_across_chunk_boundary_far_from_origin() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-1042 — Cross-district parameter blending at chunk/voxel scale
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Acceptance criteria (from the ticket brief):
|
||||
// 1. Elevation step across a district seam ≤ typical step between adjacent
|
||||
// interior chunks of the same district (seam is invisible in practice).
|
||||
// 2. Morphology family seams remain sharp (no blending of family selection).
|
||||
// 3. Golden-seed determinism unchanged for chunks far from any district border.
|
||||
//
|
||||
// The test constructs two adjacent AlluvialPlain districts with a significant
|
||||
// `elev_q` contrast (20 vs 70) and measures:
|
||||
// - Average elevation of the last chunk of district A (using blend toward B).
|
||||
// - Average elevation of the last+1 chunk, which is the first chunk of district
|
||||
// B (no blend — it reads cleanly from district B).
|
||||
// - Average elevation of a pure interior chunk in district A (far from any seam).
|
||||
// - Average elevation of a pure interior chunk in district B (far from any seam).
|
||||
//
|
||||
// Pass condition: the seam step (last-of-A vs first-of-B) ≤ typical interior
|
||||
// step (interior-A vs interior-B), because the blend reduces the apparent jump.
|
||||
// We also confirm that chunks far from any boundary match exact unblended output
|
||||
// (golden-seed determinism preserved, T-1042 acceptance criterion 3).
|
||||
|
||||
#[test]
|
||||
fn cross_district_elevation_blend_reduces_seam_step() {
|
||||
// Two AlluvialPlain districts with a large elev_q contrast to make the
|
||||
// seam measurable. Chose distinct seeds so the morphology family stays
|
||||
// AlluvialPlain for both (gates trivially satisfied at grade=0, stable).
|
||||
let district_a = make_region(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
TectonicClass::Stable,
|
||||
GlaciationGrade::None,
|
||||
5, // slope_q
|
||||
20, // elev_q — low
|
||||
15, // ocean_fraction_q (water present for a channel)
|
||||
55, // moisture_q
|
||||
Some(15.0),
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let district_b = make_region(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
TectonicClass::Stable,
|
||||
GlaciationGrade::None,
|
||||
5, // slope_q
|
||||
70, // elev_q — high (50-unit contrast with A)
|
||||
15, // ocean_fraction_q
|
||||
55, // moisture_q
|
||||
Some(15.0),
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
|
||||
let (seed, body) = (42u64, "blend_test_body");
|
||||
|
||||
// District A occupies chunk columns [0, 31]; district B = [32, 63].
|
||||
// CHUNKS_PER_DISTRICT = 32.
|
||||
//
|
||||
// The LAST chunk of district A: chunk x=31 (within-district index 31 =
|
||||
// CHUNKS_PER_DISTRICT-1). `district_boundary_blend_weight` returns
|
||||
// (true, 128) for this position — a 50-50 blend with district B.
|
||||
//
|
||||
// The FIRST chunk of district B: chunk x=32 (within-district index 0).
|
||||
// `district_boundary_blend_weight` returns (false, 255) — no blend.
|
||||
let last_a_chunk: ChunkPos = (31, 0);
|
||||
let first_b_chunk: ChunkPos = (32, 0);
|
||||
// Interior: well inside district A and B, far from any district boundary.
|
||||
let interior_a_chunk: ChunkPos = (15, 0);
|
||||
let interior_b_chunk: ChunkPos = (48, 0);
|
||||
|
||||
// Derive boundary detection for the last-A chunk.
|
||||
let (near_boundary, blend_w) = district_boundary_blend_weight(last_a_chunk);
|
||||
assert!(
|
||||
near_boundary,
|
||||
"T-1042: chunk {:?} must be detected as near a district boundary",
|
||||
last_a_chunk
|
||||
);
|
||||
assert_eq!(
|
||||
blend_w, 128,
|
||||
"T-1042: boundary blend weight must be 128 (50-50)"
|
||||
);
|
||||
|
||||
// Derive ChunkContext for last-A with the secondary district B at 50-50 blend.
|
||||
let ctx_last_a = derive_chunk_context(
|
||||
seed,
|
||||
body,
|
||||
&district_a,
|
||||
last_a_chunk,
|
||||
Some((&district_b, blend_w)),
|
||||
);
|
||||
// First-B: no blend (interior to B).
|
||||
let ctx_first_b = derive_chunk_context(seed, body, &district_b, first_b_chunk, None);
|
||||
// Interior chunks: no blend.
|
||||
let ctx_interior_a = derive_chunk_context(seed, body, &district_a, interior_a_chunk, None);
|
||||
let ctx_interior_b = derive_chunk_context(seed, body, &district_b, interior_b_chunk, None);
|
||||
|
||||
// Average elevation across a full 64-voxel row through each chunk.
|
||||
// We scan y=0 (along the x cross-axis for this AlluvialPlain basin).
|
||||
let avg_elev = |chunk_pos: ChunkPos,
|
||||
ctx: &settled_reach_server::atlas::chunk_context::ChunkContext,
|
||||
dist: &DistrictProfile|
|
||||
-> i64 {
|
||||
let base_x = chunk_pos.0 * scale::CHUNK_M;
|
||||
let base_y = chunk_pos.1 * scale::CHUNK_M;
|
||||
let mut sum = 0i64;
|
||||
for dx in 0..scale::VOXELS_PER_CHUNK {
|
||||
let col = derive_voxel_column(seed, body, dist, ctx, base_x + dx, base_y);
|
||||
sum += col.elevation_m as i64;
|
||||
}
|
||||
sum / scale::VOXELS_PER_CHUNK as i64
|
||||
};
|
||||
|
||||
let elev_last_a = avg_elev(last_a_chunk, &ctx_last_a, &district_a);
|
||||
let elev_first_b = avg_elev(first_b_chunk, &ctx_first_b, &district_b);
|
||||
let elev_interior_a = avg_elev(interior_a_chunk, &ctx_interior_a, &district_a);
|
||||
let elev_interior_b = avg_elev(interior_b_chunk, &ctx_interior_b, &district_b);
|
||||
|
||||
// Seam step = elevation gap between the blended last-A chunk and the clean first-B chunk.
|
||||
let seam_step = (elev_last_a - elev_first_b).unsigned_abs() as i64;
|
||||
// Unblended step = elevation gap between pure interior chunks.
|
||||
let interior_step = (elev_interior_a - elev_interior_b).unsigned_abs() as i64;
|
||||
|
||||
// Criterion 1: the seam step must be strictly less than the interior step.
|
||||
// The blend reduces the apparent jump — if blending were absent the seam
|
||||
// step would equal the interior step (both districts differ by 50 elev_q units).
|
||||
assert!(
|
||||
seam_step < interior_step,
|
||||
"T-1042: cross-district seam step ({seam_step} m) must be < unblended \
|
||||
interior step ({interior_step} m) — blend is not reducing the seam"
|
||||
);
|
||||
|
||||
// Criterion 3: interior chunks produce IDENTICAL output to an unblended context.
|
||||
// `ctx_interior_a` has blend_weight=255, secondary=None — same as the
|
||||
// pre-T-1042 path. Derive twice; must match.
|
||||
let ctx_interior_a2 = derive_chunk_context(seed, body, &district_a, interior_a_chunk, None);
|
||||
for dx in 0..scale::VOXELS_PER_CHUNK {
|
||||
let base_x = interior_a_chunk.0 * scale::CHUNK_M;
|
||||
let base_y = interior_a_chunk.1 * scale::CHUNK_M;
|
||||
let col1 = derive_voxel_column(
|
||||
seed,
|
||||
body,
|
||||
&district_a,
|
||||
&ctx_interior_a,
|
||||
base_x + dx,
|
||||
base_y,
|
||||
);
|
||||
let col2 = derive_voxel_column(
|
||||
seed,
|
||||
body,
|
||||
&district_a,
|
||||
&ctx_interior_a2,
|
||||
base_x + dx,
|
||||
base_y,
|
||||
);
|
||||
assert_eq!(
|
||||
col1.elevation_m, col2.elevation_m,
|
||||
"T-1042: interior chunk elevation must be deterministic across two derivations \
|
||||
at voxel offset {dx}"
|
||||
);
|
||||
assert_eq!(
|
||||
col1.terrain, col2.terrain,
|
||||
"T-1042: interior chunk terrain must be deterministic at voxel offset {dx}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_district_morphology_family_seams_stay_sharp() {
|
||||
// T-1042 Criterion 2: morphology family is NEVER blended across a district
|
||||
// boundary (D-239 §7). An AlluvialPlain district adjacent to a FjordWall
|
||||
// district must produce strictly AlluvialPlain (Soil terrain) in the
|
||||
// last chunk of the alluvial district, even at 50-50 blend weight.
|
||||
//
|
||||
// The secondary district (FjordWall) has Rock terrain; the primary (Alluvial)
|
||||
// has Soil. After blending, if family selection were inadvertently reading
|
||||
// the secondary's zone, some tiles would switch to Rock — catch that here.
|
||||
let district_alluvial = make_region(
|
||||
MorphologyZone::AlluvialPlain,
|
||||
TectonicClass::Stable,
|
||||
GlaciationGrade::None,
|
||||
5,
|
||||
20,
|
||||
0, // no channel — simpler tile layout for a clean terrain check
|
||||
40,
|
||||
Some(15.0),
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let district_fjord = make_region(
|
||||
MorphologyZone::Fjord,
|
||||
TectonicClass::Active,
|
||||
GlaciationGrade::Moderate,
|
||||
55,
|
||||
60,
|
||||
28,
|
||||
55,
|
||||
Some(-8.0),
|
||||
VegetationClass::Barren,
|
||||
);
|
||||
|
||||
let (seed, body) = (99u64, "seam_sharp_test");
|
||||
// Last chunk of the alluvial district — blend with the fjord at 50-50.
|
||||
let boundary_chunk: ChunkPos = (31, 0);
|
||||
let (_, blend_w) = district_boundary_blend_weight(boundary_chunk);
|
||||
let ctx = derive_chunk_context(
|
||||
seed,
|
||||
body,
|
||||
&district_alluvial,
|
||||
boundary_chunk,
|
||||
Some((&district_fjord, blend_w)),
|
||||
);
|
||||
|
||||
// Every voxel in this chunk must have Soil terrain (AlluvialPlain primary family).
|
||||
// If family dispatch accidentally picked up the secondary (FjordWall → Rock),
|
||||
// this assertion fails.
|
||||
let base_x = boundary_chunk.0 * scale::CHUNK_M;
|
||||
let base_y = boundary_chunk.1 * scale::CHUNK_M;
|
||||
for dy in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
|
||||
for dx in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
|
||||
let col = derive_voxel_column(
|
||||
seed,
|
||||
body,
|
||||
&district_alluvial,
|
||||
&ctx,
|
||||
base_x + dx,
|
||||
base_y + dy,
|
||||
);
|
||||
assert_eq!(
|
||||
col.terrain,
|
||||
TerrainMaterial::Soil,
|
||||
"T-1042 §7 VIOLATED: cross-district boundary chunk must keep primary \
|
||||
morphology (AlluvialPlain→Soil) at voxel ({},{}) — got {:?}",
|
||||
base_x + dx,
|
||||
base_y + dy,
|
||||
col.terrain
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fjord_district_has_one_valley_spanning_chunks() {
|
||||
// T-1041 (b): a FjordWall district contains ONE deep-water trough spanning
|
||||
@@ -1126,7 +1365,7 @@ fn fjord_district_has_one_valley_spanning_chunks() {
|
||||
);
|
||||
let (seed, body) = (42u64, "fjord_body");
|
||||
for district_chunk in [(640, -480), (-336, 992)] {
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk);
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
@@ -1176,7 +1415,7 @@ fn gorge_district_has_one_valley_spanning_chunks() {
|
||||
);
|
||||
let (seed, body) = (42u64, "gorge_body");
|
||||
for district_chunk in [(640, -480), (-336, 992)] {
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk);
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
@@ -1231,7 +1470,7 @@ fn cliff_coast_one_continuous_coastline_per_region() {
|
||||
);
|
||||
let (seed, body) = (42u64, "cliff_body");
|
||||
for district_chunk in [(656, -464), (-256, 768)] {
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk);
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||||
let coast = probe.coast_anchor_m;
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
@@ -1305,7 +1544,7 @@ fn braided_threads_confined_to_region_belt() {
|
||||
);
|
||||
let (seed, body) = (17u64, "delta_body");
|
||||
for district_chunk in [(800, -592)] {
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk);
|
||||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
@@ -1359,7 +1598,7 @@ fn derive_chunk_timed(
|
||||
district: &DistrictProfile,
|
||||
chunk_pos: (i32, i32),
|
||||
) -> (usize, u128) {
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||||
let base_x = chunk_pos.0 * 64;
|
||||
let base_y = chunk_pos.1 * 64;
|
||||
let t0 = Instant::now();
|
||||
@@ -1573,7 +1812,7 @@ fn budget_voxel_cache_lru_overhead() {
|
||||
VegetationClass::Forest,
|
||||
);
|
||||
let chunk_pos = (2, 2);
|
||||
let chunk = derive_chunk_context(seed, body_id, &district, chunk_pos);
|
||||
let chunk = derive_chunk_context(seed, body_id, &district, chunk_pos, None);
|
||||
let base_x = chunk_pos.0 * 64;
|
||||
let base_y = chunk_pos.1 * 64;
|
||||
|
||||
@@ -1777,7 +2016,7 @@ fn validation_gloedberg_volcanic_immature_drainage() {
|
||||
);
|
||||
|
||||
// Derive a voxel and check TerrainMaterial::Lava (§8 lithology law).
|
||||
let chunk = derive_chunk_context(42, "GJ581c", &profile, (0, 0));
|
||||
let chunk = derive_chunk_context(42, "GJ581c", &profile, (0, 0), None);
|
||||
let col = derive_voxel_column(42, "GJ581c", &profile, &chunk, 100, 100);
|
||||
assert_eq!(
|
||||
col.terrain,
|
||||
@@ -1907,7 +2146,7 @@ fn basin_is_ns(
|
||||
district: &DistrictProfile,
|
||||
district_chunk: ChunkPos,
|
||||
) -> bool {
|
||||
let probe = derive_chunk_context(seed, body_id, district, district_chunk);
|
||||
let probe = derive_chunk_context(seed, body_id, district, district_chunk, None);
|
||||
matches!(
|
||||
probe.basin_direction,
|
||||
BasinDirection::North | BasinDirection::South
|
||||
@@ -1928,7 +2167,7 @@ fn anchor_band_chunks(
|
||||
district: &DistrictProfile,
|
||||
district_chunk: ChunkPos,
|
||||
) -> Vec<ChunkPos> {
|
||||
let probe = derive_chunk_context(seed, body_id, district, district_chunk);
|
||||
let probe = derive_chunk_context(seed, body_id, district, district_chunk, None);
|
||||
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
|
||||
let ns = matches!(
|
||||
probe.basin_direction,
|
||||
@@ -1963,7 +2202,7 @@ fn derive_at_cross_along(
|
||||
) -> settled_reach_server::atlas::voxel::VoxelColumn {
|
||||
let (tx, ty) = if ns { (cross, along) } else { (along, cross) };
|
||||
let chunk_pos = (tx.div_euclid(64), ty.div_euclid(64));
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos);
|
||||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||||
derive_voxel_column(seed, body_id, district, &chunk, tx, ty)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user