Hoshe + Tyre review (CHANGES REQUESTED): - domain_warp golden tests were hollow (discarded values, only asserted dx!=dy). Consolidate into one real golden_vector test with pinned f64 literals as the regression anchor. (Hoshe #1, Tyre #2) - Per-region temperature used body-level elevation_km for every region, so alpine and sea-level regions on a body got identical lapse — defeats the D-239 §2 per-district temperature. Derive elevation_km per region from the region's own elev_q (× MAX_REGION_ELEVATION_KM). (Tyre #1) - Region seed was derived under SeedDomain::DomainWarp (collision risk with the tile warp) and discarded unused. Remove it + the orphaned region_pos_id and SeedDomain import; keep a reserved _seed param for T-1027/T-1028. (Hoshe #2) - Fix inverted tilt_factor comment. (Hoshe #3) - ClimateConstants doc referenced a load() that doesn't exist; correct it — embedded default() is authoritative today, climate_constants.toml is the canonical mirror, runtime load lands with T-1032. (Hoshe + Tyre) - cascade.rs: drainage re-run was mislabeled 'cheap'/'no drainage needed'; document the real cost + PERF/TODO(T-1028/T-1032) against the D-239 §10 budget. (Tyre #3) cargo test 1497 pass, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
226 lines
9.0 KiB
Rust
226 lines
9.0 KiB
Rust
//! Anti-squaring domain warp (D-239 §4, T-1026).
|
|
//!
|
|
//! A stateless, hash-based pure function of `(seed, body_id, pos)` that returns
|
|
//! a `(dx, dy)` displacement in metres, bounded to ±8 m. Used by downstream
|
|
//! voxel derivation to suppress grid/seam artefacts without authoring.
|
|
//!
|
|
//! ## Determinism
|
|
//!
|
|
//! The warp is a **positional displacement in f64**, applied to the integer tile
|
|
//! position and then truncated back to an integer voxel address with `as i32` (a
|
|
//! cast, not a comparison). The ±8 m bound means the FMA-contraction ULP variance
|
|
//! (~1e-15 m) is nine orders of magnitude below the 1 m voxel — no platform guard
|
|
//! is needed (D-239 §4).
|
|
//!
|
|
//! ## D-010 compliance
|
|
//!
|
|
//! The warp is *positional math*, not a structural decision. All morphology /
|
|
//! material / gating decisions downstream receive the final **integer** voxel
|
|
//! address; the f64 path is fully contained in this module.
|
|
//!
|
|
//! ## SeedDomain extension
|
|
//!
|
|
//! Appends `DomainWarp = 7` to [`crate::seed::SeedDomain`] (discriminant-pinned;
|
|
//! the test in `seed.rs` enforces append-only — never renumber existing variants).
|
|
|
|
use crate::seed::{splitmix64, SeedChain, SeedDomain};
|
|
|
|
/// ±8 m bound — the warp displacement is clamped to this range.
|
|
const WARP_BOUND: f64 = 8.0;
|
|
|
|
/// Compute the anti-squaring domain warp for `pos` on `body_id`.
|
|
///
|
|
/// Returns `(dx, dy)` displacement in metres, each bounded to `[-8.0, 8.0]`.
|
|
/// Pure and stateless — no lookup table, no thread-local state.
|
|
///
|
|
/// ## Usage
|
|
///
|
|
/// ```ignore
|
|
/// let (dx, dy) = domain_warp(world_seed, "GJ1c", (tile_x, tile_y));
|
|
/// let voxel_x = (tile_x as f64 + dx) as i32;
|
|
/// let voxel_y = (tile_y as f64 + dy) as i32;
|
|
/// ```
|
|
///
|
|
/// The final `as i32` is a **cast** (truncation toward zero), not a comparison —
|
|
/// IEEE-754 deterministic across targets (D-239 §4).
|
|
pub(crate) fn domain_warp(seed: u64, body_id: &str, pos: (i32, i32)) -> (f64, f64) {
|
|
// Derive a per-body sub-seed using the canonical SeedChain path (D-224).
|
|
let body_seed = SeedChain::for_body(seed, body_id);
|
|
|
|
// Derive two independent streams: one for dx, one for dy.
|
|
// DomainWarp = 7 (appended to SeedDomain, never renumber).
|
|
// Use pos-derived id so each cell in the same body gets a unique stream.
|
|
let pos_id = pos_to_id(pos);
|
|
|
|
let seed_x = body_seed.derive(SeedDomain::DomainWarp, pos_id).seed();
|
|
// Second stream: mix pos_id with a prime to get an independent y channel.
|
|
let seed_y = body_seed
|
|
.derive(
|
|
SeedDomain::DomainWarp,
|
|
splitmix64(pos_id ^ 0xdeadbeef_cafebabe),
|
|
)
|
|
.seed();
|
|
|
|
let dx = u64_to_displacement(seed_x);
|
|
let dy = u64_to_displacement(seed_y);
|
|
|
|
(dx, dy)
|
|
}
|
|
|
|
/// Fold `(x, y)` tile coordinates into a single u64 id for seed derivation.
|
|
///
|
|
/// Uses a bijective Cantor-pairing-style interleave with zigzag encoding so
|
|
/// negative coordinates map to distinct non-negative ids. Integer-only (D-010).
|
|
#[inline]
|
|
fn pos_to_id(pos: (i32, i32)) -> u64 {
|
|
// Zigzag-encode each axis: 0→0, -1→1, 1→2, -2→3, 2→4, …
|
|
let zz = |v: i32| -> u64 {
|
|
let v = v as i64;
|
|
((v << 1) ^ (v >> 63)) as u64
|
|
};
|
|
let x = zz(pos.0);
|
|
let y = zz(pos.1);
|
|
// Cantor pairing: (x + y)*(x + y + 1)/2 + y — bijective N²→N.
|
|
// We use 64-bit wrapping arithmetic; for the world sizes in play (< 2³¹ tiles)
|
|
// this is collision-free in practice.
|
|
let s = x.wrapping_add(y);
|
|
s.wrapping_mul(s.wrapping_add(1))
|
|
.wrapping_div(2)
|
|
.wrapping_add(y)
|
|
}
|
|
|
|
/// Map a raw u64 seed value to a displacement in `[-WARP_BOUND, +WARP_BOUND]`.
|
|
///
|
|
/// Passes the seed through `splitmix64` for avalanche, then maps the top 53
|
|
/// bits to `[0.0, 1.0)` via the standard u64→f64 trick (`bits >> 11` gives a
|
|
/// 53-bit mantissa), then shifts to `[-0.5, +0.5)` and scales by
|
|
/// `2 * WARP_BOUND`.
|
|
#[inline]
|
|
fn u64_to_displacement(seed: u64) -> f64 {
|
|
// One more mix for full avalanche from whatever derive() left.
|
|
let mixed = splitmix64(seed);
|
|
// Top 53 bits → [0.0, 1.0) using the integer-bit casting approach (not a
|
|
// comparison, just a mantissa construction — IEEE-754 portable).
|
|
let unit = (mixed >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
|
|
// Shift to [-0.5, 0.5) then scale to [-WARP_BOUND, +WARP_BOUND).
|
|
(unit - 0.5) * (2.0 * WARP_BOUND)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::seed::fnv1a_64;
|
|
use std::thread;
|
|
|
|
/// Golden-vector regression anchor: a fixed (seed, body_id, pos) maps to
|
|
/// fixed (dx, dy). Mirrors `splitmix64_known_vector` in seed.rs. Single-
|
|
/// platform pins suffice — the ±8 m bound + downstream `as i32` truncation
|
|
/// (D-239 §4) make cross-platform ULP drift irrelevant to the voxel address.
|
|
///
|
|
/// Recompute and re-pin these literals ONLY after a deliberate algorithm
|
|
/// change (`pos_to_id`, `u64_to_displacement`, or the SeedDomain chain) —
|
|
/// such a change re-rolls the warp for every body and needs a D-record
|
|
/// amendment. If this test fails unexpectedly, the warp output drifted.
|
|
#[test]
|
|
fn golden_vector() {
|
|
let (dx, dy) = domain_warp(42, "GJ1c", (100, -50));
|
|
// Pinned literals — the canonical regression anchor for the warp output.
|
|
assert_eq!(
|
|
dx, -1.8472385880244921,
|
|
"domain_warp dx drifted — algorithm changed?"
|
|
);
|
|
assert_eq!(
|
|
dy, 5.033644613199796,
|
|
"domain_warp dy drifted — algorithm changed?"
|
|
);
|
|
// Within the ±8 m bound, and the two channels are independent.
|
|
assert!(dx.abs() <= WARP_BOUND && dy.abs() <= WARP_BOUND);
|
|
assert_ne!(dx, dy);
|
|
}
|
|
|
|
/// Truncation-as-cast (not rounding): the downstream consumer uses `as i32`.
|
|
#[test]
|
|
fn truncation_cast_not_rounding() {
|
|
// Positive displacement truncates toward zero.
|
|
let v: f64 = 3.9;
|
|
assert_eq!(v as i32, 3, "positive truncates toward zero");
|
|
// Negative displacement truncates toward zero (not floor).
|
|
let v: f64 = -3.9;
|
|
assert_eq!(v as i32, -3, "negative truncates toward zero");
|
|
// Applying domain_warp to a tile and casting to voxel address.
|
|
let (dx, dy) = domain_warp(1, "test_body", (10, 20));
|
|
let vx = (10_f64 + dx) as i32;
|
|
let vy = (20_f64 + dy) as i32;
|
|
// The cast should produce a valid address (within ±WARP_BOUND of the tile).
|
|
assert!((vx - 10).abs() <= WARP_BOUND as i32 + 1);
|
|
assert!((vy - 20).abs() <= WARP_BOUND as i32 + 1);
|
|
}
|
|
|
|
/// Two threads with the same inputs produce identical results (order-independence).
|
|
#[test]
|
|
fn two_threads_same_inputs_identical() {
|
|
let handle_a = thread::spawn(|| domain_warp(99, "Kallast", (42, -17)));
|
|
let handle_b = thread::spawn(|| domain_warp(99, "Kallast", (42, -17)));
|
|
let (dxa, dya) = handle_a.join().unwrap();
|
|
let (dxb, dyb) = handle_b.join().unwrap();
|
|
assert_eq!(dxa, dxb, "dx must be identical across threads");
|
|
assert_eq!(dya, dyb, "dy must be identical across threads");
|
|
}
|
|
|
|
/// The warp varies with position (not a constant displacement).
|
|
#[test]
|
|
fn varies_with_position() {
|
|
let a = domain_warp(1, "body", (0, 0));
|
|
let b = domain_warp(1, "body", (1, 0));
|
|
let c = domain_warp(1, "body", (0, 1));
|
|
// Adjacent positions should produce different displacements.
|
|
assert!(a != b || a != c, "warp must vary with position");
|
|
}
|
|
|
|
/// The warp varies with body_id (different bodies get different warps).
|
|
#[test]
|
|
fn varies_with_body_id() {
|
|
let a = domain_warp(1, "BodyA", (50, 50));
|
|
let b = domain_warp(1, "BodyB", (50, 50));
|
|
assert_ne!(a, b, "warp must vary with body_id");
|
|
}
|
|
|
|
/// The warp varies with seed (different world seeds produce different warps).
|
|
#[test]
|
|
fn varies_with_seed() {
|
|
let a = domain_warp(1, "BodyA", (50, 50));
|
|
let b = domain_warp(2, "BodyA", (50, 50));
|
|
assert_ne!(a, b, "warp must vary with seed");
|
|
}
|
|
|
|
/// Displacement is always within ±8 m bound.
|
|
#[test]
|
|
fn bounds_respected() {
|
|
for seed in [0u64, 1, 42, u64::MAX] {
|
|
for body in ["GJ1c", "test", "Velen", "Kallast"] {
|
|
for x in [-1000i32, -1, 0, 1, 1000] {
|
|
for y in [-1000i32, -1, 0, 1, 1000] {
|
|
let (dx, dy) = domain_warp(seed, body, (x, y));
|
|
assert!(
|
|
dx.abs() <= WARP_BOUND,
|
|
"dx={dx} out of ±{WARP_BOUND} for ({x},{y})"
|
|
);
|
|
assert!(
|
|
dy.abs() <= WARP_BOUND,
|
|
"dy={dy} out of ±{WARP_BOUND} for ({x},{y})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `fnv1a_64` is the canonical body_id hasher used in SeedChain::for_body.
|
|
/// Verify it's available here (compile-time sanity check).
|
|
#[test]
|
|
fn fnv1a_is_accessible() {
|
|
let h = fnv1a_64("GJ1c");
|
|
assert_ne!(h, 0);
|
|
}
|
|
}
|