diff --git a/Cargo.lock b/Cargo.lock index 784778e..aa0e4ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + [[package]] name = "js-sys" version = "0.3.81" @@ -93,6 +99,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "minicov" version = "0.3.7" @@ -137,6 +149,9 @@ version = "0.1.0" dependencies = [ "blake3", "bytes", + "once_cell", + "serde", + "serde_json", "thiserror", ] @@ -164,6 +179,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "same-file" version = "1.0.6" @@ -173,6 +194,49 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/crates/rmg-core/Cargo.toml b/crates/rmg-core/Cargo.toml index 459555a..b524894 100644 --- a/crates/rmg-core/Cargo.toml +++ b/crates/rmg-core/Cargo.toml @@ -7,3 +7,8 @@ edition = "2024" blake3 = "1" bytes = "1" thiserror = "1" + +[dev-dependencies] +once_cell = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/rmg-core/src/lib.rs b/crates/rmg-core/src/lib.rs index a793dad..0c3d745 100644 --- a/crates/rmg-core/src/lib.rs +++ b/crates/rmg-core/src/lib.rs @@ -11,6 +11,8 @@ use blake3::Hasher; use bytes::Bytes; use thiserror::Error; +pub mod math; + const POSITION_VELOCITY_BYTES: usize = 24; /// Public identifier for the built-in motion update rule. pub const MOTION_RULE_NAME: &str = "motion/update"; diff --git a/crates/rmg-core/src/math.rs b/crates/rmg-core/src/math.rs new file mode 100644 index 0000000..60d8c16 --- /dev/null +++ b/crates/rmg-core/src/math.rs @@ -0,0 +1,468 @@ +//! Deterministic math helpers covering scalar utilities, linear algebra +//! primitives, quaternions, and a timeline-friendly PRNG. +//! +//! The API intentionally rounds everything to `f32` to mirror the engine's +//! float32 mode and keep behaviour identical across environments. + +use std::f32::consts::TAU; + +const EPSILON: f32 = 1e-6; + +/// Clamps `value` to the inclusive `[min, max]` range using float32 rounding. +pub fn clamp(value: f32, min: f32, max: f32) -> f32 { + assert!(min <= max, "invalid clamp range: {min} > {max}"); + value.max(min).min(max) +} + +/// Converts degrees to radians with float32 precision. +pub fn deg_to_rad(value: f32) -> f32 { + value * (TAU / 360.0) +} + +/// Converts radians to degrees with float32 precision. +pub fn rad_to_deg(value: f32) -> f32 { + value * (360.0 / TAU) +} + +/// Deterministic 3D vector used throughout the engine. +/// +/// * Components encode world-space metres and may represent either points or +/// directions depending on the calling context. +/// * Arithmetic clamps to `f32` so results match the runtime’s float32 mode. +/// * Use [`Mat4::transform_point`] for points (homogeneous `w = 1`) and +/// [`Mat4::transform_direction`] for directions (homogeneous `w = 0`). +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Vec3 { + data: [f32; 3], +} + +impl Vec3 { + /// Creates a vector from components. + /// + /// Inputs are interpreted as metres in world coordinates; callers must + /// ensure values are finite. + pub const fn new(x: f32, y: f32, z: f32) -> Self { + Self { data: [x, y, z] } + } + + /// Returns the components as an array. + pub fn to_array(self) -> [f32; 3] { + self.data + } + + fn component(&self, idx: usize) -> f32 { + self.data[idx] + } + + /// Adds two vectors. + pub fn add(&self, other: &Self) -> Self { + Self::new( + self.component(0) + other.component(0), + self.component(1) + other.component(1), + self.component(2) + other.component(2), + ) + } + + /// Subtracts another vector. + pub fn sub(&self, other: &Self) -> Self { + Self::new( + self.component(0) - other.component(0), + self.component(1) - other.component(1), + self.component(2) - other.component(2), + ) + } + + /// Scales the vector by a scalar. + pub fn scale(&self, scalar: f32) -> Self { + Self::new( + self.component(0) * scalar, + self.component(1) * scalar, + self.component(2) * scalar, + ) + } + + /// Dot product with another vector. + pub fn dot(&self, other: &Self) -> f32 { + self.component(0) * other.component(0) + + self.component(1) * other.component(1) + + self.component(2) * other.component(2) + } + + /// Cross product with another vector. + pub fn cross(&self, other: &Self) -> Self { + let ax = self.component(0); + let ay = self.component(1); + let az = self.component(2); + let bx = other.component(0); + let by = other.component(1); + let bz = other.component(2); + Self::new(ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx) + } + + /// Vector length (magnitude). + pub fn length(&self) -> f32 { + self.dot(self).sqrt() + } + + /// Squared magnitude of the vector. + pub fn length_squared(&self) -> f32 { + self.dot(self) + } + + /// Normalises the vector, returning zero vector if length is ~0. + /// + /// Zero-length inputs remain the zero vector so downstream callers can + /// detect degenerate directions deterministically. + pub fn normalize(&self) -> Self { + let len = self.length(); + if len <= EPSILON { + return Self::new(0.0, 0.0, 0.0); + } + self.scale(1.0 / len) + } +} + +impl From<[f32; 3]> for Vec3 { + fn from(value: [f32; 3]) -> Self { + Self { data: value } + } +} + +/// Column-major 4×4 matrix matching Echo’s deterministic math layout. +/// +/// * Stored in column-major order to align with GPU uploads and ECS storage. +/// * Represents affine transforms; perspective terms are preserved but the +/// helpers in this module treat them homogeneously (`w = 1` for points). +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Mat4 { + data: [f32; 16], +} + +impl Mat4 { + /// Creates a matrix from column-major array data. + /// + /// Callers must supply 16 finite values already laid out column-major. + pub const fn new(data: [f32; 16]) -> Self { + Self { data } + } + + /// Returns the matrix as a column-major array. + pub fn to_array(self) -> [f32; 16] { + self.data + } + + fn at(&self, row: usize, col: usize) -> f32 { + self.data[col * 4 + row] + } + + /// Multiplies the matrix with another matrix (self * rhs). + /// + /// Multiplication follows column-major semantics (`self` on the left, + /// `rhs` on the right) to mirror GPU-style transforms. + pub fn multiply(&self, rhs: &Self) -> Self { + let mut out = [0.0; 16]; + for row in 0..4 { + for col in 0..4 { + let mut sum = 0.0; + for k in 0..4 { + sum += self.at(row, k) * rhs.at(k, col); + } + out[col * 4 + row] = sum; + } + } + Self::new(out) + } + + /// Transforms a point (assumes `w = 1`, no perspective divide). + /// + /// Translation components are applied and the resulting vector is returned + /// with `w` implicitly equal to `1`. + pub fn transform_point(&self, point: &Vec3) -> Vec3 { + let x = point.component(0); + let y = point.component(1); + let z = point.component(2); + let w = 1.0; + + let nx = self.at(0, 0) * x + self.at(0, 1) * y + self.at(0, 2) * z + self.at(0, 3) * w; + let ny = self.at(1, 0) * x + self.at(1, 1) * y + self.at(1, 2) * z + self.at(1, 3) * w; + let nz = self.at(2, 0) * x + self.at(2, 1) * y + self.at(2, 2) * z + self.at(2, 3) * w; + + Vec3::new(nx, ny, nz) + } + + /// Transforms a direction vector (ignores translation, `w = 0`). + /// + /// Only the rotational and scaling parts of the matrix affect the result. + pub fn transform_direction(&self, direction: &Vec3) -> Vec3 { + let x = direction.component(0); + let y = direction.component(1); + let z = direction.component(2); + + let nx = self.at(0, 0) * x + self.at(0, 1) * y + self.at(0, 2) * z; + let ny = self.at(1, 0) * x + self.at(1, 1) * y + self.at(1, 2) * z; + let nz = self.at(2, 0) * x + self.at(2, 1) * y + self.at(2, 2) * z; + + Vec3::new(nx, ny, nz) + } +} + +impl From<[f32; 16]> for Mat4 { + fn from(value: [f32; 16]) -> Self { + Self { data: value } + } +} + +/// Quaternion stored as `(x, y, z, w)` with deterministic float32 rounding. +/// +/// * All angles are expressed in radians. +/// * Normalisation clamps to `f32` to match runtime behaviour. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Quat { + data: [f32; 4], +} + +impl Quat { + /// Creates a quaternion from components. + /// + /// Callers should provide finite components; use + /// [`Quat::from_axis_angle`] for axis/angle construction. + pub const fn new(x: f32, y: f32, z: f32, w: f32) -> Self { + Self { data: [x, y, z, w] } + } + + /// Returns the quaternion as an array. + pub fn to_array(self) -> [f32; 4] { + self.data + } + + fn component(&self, idx: usize) -> f32 { + self.data[idx] + } + + /// Constructs a quaternion from a rotation axis and angle in radians. + /// + /// Returns the identity quaternion when the axis has zero length to avoid + /// undefined orientations and preserve deterministic behaviour. + pub fn from_axis_angle(axis: Vec3, angle: f32) -> Self { + let len_sq = axis.length_squared(); + if len_sq <= EPSILON * EPSILON { + return Self::identity(); + } + let len = len_sq.sqrt(); + let norm_axis = axis.scale(1.0 / len); + let half = angle * 0.5; + let (sin_half, cos_half) = half.sin_cos(); + let scaled = norm_axis.scale(sin_half); + Self::new( + scaled.component(0), + scaled.component(1), + scaled.component(2), + cos_half, + ) + } + + /// Multiplies two quaternions (self * other). + pub fn multiply(&self, other: &Self) -> Self { + let ax = self.component(0); + let ay = self.component(1); + let az = self.component(2); + let aw = self.component(3); + + let bx = other.component(0); + let by = other.component(1); + let bz = other.component(2); + let bw = other.component(3); + + Self::new( + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ) + } + + /// Normalises the quaternion; returns identity when norm is ~0. + pub fn normalize(&self) -> Self { + let len = (self.component(0) * self.component(0) + + self.component(1) * self.component(1) + + self.component(2) * self.component(2) + + self.component(3) * self.component(3)) + .sqrt(); + if len.abs() <= EPSILON { + return Self::identity(); + } + let inv = 1.0 / len; + Self::new( + self.component(0) * inv, + self.component(1) * inv, + self.component(2) * inv, + self.component(3) * inv, + ) + } + + /// Returns the identity quaternion. + pub const fn identity() -> Self { + Self::new(0.0, 0.0, 0.0, 1.0) + } + + /// Converts the quaternion to a rotation matrix (column-major 4x4). + pub fn to_mat4(&self) -> Mat4 { + let q = self.normalize(); + let x = q.component(0); + let y = q.component(1); + let z = q.component(2); + let w = q.component(3); + + let xx = x * x; + let yy = y * y; + let zz = z * z; + let xy = x * y; + let xz = x * z; + let yz = y * z; + let wx = w * x; + let wy = w * y; + let wz = w * z; + + Mat4::new([ + 1.0 - 2.0 * (yy + zz), + 2.0 * (xy + wz), + 2.0 * (xz - wy), + 0.0, + 2.0 * (xy - wz), + 1.0 - 2.0 * (xx + zz), + 2.0 * (yz + wx), + 0.0, + 2.0 * (xz + wy), + 2.0 * (yz - wx), + 1.0 - 2.0 * (xx + yy), + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ]) + } +} + +impl From<[f32; 4]> for Quat { + fn from(value: [f32; 4]) -> Self { + Self { data: value } + } +} + +/// Stateful `xoroshiro128+` pseudo-random number generator for deterministic timelines. +/// +/// * Not cryptographically secure; use only for gameplay/state simulation. +/// * Seeding controls reproducibility within a single process/run and matching +/// seeds yield identical sequences across supported platforms. +#[derive(Debug, Clone, Copy)] +pub struct Prng { + state: [u64; 2], +} + +impl Prng { + /// Constructs a PRNG from two 64-bit seeds. + /// + /// Identical seeds produce identical sequences; the generator remains + /// deterministic as long as each process consumes random numbers in the + /// same order. + pub fn from_seed(seed0: u64, seed1: u64) -> Self { + let mut state = [seed0, seed1]; + if state[0] == 0 && state[1] == 0 { + state[0] = 0x9e3779b97f4a7c15; + } + Self { state } + } + + fn next_u64(&mut self) -> u64 { + let s0 = self.state[0]; + let mut s1 = self.state[1]; + let result = s0.wrapping_add(s1); + + s1 ^= s0; + self.state[0] = s0.rotate_left(55) ^ s1 ^ (s1 << 14); + self.state[1] = s1.rotate_left(36); + + result + } + + /// Returns the next float in `[0, 1)`. + /// + /// Uses the high 23 bits of the xoroshiro128+ state to fill the mantissa, + /// ensuring uniform float32 sampling without relying on platform RNGs. + pub fn next_f32(&mut self) -> f32 { + let raw = self.next_u64(); + let bits = ((raw >> 41) as u32) | 0x3f80_0000; + f32::from_bits(bits) - 1.0 + } + + /// Returns the next integer in the inclusive range `[min, max]`. + /// + /// Uses rejection sampling to avoid modulo bias, ensuring every value in + /// the range is produced with equal probability. + pub fn next_int(&mut self, min: i32, max: i32) -> i32 { + assert!(min <= max, "invalid range: {min}..={max}"); + let span = (i64::from(max) - i64::from(min)) as u64 + 1; + if span == 1 { + return min; + } + + let value = if span.is_power_of_two() { + self.next_u64() & (span - 1) + } else { + let bound = u64::MAX - u64::MAX % span; + loop { + let candidate = self.next_u64(); + if candidate < bound { + break candidate % span; + } + } + }; + + let offset = value as i64 + i64::from(min); + offset as i32 + } + + /// Constructs a PRNG from a single 64-bit seed via SplitMix64 expansion. + pub fn from_seed_u64(seed: u64) -> Self { + fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + + let mut sm_state = seed; + let mut state = [splitmix64(&mut sm_state), splitmix64(&mut sm_state)]; + if state[0] == 0 && state[1] == 0 { + state[0] = 0x9e37_79b9_7f4a_7c15; + } + Self { state } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_int_returns_single_value_for_equal_bounds() { + let mut prng = Prng::from_seed(42, 99); + assert_eq!(prng.next_int(7, 7), 7); + } + + #[test] + fn next_int_handles_full_i32_range() { + let mut prng = Prng::from_seed(0xDEADBEEF, 0xFACEFEED); + let values: Vec = (0..3).map(|_| prng.next_int(i32::MIN, i32::MAX)).collect(); + assert_eq!(values, vec![1501347292, 1946982111, -117316573]); + } + + #[test] + fn next_int_handles_negative_ranges() { + let mut prng = Prng::from_seed(123, 456); + let values: Vec = (0..3).map(|_| prng.next_int(-10, -3)).collect(); + assert_eq!(values, vec![-7, -7, -7]); + } +} diff --git a/crates/rmg-core/tests/fixtures/math-fixtures.json b/crates/rmg-core/tests/fixtures/math-fixtures.json new file mode 100644 index 0000000..ab1c051 --- /dev/null +++ b/crates/rmg-core/tests/fixtures/math-fixtures.json @@ -0,0 +1,129 @@ +{ + "tolerance": { + "absolute": 1e-6, + "relative": 1e-6 + }, + "scalars": { + "clamp": [ + {"value": -2.75, "min": -1.0, "max": 1.0, "expected": -1.0}, + {"value": 0.125, "min": -1.0, "max": 1.0, "expected": 0.125}, + {"value": 42.0, "min": -10.0, "max": 10.0, "expected": 10.0} + ], + "deg_to_rad": [ + {"value": 0.0, "expected": 0.0}, + {"value": 90.0, "expected": 1.5707964}, + {"value": 180.0, "expected": 3.1415927} + ], + "rad_to_deg": [ + {"value": 0.0, "expected": 0.0}, + {"value": 3.1415927, "expected": 180.0}, + {"value": -1.5707964, "expected": -90.0} + ] + }, + "vec3": { + "add": [ + {"a": [1.0, 2.0, 3.0], "b": [4.5, -1.0, 0.25], "expected": [5.5, 1.0, 3.25]}, + {"a": [-2.0, 0.0, 0.5], "b": [1.0, 3.0, -0.25], "expected": [-1.0, 3.0, 0.25]} + ], + "dot": [ + {"a": [1.0, 0.0, 0.0], "b": [0.0, 1.0, 0.0], "expected": 0.0}, + {"a": [3.0, -2.0, 5.0], "b": [4.0, 1.0, -2.0], "expected": 0.0} + ], + "cross": [ + {"a": [1.0, 0.0, 0.0], "b": [0.0, 1.0, 0.0], "expected": [0.0, 0.0, 1.0]}, + {"a": [2.0, 3.0, 4.0], "b": [-1.0, 5.0, 2.0], "expected": [-14.0, -8.0, 13.0]} + ], + "length": [ + {"value": [3.0, 4.0, 0.0], "expected": 5.0}, + {"value": [2.0, 3.0, 6.0], "expected": 7.0} + ], + "normalize": [ + {"value": [3.0, 0.0, 4.0], "expected": [0.6, 0.0, 0.8]}, + {"value": [1.0, 2.0, 2.0], "expected": [0.33333334, 0.6666667, 0.6666667]}, + {"value": [0.0, 0.0, 0.0], "expected": [0.0, 0.0, 0.0]} + ] + }, + "mat4": { + "multiply": [ + { + "a": [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.5, 0.0, 1.0 + ], + "b": [ + 0.8660254, 0.0, 0.5, 0.0, + 0.0, 1.0, 0.0, 0.0, + -0.5, 0.0, 0.8660254, 0.0, + 1.0, 2.0, 3.0, 1.0 + ], + "expected": [ + 0.8660254, 0.0, 0.5, 0.0, + 0.0, 1.0, 0.0, 0.0, + -0.5, 0.0, 0.8660254, 0.0, + 1.0, 2.5, 3.0, 1.0 + ] + } + ], + "transform_point": [ + { + "matrix": [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, -1.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 5.0, -3.0, 2.0, 1.0 + ], + "vector": [2.0, 4.0, -1.0], + "expected": [7.0, -4.0, -2.0] + } + ], + "transform_direction": [ + { + "matrix": [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, -1.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 5.0, -3.0, 2.0, 1.0 + ], + "vector": [2.0, 4.0, -1.0], + "expected": [2.0, -1.0, -4.0] + } + ] + }, + "quat": { + "from_axis_angle": [ + {"axis": [0.0, 1.0, 0.0], "angle": 1.5707964, "expected": [0.0, 0.70710677, 0.0, 0.70710677]}, + {"axis": [1.0, 0.0, 0.0], "angle": 3.1415927, "expected": [1.0, 0.0, 0.0, 6.123234e-17]}, + {"axis": [0.0, 0.0, 0.0], "angle": 2.0943952, "expected": [0.0, 0.0, 0.0, 1.0]} + ], + "multiply": [ + { + "a": [0.0, 0.70710677, 0.0, 0.70710677], + "b": [0.0, 0.0, 0.70710677, 0.70710677], + "expected": [0.5, 0.5, 0.5, 0.5] + } + ], + "normalize": [ + {"value": [0.0, 2.0, 0.0, 0.0], "expected": [0.0, 1.0, 0.0, 0.0]} + ], + "to_mat4": [ + { + "value": [0.0, 0.70710677, 0.0, 0.70710677], + "expected": [ + 0.0, 0.0, -1.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ] + } + ] + }, + "prng": [ + { + "seed": [173476091849832, 91255900676441], + "expected_next": [0.000014305115, 0.49138594, 0.75950694], + "expected_ints": {"min": 5, "max": 10, "values": [10, 10, 6]} + } + ] +} diff --git a/crates/rmg-core/tests/math_validation.rs b/crates/rmg-core/tests/math_validation.rs new file mode 100644 index 0000000..a762d83 --- /dev/null +++ b/crates/rmg-core/tests/math_validation.rs @@ -0,0 +1,495 @@ +//! Deterministic math validation harness for the motion rewrite spike. +//! +//! Ensures scalar, vector, matrix, quaternion, and PRNG behaviour stays +//! consistent with the documented fixtures across platforms. + +use once_cell::sync::Lazy; +use serde::Deserialize; + +use rmg_core::math::{self, Mat4, Prng, Quat, Vec3}; + +const FIXTURE_PATH: &str = "crates/rmg-core/tests/fixtures/math-fixtures.json"; +static RAW_FIXTURES: &str = include_str!("fixtures/math-fixtures.json"); + +static FIXTURES: Lazy = Lazy::new(|| { + let fixtures: MathFixtures = serde_json::from_str(RAW_FIXTURES) + .unwrap_or_else(|err| panic!("failed to parse math fixtures at {FIXTURE_PATH}: {err}")); + fixtures.validate(); + fixtures +}); + +#[derive(Debug, Deserialize)] +struct MathFixtures { + #[serde(default)] + tolerance: Tolerance, + scalars: ScalarFixtures, + vec3: Vec3Fixtures, + mat4: Mat4Fixtures, + quat: QuatFixtures, + prng: Vec, +} + +impl MathFixtures { + fn validate(&self) { + fn ensure(name: &str, slice: &[T]) { + if slice.is_empty() { + panic!("math fixtures set '{name}' must not be empty"); + } + } + + ensure("scalars.clamp", &self.scalars.clamp); + ensure("scalars.deg_to_rad", &self.scalars.deg_to_rad); + ensure("scalars.rad_to_deg", &self.scalars.rad_to_deg); + ensure("vec3.add", &self.vec3.add); + ensure("vec3.dot", &self.vec3.dot); + ensure("vec3.cross", &self.vec3.cross); + ensure("vec3.length", &self.vec3.length); + ensure("vec3.normalize", &self.vec3.normalize); + ensure("mat4.multiply", &self.mat4.multiply); + ensure("mat4.transform_point", &self.mat4.transform_point); + ensure("mat4.transform_direction", &self.mat4.transform_direction); + ensure("quat.from_axis_angle", &self.quat.from_axis_angle); + ensure("quat.multiply", &self.quat.multiply); + ensure("quat.normalize", &self.quat.normalize); + ensure("quat.to_mat4", &self.quat.to_mat4); + ensure("prng", &self.prng); + } +} + +#[derive(Debug, Clone, Deserialize)] +struct Tolerance { + #[serde(default = "Tolerance::default_absolute")] + absolute: f32, + #[serde(default = "Tolerance::default_relative")] + relative: f32, +} + +impl Tolerance { + const fn default_absolute() -> f32 { + 1e-6 + } + + const fn default_relative() -> f32 { + 1e-6 + } + + fn allowed_error(&self, reference: f32) -> f32 { + self.absolute.max(self.relative * reference.abs()) + } +} + +impl Default for Tolerance { + fn default() -> Self { + Self { + absolute: Self::default_absolute(), + relative: Self::default_relative(), + } + } +} + +#[derive(Debug, Deserialize)] +struct ScalarFixtures { + clamp: Vec, + deg_to_rad: Vec, + rad_to_deg: Vec, +} + +#[derive(Debug, Deserialize)] +struct ClampFixture { + value: f32, + min: f32, + max: f32, + expected: f32, +} + +#[derive(Debug, Deserialize)] +struct UnaryFixture { + value: f32, + expected: f32, +} + +#[derive(Debug, Deserialize)] +struct Vec3Fixtures { + add: Vec, + dot: Vec, + cross: Vec, + length: Vec, + normalize: Vec, +} + +#[derive(Debug, Deserialize)] +struct Vec3BinaryFixture { + a: [f32; 3], + b: [f32; 3], + expected: [f32; 3], +} + +#[derive(Debug, Deserialize)] +struct Vec3DotFixture { + a: [f32; 3], + b: [f32; 3], + expected: f32, +} + +#[derive(Debug, Deserialize)] +struct Vec3UnaryFixture { + value: [f32; 3], + expected: FixtureValue, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum FixtureValue { + Scalar(f32), + Vector([f32; 3]), +} + +#[derive(Debug, Deserialize)] +struct Mat4Fixtures { + multiply: Vec, + #[serde(rename = "transform_point")] + transform_point: Vec, + #[serde(rename = "transform_direction")] + transform_direction: Vec, +} + +#[derive(Debug, Deserialize)] +struct Mat4BinaryFixture { + a: [f32; 16], + b: [f32; 16], + expected: [f32; 16], +} + +#[derive(Debug, Deserialize)] +struct Mat4Vec3Fixture { + matrix: [f32; 16], + vector: [f32; 3], + expected: [f32; 3], +} + +#[derive(Debug, Deserialize)] +struct QuatFixtures { + from_axis_angle: Vec, + multiply: Vec, + normalize: Vec, + to_mat4: Vec, +} + +#[derive(Debug, Deserialize)] +struct QuatAxisAngleFixture { + axis: [f32; 3], + angle: f32, + expected: [f32; 4], +} + +#[derive(Debug, Deserialize)] +struct QuatBinaryFixture { + a: [f32; 4], + b: [f32; 4], + expected: [f32; 4], +} + +#[derive(Debug, Deserialize)] +struct QuatUnaryFixture { + value: [f32; 4], + expected: [f32; 4], +} + +#[derive(Debug, Deserialize)] +struct QuatMat4Fixture { + value: [f32; 4], + expected: [f32; 16], +} + +#[derive(Debug, Deserialize)] +struct PrngFixture { + seed: [u64; 2], + expected_next: Vec, + #[serde(default)] + expected_ints: Option, +} + +#[derive(Debug, Deserialize)] +struct PrngIntFixture { + min: i32, + max: i32, + values: Vec, +} + +fn assert_scalar(actual: f32, expected: f32, tol: &Tolerance, ctx: &str) { + let diff = (actual - expected).abs(); + let allowed = tol.allowed_error(expected); + assert!( + diff <= allowed, + "{ctx}: expected {expected}, got {actual} (diff {diff} > {allowed})" + ); +} + +fn assert_vec3(actual: Vec3, expected: [f32; 3], tol: &Tolerance, ctx: &str) { + let arr = actual.to_array(); + for (i, (a, e)) in arr.iter().zip(expected.iter()).enumerate() { + let diff = (a - e).abs(); + let allowed = tol.allowed_error(*e); + assert!( + diff <= allowed, + "{ctx}[{i}]: expected {e}, got {a} (diff {diff} > {allowed})" + ); + } +} + +fn assert_quat(actual: Quat, expected: [f32; 4], tol: &Tolerance, ctx: &str) { + let arr = actual.to_array(); + for (i, (a, e)) in arr.iter().zip(expected.iter()).enumerate() { + let diff = (a - e).abs(); + let allowed = tol.allowed_error(*e); + assert!( + diff <= allowed, + "{ctx}[{i}]: expected {e}, got {a} (diff {diff} > {allowed})" + ); + } +} + +fn assert_mat4(actual: Mat4, expected: [f32; 16], tol: &Tolerance, ctx: &str) { + let arr = actual.to_array(); + for (i, (a, e)) in arr.iter().zip(expected.iter()).enumerate() { + let diff = (a - e).abs(); + let allowed = tol.allowed_error(*e); + assert!( + diff <= allowed, + "{ctx}[{i}]: expected {e}, got {a} (diff {diff} > {allowed})" + ); + } +} + +#[test] +fn scalar_fixtures_all_match() { + let tol = &FIXTURES.tolerance; + for fix in &FIXTURES.scalars.clamp { + let actual = math::clamp(fix.value, fix.min, fix.max); + assert_scalar( + actual, + fix.expected, + tol, + &format!( + "scalars.clamp value={}, range=[{}, {}]", + fix.value, fix.min, fix.max + ), + ); + } + + for fix in &FIXTURES.scalars.deg_to_rad { + let actual = math::deg_to_rad(fix.value); + assert_scalar( + actual, + fix.expected, + tol, + &format!("scalars.deg_to_rad value={}", fix.value), + ); + } + + for fix in &FIXTURES.scalars.rad_to_deg { + let actual = math::rad_to_deg(fix.value); + assert_scalar( + actual, + fix.expected, + tol, + &format!("scalars.rad_to_deg value={}", fix.value), + ); + } +} + +#[test] +fn vec3_fixtures_cover_operations() { + let tol = &FIXTURES.tolerance; + for fix in &FIXTURES.vec3.add { + let a = Vec3::from(fix.a); + let b = Vec3::from(fix.b); + let actual = a.add(&b); + assert_vec3( + actual, + fix.expected, + tol, + &format!("vec3.add a={:?} b={:?}", fix.a, fix.b), + ); + } + + for fix in &FIXTURES.vec3.dot { + let a = Vec3::from(fix.a); + let b = Vec3::from(fix.b); + let actual = a.dot(&b); + assert_scalar( + actual, + fix.expected, + tol, + &format!("vec3.dot a={:?} b={:?}", fix.a, fix.b), + ); + } + + for fix in &FIXTURES.vec3.cross { + let a = Vec3::from(fix.a); + let b = Vec3::from(fix.b); + let actual = a.cross(&b); + assert_vec3( + actual, + fix.expected, + tol, + &format!("vec3.cross a={:?} b={:?}", fix.a, fix.b), + ); + } + + for (idx, fix) in FIXTURES.vec3.length.iter().enumerate() { + let value = Vec3::from(fix.value); + let actual = value.length(); + match &fix.expected { + FixtureValue::Scalar(exp) => assert_scalar( + actual, + *exp, + tol, + &format!("vec3.length#[{idx}] value={:?}", fix.value), + ), + FixtureValue::Vector(v) => panic!( + "vec3.length fixture #[{idx}] (value={:?}) expected scalar but got vector {:?}", + fix.value, v + ), + } + } + + for (idx, fix) in FIXTURES.vec3.normalize.iter().enumerate() { + let value = Vec3::from(fix.value); + let actual = value.normalize(); + match &fix.expected { + FixtureValue::Scalar(s) => panic!( + "vec3.normalize fixture #[{idx}] (value={:?}) expected vector but got scalar {}", + fix.value, s + ), + FixtureValue::Vector(exp) => assert_vec3( + actual, + *exp, + tol, + &format!("vec3.normalize#[{idx}] value={:?}", fix.value), + ), + } + } +} + +#[test] +fn mat4_fixtures_validate_transformations() { + let tol = &FIXTURES.tolerance; + for (i, fix) in FIXTURES.mat4.multiply.iter().enumerate() { + let a = Mat4::from(fix.a); + let b = Mat4::from(fix.b); + let actual = a.multiply(&b); + let context = format!("mat4.multiply[{}] a0={:.3} b0={:.3}", i, fix.a[0], fix.b[0]); + assert_mat4(actual, fix.expected, tol, &context); + } + + for fix in &FIXTURES.mat4.transform_point { + let matrix = Mat4::from(fix.matrix); + let vector = Vec3::from(fix.vector); + // Fixture vectors are treated as points (homogeneous w = 1). + let actual = matrix.transform_point(&vector); + assert_vec3( + actual, + fix.expected, + tol, + &format!("mat4.transform_point vector={:?}", fix.vector), + ); + } + + for fix in &FIXTURES.mat4.transform_direction { + let matrix = Mat4::from(fix.matrix); + let vector = Vec3::from(fix.vector); + let actual = matrix.transform_direction(&vector); + assert_vec3( + actual, + fix.expected, + tol, + &format!("mat4.transform_direction vector={:?}", fix.vector), + ); + } +} + +#[test] +fn quat_fixtures_validate_operations() { + let tol = &FIXTURES.tolerance; + for fix in &FIXTURES.quat.from_axis_angle { + let axis = Vec3::from(fix.axis); + let actual = Quat::from_axis_angle(axis, fix.angle); + assert_quat( + actual, + fix.expected, + tol, + &format!( + "quat.from_axis_angle axis={:?} angle={}", + fix.axis, fix.angle + ), + ); + } + + for fix in &FIXTURES.quat.multiply { + let a = Quat::from(fix.a); + let b = Quat::from(fix.b); + let actual = a.multiply(&b); + assert_quat( + actual, + fix.expected, + tol, + &format!("quat.multiply a={:?} b={:?}", fix.a, fix.b), + ); + } + + for fix in &FIXTURES.quat.normalize { + let value = Quat::from(fix.value); + let actual = value.normalize(); + assert_quat( + actual, + fix.expected, + tol, + &format!("quat.normalize value={:?}", fix.value), + ); + } + + for fix in &FIXTURES.quat.to_mat4 { + let value = Quat::from(fix.value); + let actual = value.to_mat4(); + assert_mat4( + actual, + fix.expected, + tol, + &format!("quat.to_mat4 value={:?}", fix.value), + ); + } +} + +#[test] +fn prng_fixture_replays_sequence() { + for fix in &FIXTURES.prng { + let mut prng = Prng::from_seed(fix.seed[0], fix.seed[1]); + + let tol = &FIXTURES.tolerance; + + for (i, expected) in fix.expected_next.iter().enumerate() { + let actual = prng.next_f32(); + assert_scalar( + actual, + *expected, + tol, + &format!("prng.expected_next seed={:?} index={i}", fix.seed), + ); + } + + if let Some(int_fixture) = &fix.expected_ints { + let mut prng = Prng::from_seed(fix.seed[0], fix.seed[1]); + let actual: Vec = int_fixture + .values + .iter() + .map(|_| prng.next_int(int_fixture.min, int_fixture.max)) + .collect(); + assert_eq!( + actual, int_fixture.values, + "prng.expected_ints seed={:?} expected {:?} got {:?}", + fix.seed, int_fixture.values, actual + ); + } + } +} diff --git a/docs/decision-log.md b/docs/decision-log.md index 8b93c4d..cf6f00f 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -10,5 +10,6 @@ | 2025-10-25 | Serialization protocol | Canonical encoding using BLAKE3 | Cross-platform determinism | Replay tooling groundwork | | 2025-10-25 | Temporal bridge doc | Formalized retro delivery & paradox guard | Ensure cross-branch consistency | Entropy hooks refined | | 2025-10-25 | Replay plan | Golden hashes + CLI contract | Ensure reproducibility | Phase 1 test suite scope | +| 2025-10-25 | Math validation harness | Landed Rust fixture suite & tolerance checks for deterministic math | Keep scalar/vector/matrix/quaternion results stable across environments | Extend coverage to browser + fixed-point modes | | 2025-10-26 | EPI bundle | Adopt entropy, plugin, inspector, runtime config specs (Phase 0.75) | Close causality & extensibility gap | Phase 1 implementation backlog defined | | 2025-10-26 | RMG + Confluence | Adopt RMG v2 (typed DPOi engine) and Confluence synchronization as core architecture | Unify runtime/persistence/tooling on deterministic rewrites | Launch Rust workspace (rmg-core/ffi/wasm/cli), port ECS rules, set up Confluence networking | diff --git a/docs/execution-plan.md b/docs/execution-plan.md index e53ee7d..f0801d4 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -5,6 +5,7 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s --- ## Operating Rhythm + - **Before Starting** 1. Ensure `git status` is clean. If not, capture the state in `docs/decision-log.md` and wait for human guidance. 2. Skim the latest updates in this document and `docs/decision-log.md` to synchronize with the active timeline. @@ -31,10 +32,11 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s --- ## Today’s Intent + > Write the top priority for the current session and what “done” means. -- **Focus**: Plan math validation fixtures & tests implementation. -- **Definition of done**: Outline concrete tasks for generating fixtures, writing suites, and integrating cross-environment checks. +- **Focus**: Draft ECS storage implementation plan (archetype storage port to Rust). +- **Definition of done**: Identify storage milestones, required data structures, and sequencing for integration with branch diff engine. --- @@ -77,7 +79,7 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s ### Tooling & Docs - [ ] Build `docs/data-structures.md` with Mermaid diagrams (storage, branch tree with roaring bitmaps). - [ ] Extend `docs/diagrams.md` with scheduler flow & command queue animations. -- [ ] Document lightweight journaling workflow in `docs/decision-log.md` (include entry template + daily/weekly cadence guidance; owner: Documentation squad before Phase 1 kickoff). +- [ ] Publish decision-log quick reference (templates, cadence, examples; owner: Documentation squad before Phase 1 kickoff). - [ ] Design test fixture layout (`test/fixtures/…`) with sample component schemas. - [ ] Document roaring bitmap integration and merge strategies. - [ ] Update future inspector roadmap with conflict heatmaps and causality lens. @@ -91,6 +93,7 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s | 2025-10-23 | Monorepo seeded with pnpm & TypeScript skeleton | Baseline repo reset from Caverns to Echo | Implement Phase 0 specs | | 2025-10-24 | Branch tree spec v0.1: roaring bitmaps, chunk epochs, content-addressed IDs | Feedback loop to handle deterministic merges | Implement roaring bitmap integration | | 2025-10-25 | Language direction pivot: Echo core to Rust | TypeScript validated specs; long-term determinism enforced via Rust + C ABI + Lua scripting | Update Phase 1 backlog: scaffold Rust workspace, port ECS/diff engine, FFI bindings | +| 2025-10-25 | Math validation fixtures & Rust test harness | Established deterministic scalar/vector/matrix/quaternion/PRNG coverage in rmg-core | Extend coverage to browser environments and fixed-point mode | | 2025-10-26 | Adopt RMG + Confluence as core architecture | RMG v2 (typed DPOi engine) + Confluence replication baseline | Scaffold rmg-core/ffi/wasm/cli crates; implement rewrite executor spike; integrate Rust CI; migrate TS prototype to `/reference` | (Keep this table updated; include file references or commit hashes when useful.) @@ -98,18 +101,20 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s --- ## Next Up Queue -1. Math validation fixtures & tests implementation -2. ECS storage implementation plan -3. Branch tree BlockStore abstraction design -4. Temporal Bridge implementation plan -5. Serialization protocol review + +1. ECS storage implementation plan *(in progress)* +2. Branch tree BlockStore abstraction design +3. Temporal Bridge implementation plan +4. Serialization protocol review +5. Math validation cross-environment rollout Populate with concrete tasks in priority order. When you start one, move it to “Today’s Intent.” --- ## Notes to Future Codex -- Update this document (and `docs/decision-log.md`) for daily runtime updates. + +- Update this document and `docs/decision-log.md` for daily runtime updates. - Record test coverage gaps as they appear; they inform future backlog items. - Ensure roaring bitmap and hashing dependencies are deterministic across environments. - Inspector pins must be recorded to keep GC deterministic.