diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9e0424..d068d6ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,23 @@ jobs: - name: cargo clippy run: cargo clippy --all-targets -- -D warnings -D missing_docs + clippy-det-fixed: + name: Clippy (det_fixed) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + - name: cargo clippy (warp-core, det_fixed) + run: cargo clippy -p warp-core --all-targets --features det_fixed -- -D warnings -D missing_docs + test: name: Tests runs-on: ubuntu-latest @@ -80,6 +97,70 @@ jobs: - name: cargo test (warp-core, musl) run: cargo test -p warp-core --target x86_64-unknown-linux-musl + test-macos: + name: Tests (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + - name: cargo test (warp-core) + run: cargo test -p warp-core + + test-macos-det-fixed: + name: Tests (macOS, det_fixed) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + - name: cargo test (warp-core, det_fixed) + run: cargo test -p warp-core --features det_fixed + + test-det-fixed: + name: Tests (det_fixed) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + - name: cargo test (warp-core, det_fixed) + run: cargo test -p warp-core --features det_fixed + + test-musl-det-fixed: + name: Tests (musl, det_fixed) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + with: + targets: x86_64-unknown-linux-musl + - name: Install musl tools + run: sudo apt-get update && sudo apt-get install -y musl-tools + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + - name: cargo test (warp-core, musl, det_fixed) + run: cargo test -p warp-core --features det_fixed --target x86_64-unknown-linux-musl + docs: name: Docs Guard runs-on: ubuntu-latest @@ -159,6 +240,23 @@ jobs: set -euo pipefail bash scripts/tests/check_task_lists_test.sh + deterministic-math-guard: + name: Deterministic Math Guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Forbid raw trig in warp-core math + shell: bash + run: | + set -euo pipefail + script="scripts/check_no_raw_trig.sh" + if [[ ! -x "$script" ]]; then + echo "Error: $script is missing or not executable" >&2 + ls -la scripts || true + exit 1 + fi + "$script" + # MSRV job removed per policy: use @stable everywhere rustdoc: diff --git a/crates/warp-core/Cargo.toml b/crates/warp-core/Cargo.toml index f7bb78f2..485ed2de 100644 --- a/crates/warp-core/Cargo.toml +++ b/crates/warp-core/Cargo.toml @@ -34,5 +34,11 @@ default = [] golden_prng = [] telemetry = ["serde", "serde_json", "hex"] +# Scalar backend lanes (CI/test orchestration). +# - `det_float`: default float32-backed lane using `F32Scalar`. +# - `det_fixed`: fixed-point Q32.32 lane using `DFix64` (experimental, test-only). +det_float = [] +det_fixed = [] + [build-dependencies] blake3 = "1.0" diff --git a/crates/warp-core/src/bin/gen_sin_qtr_lut.rs b/crates/warp-core/src/bin/gen_sin_qtr_lut.rs new file mode 100644 index 00000000..594cf394 --- /dev/null +++ b/crates/warp-core/src/bin/gen_sin_qtr_lut.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS + +//! Developer utility: generate the quarter-wave `sin` LUT for deterministic trig. +//! +//! This binary is not used by the runtime. It exists to regenerate the checked-in +//! LUT in `crates/warp-core/src/math/trig_lut.rs`. +//! +//! Notes: +//! - The LUT is generated by sampling `sin` in `f64`, casting to `f32`, and +//! storing the resulting `f32::to_bits()` value. +//! - The checked-in table is the source of truth for determinism; this tool is +//! purely for maintenance and review. + +use std::f64::consts::FRAC_PI_2; + +fn main() { + // MUST match `warp_core::math::trig_lut::SIN_QTR_SEGMENTS` (currently 1024). + // + // Note: `SIN_QTR_SEGMENTS` is `pub(crate)` inside `warp-core`, so this tool + // cannot reference it directly without widening visibility. Keep this in + // lockstep with `crates/warp-core/src/math/trig_lut.rs`. + const SEGMENTS: usize = 1024; + const N: usize = SEGMENTS + 1; + let mut bits: [u32; N] = [0; N]; + + let denom = SEGMENTS as f64; + for (i, slot) in bits.iter_mut().enumerate() { + let a = (i as f64) * FRAC_PI_2 / denom; + let s = a.sin(); + let f = s as f32; + *slot = f.to_bits(); + } + + // Force exact endpoints to guarantee `sin(0) = 0.0` and `sin(PI/2) = 1.0`. + bits[0] = 0.0f32.to_bits(); + bits[SEGMENTS] = 1.0f32.to_bits(); + + println!("// Generated by `cargo run -p warp-core --bin gen_sin_qtr_lut`"); + println!("// SEGMENTS = {SEGMENTS}"); + println!("pub(crate) const SIN_QTR_LUT_BITS: [u32; SIN_QTR_SEGMENTS + 1] = ["); + + for chunk in bits.chunks(8) { + print!(" "); + for (idx, b) in chunk.iter().enumerate() { + if idx > 0 { + print!(", "); + } + print!("0x{b:08x}"); + } + println!(","); + } + + println!("];"); + println!("const _: [(); SIN_QTR_SEGMENTS + 1] = [(); SIN_QTR_LUT_BITS.len()];"); +} diff --git a/crates/warp-core/src/demo/motion.rs b/crates/warp-core/src/demo/motion.rs index 7e008e56..537e4786 100644 --- a/crates/warp-core/src/demo/motion.rs +++ b/crates/warp-core/src/demo/motion.rs @@ -8,7 +8,7 @@ use crate::footprint::{AttachmentSet, Footprint, IdSet, PortSet}; use crate::graph::GraphStore; use crate::ident::{make_node_id, make_type_id, Hash, NodeId}; use crate::payload::{ - decode_motion_atom_payload, decode_motion_payload, encode_motion_payload, + decode_motion_atom_payload, decode_motion_atom_payload_q32_32, encode_motion_payload_q32_32, motion_payload_type_id, }; use crate::record::NodeRecord; @@ -20,8 +20,12 @@ include!(concat!(env!("OUT_DIR"), "/rule_ids.rs")); /// /// Pass this name to [`Engine::apply`] to execute the motion update rule, /// which advances an entity's position by its velocity. Operates on nodes -/// whose payload is a valid 24-byte motion encoding (position + velocity as -/// 6 × f32 little-endian). +/// whose payload is a valid motion encoding. +/// +/// Canonical payload encoding is v2: +/// - 6 × `i64` Q32.32 little-endian (48 bytes). +/// - Legacy v0 decoding is supported for compatibility: +/// 6 × `f32` little-endian (24 bytes). /// /// Example usage (in tests): /// ```ignore @@ -33,6 +37,46 @@ include!(concat!(env!("OUT_DIR"), "/rule_ids.rs")); /// ``` pub const MOTION_RULE_NAME: &str = "motion/update"; +#[cfg(feature = "det_fixed")] +/// Scalar backend using deterministic fixed-point (`DFix64`) directly. +mod motion_scalar_backend { + use crate::math::scalar::DFix64; + + pub(super) type MotionScalar = DFix64; + + /// Converts a raw Q32.32 integer directly to `DFix64`. + pub(super) fn scalar_from_raw(raw: i64) -> MotionScalar { + MotionScalar::from_raw(raw) + } + + /// Extracts the raw Q32.32 integer representation from `DFix64`. + pub(super) fn scalar_to_raw(value: MotionScalar) -> i64 { + value.raw() + } +} + +#[cfg(not(feature = "det_fixed"))] +/// Scalar backend using `F32Scalar` with Q32.32 ↔ f32 conversion. +mod motion_scalar_backend { + use crate::math::fixed_q32_32; + use crate::math::scalar::F32Scalar; + use crate::math::Scalar; + + pub(super) type MotionScalar = F32Scalar; + + /// Converts raw Q32.32 to `f32`, then wraps it in `F32Scalar`. + pub(super) fn scalar_from_raw(raw: i64) -> MotionScalar { + MotionScalar::from_f32(fixed_q32_32::to_f32(raw)) + } + + /// Unwraps `F32Scalar` to `f32`, then deterministically quantizes to Q32.32. + pub(super) fn scalar_to_raw(value: MotionScalar) -> i64 { + fixed_q32_32::from_f32(value.to_f32()) + } +} + +use motion_scalar_backend::{scalar_from_raw, scalar_to_raw}; + fn motion_executor(store: &mut GraphStore, scope: &NodeId) { if store.node(scope).is_none() { return; @@ -40,15 +84,43 @@ fn motion_executor(store: &mut GraphStore, scope: &NodeId) { let Some(AttachmentValue::Atom(payload)) = store.node_attachment_mut(scope) else { return; }; - if payload.type_id != motion_payload_type_id() { + + // Supports both canonical v2 and legacy v0 payloads: + // - v2 decodes directly to Q32.32. + // - v0 deterministically quantizes f32 values to Q32.32 at the boundary. + let Some((pos_raw, vel_raw)) = decode_motion_atom_payload_q32_32(payload) else { return; + }; + + let mut pos = [ + scalar_from_raw(pos_raw[0]), + scalar_from_raw(pos_raw[1]), + scalar_from_raw(pos_raw[2]), + ]; + let vel = [ + scalar_from_raw(vel_raw[0]), + scalar_from_raw(vel_raw[1]), + scalar_from_raw(vel_raw[2]), + ]; + + for i in 0..3 { + pos[i] = pos[i] + vel[i]; } - if let Some((mut pos, vel)) = decode_motion_payload(&payload.bytes) { - pos[0] += vel[0]; - pos[1] += vel[1]; - pos[2] += vel[2]; - payload.bytes = encode_motion_payload(pos, vel); - } + + let new_pos_raw = [ + scalar_to_raw(pos[0]), + scalar_to_raw(pos[1]), + scalar_to_raw(pos[2]), + ]; + let vel_out_raw = [ + scalar_to_raw(vel[0]), + scalar_to_raw(vel[1]), + scalar_to_raw(vel[2]), + ]; + + // Always upgrade to the canonical v2 payload encoding on write. + payload.type_id = motion_payload_type_id(); + payload.bytes = encode_motion_payload_q32_32(new_pos_raw, vel_out_raw); } fn motion_matcher(store: &GraphStore, scope: &NodeId) -> bool { @@ -63,9 +135,9 @@ const MOTION_RULE_ID: Hash = MOTION_UPDATE_FAMILY_ID; /// Returns a rewrite rule that updates entity positions based on velocity. /// -/// This rule matches any node containing a valid 24-byte motion payload -/// (position + velocity encoded as 6 × f32 little-endian) and updates the -/// position by adding the velocity component-wise. +/// This rule matches any node containing a valid motion payload and updates +/// the position by adding the velocity component-wise under deterministic +/// scalar semantics. /// /// Register this rule with [`Engine::register_rule`], then apply it with /// [`Engine::apply`] using [`MOTION_RULE_NAME`]. @@ -138,6 +210,7 @@ pub fn build_motion_demo_engine() -> Engine { #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; #[test] fn motion_rule_id_matches_domain_separated_name() { @@ -182,10 +255,51 @@ mod tests { assert_eq!(new_pos[i].to_bits(), expected); } // Encoding round-trip should match re-encoding of updated values exactly. - let expected_bytes = encode_motion_payload(new_pos, new_vel); + assert_eq!(bytes.type_id, motion_payload_type_id()); + let expected_bytes = crate::encode_motion_payload(new_pos, new_vel); let Some(AttachmentValue::Atom(bytes)) = store.node_attachment(&ent) else { unreachable!("payload present after executor"); }; assert_eq!(bytes.bytes, expected_bytes); } + + fn encode_motion_payload_v0_bytes(position: [f32; 3], velocity: [f32; 3]) -> Bytes { + crate::payload::encode_motion_payload_v0(position, velocity) + } + + #[test] + fn motion_executor_accepts_v0_and_upgrades_to_v2() { + let mut store = GraphStore::default(); + let ent = make_node_id("entity-motion-v0-upgrade"); + let ty = make_type_id("entity"); + + let pos = [1.0, 2.0, 3.0]; + let vel = [0.5, -1.0, 0.25]; + let payload = crate::attachment::AtomPayload::new( + crate::payload::motion_payload_type_id_v0(), + encode_motion_payload_v0_bytes(pos, vel), + ); + + store.insert_node(ent, NodeRecord { ty }); + store.set_node_attachment(ent, Some(AttachmentValue::Atom(payload))); + + motion_executor(&mut store, &ent); + + let Some(AttachmentValue::Atom(payload)) = store.node_attachment(&ent) else { + unreachable!("payload present after executor"); + }; + assert_eq!( + payload.type_id, + motion_payload_type_id(), + "executor should upgrade to v2" + ); + let Some((new_pos, new_vel)) = decode_motion_atom_payload(payload) else { + unreachable!("payload decode"); + }; + for i in 0..3 { + assert_eq!(new_vel[i].to_bits(), vel[i].to_bits()); + let expected = (pos[i] + vel[i]).to_bits(); + assert_eq!(new_pos[i].to_bits(), expected); + } + } } diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index 4ce39c66..4758073f 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -89,7 +89,8 @@ pub use ident::{ /// Motion payload encoding/decoding helpers. pub use payload::{ decode_motion_atom_payload, decode_motion_payload, encode_motion_atom_payload, - encode_motion_payload, motion_payload_type_id, + encode_motion_atom_payload_v0, encode_motion_payload, encode_motion_payload_q32_32, + encode_motion_payload_v0, motion_payload_type_id, motion_payload_type_id_v0, }; /// Tick receipts for deterministic commits (accepted vs rejected rewrites). pub use receipt::{TickReceipt, TickReceiptDisposition, TickReceiptEntry, TickReceiptRejection}; diff --git a/crates/warp-core/src/math/fixed_q32_32.rs b/crates/warp-core/src/math/fixed_q32_32.rs new file mode 100644 index 00000000..d939146d --- /dev/null +++ b/crates/warp-core/src/math/fixed_q32_32.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Deterministic helpers for the Q32.32 fixed-point encoding used by Echo. +//! +//! These helpers are shared by: +//! - `DFix64` (feature-gated fixed-point scalar backend), and +//! - payload encodings that use fixed-point as a stable, cross-language wire format. +//! +//! The representation is an `i64` storing an integer scaled by `2^32`: +//! `real_value = raw / 2^32`. + +/// Number of fractional bits in the Q32.32 fixed-point encoding. +pub(crate) const FRAC_BITS: u32 = 32; + +/// The raw integer value corresponding to `1.0` in Q32.32. +#[cfg(feature = "det_fixed")] +pub(crate) const ONE_RAW: i64 = 1_i64 << FRAC_BITS; + +fn round_shift_right_u64(value: u64, shift: u32) -> u64 { + if shift == 0 { + return value; + } + if shift >= 64 { + return 0; + } + + let q = value >> shift; + let mask = (1_u64 << shift) - 1; + let r = value & mask; + let half = 1_u64 << (shift - 1); + + if r > half { + q + 1 + } else if r < half { + q + } else if (q & 1) == 1 { + q + 1 + } else { + q + } +} + +fn round_shift_right_u128(value: u128, shift: u32) -> u128 { + if shift == 0 { + return value; + } + if shift >= 128 { + return 0; + } + + let q = value >> shift; + let mask = (1_u128 << shift) - 1; + let r = value & mask; + let half = 1_u128 << (shift - 1); + + if r > half { + q + 1 + } else if r < half { + q + } else if (q & 1) == 1 { + q + 1 + } else { + q + } +} + +fn saturate_i128_to_i64(value: i128) -> i64 { + i64::try_from(value).unwrap_or_else(|_| { + if value.is_negative() { + i64::MIN + } else { + i64::MAX + } + }) +} + +/// Deterministically converts an `f32` to a Q32.32 raw `i64`. +/// +/// Semantics: +/// - `NaN` maps to `0` (fixed-point has no NaN representation). +/// - `+∞`/`-∞` saturate to `i64::MAX`/`i64::MIN`. +/// - Values are rounded to nearest with ties-to-even at the Q32.32 boundary. +pub(crate) fn from_f32(value: f32) -> i64 { + if value.is_nan() { + return 0; + } + if value == f32::INFINITY { + return i64::MAX; + } + if value == f32::NEG_INFINITY { + return i64::MIN; + } + + let bits = value.to_bits(); + let sign = (bits >> 31) != 0; + // Masking yields a value in 0..=255. + #[allow(clippy::cast_possible_truncation)] + let exp_u8 = ((bits >> 23) & 0xff) as u8; + let exp = i32::from(exp_u8); + let mant = bits & 0x7fffff; + + if exp == 0 && mant == 0 { + return 0; + } + + let mantissa: u64 = if exp == 0 { + // subnormal: exponent is fixed at -126, no implicit 1. + u64::from(mant) + } else { + // normal: implicit leading 1. + u64::from((1_u32 << 23) | mant) + }; + + // value = mantissa * 2^(exp - 127 - 23) + // scaled = value * 2^FRAC_BITS = mantissa * 2^(exp - 127 + (FRAC_BITS - 23)) + // For subnormals exp is treated as 1 - 127 = -126. + let unbiased = if exp == 0 { -126 } else { exp - 127 }; + #[allow(clippy::cast_possible_wrap)] + let frac_i32 = FRAC_BITS as i32; + let shift = unbiased + (frac_i32 - 23); + + // Produce the signed fixed-point raw value, saturating if needed. + let abs_raw: i128 = if shift >= 0 { + // `shift` is non-negative in this branch; unsigned_abs preserves the value. + let shift_u = shift.unsigned_abs(); + // mantissa is ~24 bits; shifting beyond 103 would exceed i128's range. + if shift_u > 103 { + i128::MAX + } else { + i128::from(mantissa) << shift_u + } + } else { + // Safe: shift is negative; `unsigned_abs` handles the i32::MIN case. + let rshift = shift.unsigned_abs(); + let rounded = round_shift_right_u64(mantissa, rshift); + i128::from(rounded) + }; + + let signed_raw = if sign { -abs_raw } else { abs_raw }; + saturate_i128_to_i64(signed_raw) +} + +/// Deterministically converts a Q32.32 raw `i64` to an `f32`. +/// +/// Rounds to nearest with ties-to-even at the `f32` boundary. +pub(crate) fn to_f32(raw: i64) -> f32 { + if raw == 0 { + return 0.0; + } + + let sign = raw.is_negative(); + let abs: u64 = raw.unsigned_abs(); + if abs == 0 { + // Canonicalize -0.0 to +0.0. + return 0.0; + } + + // raw is an integer scaled by 2^32. + // If raw's highest set bit is at position k, then: + // abs ∈ [2^k, 2^(k+1)) and value = abs * 2^-32 has exponent (k - 32). + let k = 63_u32.saturating_sub(abs.leading_zeros()); + #[allow(clippy::cast_possible_wrap)] + let frac_i32 = FRAC_BITS as i32; + #[allow(clippy::cast_possible_wrap)] + let mut exp = (k as i32) - frac_i32; + + // Build a 24-bit significand (including the implicit leading 1) with + // ties-to-even rounding, then drop the implicit bit into the mantissa field. + let mut sig: u128 = if k > 23 { + let rshift = k - 23; + round_shift_right_u128(u128::from(abs), rshift) + } else { + let lshift = 23 - k; + u128::from(abs) << lshift + }; + + // Handle rounding overflow (e.g., 1.111.. rounds up to 10.000..). + if sig >= (1_u128 << 24) { + sig >>= 1; + exp = exp.saturating_add(1); + } + + #[allow(clippy::cast_sign_loss)] + let exp_field = (exp + 127) as u32; + #[allow(clippy::cast_possible_truncation)] + let mantissa = (sig & ((1_u128 << 23) - 1)) as u32; + let bits = (u32::from(sign) << 31) | (exp_field << 23) | mantissa; + f32::from_bits(bits) +} diff --git a/crates/warp-core/src/math/mat4.rs b/crates/warp-core/src/math/mat4.rs index a4c3c164..675feedc 100644 --- a/crates/warp-core/src/math/mat4.rs +++ b/crates/warp-core/src/math/mat4.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // © James Ross Ω FLYING•ROBOTS +use crate::math::trig; use crate::math::{Quat, Vec3}; /// Column-major 4×4 matrix matching Echo’s deterministic math layout. @@ -64,10 +65,8 @@ impl Mat4 { /// looking down the +X axis toward the origin. See /// [`Mat4::rotation_from_euler`] for the full convention. pub fn rotation_x(angle: f32) -> Self { - let (s_raw, c_raw) = angle.sin_cos(); - let s = if s_raw == 0.0 { 0.0 } else { s_raw }; - let c = if c_raw == 0.0 { 0.0 } else { c_raw }; - let ns = if s == 0.0 { 0.0 } else { -s }; + let (s, c) = trig::sin_cos_f32(angle); + let ns = trig::canonicalize_zero(-s); Self::new([ 1.0, 0.0, 0.0, 0.0, 0.0, c, s, 0.0, 0.0, ns, c, 0.0, 0.0, 0.0, 0.0, 1.0, ]) @@ -79,10 +78,8 @@ impl Mat4 { /// looking down the +Y axis toward the origin. See /// [`Mat4::rotation_from_euler`] for the full convention. pub fn rotation_y(angle: f32) -> Self { - let (s_raw, c_raw) = angle.sin_cos(); - let s = if s_raw == 0.0 { 0.0 } else { s_raw }; - let c = if c_raw == 0.0 { 0.0 } else { c_raw }; - let ns = if s == 0.0 { 0.0 } else { -s }; + let (s, c) = trig::sin_cos_f32(angle); + let ns = trig::canonicalize_zero(-s); Self::new([ c, 0.0, ns, 0.0, 0.0, 1.0, 0.0, 0.0, s, 0.0, c, 0.0, 0.0, 0.0, 0.0, 1.0, ]) @@ -94,10 +91,8 @@ impl Mat4 { /// looking down the +Z axis toward the origin. See /// [`Mat4::rotation_from_euler`] for the full convention. pub fn rotation_z(angle: f32) -> Self { - let (s_raw, c_raw) = angle.sin_cos(); - let s = if s_raw == 0.0 { 0.0 } else { s_raw }; - let c = if c_raw == 0.0 { 0.0 } else { c_raw }; - let ns = if s == 0.0 { 0.0 } else { -s }; + let (s, c) = trig::sin_cos_f32(angle); + let ns = trig::canonicalize_zero(-s); Self::new([ c, s, 0.0, 0.0, ns, c, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ]) diff --git a/crates/warp-core/src/math/mod.rs b/crates/warp-core/src/math/mod.rs index 50400d40..14b3c599 100644 --- a/crates/warp-core/src/math/mod.rs +++ b/crates/warp-core/src/math/mod.rs @@ -8,10 +8,14 @@ use std::f32::consts::TAU; +/// Deterministic Q32.32 conversion helpers used by fixed-point lanes and payload codecs. +pub(crate) mod fixed_q32_32; mod mat4; mod prng; mod quat; pub mod scalar; +mod trig; +mod trig_lut; mod vec3; #[doc(inline)] diff --git a/crates/warp-core/src/math/quat.rs b/crates/warp-core/src/math/quat.rs index 8d3d96d5..1e791e80 100644 --- a/crates/warp-core/src/math/quat.rs +++ b/crates/warp-core/src/math/quat.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // © James Ross Ω FLYING•ROBOTS -use crate::math::{Mat4, Vec3, EPSILON}; +use crate::math::{trig, Mat4, Vec3, EPSILON}; /// Quaternion stored as `(x, y, z, w)` with deterministic float32 rounding. /// @@ -50,7 +50,7 @@ impl Quat { 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 (sin_half, cos_half) = trig::sin_cos_f32(half); let scaled = norm_axis.scale(sin_half); Self::new( scaled.component(0), diff --git a/crates/warp-core/src/math/scalar.rs b/crates/warp-core/src/math/scalar.rs index 0e1d050c..f1872f85 100644 --- a/crates/warp-core/src/math/scalar.rs +++ b/crates/warp-core/src/math/scalar.rs @@ -14,12 +14,11 @@ //! - Core transcendentals: sin, cos (angles in radians). //! //! Out of scope for this commit: -//! - Subnormal flushing (to be handled by concrete float wrappers in a -//! follow-up task). -//! - Lookup-table or polynomial-backed trig implementations (tracked separately; -//! this trait only declares the API). -//! - Concrete backends: `F32Scalar` and `DFix64` will implement this trait in -//! subsequent changes. +//! - Scalar backend selection plumbing across the whole engine (feature gates +//! exist, but wiring generic engine code to switch lanes is follow-up work). +//! - More advanced deterministic transcendental backends (e.g., higher-order +//! interpolation or polynomial approximations) beyond the initial LUT-backed +//! implementation. //! //! Determinism contract: //! - Operations must be pure and total for all valid inputs of the @@ -29,11 +28,20 @@ //! - Trigonometric functions interpret arguments as radians and must be //! consistent across platforms for identical inputs (e.g., via LUT/polynomial //! in later work). +//! +//! Implementation note: +//! - `F32Scalar::{sin,cos,sin_cos}` are implemented using a deterministic +//! LUT-backed approximation in `warp_core::math::trig`. use core::cmp::Ordering; use core::fmt; use core::ops::{Add, Div, Mul, Neg, Sub}; +use crate::math::trig; + +#[cfg(feature = "det_fixed")] +use crate::math::fixed_q32_32; + /// Deterministic scalar arithmetic and basic transcendentals. /// /// This trait abstracts the numeric core used by Echo so that engine code can @@ -95,7 +103,7 @@ pub struct F32Scalar { /// /// # Invariant /// This field is private to enforce canonicalization via `new()`. - /// It must NEVER contain `-0.0`, non-canonical NaNs, or subnormals (future). + /// It must NEVER contain `-0.0`, non-canonical NaNs, or subnormals. value: f32, } @@ -183,15 +191,18 @@ impl Scalar for F32Scalar { } fn sin(self) -> Self { - Self::new(self.value.sin()) + let (s, _) = trig::sin_cos_f32(self.value); + Self::new(s) } fn cos(self) -> Self { - Self::new(self.value.cos()) + let (_, c) = trig::sin_cos_f32(self.value); + Self::new(c) } fn sin_cos(self) -> (Self, Self) { - (Self::new(self.value.sin()), Self::new(self.value.cos())) + let (s, c) = trig::sin_cos_f32(self.value); + (Self::new(s), Self::new(c)) } fn from_f32(value: f32) -> Self { @@ -237,3 +248,209 @@ impl Neg for F32Scalar { Self::new(-self.value) } } + +/// Deterministic fixed-point scalar with Q32.32 encoding stored in an `i64`. +/// +/// The underlying integer stores the value scaled by `2^32`: +/// +/// ```text +/// real_value = raw / 2^32 +/// ``` +/// +/// # Determinism contract +/// +/// - All arithmetic is performed in integer space with saturating overflow. +/// - Multiplication/division use round-to-nearest, ties-to-even semantics. +/// - `from_f32` is deterministic and does not rely on platform transcendentals. +#[cfg(feature = "det_fixed")] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub struct DFix64 { + raw: i64, +} + +#[cfg(feature = "det_fixed")] +impl DFix64 { + const FRAC_BITS: u32 = fixed_q32_32::FRAC_BITS; + const ONE_RAW: i64 = fixed_q32_32::ONE_RAW; + + /// The fixed-point zero value. + pub const ZERO: Self = Self { raw: 0 }; + + /// The fixed-point one value. + pub const ONE: Self = Self { raw: Self::ONE_RAW }; + + /// Constructs a fixed-point value from a raw Q32.32 integer. + /// + /// This is an exact conversion (no scaling or rounding). `raw` is interpreted as + /// `real_value = raw / 2^32`. + #[must_use] + pub const fn from_raw(raw: i64) -> Self { + Self { raw } + } + + /// Returns the underlying Q32.32 raw storage value. + pub const fn raw(self) -> i64 { + self.raw + } + + fn saturate_i128_to_i64(value: i128) -> i64 { + i64::try_from(value).unwrap_or_else(|_| { + if value.is_negative() { + i64::MIN + } else { + i64::MAX + } + }) + } + + fn saturating_add_raw(a: i64, b: i64) -> i64 { + Self::saturate_i128_to_i64(i128::from(a) + i128::from(b)) + } + + fn saturating_sub_raw(a: i64, b: i64) -> i64 { + Self::saturate_i128_to_i64(i128::from(a) - i128::from(b)) + } + + fn saturating_neg_raw(a: i64) -> i64 { + if a == i64::MIN { + i64::MAX + } else { + -a + } + } + + fn mul_raw(a: i64, b: i64) -> i64 { + let prod = i128::from(a) * i128::from(b); + let abs: u128 = prod.unsigned_abs(); + let q = abs >> Self::FRAC_BITS; + let r = abs & ((1_u128 << Self::FRAC_BITS) - 1); + let half = 1_u128 << (Self::FRAC_BITS - 1); + + let mut rounded = q; + if r > half || (r == half && (q & 1) == 1) { + rounded = rounded.saturating_add(1); + } + + let rounded_i128 = i128::try_from(rounded).map_or(i128::MAX, |v| v); + let signed = if prod.is_negative() { + -rounded_i128 + } else { + rounded_i128 + }; + + Self::saturate_i128_to_i64(signed) + } + + fn div_raw(a: i64, b: i64) -> i64 { + if b == 0 { + if a == 0 { + // Determinism policy: 0/0 → 0 (not NaN) to preserve integer semantics. + return 0; + } + return if a.is_negative() { i64::MIN } else { i64::MAX }; + } + + let num = i128::from(a) << Self::FRAC_BITS; + let den = i128::from(b); + + let abs_num: u128 = num.unsigned_abs(); + let abs_den: u128 = den.unsigned_abs(); + + let q = abs_num / abs_den; + let r = abs_num % abs_den; + + let mut rounded = q; + let twice_r = r.saturating_mul(2); + if twice_r > abs_den || (twice_r == abs_den && (q & 1) == 1) { + rounded = rounded.saturating_add(1); + } + + let rounded_i128 = i128::try_from(rounded).map_or(i128::MAX, |v| v); + let signed = if (a < 0) ^ (b < 0) { + -rounded_i128 + } else { + rounded_i128 + }; + + Self::saturate_i128_to_i64(signed) + } +} + +#[cfg(feature = "det_fixed")] +impl Scalar for DFix64 { + fn zero() -> Self { + Self::ZERO + } + + fn one() -> Self { + Self::ONE + } + + fn sin(self) -> Self { + let (s, _) = crate::math::trig::sin_cos_f32(self.to_f32()); + Self::from_f32(s) + } + + fn cos(self) -> Self { + let (_, c) = crate::math::trig::sin_cos_f32(self.to_f32()); + Self::from_f32(c) + } + + fn sin_cos(self) -> (Self, Self) { + let (s, c) = crate::math::trig::sin_cos_f32(self.to_f32()); + (Self::from_f32(s), Self::from_f32(c)) + } + + fn from_f32(value: f32) -> Self { + Self::from_raw(fixed_q32_32::from_f32(value)) + } + + fn to_f32(self) -> f32 { + fixed_q32_32::to_f32(self.raw) + } +} + +#[cfg(feature = "det_fixed")] +impl Add for DFix64 { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + Self::from_raw(Self::saturating_add_raw(self.raw, rhs.raw)) + } +} + +#[cfg(feature = "det_fixed")] +impl Sub for DFix64 { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + Self::from_raw(Self::saturating_sub_raw(self.raw, rhs.raw)) + } +} + +#[cfg(feature = "det_fixed")] +impl Mul for DFix64 { + type Output = Self; + + fn mul(self, rhs: Self) -> Self { + Self::from_raw(Self::mul_raw(self.raw, rhs.raw)) + } +} + +#[cfg(feature = "det_fixed")] +impl Div for DFix64 { + type Output = Self; + + fn div(self, rhs: Self) -> Self { + Self::from_raw(Self::div_raw(self.raw, rhs.raw)) + } +} + +#[cfg(feature = "det_fixed")] +impl Neg for DFix64 { + type Output = Self; + + fn neg(self) -> Self::Output { + Self::from_raw(Self::saturating_neg_raw(self.raw)) + } +} diff --git a/crates/warp-core/src/math/trig.rs b/crates/warp-core/src/math/trig.rs new file mode 100644 index 00000000..d7afe89e --- /dev/null +++ b/crates/warp-core/src/math/trig.rs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS + +//! Deterministic `sin`/`cos` backend for float32. +//! +//! This module provides a bit-stable approximation for `sin`/`cos` intended for +//! use inside the simulation loop. It intentionally does **not** call platform +//! transcendentals (`f32::{sin,cos}`), which can vary across hardware/libm. +//! +//! Strategy: +//! - range-reduce to `[0, TAU)` using `rem_euclid` +//! - map into a quarter-wave and use a checked-in lookup table (LUT) +//! - linearly interpolate between adjacent samples +//! - apply quadrant symmetries to reconstruct full-wave `sin` and `cos` + +use core::f32::consts::{FRAC_PI_2, PI, TAU}; + +use super::trig_lut::{sin_qtr_sample, SIN_QTR_SEGMENTS_F32}; + +const FRAC_3PI_2: f32 = 3.0 * FRAC_PI_2; + +/// Canonicalizes signed zero (`-0.0`) to `+0.0` without affecting non-zero values. +#[inline] +pub(crate) fn canonicalize_zero(value: f32) -> f32 { + if value == 0.0 { + 0.0 + } else { + value + } +} + +/// Deterministic `sin` and `cos` for `f32` radians. +/// +/// - For non-finite inputs (NaN/±∞), returns `(NaN, NaN)` (caller canonicalizes). +/// - For finite inputs, returns finite `f32` values in `[-1, 1]`. +pub(crate) fn sin_cos_f32(angle: f32) -> (f32, f32) { + if !angle.is_finite() { + return (f32::NAN, f32::NAN); + } + + // Enforce exact symmetry for sine: + // - `sin(-x)` must be the exact negation of `sin(x)` bit-for-bit. + // - `cos(-x)` must match `cos(x)` bit-for-bit. + // + // For negative angles, `rem_euclid` would map into `[0, TAU)` near the upper + // boundary, changing the interpolation path and potentially introducing a + // 1-ULP asymmetry. We avoid that by reducing `abs(angle)` and applying the + // sign at the end. + let sign_sin = angle.is_sign_negative(); + let r = angle.abs().rem_euclid(TAU); + + // Range-split into quadrants using comparisons to avoid the subtle + // rounding hazard where `r / (PI/2)` can round up to 4.0 at the top edge. + let (quadrant, a) = if r < FRAC_PI_2 { + (0_u8, r) + } else if r < PI { + (1_u8, r - FRAC_PI_2) + } else if r < FRAC_3PI_2 { + (2_u8, r - PI) + } else { + (3_u8, r - FRAC_3PI_2) + }; + + let s = sin_qtr_interp(a); + let c = sin_qtr_interp(FRAC_PI_2 - a); + + let (mut s, c) = match quadrant { + 0 => (s, c), + 1 => (c, -s), + 2 => (-s, -c), + // 3 + _ => (-c, s), + }; + + if sign_sin { + s = -s; + } + + (canonicalize_zero(s), canonicalize_zero(c)) +} + +#[inline] +fn sin_qtr_interp(angle_qtr: f32) -> f32 { + // `angle_qtr` should always be within [0, PI/2] here, but keep behavior + // defined even if upstream range reduction changes. + if !(0.0..=FRAC_PI_2).contains(&angle_qtr) { + return f32::NAN; + } + + let t = angle_qtr * SIN_QTR_SEGMENTS_F32 / FRAC_PI_2; + + if t >= SIN_QTR_SEGMENTS_F32 { + // Inclusive endpoint (PI/2) maps to exactly 1.0. + return 1.0; + } + + // Safe: 0 <= t < SIN_QTR_SEGMENTS, so i0 in 0..SIN_QTR_SEGMENTS. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let i0 = t as usize; + let frac = t.fract(); + let y0 = sin_qtr_sample(i0); + let y1 = sin_qtr_sample(i0 + 1); + y0 + frac * (y1 - y0) +} diff --git a/crates/warp-core/src/math/trig_lut.rs b/crates/warp-core/src/math/trig_lut.rs new file mode 100644 index 00000000..28e731fb --- /dev/null +++ b/crates/warp-core/src/math/trig_lut.rs @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS + +//! Deterministic trig lookup tables. +//! +//! This module contains precomputed lookup tables (LUTs) used by the +//! deterministic `sin`/`cos` backend for `F32Scalar`. Tables are checked-in +//! as `u32` bit patterns to avoid any float-literal parsing or host-dependent +//! generation during the build. + +/// Quarter-wave resolution (segments) for the `sin` LUT. +pub(crate) const SIN_QTR_SEGMENTS: usize = 1024; + +/// `SIN_QTR_SEGMENTS` as `f32` (kept as a literal to satisfy strict clippy lints). +pub(crate) const SIN_QTR_SEGMENTS_F32: f32 = 1024.0; + +/// Quarter-wave `sin` samples over `[0, PI/2]` (inclusive), stored as raw `f32` bits. +/// +/// - Length: `SIN_QTR_SEGMENTS + 1` +/// - `SIN_QTR_LUT_BITS[0] == 0.0` +/// - `SIN_QTR_LUT_BITS[SIN_QTR_SEGMENTS] == 1.0` +pub(crate) const SIN_QTR_LUT_BITS: [u32; SIN_QTR_SEGMENTS + 1] = [ + 0x00000000, 0x3ac90fd5, 0x3b490fc6, 0x3b96cbc1, 0x3bc90f88, 0x3bfb5330, 0x3c16cb58, 0x3c2fed02, + 0x3c490e90, 0x3c622fff, 0x3c7b514b, 0x3c8a3938, 0x3c96c9b6, 0x3ca35a1c, 0x3cafea69, 0x3cbc7a9b, + 0x3cc90ab0, 0x3cd59aa6, 0x3ce22a7a, 0x3ceeba2c, 0x3cfb49ba, 0x3d03ec90, 0x3d0a342f, 0x3d107bb8, + 0x3d16c32c, 0x3d1d0a88, 0x3d2351cb, 0x3d2998f6, 0x3d2fe007, 0x3d3626fc, 0x3d3c6dd5, 0x3d42b491, + 0x3d48fb30, 0x3d4f41af, 0x3d55880e, 0x3d5bce4c, 0x3d621469, 0x3d685a62, 0x3d6ea038, 0x3d74e5e9, + 0x3d7b2b74, 0x3d80b86c, 0x3d83db0a, 0x3d86fd94, 0x3d8a200a, 0x3d8d426a, 0x3d9064b4, 0x3d9386e7, + 0x3d96a905, 0x3d99cb0a, 0x3d9cecf9, 0x3da00ecf, 0x3da3308c, 0x3da65230, 0x3da973ba, 0x3dac952b, + 0x3dafb680, 0x3db2d7bb, 0x3db5f8da, 0x3db919dd, 0x3dbc3ac3, 0x3dbf5b8d, 0x3dc27c39, 0x3dc59cc6, + 0x3dc8bd36, 0x3dcbdd86, 0x3dcefdb7, 0x3dd21dc8, 0x3dd53db9, 0x3dd85d89, 0x3ddb7d37, 0x3dde9cc4, + 0x3de1bc2e, 0x3de4db76, 0x3de7fa9a, 0x3deb199a, 0x3dee3876, 0x3df1572e, 0x3df475c0, 0x3df7942c, + 0x3dfab273, 0x3dfdd092, 0x3e007745, 0x3e02062e, 0x3e039502, 0x3e0523c2, 0x3e06b26e, 0x3e084105, + 0x3e09cf86, 0x3e0b5df3, 0x3e0cec4a, 0x3e0e7a8b, 0x3e1008b7, 0x3e1196cc, 0x3e1324ca, 0x3e14b2b2, + 0x3e164083, 0x3e17ce3d, 0x3e195be0, 0x3e1ae96b, 0x3e1c76de, 0x3e1e0438, 0x3e1f917b, 0x3e211ea5, + 0x3e22abb6, 0x3e2438ad, 0x3e25c58c, 0x3e275251, 0x3e28defc, 0x3e2a6b8d, 0x3e2bf804, 0x3e2d8461, + 0x3e2f10a2, 0x3e309cc9, 0x3e3228d4, 0x3e33b4c4, 0x3e354098, 0x3e36cc50, 0x3e3857ec, 0x3e39e36c, + 0x3e3b6ecf, 0x3e3cfa15, 0x3e3e853e, 0x3e401049, 0x3e419b37, 0x3e432607, 0x3e44b0b9, 0x3e463b4d, + 0x3e47c5c2, 0x3e495018, 0x3e4ada4f, 0x3e4c6467, 0x3e4dee60, 0x3e4f7838, 0x3e5101f1, 0x3e528b89, + 0x3e541501, 0x3e559e58, 0x3e57278f, 0x3e58b0a4, 0x3e5a3997, 0x3e5bc26a, 0x3e5d4b1a, 0x3e5ed3a8, + 0x3e605c13, 0x3e61e45c, 0x3e636c83, 0x3e64f486, 0x3e667c66, 0x3e680422, 0x3e698bba, 0x3e6b132f, + 0x3e6c9a7f, 0x3e6e21ab, 0x3e6fa8b2, 0x3e712f94, 0x3e72b651, 0x3e743ce8, 0x3e75c35a, 0x3e7749a6, + 0x3e78cfcc, 0x3e7a55cb, 0x3e7bdba4, 0x3e7d6156, 0x3e7ee6e1, 0x3e803622, 0x3e80f8c0, 0x3e81bb4a, + 0x3e827dc0, 0x3e834022, 0x3e840270, 0x3e84c4aa, 0x3e8586ce, 0x3e8648df, 0x3e870ada, 0x3e87ccc1, + 0x3e888e93, 0x3e895050, 0x3e8a11f7, 0x3e8ad38a, 0x3e8b9507, 0x3e8c566e, 0x3e8d17c0, 0x3e8dd8fc, + 0x3e8e9a22, 0x3e8f5b32, 0x3e901c2c, 0x3e90dd10, 0x3e919ddd, 0x3e925e94, 0x3e931f35, 0x3e93dfbf, + 0x3e94a031, 0x3e95608d, 0x3e9620d2, 0x3e96e100, 0x3e97a117, 0x3e986116, 0x3e9920fe, 0x3e99e0ce, + 0x3e9aa086, 0x3e9b6027, 0x3e9c1faf, 0x3e9cdf20, 0x3e9d9e78, 0x3e9e5db8, 0x3e9f1cdf, 0x3e9fdbee, + 0x3ea09ae5, 0x3ea159c2, 0x3ea21887, 0x3ea2d733, 0x3ea395c5, 0x3ea4543f, 0x3ea5129f, 0x3ea5d0e5, + 0x3ea68f12, 0x3ea74d25, 0x3ea80b1f, 0x3ea8c8fe, 0x3ea986c4, 0x3eaa446f, 0x3eab0201, 0x3eabbf77, + 0x3eac7cd4, 0x3ead3a15, 0x3eadf73c, 0x3eaeb449, 0x3eaf713a, 0x3eb02e10, 0x3eb0eacb, 0x3eb1a76b, + 0x3eb263ef, 0x3eb32058, 0x3eb3dca5, 0x3eb498d6, 0x3eb554ec, 0x3eb610e6, 0x3eb6ccc3, 0x3eb78884, + 0x3eb8442a, 0x3eb8ffb2, 0x3eb9bb1e, 0x3eba766e, 0x3ebb31a0, 0x3ebbecb6, 0x3ebca7af, 0x3ebd628b, + 0x3ebe1d4a, 0x3ebed7eb, 0x3ebf926f, 0x3ec04cd5, 0x3ec1071e, 0x3ec1c148, 0x3ec27b55, 0x3ec33544, + 0x3ec3ef15, 0x3ec4a8c8, 0x3ec5625c, 0x3ec61bd2, 0x3ec6d529, 0x3ec78e62, 0x3ec8477c, 0x3ec90077, + 0x3ec9b953, 0x3eca7210, 0x3ecb2aae, 0x3ecbe32c, 0x3ecc9b8b, 0x3ecd53ca, 0x3ece0bea, 0x3ecec3ea, + 0x3ecf7bca, 0x3ed0338a, 0x3ed0eb2a, 0x3ed1a2aa, 0x3ed25a09, 0x3ed31148, 0x3ed3c867, 0x3ed47f64, + 0x3ed53641, 0x3ed5ecfd, 0x3ed6a399, 0x3ed75a13, 0x3ed8106b, 0x3ed8c6a3, 0x3ed97cb9, 0x3eda32ad, + 0x3edae880, 0x3edb9e31, 0x3edc53c1, 0x3edd092e, 0x3eddbe79, 0x3ede73a2, 0x3edf28a9, 0x3edfdd8d, + 0x3ee0924f, 0x3ee146ee, 0x3ee1fb6a, 0x3ee2afc4, 0x3ee363fa, 0x3ee4180e, 0x3ee4cbfe, 0x3ee57fcb, + 0x3ee63375, 0x3ee6e6fb, 0x3ee79a5d, 0x3ee84d9c, 0x3ee900b7, 0x3ee9b3ae, 0x3eea6681, 0x3eeb1930, + 0x3eebcbbb, 0x3eec7e21, 0x3eed3063, 0x3eede280, 0x3eee9479, 0x3eef464c, 0x3eeff7fb, 0x3ef0a985, + 0x3ef15aea, 0x3ef20c29, 0x3ef2bd43, 0x3ef36e38, 0x3ef41f07, 0x3ef4cfb1, 0x3ef58035, 0x3ef63093, + 0x3ef6e0cb, 0x3ef790dc, 0x3ef840c8, 0x3ef8f08e, 0x3ef9a02d, 0x3efa4fa5, 0x3efafef7, 0x3efbae22, + 0x3efc5d27, 0x3efd0c04, 0x3efdbabb, 0x3efe694a, 0x3eff17b2, 0x3effc5f3, 0x3f003a06, 0x3f0090ff, + 0x3f00e7e4, 0x3f013eb5, 0x3f019573, 0x3f01ec1c, 0x3f0242b1, 0x3f029932, 0x3f02ef9f, 0x3f0345f8, + 0x3f039c3d, 0x3f03f26d, 0x3f044889, 0x3f049e91, 0x3f04f484, 0x3f054a62, 0x3f05a02c, 0x3f05f5e2, + 0x3f064b82, 0x3f06a10e, 0x3f06f686, 0x3f074be8, 0x3f07a136, 0x3f07f66f, 0x3f084b92, 0x3f08a0a1, + 0x3f08f59b, 0x3f094a7f, 0x3f099f4e, 0x3f09f409, 0x3f0a48ad, 0x3f0a9d3d, 0x3f0af1b7, 0x3f0b461c, + 0x3f0b9a6b, 0x3f0beea5, 0x3f0c42c9, 0x3f0c96d7, 0x3f0cead0, 0x3f0d3eb3, 0x3f0d9281, 0x3f0de638, + 0x3f0e39da, 0x3f0e8d65, 0x3f0ee0db, 0x3f0f343b, 0x3f0f8784, 0x3f0fdab8, 0x3f102dd5, 0x3f1080dc, + 0x3f10d3cd, 0x3f1126a7, 0x3f11796b, 0x3f11cc19, 0x3f121eb0, 0x3f127130, 0x3f12c39a, 0x3f1315ee, + 0x3f13682a, 0x3f13ba50, 0x3f140c5f, 0x3f145e58, 0x3f14b039, 0x3f150204, 0x3f1553b7, 0x3f15a554, + 0x3f15f6d9, 0x3f164847, 0x3f16999f, 0x3f16eade, 0x3f173c07, 0x3f178d18, 0x3f17de12, 0x3f182ef5, + 0x3f187fc0, 0x3f18d073, 0x3f19210f, 0x3f197194, 0x3f19c200, 0x3f1a1255, 0x3f1a6293, 0x3f1ab2b8, + 0x3f1b02c6, 0x3f1b52bb, 0x3f1ba299, 0x3f1bf25f, 0x3f1c420c, 0x3f1c91a2, 0x3f1ce11f, 0x3f1d3084, + 0x3f1d7fd1, 0x3f1dcf06, 0x3f1e1e22, 0x3f1e6d26, 0x3f1ebc12, 0x3f1f0ae5, 0x3f1f599f, 0x3f1fa841, + 0x3f1ff6cb, 0x3f20453b, 0x3f209393, 0x3f20e1d2, 0x3f212ff9, 0x3f217e06, 0x3f21cbfb, 0x3f2219d7, + 0x3f226799, 0x3f22b543, 0x3f2302d3, 0x3f23504b, 0x3f239da9, 0x3f23eaee, 0x3f24381a, 0x3f24852c, + 0x3f24d225, 0x3f251f04, 0x3f256bcb, 0x3f25b877, 0x3f26050a, 0x3f265184, 0x3f269de3, 0x3f26ea2a, + 0x3f273656, 0x3f278268, 0x3f27ce61, 0x3f281a40, 0x3f286605, 0x3f28b1b0, 0x3f28fd41, 0x3f2948b8, + 0x3f299415, 0x3f29df57, 0x3f2a2a80, 0x3f2a758e, 0x3f2ac082, 0x3f2b0b5b, 0x3f2b561b, 0x3f2ba0bf, + 0x3f2beb4a, 0x3f2c35b9, 0x3f2c800f, 0x3f2cca49, 0x3f2d1469, 0x3f2d5e6f, 0x3f2da859, 0x3f2df229, + 0x3f2e3bde, 0x3f2e8578, 0x3f2ecef7, 0x3f2f185b, 0x3f2f61a5, 0x3f2faad3, 0x3f2ff3e6, 0x3f303cde, + 0x3f3085bb, 0x3f30ce7c, 0x3f311722, 0x3f315fad, 0x3f31a81d, 0x3f31f071, 0x3f3238aa, 0x3f3280c7, + 0x3f32c8c9, 0x3f3310af, 0x3f33587a, 0x3f33a029, 0x3f33e7bc, 0x3f342f34, 0x3f34768f, 0x3f34bdcf, + 0x3f3504f3, 0x3f354bfb, 0x3f3592e7, 0x3f35d9b8, 0x3f36206c, 0x3f366704, 0x3f36ad7f, 0x3f36f3df, + 0x3f373a23, 0x3f37804a, 0x3f37c655, 0x3f380c43, 0x3f385216, 0x3f3897cb, 0x3f38dd65, 0x3f3922e1, + 0x3f396842, 0x3f39ad85, 0x3f39f2ac, 0x3f3a37b7, 0x3f3a7ca4, 0x3f3ac175, 0x3f3b0629, 0x3f3b4ac1, + 0x3f3b8f3b, 0x3f3bd398, 0x3f3c17d9, 0x3f3c5bfc, 0x3f3ca003, 0x3f3ce3ec, 0x3f3d27b8, 0x3f3d6b67, + 0x3f3daef9, 0x3f3df26e, 0x3f3e35c5, 0x3f3e78ff, 0x3f3ebc1b, 0x3f3eff1b, 0x3f3f41fc, 0x3f3f84c0, + 0x3f3fc767, 0x3f4009f0, 0x3f404c5c, 0x3f408ea9, 0x3f40d0da, 0x3f4112ec, 0x3f4154e1, 0x3f4196b7, + 0x3f41d870, 0x3f421a0b, 0x3f425b89, 0x3f429ce8, 0x3f42de29, 0x3f431f4c, 0x3f436051, 0x3f43a138, + 0x3f43e200, 0x3f4422ab, 0x3f446337, 0x3f44a3a5, 0x3f44e3f5, 0x3f452426, 0x3f456439, 0x3f45a42d, + 0x3f45e403, 0x3f4623bb, 0x3f466354, 0x3f46a2ce, 0x3f46e22a, 0x3f472167, 0x3f476085, 0x3f479f84, + 0x3f47de65, 0x3f481d27, 0x3f485bca, 0x3f489a4e, 0x3f48d8b3, 0x3f4916fa, 0x3f495521, 0x3f499329, + 0x3f49d112, 0x3f4a0edc, 0x3f4a4c87, 0x3f4a8a13, 0x3f4ac77f, 0x3f4b04cc, 0x3f4b41fa, 0x3f4b7f09, + 0x3f4bbbf8, 0x3f4bf8c7, 0x3f4c3578, 0x3f4c7208, 0x3f4cae79, 0x3f4ceacb, 0x3f4d26fd, 0x3f4d6310, + 0x3f4d9f02, 0x3f4ddad5, 0x3f4e1689, 0x3f4e521c, 0x3f4e8d90, 0x3f4ec8e4, 0x3f4f0417, 0x3f4f3f2b, + 0x3f4f7a1f, 0x3f4fb4f4, 0x3f4fefa8, 0x3f502a3b, 0x3f5064af, 0x3f509f03, 0x3f50d937, 0x3f51134a, + 0x3f514d3d, 0x3f518710, 0x3f51c0c2, 0x3f51fa54, 0x3f5233c6, 0x3f526d18, 0x3f52a649, 0x3f52df59, + 0x3f531849, 0x3f535118, 0x3f5389c7, 0x3f53c255, 0x3f53fac3, 0x3f54330f, 0x3f546b3b, 0x3f54a347, + 0x3f54db31, 0x3f5512fb, 0x3f554aa4, 0x3f55822c, 0x3f55b993, 0x3f55f0d9, 0x3f5627fe, 0x3f565f02, + 0x3f5695e5, 0x3f56cca7, 0x3f570348, 0x3f5739c7, 0x3f577026, 0x3f57a663, 0x3f57dc7f, 0x3f581279, + 0x3f584853, 0x3f587e0b, 0x3f58b3a1, 0x3f58e916, 0x3f591e6a, 0x3f59539c, 0x3f5988ad, 0x3f59bd9c, + 0x3f59f26a, 0x3f5a2716, 0x3f5a5ba0, 0x3f5a9009, 0x3f5ac450, 0x3f5af875, 0x3f5b2c79, 0x3f5b605a, + 0x3f5b941a, 0x3f5bc7b8, 0x3f5bfb34, 0x3f5c2e8e, 0x3f5c61c7, 0x3f5c94dd, 0x3f5cc7d1, 0x3f5cfaa3, + 0x3f5d2d53, 0x3f5d5fe1, 0x3f5d924d, 0x3f5dc497, 0x3f5df6be, 0x3f5e28c3, 0x3f5e5aa6, 0x3f5e8c67, + 0x3f5ebe05, 0x3f5eef81, 0x3f5f20db, 0x3f5f5212, 0x3f5f8327, 0x3f5fb419, 0x3f5fe4e9, 0x3f601596, + 0x3f604621, 0x3f607689, 0x3f60a6cf, 0x3f60d6f2, 0x3f6106f2, 0x3f6136d0, 0x3f61668a, 0x3f619622, + 0x3f61c598, 0x3f61f4ea, 0x3f62241a, 0x3f625326, 0x3f628210, 0x3f62b0d7, 0x3f62df7b, 0x3f630dfc, + 0x3f633c5a, 0x3f636a95, 0x3f6398ac, 0x3f63c6a1, 0x3f63f473, 0x3f642221, 0x3f644fac, 0x3f647d14, + 0x3f64aa59, 0x3f64d77b, 0x3f650479, 0x3f653154, 0x3f655e0b, 0x3f658aa0, 0x3f65b710, 0x3f65e35e, + 0x3f660f88, 0x3f663b8e, 0x3f666771, 0x3f669330, 0x3f66becc, 0x3f66ea45, 0x3f671599, 0x3f6740ca, + 0x3f676bd8, 0x3f6796c1, 0x3f67c187, 0x3f67ec29, 0x3f6816a8, 0x3f684103, 0x3f686b39, 0x3f68954c, + 0x3f68bf3c, 0x3f68e907, 0x3f6912ae, 0x3f693c32, 0x3f696591, 0x3f698ecc, 0x3f69b7e4, 0x3f69e0d7, + 0x3f6a09a7, 0x3f6a3252, 0x3f6a5ad9, 0x3f6a833c, 0x3f6aab7b, 0x3f6ad395, 0x3f6afb8c, 0x3f6b235e, + 0x3f6b4b0c, 0x3f6b7295, 0x3f6b99fb, 0x3f6bc13b, 0x3f6be858, 0x3f6c0f50, 0x3f6c3624, 0x3f6c5cd4, + 0x3f6c835e, 0x3f6ca9c5, 0x3f6cd007, 0x3f6cf624, 0x3f6d1c1d, 0x3f6d41f2, 0x3f6d67a1, 0x3f6d8d2d, + 0x3f6db293, 0x3f6dd7d5, 0x3f6dfcf2, 0x3f6e21eb, 0x3f6e46be, 0x3f6e6b6d, 0x3f6e8ff8, 0x3f6eb45d, + 0x3f6ed89e, 0x3f6efcba, 0x3f6f20b0, 0x3f6f4483, 0x3f6f6830, 0x3f6f8bb8, 0x3f6faf1b, 0x3f6fd25a, + 0x3f6ff573, 0x3f701867, 0x3f703b37, 0x3f705de1, 0x3f708066, 0x3f70a2c6, 0x3f70c501, 0x3f70e717, + 0x3f710908, 0x3f712ad4, 0x3f714c7a, 0x3f716dfb, 0x3f718f57, 0x3f71b08e, 0x3f71d19f, 0x3f71f28c, + 0x3f721352, 0x3f7233f4, 0x3f725470, 0x3f7274c7, 0x3f7294f8, 0x3f72b504, 0x3f72d4eb, 0x3f72f4ac, + 0x3f731447, 0x3f7333be, 0x3f73530e, 0x3f737239, 0x3f73913f, 0x3f73b01f, 0x3f73ced9, 0x3f73ed6e, + 0x3f740bdd, 0x3f742a27, 0x3f74484b, 0x3f746649, 0x3f748422, 0x3f74a1d5, 0x3f74bf62, 0x3f74dcc9, + 0x3f74fa0b, 0x3f751727, 0x3f75341d, 0x3f7550ed, 0x3f756d97, 0x3f758a1c, 0x3f75a67b, 0x3f75c2b3, + 0x3f75dec6, 0x3f75fab3, 0x3f76167a, 0x3f76321b, 0x3f764d97, 0x3f7668ec, 0x3f76841b, 0x3f769f24, + 0x3f76ba07, 0x3f76d4c4, 0x3f76ef5b, 0x3f7709cc, 0x3f772417, 0x3f773e3c, 0x3f77583a, 0x3f777213, + 0x3f778bc5, 0x3f77a551, 0x3f77beb7, 0x3f77d7f7, 0x3f77f110, 0x3f780a04, 0x3f7822d1, 0x3f783b77, + 0x3f7853f8, 0x3f786c52, 0x3f788486, 0x3f789c93, 0x3f78b47b, 0x3f78cc3b, 0x3f78e3d6, 0x3f78fb4a, + 0x3f791298, 0x3f7929bf, 0x3f7940c0, 0x3f79579a, 0x3f796e4e, 0x3f7984dc, 0x3f799b43, 0x3f79b183, + 0x3f79c79d, 0x3f79dd91, 0x3f79f35e, 0x3f7a0904, 0x3f7a1e84, 0x3f7a33dd, 0x3f7a4910, 0x3f7a5e1c, + 0x3f7a7302, 0x3f7a87c1, 0x3f7a9c59, 0x3f7ab0cb, 0x3f7ac516, 0x3f7ad93a, 0x3f7aed37, 0x3f7b010e, + 0x3f7b14be, 0x3f7b2848, 0x3f7b3bab, 0x3f7b4ee7, 0x3f7b61fc, 0x3f7b74ea, 0x3f7b87b2, 0x3f7b9a53, + 0x3f7baccd, 0x3f7bbf20, 0x3f7bd14d, 0x3f7be353, 0x3f7bf531, 0x3f7c06e9, 0x3f7c187a, 0x3f7c29e5, + 0x3f7c3b28, 0x3f7c4c44, 0x3f7c5d3a, 0x3f7c6e08, 0x3f7c7eb0, 0x3f7c8f31, 0x3f7c9f8a, 0x3f7cafbd, + 0x3f7cbfc9, 0x3f7ccfae, 0x3f7cdf6c, 0x3f7cef03, 0x3f7cfe73, 0x3f7d0dbc, 0x3f7d1cdd, 0x3f7d2bd8, + 0x3f7d3aac, 0x3f7d4959, 0x3f7d57de, 0x3f7d663d, 0x3f7d7474, 0x3f7d8285, 0x3f7d906e, 0x3f7d9e30, + 0x3f7dabcc, 0x3f7db940, 0x3f7dc68c, 0x3f7dd3b2, 0x3f7de0b1, 0x3f7ded88, 0x3f7dfa38, 0x3f7e06c2, + 0x3f7e1324, 0x3f7e1f5e, 0x3f7e2b72, 0x3f7e375e, 0x3f7e4323, 0x3f7e4ec1, 0x3f7e5a38, 0x3f7e6588, + 0x3f7e70b0, 0x3f7e7bb1, 0x3f7e868b, 0x3f7e913d, 0x3f7e9bc9, 0x3f7ea62d, 0x3f7eb069, 0x3f7eba7f, + 0x3f7ec46d, 0x3f7ece34, 0x3f7ed7d4, 0x3f7ee14c, 0x3f7eea9d, 0x3f7ef3c7, 0x3f7efcc9, 0x3f7f05a4, + 0x3f7f0e58, 0x3f7f16e4, 0x3f7f1f49, 0x3f7f2787, 0x3f7f2f9d, 0x3f7f378c, 0x3f7f3f54, 0x3f7f46f4, + 0x3f7f4e6d, 0x3f7f55bf, 0x3f7f5ce9, 0x3f7f63ec, 0x3f7f6ac7, 0x3f7f717b, 0x3f7f7808, 0x3f7f7e6d, + 0x3f7f84ab, 0x3f7f8ac2, 0x3f7f90b1, 0x3f7f9678, 0x3f7f9c18, 0x3f7fa191, 0x3f7fa6e3, 0x3f7fac0d, + 0x3f7fb10f, 0x3f7fb5ea, 0x3f7fba9e, 0x3f7fbf2a, 0x3f7fc38f, 0x3f7fc7cc, 0x3f7fcbe2, 0x3f7fcfd1, + 0x3f7fd397, 0x3f7fd737, 0x3f7fdaaf, 0x3f7fde00, 0x3f7fe129, 0x3f7fe42b, 0x3f7fe705, 0x3f7fe9b8, + 0x3f7fec43, 0x3f7feea7, 0x3f7ff0e3, 0x3f7ff2f8, 0x3f7ff4e6, 0x3f7ff6ac, 0x3f7ff84a, 0x3f7ff9c1, + 0x3f7ffb11, 0x3f7ffc39, 0x3f7ffd39, 0x3f7ffe13, 0x3f7ffec4, 0x3f7fff4e, 0x3f7fffb1, 0x3f7fffec, + 0x3f800000, +]; + +/// Looks up a quarter-wave `sin` sample as `f32`. +/// +/// Valid indices are `0..=SIN_QTR_SEGMENTS`. +/// +/// # Determinism +/// This function returns the exact `f32` bit-pattern stored in +/// [`SIN_QTR_LUT_BITS`]; callers should treat it as a stable, deterministic +/// oracle (not a recomputation). +#[inline] +pub(crate) fn sin_qtr_sample(index: usize) -> f32 { + debug_assert!(index <= SIN_QTR_SEGMENTS); + f32::from_bits(SIN_QTR_LUT_BITS[index]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lut_length_and_endpoints_are_correct() { + assert_eq!(SIN_QTR_LUT_BITS.len(), SIN_QTR_SEGMENTS + 1); + assert_eq!(SIN_QTR_LUT_BITS[0], 0.0_f32.to_bits()); + assert_eq!(SIN_QTR_LUT_BITS[SIN_QTR_SEGMENTS], 1.0_f32.to_bits()); + } + + #[test] + fn lut_is_finite_and_monotonic_non_decreasing() { + let mut prev = f32::from_bits(SIN_QTR_LUT_BITS[0]); + assert!(prev.is_finite()); + for &bits in &SIN_QTR_LUT_BITS[1..] { + let cur = f32::from_bits(bits); + assert!(cur.is_finite()); + assert!(cur >= prev); + prev = cur; + } + } +} diff --git a/crates/warp-core/src/payload.rs b/crates/warp-core/src/payload.rs index 4e8e95ba..7f4e47f2 100644 --- a/crates/warp-core/src/payload.rs +++ b/crates/warp-core/src/payload.rs @@ -9,38 +9,88 @@ use bytes::Bytes; use crate::attachment::AtomPayload; use crate::ident::{make_type_id, TypeId}; -const POSITION_VELOCITY_BYTES: usize = 24; +const MOTION_PAYLOAD_V0_BYTES: usize = 24; +const MOTION_PAYLOAD_V2_BYTES: usize = 48; -static MOTION_PAYLOAD_TYPE_ID: OnceLock = OnceLock::new(); +static MOTION_PAYLOAD_TYPE_ID_V0: OnceLock = OnceLock::new(); +static MOTION_PAYLOAD_TYPE_ID_V2: OnceLock = OnceLock::new(); -/// Returns the canonical payload `TypeId` for the motion demo atom payload. +/// Returns the legacy motion payload `TypeId` (`payload/motion/v0`). +/// +/// This format stores six little-endian `f32` values (position + velocity). +#[must_use] +pub fn motion_payload_type_id_v0() -> TypeId { + *MOTION_PAYLOAD_TYPE_ID_V0.get_or_init(|| make_type_id("payload/motion/v0")) +} + +/// Returns the canonical payload `TypeId` for the motion demo atom payload (`payload/motion/v2`). /// /// This is used as the attachment-plane `type_id` for motion component bytes. /// It is cached after the first call to avoid repeated hashing overhead. #[must_use] pub fn motion_payload_type_id() -> TypeId { - *MOTION_PAYLOAD_TYPE_ID.get_or_init(|| make_type_id("payload/motion/v0")) + *MOTION_PAYLOAD_TYPE_ID_V2.get_or_init(|| make_type_id("payload/motion/v2")) } -/// Serialises a 3D position + velocity pair into the canonical payload. +/// Serialises a 3D position + velocity pair into the canonical motion payload. +/// +/// **Breaking change from v0:** This function now produces the v2 encoding (48 bytes Q32.32). +/// Legacy v0 payloads (24 bytes f32) remain readable via [`decode_motion_payload`], but new +/// writes always use v2. /// -/// Note: Values are encoded verbatim as `f32` little‑endian bytes; callers are -/// responsible for ensuring finiteness if deterministic behaviour is required -/// (NaN bit patterns compare unequal across some platforms). +/// The canonical format is Q32.32 fixed-point stored as six `i64` values (little-endian). +/// This provides a stable, cross-platform, cross-language wire encoding even when callers +/// originate values as `f32`. /// /// Layout (little‑endian): -/// - bytes 0..12: position [x, y, z] as 3 × f32 -/// - bytes 12..24: velocity [vx, vy, vz] as 3 × f32 -/// Always 24 bytes. +/// - bytes 0..24: position [x, y, z] as 3 × i64 (Q32.32) +/// - bytes 24..48: velocity [vx, vy, vz] as 3 × i64 (Q32.32) +/// Always 48 bytes. +/// +/// Non-finite inputs are mapped deterministically: +/// - `NaN` → `0` +/// - `+∞`/`-∞` → saturated extrema #[inline] +#[must_use] pub fn encode_motion_payload(position: [f32; 3], velocity: [f32; 3]) -> Bytes { - let mut buf = Vec::with_capacity(POSITION_VELOCITY_BYTES); + let mut buf = Vec::with_capacity(MOTION_PAYLOAD_V2_BYTES); + for value in position.into_iter().chain(velocity.into_iter()) { + let raw = crate::math::fixed_q32_32::from_f32(value); + buf.extend_from_slice(&raw.to_le_bytes()); + } + Bytes::from(buf) +} + +/// Serialises a 3D position + velocity pair into the legacy v0 motion payload encoding. +/// +/// This is retained for compatibility testing and migration tooling. New writes inside +/// the deterministic runtime should prefer the canonical v2 encoder ([`encode_motion_payload`]). +/// +/// Layout (little-endian): 6 × `f32` = 24 bytes. +#[inline] +#[must_use] +pub fn encode_motion_payload_v0(position: [f32; 3], velocity: [f32; 3]) -> Bytes { + let mut buf = Vec::with_capacity(MOTION_PAYLOAD_V0_BYTES); for value in position.into_iter().chain(velocity.into_iter()) { buf.extend_from_slice(&value.to_le_bytes()); } Bytes::from(buf) } +/// Serialises a Q32.32 raw position + velocity pair into the canonical motion payload. +/// +/// Layout is identical to [`encode_motion_payload`], but callers supply pre-scaled +/// Q32.32 raw integers directly. +#[inline] +#[must_use] +pub fn encode_motion_payload_q32_32(position_raw: [i64; 3], velocity_raw: [i64; 3]) -> Bytes { + let mut buf = Vec::with_capacity(MOTION_PAYLOAD_V2_BYTES); + for raw in position_raw.into_iter().chain(velocity_raw.into_iter()) { + buf.extend_from_slice(&raw.to_le_bytes()); + } + Bytes::from(buf) +} + /// Serialises motion data into a typed atom payload (`AtomPayload`). /// /// Equivalent to `AtomPayload { type_id: motion_payload_type_id(), bytes: encode_motion_payload(...) }`. @@ -52,16 +102,20 @@ pub fn encode_motion_atom_payload(position: [f32; 3], velocity: [f32; 3]) -> Ato ) } -/// Deserialises a canonical motion payload into `(position, velocity)` arrays. -/// -/// Expects exactly 24 bytes laid out as six little-endian `f32` values in -/// the order: position `[x, y, z]` followed by velocity `[vx, vy, vz]`. +/// Serialises motion data into a typed legacy v0 atom payload (`AtomPayload`). /// -/// Returns `None` if `bytes.len() != 24` or if any 4-byte chunk cannot be -/// converted into an `f32` (invalid input). On success, returns two `[f32; 3]` -/// arrays representing position and velocity respectively. -pub fn decode_motion_payload(bytes: &Bytes) -> Option<([f32; 3], [f32; 3])> { - if bytes.len() != POSITION_VELOCITY_BYTES { +/// This produces the legacy 24-byte 6×f32 encoding (`payload/motion/v0`). New writes in the +/// deterministic runtime should prefer the canonical v2 encoder ([`encode_motion_atom_payload`]). +#[must_use] +pub fn encode_motion_atom_payload_v0(position: [f32; 3], velocity: [f32; 3]) -> AtomPayload { + AtomPayload::new( + motion_payload_type_id_v0(), + encode_motion_payload_v0(position, velocity), + ) +} + +fn decode_motion_payload_v0(bytes: &Bytes) -> Option<([f32; 3], [f32; 3])> { + if bytes.len() != MOTION_PAYLOAD_V0_BYTES { return None; } let mut floats = [0f32; 6]; @@ -73,16 +127,99 @@ pub fn decode_motion_payload(bytes: &Bytes) -> Option<([f32; 3], [f32; 3])> { Some((position, velocity)) } +fn decode_motion_payload_v2(bytes: &Bytes) -> Option<([f32; 3], [f32; 3])> { + if bytes.len() != MOTION_PAYLOAD_V2_BYTES { + return None; + } + let mut floats = [0f32; 6]; + for (index, chunk) in bytes.chunks_exact(8).enumerate() { + let raw = i64::from_le_bytes(chunk.try_into().ok()?); + floats[index] = crate::math::fixed_q32_32::to_f32(raw); + } + let position = [floats[0], floats[1], floats[2]]; + let velocity = [floats[3], floats[4], floats[5]]; + Some((position, velocity)) +} + +fn decode_motion_payload_q32_32_v2(bytes: &Bytes) -> Option<([i64; 3], [i64; 3])> { + if bytes.len() != MOTION_PAYLOAD_V2_BYTES { + return None; + } + let mut raw = [0_i64; 6]; + for (index, chunk) in bytes.chunks_exact(8).enumerate() { + raw[index] = i64::from_le_bytes(chunk.try_into().ok()?); + } + let position = [raw[0], raw[1], raw[2]]; + let velocity = [raw[3], raw[4], raw[5]]; + Some((position, velocity)) +} + +fn decode_motion_payload_q32_32_v0(bytes: &Bytes) -> Option<([i64; 3], [i64; 3])> { + let (pos, vel) = decode_motion_payload_v0(bytes)?; + let position = [ + crate::math::fixed_q32_32::from_f32(pos[0]), + crate::math::fixed_q32_32::from_f32(pos[1]), + crate::math::fixed_q32_32::from_f32(pos[2]), + ]; + let velocity = [ + crate::math::fixed_q32_32::from_f32(vel[0]), + crate::math::fixed_q32_32::from_f32(vel[1]), + crate::math::fixed_q32_32::from_f32(vel[2]), + ]; + Some((position, velocity)) +} + +/// Deserialises a canonical motion payload into `(position, velocity)` arrays. +/// +/// Supports two encodings: +/// - v0: 6 × `f32` little-endian (24 bytes) +/// - v2: 6 × `i64` Q32.32 little-endian (48 bytes) +/// +/// **Note:** This function dispatches by byte length alone. When `type_id` is +/// available, prefer [`decode_motion_atom_payload`] for unambiguous routing. +/// +/// Returns `None` if the payload does not match either canonical encoding or if any +/// chunk cannot be converted (invalid input). +#[must_use] +pub fn decode_motion_payload(bytes: &Bytes) -> Option<([f32; 3], [f32; 3])> { + if bytes.len() == MOTION_PAYLOAD_V2_BYTES { + return decode_motion_payload_v2(bytes); + } + if bytes.len() == MOTION_PAYLOAD_V0_BYTES { + return decode_motion_payload_v0(bytes); + } + None +} + /// Deserialises a typed atom payload into `(position, velocity)` arrays. /// -/// Returns `None` if the payload `type_id` is not `motion_payload_type_id()` or -/// if the underlying bytes do not match the canonical motion encoding. +/// Returns `None` if the payload `type_id` is not a supported motion payload type id +/// or if the underlying bytes do not match the canonical motion encoding. #[must_use] pub fn decode_motion_atom_payload(payload: &AtomPayload) -> Option<([f32; 3], [f32; 3])> { - if payload.type_id != motion_payload_type_id() { - return None; + if payload.type_id == motion_payload_type_id() { + return decode_motion_payload_v2(&payload.bytes); + } + if payload.type_id == motion_payload_type_id_v0() { + return decode_motion_payload_v0(&payload.bytes); + } + None +} + +/// Deserialises a typed atom payload into Q32.32 raw `(position, velocity)` arrays. +/// +/// This is the canonical form used by deterministic motion logic: +/// - v2 payloads decode directly to raw integers. +/// - v0 payloads decode through f32 and are deterministically quantized to Q32.32. +#[must_use] +pub fn decode_motion_atom_payload_q32_32(payload: &AtomPayload) -> Option<([i64; 3], [i64; 3])> { + if payload.type_id == motion_payload_type_id() { + return decode_motion_payload_q32_32_v2(&payload.bytes); } - decode_motion_payload(&payload.bytes) + if payload.type_id == motion_payload_type_id_v0() { + return decode_motion_payload_q32_32_v0(&payload.bytes); + } + None } #[cfg(test)] @@ -96,22 +233,71 @@ mod tests { use super::*; #[test] - fn round_trip_ok() { + fn q32_32_decoder_accepts_v0_and_quantizes_deterministically() { + let pos = [1.0, 0.5, -1.0]; + let vel = [2.0, -0.25, 0.0]; + + let payload = encode_motion_atom_payload_v0(pos, vel); + let (p_raw, v_raw) = + decode_motion_atom_payload_q32_32(&payload).expect("v0 atom payload should decode"); + + for i in 0..3 { + assert_eq!(p_raw[i], crate::math::fixed_q32_32::from_f32(pos[i])); + assert_eq!(v_raw[i], crate::math::fixed_q32_32::from_f32(vel[i])); + } + } + + #[test] + fn round_trip_v0_ok() { let pos = [1.0, 2.0, 3.0]; let vel = [0.5, -1.0, 0.25]; - let bytes = encode_motion_payload(pos, vel); - let (p, v) = decode_motion_payload(&bytes).expect("24-byte payload"); + let bytes = encode_motion_payload_v0(pos, vel); + let (p, v) = decode_motion_payload(&bytes).expect("v0 payload"); for i in 0..3 { assert_eq!(p[i].to_bits(), pos[i].to_bits()); assert_eq!(v[i].to_bits(), vel[i].to_bits()); } } + #[test] + fn round_trip_v2_ok_for_exact_values() { + let pos = [1.0, 2.0, 3.0]; + let vel = [0.5, -1.0, 0.25]; + let bytes = encode_motion_payload(pos, vel); + assert_eq!(bytes.len(), MOTION_PAYLOAD_V2_BYTES); + let (p, v) = decode_motion_payload(&bytes).expect("v2 payload"); + assert_eq!(p, pos); + assert_eq!(v, vel); + } + + #[test] + fn q32_32_round_trip_matches_v2_bytes() { + let pos = [1.0, 2.0, 3.0]; + let vel = [0.5, -1.0, 0.25]; + let raw_pos = [ + crate::math::fixed_q32_32::from_f32(pos[0]), + crate::math::fixed_q32_32::from_f32(pos[1]), + crate::math::fixed_q32_32::from_f32(pos[2]), + ]; + let raw_vel = [ + crate::math::fixed_q32_32::from_f32(vel[0]), + crate::math::fixed_q32_32::from_f32(vel[1]), + crate::math::fixed_q32_32::from_f32(vel[2]), + ]; + let a = encode_motion_payload(pos, vel); + let b = encode_motion_payload_q32_32(raw_pos, raw_vel); + assert_eq!(a, b); + } + #[test] fn reject_wrong_len() { let b = Bytes::from_static(&[0u8; 23]); assert!(decode_motion_payload(&b).is_none()); let b = Bytes::from_static(&[0u8; 25]); assert!(decode_motion_payload(&b).is_none()); + let b = Bytes::from_static(&[0u8; 47]); + assert!(decode_motion_payload(&b).is_none()); + let b = Bytes::from_static(&[0u8; 49]); + assert!(decode_motion_payload(&b).is_none()); } } diff --git a/crates/warp-core/tests/deterministic_sin_cos_tests.rs b/crates/warp-core/tests/deterministic_sin_cos_tests.rs new file mode 100644 index 00000000..cf0bee0c --- /dev/null +++ b/crates/warp-core/tests/deterministic_sin_cos_tests.rs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS + +#![allow(missing_docs)] + +use std::f32::consts::TAU; + +use warp_core::math::scalar::F32Scalar; +use warp_core::math::Scalar; + +const ZERO: u32 = 0x0000_0000; +const ONE: u32 = 0x3f80_0000; +const CANON_NAN: u32 = 0x7fc0_0000; + +/// Temporary helper for tests while the deterministic trig backend is in flux. +/// +/// Once `F32Scalar::{sin,cos,sin_cos}` are backed by a deterministic LUT or +/// polynomial, these tests should validate both determinism and error budgets. +fn deterministic_sin_cos_f32(angle: f32) -> (f32, f32) { + let scalar = F32Scalar::new(angle); + let (s, c) = scalar.sin_cos(); + (s.to_f32(), c.to_f32()) +} + +fn ulp_diff(a: f32, b: f32) -> u32 { + if a.is_nan() || b.is_nan() { + return u32::MAX; + } + + let a_bits = a.to_bits(); + let b_bits = b.to_bits(); + + // "Ordered float" mapping so `abs_diff` matches ULP distance: + // - negative floats map to the lower half of the integer range (in order) + // - positive floats map to the upper half (in order) + fn ordered(bits: u32) -> u32 { + if bits & 0x8000_0000 != 0 { + !bits + } else { + bits | 0x8000_0000 + } + } + + ordered(a_bits).abs_diff(ordered(b_bits)) +} + +fn assert_canonical_f32(value: f32) { + let bits = value.to_bits(); + + // Canonicalize -0.0 => +0.0. + assert_ne!(bits, 0x8000_0000, "value must never be -0.0"); + + // Determinism policy: flush subnormals to +0.0. + assert!(!value.is_subnormal(), "value must never be subnormal"); + + // Determinism policy: canonicalize NaNs to a single bit-pattern. + if value.is_nan() { + assert_eq!(bits, 0x7fc0_0000, "NaN must be canonical"); + } +} + +#[test] +fn test_trig_special_cases_golden_bits() { + // (angle_bits, expected_sin_bits, expected_cos_bits) + let vectors: &[(u32, u32, u32)] = &[ + // 0 and -0 are canonicalized to +0; sin(0)=0, cos(0)=1. + (0x0000_0000, ZERO, ONE), + (0x8000_0000, ZERO, ONE), + // Subnormals are flushed to +0 at construction time. + (0x0000_0001, ZERO, ONE), + (0x8000_0001, ZERO, ONE), + (0x007f_ffff, ZERO, ONE), + (0x807f_ffff, ZERO, ONE), + // Infinities yield NaN for sin/cos (then canonicalized). + (0x7f80_0000, CANON_NAN, CANON_NAN), + (0xff80_0000, CANON_NAN, CANON_NAN), + // NaNs are canonicalized before and after trig. + (0x7fc0_0000, CANON_NAN, CANON_NAN), + (0xffc0_0000, CANON_NAN, CANON_NAN), + (0x7f80_dead, CANON_NAN, CANON_NAN), + ]; + + for (angle_bits, expected_sin_bits, expected_cos_bits) in vectors { + let angle = f32::from_bits(*angle_bits); + let (s, c) = deterministic_sin_cos_f32(angle); + + assert_eq!( + s.to_bits(), + *expected_sin_bits, + "sin bits mismatch for angle_bits={:#010x}", + angle_bits + ); + assert_eq!( + c.to_bits(), + *expected_cos_bits, + "cos bits mismatch for angle_bits={:#010x}", + angle_bits + ); + } +} + +#[test] +fn test_trig_outputs_are_canonical_over_sample_range() { + let step = TAU / 1024.0; + for i in 0..=2048_u32 { + let angle = -TAU + (i as f32) * step; + let (s, c) = deterministic_sin_cos_f32(angle); + + assert_canonical_f32(s); + assert_canonical_f32(c); + + if angle.is_finite() { + assert!(!s.is_nan(), "sin must be finite for finite angle={angle}"); + assert!(!c.is_nan(), "cos must be finite for finite angle={angle}"); + } + } +} + +#[test] +fn test_trig_known_angle_golden_bits() { + // These angles are chosen to be exactly representable `f32` constants so + // that this test is stable across platforms and toolchains. + // + // (angle_bits, expected_sin_bits, expected_cos_bits) + let vectors: &[(u32, u32, u32)] = &[ + // pi/8 + (0x3ec9_0fdb, 0x3ec3_ef15, 0x3f6c_835e), + // pi/4 + (0x3f49_0fdb, 0x3f35_04f3, 0x3f35_04f3), + // pi/2 + (0x3fc9_0fdb, 0x3f80_0000, 0x0000_0000), + // pi + (0x4049_0fdb, 0x0000_0000, 0xbf80_0000), + // 3pi/2 + (0x4096_cbe4, 0xbf80_0000, 0x0000_0000), + // 2pi + (0x40c9_0fdb, 0x0000_0000, 0x3f80_0000), + // -pi/8 + (0xbec9_0fdb, 0xbec3_ef15, 0x3f6c_835e), + ]; + + for (angle_bits, expected_sin_bits, expected_cos_bits) in vectors { + let angle = f32::from_bits(*angle_bits); + let (s, c) = deterministic_sin_cos_f32(angle); + + assert_eq!( + s.to_bits(), + *expected_sin_bits, + "sin bits mismatch for angle_bits={:#010x}", + angle_bits + ); + assert_eq!( + c.to_bits(), + *expected_cos_bits, + "cos bits mismatch for angle_bits={:#010x}", + angle_bits + ); + } +} + +#[test] +// TODO(#177): Replace libm-derived reference with a deterministic oracle and pin an explicit budget. +#[ignore = "Reference uses platform libm (see #177); keep ignored unless auditing error budgets"] +fn test_sin_cos_error_budget_wip() { + // NOTE: This test intentionally measures error against a high-precision-ish + // reference, but does not yet pin an explicit budget. Once the deterministic + // backend is implemented, add concrete acceptance thresholds and a compact + // "golden vector" suite for cross-platform CI. + + let mut max_ulp: u32 = 0; + let mut max_abs: f32 = 0.0; + let mut worst_angle: f32 = 0.0; + + let step = TAU / 4096.0; + for i in 0..=16_384_u32 { + let angle = -2.0 * TAU + (i as f32) * step; + let (s, c) = deterministic_sin_cos_f32(angle); + + // Reference: f64 trig, then cast down to float32. This is a measurement + // baseline only; it is not currently a strict determinism oracle. + let angle64 = angle as f64; + let s_ref = (angle64.sin() as f32) + 0.0; + let c_ref = (angle64.cos() as f32) + 0.0; + + let sin_ulp = ulp_diff(s, s_ref); + let cos_ulp = ulp_diff(c, c_ref); + + let ulp = sin_ulp.max(cos_ulp); + if ulp > max_ulp { + max_ulp = ulp; + worst_angle = angle; + } + + max_abs = max_abs.max((s - s_ref).abs()); + max_abs = max_abs.max((c - c_ref).abs()); + } + + eprintln!("wip trig error: max_ulp={max_ulp} max_abs={max_abs} at angle={worst_angle}"); +} diff --git a/crates/warp-core/tests/dfix64_tests.rs b/crates/warp-core/tests/dfix64_tests.rs new file mode 100644 index 00000000..862fef12 --- /dev/null +++ b/crates/warp-core/tests/dfix64_tests.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS + +#![allow(missing_docs)] +#![cfg(feature = "det_fixed")] + +use warp_core::math::scalar::DFix64; +use warp_core::math::Scalar; + +#[test] +fn dfix64_constants_and_raw_encoding() { + assert_eq!(DFix64::ZERO.raw(), 0); + assert_eq!(DFix64::ONE.raw(), 1_i64 << 32); +} + +#[test] +fn dfix64_from_f32_exact_values() { + assert_eq!(DFix64::from_f32(0.0).raw(), 0); + assert_eq!(DFix64::from_f32(-0.0).raw(), 0); + + assert_eq!(DFix64::from_f32(1.0).raw(), 1_i64 << 32); + assert_eq!(DFix64::from_f32(-1.0).raw(), -(1_i64 << 32)); + + assert_eq!(DFix64::from_f32(0.5).raw(), 1_i64 << 31); + assert_eq!(DFix64::from_f32(1.5).raw(), (1_i64 << 32) + (1_i64 << 31)); +} + +#[test] +fn dfix64_to_f32_roundtrips_basic_values() { + let values = [0.0, -0.0, 1.0, -1.0, 0.5, 1.5]; + for v in values { + let fx = DFix64::from_f32(v); + assert_eq!(fx.to_f32(), v); + } +} + +#[test] +fn dfix64_infinite_inputs_saturate() { + assert_eq!(DFix64::from_f32(f32::INFINITY).raw(), i64::MAX); + assert_eq!(DFix64::from_f32(f32::NEG_INFINITY).raw(), i64::MIN); +} + +#[test] +fn dfix64_nan_inputs_become_zero() { + assert_eq!(DFix64::from_f32(f32::NAN).raw(), 0); +} + +#[test] +fn dfix64_basic_arithmetic_is_reasonable() { + let a = DFix64::from_f32(1.5); + let b = DFix64::from_f32(2.0); + assert_eq!((a + b).to_f32(), 3.5); + assert_eq!((b - a).to_f32(), 0.5); + assert_eq!((a * b).to_f32(), 3.0); + assert_eq!((b / a).to_f32(), (2.0_f32 / 1.5_f32)); +} + +#[test] +fn dfix64_sin_cos_at_zero_is_exact() { + let (s, c) = DFix64::from_f32(0.0).sin_cos(); + assert_eq!(s.raw(), 0); + assert_eq!(c.raw(), 1_i64 << 32); +} diff --git a/crates/warp-core/tests/engine_motion_negative_tests.rs b/crates/warp-core/tests/engine_motion_negative_tests.rs index 45acbb69..6559e364 100644 --- a/crates/warp-core/tests/engine_motion_negative_tests.rs +++ b/crates/warp-core/tests/engine_motion_negative_tests.rs @@ -2,58 +2,33 @@ // © James Ross Ω FLYING•ROBOTS #![allow(missing_docs)] -//! Negative/edge-case tests for the motion rule. +//! Negative/edge-case tests for the motion rule under deterministic payload semantics. //! -//! These tests document behavior when payloads contain non-finite values -//! (NaN/Infinity) and when payload length is invalid. The runtime does not -//! sanitize non-finite inputs; NaN propagates and Infinity is preserved. An -//! invalid payload size results in `ApplyResult::NoMatch` at the apply boundary. +//! The motion payload is now canonicalized into a Q32.32 fixed-point encoding (v2) so that +//! the attachment-plane bytes are stable across platforms and language runtimes. +//! +//! Compatibility: +//! - Legacy v0 payloads (`payload/motion/v0`, 24 bytes = 6×f32) are accepted for decode/match. +//! - On write, the motion executor upgrades to the canonical v2 payload (`payload/motion/v2`, +//! 48 bytes = 6×i64 Q32.32). +//! +//! Non-finite values have no representation in Q32.32 and are deterministically mapped: +//! - `NaN` → `0` +//! - `+∞`/`-∞` → saturated extrema (≈ ±2^31 when decoded as `f32`) + use bytes::Bytes; use warp_core::{ - decode_motion_atom_payload, encode_motion_atom_payload, make_node_id, make_type_id, + decode_motion_atom_payload, encode_motion_atom_payload_v0, make_node_id, make_type_id, motion_payload_type_id, ApplyResult, AtomPayload, AttachmentValue, Engine, GraphStore, NodeRecord, MOTION_RULE_NAME, }; -fn run_motion_once(pos: [f32; 3], vel: [f32; 3]) -> ([f32; 3], [f32; 3]) { +fn run_motion_once_with_payload(payload: AtomPayload) -> (warp_core::TypeId, [f32; 3], [f32; 3]) { let ent = make_node_id("case"); let ty = make_type_id("entity"); let mut store = GraphStore::default(); store.insert_node(ent, NodeRecord { ty }); - store.set_node_attachment( - ent, - Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))), - ); - let mut engine = Engine::new(store, ent); - engine - .register_rule(warp_core::motion_rule()) - .expect("register motion rule"); - let tx = engine.begin(); - let _ = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply"); - engine.commit(tx).expect("commit"); - let payload = engine - .node_attachment(&ent) - .expect("node_attachment ok") - .expect("payload present"); - let AttachmentValue::Atom(payload) = payload else { - panic!("expected Atom payload, got {payload:?}"); - }; - decode_motion_atom_payload(payload).expect("decode") -} - -#[test] -fn motion_nan_propagates_and_rule_applies() { - let ent = make_node_id("nan-case"); - let ty = make_type_id("entity"); - let pos = [f32::NAN, 0.0, 1.0]; - let vel = [0.0, f32::NAN, 2.0]; - - let mut store = GraphStore::default(); - store.insert_node(ent, NodeRecord { ty }); - store.set_node_attachment( - ent, - Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))), - ); + store.set_node_attachment(ent, Some(AttachmentValue::Atom(payload))); let mut engine = Engine::new(store, ent); engine @@ -62,51 +37,10 @@ fn motion_nan_propagates_and_rule_applies() { let tx = engine.begin(); let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply"); - assert!(matches!(res, ApplyResult::Applied)); - engine.commit(tx).expect("commit"); - - let payload = engine - .node_attachment(&ent) - .expect("node_attachment ok") - .expect("payload present"); - let AttachmentValue::Atom(payload) = payload else { - panic!("expected Atom payload, got {payload:?}"); - }; - let (new_pos, new_vel) = decode_motion_atom_payload(payload).expect("decode"); - - // NaN arithmetic propagates; check using is_nan rather than bitwise. - assert!(new_pos[0].is_nan(), "pos.x should be NaN after update"); - assert!(new_pos[1].is_nan(), "pos.y should be NaN after update"); - assert_eq!(new_pos[2].to_bits(), (1.0f32 + 2.0f32).to_bits()); - - // Velocity preserved; NaN stays NaN; finite components equal bitwise. - assert!(new_vel[1].is_nan(), "vel.y should remain NaN"); - assert_eq!(new_vel[0].to_bits(), 0.0f32.to_bits()); - assert_eq!(new_vel[2].to_bits(), 2.0f32.to_bits()); -} - -#[test] -fn motion_infinity_preserves_infinite_values() { - let ent = make_node_id("inf-case"); - let ty = make_type_id("entity"); - let pos = [f32::INFINITY, 1.0, f32::NEG_INFINITY]; - let vel = [1.0, 2.0, 3.0]; - - let mut store = GraphStore::default(); - store.insert_node(ent, NodeRecord { ty }); - store.set_node_attachment( - ent, - Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))), + assert!( + matches!(res, ApplyResult::Applied), + "expected Applied, got {res:?}" ); - - let mut engine = Engine::new(store, ent); - engine - .register_rule(warp_core::motion_rule()) - .expect("register motion rule"); - - let tx = engine.begin(); - let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply"); - assert!(matches!(res, ApplyResult::Applied)); engine.commit(tx).expect("commit"); let payload = engine @@ -116,15 +50,9 @@ fn motion_infinity_preserves_infinite_values() { let AttachmentValue::Atom(payload) = payload else { panic!("expected Atom payload, got {payload:?}"); }; - let (new_pos, new_vel) = decode_motion_atom_payload(payload).expect("decode"); - - assert!(new_pos[0].is_infinite() && new_pos[0].is_sign_positive()); - assert_eq!(new_pos[1].to_bits(), 3.0f32.to_bits()); - assert!(new_pos[2].is_infinite() && new_pos[2].is_sign_negative()); - - for i in 0..3 { - assert_eq!(new_vel[i].to_bits(), vel[i].to_bits()); - } + let ty = payload.type_id; + let (pos, vel) = decode_motion_atom_payload(payload).expect("decode"); + (ty, pos, vel) } #[test] @@ -140,6 +68,7 @@ fn motion_invalid_payload_size_returns_nomatch() { Bytes::from(vec![0u8; 10]), ))), ); + let mut engine = Engine::new(store, ent); engine .register_rule(warp_core::motion_rule()) @@ -150,200 +79,70 @@ fn motion_invalid_payload_size_returns_nomatch() { } #[test] -fn motion_all_position_components_nan_stay_nan() { - let (new_pos, new_vel) = run_motion_once([f32::NAN, f32::NAN, f32::NAN], [0.0, 0.0, 0.0]); - assert!(new_pos[0].is_nan()); - assert!(new_pos[1].is_nan()); - assert!(new_pos[2].is_nan()); - // Velocity preserved - assert_eq!(new_vel, [0.0, 0.0, 0.0]); -} - -#[test] -fn motion_all_velocity_components_nan_propagate_to_position_nan() { - let (new_pos, new_vel) = run_motion_once([1.0, 2.0, 3.0], [f32::NAN, f32::NAN, f32::NAN]); - assert!(new_pos[0].is_nan()); - assert!(new_pos[1].is_nan()); - assert!(new_pos[2].is_nan()); - assert!(new_vel[0].is_nan()); - assert!(new_vel[1].is_nan()); - assert!(new_vel[2].is_nan()); -} - -#[test] -fn motion_infinity_plus_infinity_remains_infinite() { - let (new_pos, new_vel) = run_motion_once( - [f32::INFINITY, f32::NEG_INFINITY, 0.0], - [f32::INFINITY, f32::NEG_INFINITY, 0.0], +fn motion_v0_payload_is_accepted_and_upgraded_to_v2() { + let payload = encode_motion_atom_payload_v0([1.0, 2.0, 3.0], [0.5, -1.0, 0.25]); + + let (ty, pos, vel) = run_motion_once_with_payload(payload); + assert_eq!( + ty, + motion_payload_type_id(), + "executor should upgrade to v2" ); - assert!(new_pos[0].is_infinite() && new_pos[0].is_sign_positive()); - assert!(new_pos[1].is_infinite() && new_pos[1].is_sign_negative()); - assert_eq!(new_pos[2].to_bits(), 0.0f32.to_bits()); - for i in 0..3 { - assert_eq!( - new_vel[i].to_bits(), - [f32::INFINITY, f32::NEG_INFINITY, 0.0][i].to_bits() - ); - } + assert_eq!(pos, [1.5, 1.0, 3.25]); + assert_eq!(vel, [0.5, -1.0, 0.25]); } #[test] -fn motion_infinity_minus_infinity_results_nan() { - // +inf + (-inf) → NaN, and -inf + (+inf) → NaN - let (new_pos, _) = run_motion_once( - [f32::INFINITY, f32::NEG_INFINITY, 0.0], - [f32::NEG_INFINITY, f32::INFINITY, 0.0], - ); - assert!(new_pos[0].is_nan()); - assert!(new_pos[1].is_nan()); +fn motion_v0_payload_nan_clamps_to_zero_on_upgrade() { + let payload = encode_motion_atom_payload_v0([f32::NAN, 0.0, 1.0], [0.0, f32::NAN, 2.0]); + + let (ty, pos, vel) = run_motion_once_with_payload(payload); + assert_eq!(ty, motion_payload_type_id()); + + // NaNs clamp to 0 at the fixed-point boundary. + assert_eq!(pos[0].to_bits(), 0.0f32.to_bits()); + assert_eq!(pos[1].to_bits(), 0.0f32.to_bits()); + assert_eq!(pos[2].to_bits(), 3.0f32.to_bits()); + assert_eq!(vel[0].to_bits(), 0.0f32.to_bits()); + assert_eq!(vel[1].to_bits(), 0.0f32.to_bits()); + assert_eq!(vel[2].to_bits(), 2.0f32.to_bits()); } #[test] -fn motion_mixed_nan_and_infinity_behaves_as_expected() { - // NaN dominates arithmetic; Infinity preserves sign where finite partner exists; - // Infinity + (-Infinity) becomes NaN per IEEE-754. - let (new_pos, new_vel) = run_motion_once( - [f32::NAN, f32::INFINITY, 1.0], - [2.0, f32::NEG_INFINITY, f32::NAN], - ); - assert!(new_pos[0].is_nan()); - assert!(new_pos[2].is_nan()); - assert!(new_pos[1].is_nan()); - assert_eq!(new_vel[0].to_bits(), 2.0f32.to_bits()); - assert!(new_vel[1].is_infinite() && new_vel[1].is_sign_negative()); - assert!(new_vel[2].is_nan()); +fn motion_v0_payload_infinity_saturates_on_upgrade() { + let payload = + encode_motion_atom_payload_v0([f32::INFINITY, 1.0, f32::NEG_INFINITY], [1.0, 2.0, 3.0]); + + let (ty, pos, vel) = run_motion_once_with_payload(payload); + assert_eq!(ty, motion_payload_type_id()); + + // Saturated Q32.32 extrema decode to ±2^31 as f32. + // + // This is effectively `i64::MAX / 2^32` rounded to f32. + const SATURATED_POS_F32: f32 = 2147483648.0; + assert_eq!(pos[0].to_bits(), SATURATED_POS_F32.to_bits()); + assert_eq!(pos[1].to_bits(), 3.0f32.to_bits()); + assert_eq!(pos[2].to_bits(), (-SATURATED_POS_F32).to_bits()); + assert_eq!(vel, [1.0, 2.0, 3.0]); } #[test] -fn motion_signed_zero_preservation_against_expected_math() { - // Compare to direct arithmetic to avoid making assumptions about zero sign rules. - let pos = [0.0f32, -0.0, 0.0]; - let vel = [-0.0f32, 0.0, -0.0]; - let (new_pos, new_vel) = run_motion_once(pos, vel); - for i in 0..3 { - assert_eq!(new_pos[i].to_bits(), (pos[i] + vel[i]).to_bits()); - assert_eq!(new_vel[i].to_bits(), vel[i].to_bits()); - } -} +fn motion_signed_zero_is_canonicalized_to_positive_zero() { + let payload = encode_motion_atom_payload_v0([0.0f32, -0.0, 0.0], [-0.0f32, 0.0, -0.0]); -#[test] -fn motion_subnormal_and_extreme_values_follow_ieee_math() { - let sub = f32::from_bits(1); // smallest positive subnormal - let pos = [f32::MAX, -f32::MAX, sub]; - let vel = [sub, sub, sub]; - let (new_pos, new_vel) = run_motion_once(pos, vel); + let (_ty, pos, vel) = run_motion_once_with_payload(payload); for i in 0..3 { - assert_eq!(new_pos[i].to_bits(), (pos[i] + vel[i]).to_bits()); - assert_eq!(new_vel[i].to_bits(), vel[i].to_bits()); - } -} - -#[test] -fn motion_zero_length_payload_returns_nomatch() { - let ent = make_node_id("bad-size-0"); - let ty = make_type_id("entity"); - let mut store = GraphStore::default(); - store.insert_node(ent, NodeRecord { ty }); - store.set_node_attachment( - ent, - Some(AttachmentValue::Atom(AtomPayload::new( - motion_payload_type_id(), - Bytes::from(vec![]), - ))), - ); - let mut engine = Engine::new(store, ent); - engine.register_rule(warp_core::motion_rule()).unwrap(); - let tx = engine.begin(); - let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply"); - assert!(matches!(res, ApplyResult::NoMatch)); -} - -#[test] -fn motion_boundary_payload_sizes() { - for &len in &[1usize, 23, 25, 32, 4096] { - let ent = make_node_id(&format!("bad-size-{}", len)); - let ty = make_type_id("entity"); - let mut store = GraphStore::default(); - store.insert_node(ent, NodeRecord { ty }); - store.set_node_attachment( - ent, - Some(AttachmentValue::Atom(AtomPayload::new( - motion_payload_type_id(), - Bytes::from(vec![0u8; len]), - ))), - ); - let mut engine = Engine::new(store, ent); - engine.register_rule(warp_core::motion_rule()).unwrap(); - let tx = engine.begin(); - let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply"); - assert!( - matches!(res, ApplyResult::NoMatch), - "len={} should be NoMatch", - len - ); + assert_eq!(pos[i].to_bits(), 0.0f32.to_bits()); + assert_eq!(vel[i].to_bits(), 0.0f32.to_bits()); } } #[test] -fn motion_exact_24_bytes_with_weird_bits_is_accepted_and_propagates() { - // 24 bytes of 0xFF -> three NaNs for pos, three NaNs for vel - let weird = AtomPayload::new(motion_payload_type_id(), Bytes::from(vec![0xFFu8; 24])); - let ent = make_node_id("weird-24"); - let ty = make_type_id("entity"); - let mut store = GraphStore::default(); - store.insert_node(ent, NodeRecord { ty }); - store.set_node_attachment(ent, Some(AttachmentValue::Atom(weird))); - let mut engine = Engine::new(store, ent); - engine.register_rule(warp_core::motion_rule()).unwrap(); - let tx = engine.begin(); - let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply"); - assert!(matches!(res, ApplyResult::Applied)); - engine.commit(tx).unwrap(); - let (pos, vel) = { - let payload = engine - .node_attachment(&ent) - .expect("node_attachment ok") - .expect("payload present"); - let AttachmentValue::Atom(payload) = payload else { - panic!("expected Atom payload, got {payload:?}"); - }; - decode_motion_atom_payload(payload).unwrap() - }; - assert!(pos.iter().all(|v| v.is_nan())); - assert!(vel.iter().all(|v| v.is_nan())); -} +fn motion_subnormal_inputs_are_flushed_to_zero() { + let sub = f32::from_bits(1); // smallest positive subnormal + let payload = encode_motion_atom_payload_v0([sub, sub, sub], [sub, sub, sub]); -#[test] -fn motion_nan_idempotency_applies_twice_stays_nan() { - let ent = make_node_id("nan-twice"); - let ty = make_type_id("entity"); - let mut store = GraphStore::default(); - store.insert_node(ent, NodeRecord { ty }); - store.set_node_attachment( - ent, - Some(AttachmentValue::Atom(encode_motion_atom_payload( - [f32::NAN, f32::NAN, f32::NAN], - [0.0, 0.0, 0.0], - ))), - ); - let mut engine = Engine::new(store, ent); - engine.register_rule(warp_core::motion_rule()).unwrap(); - for _ in 0..2 { - let tx = engine.begin(); - let res = engine.apply(tx, MOTION_RULE_NAME, &ent).unwrap(); - assert!(matches!(res, ApplyResult::Applied)); - engine.commit(tx).unwrap(); - } - let (pos, vel) = { - let payload = engine - .node_attachment(&ent) - .expect("node_attachment ok") - .expect("payload present"); - let AttachmentValue::Atom(payload) = payload else { - panic!("expected Atom payload, got {payload:?}"); - }; - decode_motion_atom_payload(payload).unwrap() - }; - assert!(pos.iter().all(|v| v.is_nan())); + let (_ty, pos, vel) = run_motion_once_with_payload(payload); + assert_eq!(pos, [0.0, 0.0, 0.0]); assert_eq!(vel, [0.0, 0.0, 0.0]); } diff --git a/crates/warp-core/tests/math_rotation_tests.rs b/crates/warp-core/tests/math_rotation_tests.rs index f21da01a..0096dbfa 100644 --- a/crates/warp-core/tests/math_rotation_tests.rs +++ b/crates/warp-core/tests/math_rotation_tests.rs @@ -6,7 +6,9 @@ use core::f32::consts::FRAC_PI_2; use warp_core::math::{Mat4, Vec3}; fn approx_eq3(a: [f32; 3], b: [f32; 3]) { - const ABS_TOL: f32 = 1e-7; + // Deterministic trig backend is LUT/interpolation-based, so allow a small + // absolute tolerance (≈ 1–2 ulp at unit scale). + const ABS_TOL: f32 = 2e-7; const REL_TOL: f32 = 1e-6; for i in 0..3 { let ai = a[i]; diff --git a/crates/warp-core/tests/math_scalar_tests.rs b/crates/warp-core/tests/math_scalar_tests.rs index cb08b99c..52b74cd8 100644 --- a/crates/warp-core/tests/math_scalar_tests.rs +++ b/crates/warp-core/tests/math_scalar_tests.rs @@ -23,8 +23,8 @@ fn test_f32_basics() { assert_eq!((a / b).to_f32(), 2.5); let angle = F32Scalar::new(std::f32::consts::PI); - assert_eq!(angle.sin().to_f32(), angle.to_f32().sin()); - assert_eq!(angle.cos().to_f32(), angle.to_f32().cos()); + assert_eq!(angle.sin().to_f32().to_bits(), 0x0000_0000); + assert_eq!(angle.cos().to_f32().to_bits(), 0xbf80_0000); } #[test] diff --git a/crates/warp-core/tests/proptest_seed_pinning.rs b/crates/warp-core/tests/proptest_seed_pinning.rs index 188f75f9..78af4523 100644 --- a/crates/warp-core/tests/proptest_seed_pinning.rs +++ b/crates/warp-core/tests/proptest_seed_pinning.rs @@ -6,8 +6,9 @@ use proptest::prelude::*; use proptest::test_runner::{Config as PropConfig, RngAlgorithm, TestRng, TestRunner}; use warp_core::{ - decode_motion_atom_payload, encode_motion_atom_payload, make_node_id, make_type_id, - ApplyResult, AttachmentValue, Engine, GraphStore, NodeRecord, MOTION_RULE_NAME, + decode_motion_atom_payload, decode_motion_payload, encode_motion_atom_payload, + encode_motion_payload, make_node_id, make_type_id, ApplyResult, AttachmentValue, Engine, + GraphStore, NodeRecord, MOTION_RULE_NAME, }; // Demonstrates how to pin a deterministic seed for property tests so failures @@ -67,10 +68,22 @@ fn proptest_seed_pinned_motion_updates() { }; let (new_pos, new_vel) = decode_motion_atom_payload(payload).expect("decode"); - // Velocity is preserved; position += vel * dt (dt = 1.0). + // Payloads are canonicalized to Q32.32 at the boundary, so the effective + // inputs and outputs are the fixed-point quantized values. + let (pos_q, vel_q) = decode_motion_payload(&encode_motion_payload(pos, vel)) + .expect("encode/decode canonical inputs"); + let updated_pos = [ + pos_q[0] + vel_q[0], + pos_q[1] + vel_q[1], + pos_q[2] + vel_q[2], + ]; + let (expected_pos, expected_vel) = + decode_motion_payload(&encode_motion_payload(updated_pos, vel_q)) + .expect("encode/decode canonical outputs"); + for i in 0..3 { - prop_assert_eq!(new_vel[i].to_bits(), vel[i].to_bits()); - prop_assert_eq!(new_pos[i].to_bits(), (pos[i] + vel[i]).to_bits()); + prop_assert_eq!(new_vel[i].to_bits(), expected_vel[i].to_bits()); + prop_assert_eq!(new_pos[i].to_bits(), expected_pos[i].to_bits()); } Ok(()) }) diff --git a/docs/SPEC_DETERMINISTIC_MATH.md b/docs/SPEC_DETERMINISTIC_MATH.md index d590ab69..4e5c1d02 100644 --- a/docs/SPEC_DETERMINISTIC_MATH.md +++ b/docs/SPEC_DETERMINISTIC_MATH.md @@ -38,17 +38,46 @@ Implementations of `Eq` for floating-point types **must** be reflexive. An audit of `warp-core` identified the following risks: -* **Hardware Transcendentals:** `F32Scalar::sin/cos` currently delegate to `f32::sin/cos`. **Risk:** High. These vary across libc/hardware implementations. - * *Action:* Replace with deterministic software implementation (Issue #115). +* **Hardware Transcendentals:** `F32Scalar::sin/cos` previously delegated to `f32::sin/cos`. **Risk:** High (varies across libc/hardware implementations). + * *Status:* Implemented deterministic LUT-backed trig in `warp_core::math::trig` (Issue #107). * **Implicit Hardware Ops:** `Add`, `Sub`, `Mul`, `Div` rely on standard `f32` ops. - * *Risk:* Subnormal handling (DAZ/FTZ) depends on CPU flags. - * *Action:* `F32Scalar::new` (result wrapper) needs to explicitly flush subnormals. + * *Risk:* Subnormal handling (DAZ/FTZ) depends on CPU flags. + * *Status:* `F32Scalar::new` flushes subnormals to `+0.0` at construction and after operations. * **NaN Propagation:** `f32` ops produce hardware-specific NaN payloads. - * *Action:* `F32Scalar::new` must sanitize NaNs. + * *Status:* `F32Scalar::new` canonicalizes NaNs to `0x7fc0_0000`. ## 4. Implementation Checklist - [x] Canonicalize `-0.0` to `+0.0` (PR #123). -- [ ] Canonicalize `NaN` payloads (Planned). -- [ ] Flush subnormals to `+0.0` (Planned). -- [ ] Replace `sin`/`cos` with deterministic approximation (Planned). +- [x] Canonicalize `NaN` payloads (`F32Scalar::new`). +- [x] Flush subnormals to `+0.0` (`F32Scalar::new`). +- [x] Replace `sin`/`cos` with deterministic approximation (`warp_core::math::trig` LUT backend). + +## 5. Local Validation (CI parity) + +Echo’s deterministic-math CI lanes are intentionally “boring”: they run the same commands you +should run locally before proposing changes to scalar backends or transcendentals. + +### Default lane (`det_float`) + +The default `warp-core` build uses the float32-backed lane (`F32Scalar`) and the deterministic +trig backend (`warp_core::math::trig`). + +- `cargo test -p warp-core` +- `cargo clippy -p warp-core --all-targets -- -D warnings -D missing_docs` + +### Fixed-point lane (`det_fixed`) + +`DFix64` (Q32.32) is currently feature-gated so we can evolve it without destabilizing the +default runtime surface. + +- `cargo test -p warp-core --features det_fixed` +- `cargo clippy -p warp-core --all-targets --features det_fixed -- -D warnings -D missing_docs` + +### MUSL (Linux portability lane) + +CI also runs `warp-core` under MUSL to catch portability and toolchain drift. + +- Install: `sudo apt-get update && sudo apt-get install -y musl-tools` +- Test (float lane): `cargo test -p warp-core --target x86_64-unknown-linux-musl` +- Test (fixed lane): `cargo test -p warp-core --features det_fixed --target x86_64-unknown-linux-musl` diff --git a/docs/decision-log.md b/docs/decision-log.md index fce94a51..b93e90d8 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -9,6 +9,11 @@ | 2026-01-01 | Paper VI + roadmap hygiene (issue #180) | Add and maintain a Capability Ownership Matrix doc as a first-class artifact, alongside time-travel determinism notes, to keep ownership/determinism/provenance boundaries explicit. | As Echo grows, “who owns what” drifts silently; writing it down early prevents nondeterminism from leaking into the kernel and keeps tool/replay requirements grounded. | Future design and implementation work references the matrix for boundary decisions; specs and tooling tasks can be triaged against explicit determinism/provenance expectations. | | 2026-01-01 | WVP demo hardening: review nits follow-up | Demo 1 — Tighten loopback/publish tests to be defensively correct (overflow-safe packet sizing and explicit non-empty graph assertion) in response to CodeRabbit review feedback. | Even test-only code should not encode obvious footguns (unchecked `len + 32`) or rely on undocumented invariants without asserting them. | The test suite remains robust under adversarial framing and continues to document WVP demo assumptions explicitly. | | 2026-01-01 | WVP demo hardening (issue #169) | Demo 1 — Add loopback tests (service + viewer) that pin snapshot-first publish behavior, gapless epoch monotonicity, and forbidden/epoch-gap rejection. | The WVP “happy path” is demo-critical and easy to regress during refactors. Loopback tests provide fast feedback without requiring a real Unix socket listener or a browser/GUI harness. | `cargo test` now catches protocol regressions (authority, snapshot required, epoch gaps) and viewer publish-state bugs before they make the demo path flaky. | +| 2026-01-01 | Motion payload determinism (v2) | Promote the motion demo payload to a deterministic Q32.32 fixed-point v2 encoding (`payload/motion/v2`, 6×i64 LE) while preserving v0 decode compatibility (`payload/motion/v0`, 6×f32 LE). Port the motion executor to `Scalar` and upgrade v0 payloads to v2 on write. | Motion payload bytes participate in deterministic state hashing and cross-platform replay; raw `f32` bytes reintroduce NaN payload and subnormal drift risks. Upgrading v0-on-write keeps backwards compatibility while converging stores on a stable canonical encoding. | Motion rule remains usable with legacy fixtures, but resulting state always converges to the deterministic v2 payload after the first write; non-finite values are mapped deterministically (NaN→0; ±∞ saturate). | +| 2026-01-01 | Deterministic math: fixed-point lane (`DFix64`) | Introduce a deterministic fixed-point scalar (`DFix64`, Q32.32 in `i64`) behind a `det_fixed` feature flag, with saturating arithmetic and ties-to-even rounding for mul/div; add `warp-core` tests and CI lanes that run `cargo test -p warp-core --features det_fixed` (glibc + musl). | Fixed-point is a key alternative backend for determinism audits and “no-float” environments; keeping it feature-gated and continuously tested lets us evolve it without destabilizing the default `F32Scalar` lane. | The repo gains a continuously exercised fixed-point backend; CI will catch regressions in the fixed-point surface and cross-platform compilation early (including musl). | +| 2026-01-01 | Deterministic math guardrails | Forbid raw trig calls in `warp-core` math modules (outside the deterministic trig backend + scalar wrapper surface) via a dedicated CI/script guard. | Prevents regressions where platform/libm transcendentals sneak back into runtime math, breaking cross-platform determinism. | CI fails fast if `mat4`, `quat`, etc. reintroduce `.sin/.cos/.sin_cos` calls; trig stays centralized in `warp_core::math::trig` and surfaced through scalar wrappers. | +| 2026-01-01 | Deterministic math: transcendental stability (`F32Scalar`) | Implement LUT-backed deterministic `F32Scalar::{sin,cos,sin_cos}` via a checked-in quarter-wave table + linear interpolation; add golden-vector trig tests to pin exact output bits. | Removes platform/libm variance while preserving `F32Scalar` canonicalization invariants (no `-0.0`, no subnormals, canonical NaNs). The existing “error budget” test remains opt-in because its reference uses platform libm. | Trig is now deterministic across supported platforms; CI can enforce stable bits via golden vectors; developers can still audit approximation error by running ignored tests explicitly. | +| 2026-01-01 | PR #167 review cleanup (deterministic math) | Tighten determinism invariants surfaced by review: enforce exact odd symmetry for sine by range-reducing `abs(angle)` and applying sign at the end; centralize signed-zero canonicalization via `trig::canonicalize_zero`; remove magic endpoint bits in the LUT generator; add tests asserting the motion executor accepts v0 payloads and upgrades to v2; document deterministic `0/0 → 0` policy; add `// TODO(#177)` tracking for the ignored trig error-budget audit. | Code review found a 1‑ULP mismatch between `sin(±π/8)` golden vectors and flagged duplicated signed-zero handling / implicit invariants. Making symmetry and canonicalization explicit prevents bit drift and reduces the chance of reintroducing nondeterministic edge behavior. | Golden vectors for `sin(-x)` are bit-stable with `sin(x)`; canonicalization logic is centralized and reused by Mat4/Quat; motion v0 support is executable (not just documented); remaining work: convert the ignored audit test into a deterministic oracle once budgets are pinned (#177). | | 2025-12-30 | PR #164 review follow-ups (WVP demo robustness; related: WVP demo path entry below) | Reset per-connection publish state on successful connect (force snapshot before diffs), harden handshake/outbound encoding failures (log + bail instead of writing empty packets), and add missing rustdoc/warn logs surfaced by review. | Review comments flagged a reconnect publish dead-end and “silent failure” risks; making failures visible and forcing snapshot-on-reconnect keeps the demo deterministic, debuggable, and aligned with missing-docs policy. | Publishing resumes after reconnect without manual toggles; protocol encode failures no longer silently write empty packets; workspace `-D warnings -D missing_docs` remains green. | | 2025-12-30 | WARP View Protocol demo path (hub + 2 viewers) | Add a bidirectional tool connection (`connect_channels_for_bidir`) so tools can publish `warp_stream` frames, and wire `warp-viewer` with publish/receive toggles plus a deterministic demo mutation (“pulse”) that emits gapless snapshot+diff streams. Document the workflow in `docs/guide/wvp-demo.md` and mark WVP checklist items complete in `docs/tasks.md`. | WVP needs an end-to-end “real” demo (not just a spec): tools must be able to publish without dragging async runtimes into UI code, and publisher/subscriber roles must be testable locally. A deterministic, viewer-native mutation is the smallest way to exercise the protocol without coupling to the engine rewrite loop yet. | You can run a local hub and watch two viewers share WARP changes (publisher pulses, subscriber updates). Client and hub error surfaces are explicit via notifications/toasts; epoch gaps and forbidden publishes fail fast. Remaining follow-up: a dedicated integration test harness for multi-client loopback if/when we formalize resync/acks. | | 2025-12-30 | Stage B1 usability/docs: make descent-chain law and portal-chain slicing concrete | Expand `docs/spec-warp-core.md` with worked Stage B1 examples (descent-chain reads → `Footprint.a_read` → patch `in_slots`, and portal-chain inclusion in Paper III slicing). Add `Engine::with_state(...) -> Result` so tools/tests can initialize an engine from an externally constructed multi-instance `WarpState` without exposing internal maps. | Stage B1 correctness depends on subtle invariants that are easy to “get almost right” without a concrete example; documentation must prevent “looks right, doesn’t slice.” A state-based engine constructor is the smallest ergonomic primitive that enables multi-instance fixtures and patch-replay workflows while keeping the rewrite hot path unchanged. | Contributors can reproduce and reason about B1 behavior quickly; multi-instance demos/tests can be built via patch replay + `Engine::with_state` rather than internal mutation; the portal-chain dependency is explicit in both docs and slice behavior. | diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 943e697e..8625757b 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -55,6 +55,35 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s - Evidence: - PR #175 (loopback tests + publish behavior pinned; follow-up hardening for defensive test checks) +> 2026-01-01 — PR #167: deterministic math follow-ups + merge `main` (IN PROGRESS) + +- Goal: address all CodeRabbit review comments on PR #167 with minimal churn, keep the PR tightly scoped to deterministic math + warp-core motion payload work, and restore mergeability by merging `main` and resolving docs guard conflicts. +- Scope: + - Resolve merge conflicts from `origin/main` in `docs/decision-log.md` and `docs/execution-plan.md` while preserving both the WVP hardening timeline and the deterministic math timeline. + - Keep deterministic trig guardrails stable (`scripts/check_no_raw_trig.sh`) so raw platform trig calls cannot sneak back into runtime math code. +- Exit criteria: `cargo test -p warp-core` and `cargo test -p warp-core --features det_fixed` are green; `cargo clippy -p warp-core --all-targets -- -D warnings -D missing_docs` is green; PR is mergeable and CI stays green. +- Evidence (local): PR head includes deterministic trig + motion payload fixes up through `f3ca59b`; CodeRabbit reviewDecision is approved and CI is green on PR #167. Next: finish the merge commit + rerun local checks after resolving the docs conflicts. + +> 2026-01-01 — Motion payload v2 (Q32.32) + `Scalar` port (COMPLETED) + +- Goal: move the motion demo payload to a deterministic Q32.32 fixed-point encoding (v2) while preserving decode compatibility for the legacy v0 `f32` payload; port the motion executor to use the `Scalar` abstraction and upgrade v0 payloads to v2 on write. +- Evidence: `cargo test -p warp-core` green; `cargo test -p warp-core --features det_fixed` green; `cargo clippy -p warp-core --all-targets -- -D warnings -D missing_docs` green; `cargo clippy -p warp-core --all-targets --features det_fixed -- -D warnings -D missing_docs` green. + +> 2026-01-01 — Deterministic fixed-point lane (`DFix64`) + CI coverage (COMPLETED) + +- Goal: land a deterministic fixed-point scalar backend (`DFix64`, Q32.32) behind a `det_fixed` feature flag, add a dedicated test suite, and extend CI with explicit `--features det_fixed` lanes (including MUSL) so we continuously exercise cross-platform behavior. +- Evidence: commit `57d2ec3` plus the above motion work continues to validate the det_fixed lane in CI. + +> 2026-01-01 — Implement deterministic `F32Scalar` trig (COMPLETED) + +- Goal: replace `F32Scalar::{sin,cos,sin_cos}`’s platform transcendentals with a deterministic LUT-backed backend, check in the LUT, and promote the existing trig test scaffold into a cross-platform golden-vector suite. +- Evidence: `cargo test -p warp-core` green; `cargo test -p warp-core --test deterministic_sin_cos_tests` green (error-budget audit test remains `#[ignore]`); `cargo clippy -p warp-core --all-targets -- -D warnings -D missing_docs` green. + +> 2025-12-30 — Branch maintenance: resurrect `F32Scalar/sin-cos` (COMPLETED) + +- Goal: merge `main` into the legacy deterministic trig test branch, resolve the `rmg-core`→`warp-core` rename conflict, and leave the WIP test compiling (ignored by default). +- Evidence: merge commit `6cfa64d` (“Merge branch 'main' into F32Scalar/sin-cos”); `cargo test -p warp-core --test deterministic_sin_cos_tests` passes (ignored test remains opt-in). + > 2025-12-30 — Issue #163: WVP demo path (IN PROGRESS) - Goal: complete the WARP View Protocol demo path (publisher + subscriber) by adding outbound publish support to `echo-session-client` and wiring publish/subscribe toggles + a dirty publish loop in `warp-viewer`. diff --git a/docs/notes/f32scalar-deterministic-trig-implementation-guide.md b/docs/notes/f32scalar-deterministic-trig-implementation-guide.md new file mode 100644 index 00000000..51a57df2 --- /dev/null +++ b/docs/notes/f32scalar-deterministic-trig-implementation-guide.md @@ -0,0 +1,310 @@ + + +# Implementation Guide — Deterministic `sin/cos` for `F32Scalar` (LUT-backed) + +This document is a step-by-step, code-oriented guide for implementing a deterministic `sin`, `cos`, and `sin_cos` backend for `warp_core::math::scalar::F32Scalar`. + +## Status + +As of **2026-01-01**, this LUT-backed backend is implemented on the `F32Scalar/sin-cos` branch: + +- Implementation: `crates/warp-core/src/math/trig.rs` +- LUT data: `crates/warp-core/src/math/trig_lut.rs` +- Tests: `crates/warp-core/tests/deterministic_sin_cos_tests.rs` + +It is written to match the current test scaffolding on the `F32Scalar/sin-cos` branch: + +- `crates/warp-core/tests/deterministic_sin_cos_tests.rs` + +The spec/policy drivers for this work live here: + +- `docs/SPEC_DETERMINISTIC_MATH.md` (policy, checklist) +- `crates/warp-core/src/math/scalar.rs` (current `Scalar` trait + `F32Scalar` impl) + +--- + +## Goal + +Replace the hardware/libc-backed trig: + +- `F32Scalar::sin()` **must not** delegate to `f32::sin()` +- `F32Scalar::cos()` **must not** delegate to `f32::cos()` + +…with an implementation that is **bit-stable across supported platforms** (native + WASM) while keeping `F32Scalar`’s canonicalization invariants. + +--- + +## Non-goals (for this iteration) + +- Perfectly matching the platform `libm` behavior. +- Maximum-accuracy transcendental math. +- Implementing the fixed-point trig backend. +- Designing the “forever” math backend architecture. + +The intent is: *ship a deterministic trig backend with a known, documented error budget*, then iterate. + +--- + +## Determinism & API contract + +Before writing code, decide and *write down* the exact contract the implementation must obey. + +### Inputs + +`F32Scalar`’s private `value` is constructed via `F32Scalar::new`, which already: + +- canonicalizes `-0.0` → `+0.0` +- canonicalizes `NaN` → `0x7fc0_0000` +- flushes subnormals → `+0.0` + +So the trig backend can assume its `self.value` is canonical **as stored**. + +### Outputs (required) + +For any input, `sin/cos` must return a canonical `F32Scalar`: + +- never `-0.0` +- never subnormal +- if NaN, only the canonical NaN bit pattern `0x7fc0_0000` + +This can be enforced by ending the computation with `F32Scalar::new(result_f32)`. + +### Non-finite inputs (decide explicitly) + +The tests currently assume: + +- `sin(±∞)` and `cos(±∞)` return NaN (then canonicalized) +- `sin(NaN)` and `cos(NaN)` return NaN (canonical) + +Keep this behavior unless/until the spec says otherwise. + +Implementation rule: + +```text +if !angle.is_finite() => return (NaN, NaN) (canonicalized via F32Scalar::new) +``` + +--- + +## Approach overview (recommended) + +Use a **lookup table (LUT)** plus simple interpolation: + +1. Deterministic range-reduction to a canonical interval (e.g., `[0, TAU)`). +2. Convert the reduced angle to a deterministic table index + fraction. +3. Lookup adjacent samples and interpolate. +4. Apply quadrant symmetries to avoid a full-table footprint (optional but recommended). +5. Wrap results with `F32Scalar::new` for canonicalization. + +This keeps: + +- determinism: no platform `libm` +- speed: O(1) lookup, few ops +- controllable accuracy: choose table resolution & interpolation + +--- + +## Step-by-step implementation plan + +### Step 1 — Pin the table design (N, symmetry, interpolation) + +Pick **one** and document it (constants should be checked into the repo). + +Recommended starting point: + +- `N = 4096` samples over `[0, TAU)` (power of two for cheap masking) +- Linear interpolation between adjacent samples +- Quarter-wave symmetry to reduce table size by ~4× (optional) + +Trade-offs: + +- Higher `N` lowers error but increases binary size. +- Linear interpolation is easy and deterministic; cubic interpolation may improve accuracy but is more code and more ops. + +### Step 2 — Decide how the LUT is stored + +Store `u32` bit patterns, not `f32` literals: + +- avoids any “float literal parsing” concerns +- makes it easy to diff the table and compute checksums/digests + +Pattern: + +```rust +const SIN_LUT_BITS: [u32; N] = [ /* ... */ ]; +#[inline] +fn sin_lut(i: usize) -> f32 { f32::from_bits(SIN_LUT_BITS[i]) } +``` + +If you use quarter-wave symmetry, store only the first quadrant (plus endpoint): + +- `NQ = N/4` +- store `NQ + 1` entries for `[0, PI/2]` so the boundary is exact and avoids off-by-one wrap issues. + +### Step 3 — Add a table module (keep `scalar.rs` readable) + +Create a small internal module under `warp-core`: + +- Option A: `crates/warp-core/src/math/trig_lut.rs` +- Option B: `crates/warp-core/src/math/scalar_trig.rs` + +Prefer a module that: + +- exports a single `pub(crate) fn sin_cos_f32(angle: f32) -> (f32, f32)` +- keeps LUT + index math private + +Then wire it into `F32Scalar::sin/cos/sin_cos` in: + +- `crates/warp-core/src/math/scalar.rs` + +### Step 4 — Range reduction (deterministic) + +Goal: map any finite `angle` (radians) into a stable interval. + +Simplest acceptable form: + +- `r = angle.rem_euclid(TAU)` + +Notes: + +- Use `TAU` from `std::f32::consts::TAU` (already used in the codebase). +- Avoid calling `sin/cos` anywhere in this step. +- Keep the computation in `f32` (not `f64`) initially to avoid cross-type subtlety. + +### Step 5 — Map reduced angle to table index + fraction + +With `N` samples over `[0, TAU)`: + +- `scale = N as f32 / TAU` +- `t = r * scale` (expected in `[0, N)`) +- `i0 = floor(t)` as usize +- `frac = t - (i0 as f32)` in `[0, 1)` +- `i1 = (i0 + 1) & (N - 1)` if `N` is power-of-two, else modulo + +Then linear interpolation: + +- `v0 = lut[i0]` +- `v1 = lut[i1]` +- `v = v0 + frac * (v1 - v0)` + +Important: ensure the implementation cannot produce out-of-bounds indices at `r == TAU`. + +### Step 6 — Use symmetries (optional but recommended) + +To reduce table size and keep interpolation stable at quadrant boundaries: + +1. Map `r` into quadrant `q ∈ {0,1,2,3}` and local angle `a` in `[0, PI/2]`. +2. Compute `sin(a)` and `cos(a)` from the quarter-wave table (cos via `sin(PI/2 - a)`). +3. Apply signs/swaps based on quadrant: + +```text +q=0: ( s, c) = ( +sin(a), +cos(a) ) +q=1: ( s, c) = ( +cos(a), -sin(a) ) +q=2: ( s, c) = ( -sin(a), -cos(a) ) +q=3: ( s, c) = ( -cos(a), +sin(a) ) +``` + +This avoids table wrap-around edge cases and makes interpolation easier to reason about. + +### Step 7 — Canonicalize outputs + +At the very end: + +- `s = F32Scalar::new(s).to_f32()` +- `c = F32Scalar::new(c).to_f32()` + +Or, when returning `F32Scalar`: + +- `Self::new(s)` +- `Self::new(c)` + +This guarantees: + +- `-0.0` becomes `+0.0` +- subnormals flush to zero +- NaNs canonicalize + +### Step 8 — Wire into the `Scalar` impl for `F32Scalar` + +Update: + +- `impl Scalar for F32Scalar` in `crates/warp-core/src/math/scalar.rs` + +So that: + +- `sin()` / `cos()` call the deterministic backend +- `sin_cos()` calls the backend once (no duplicated range reduction) + +### Step 9 — Lock in tests (incrementally) + +Use the existing test file: + +- `crates/warp-core/tests/deterministic_sin_cos_tests.rs` + +Suggested test progression: + +1. Keep the special-case “golden bits” test passing (NaN/inf/subnormal handling). +2. Keep the “outputs are canonical” test passing for a sample sweep. +3. Turn on the WIP error-budget test: + - un-ignore it + - decide a concrete `max_ulp` and/or `max_abs` threshold + - commit that threshold with a short rationale in the test doc comment +4. Add a compact “finite golden vector” (optional): + - pick ~32 angles (including quadrant boundaries and midpoints) + - assert `sin.to_bits()` and `cos.to_bits()` equal committed constants + +### Step 10 — Document the policy compliance + +When the backend lands, update: + +- `docs/SPEC_DETERMINISTIC_MATH.md` checklist (`sin/cos` deterministic approximation) + +And add a short decision-log entry noting: + +- the chosen LUT resolution/interpolation +- the accepted error budget +- how to regenerate the LUT (if applicable) + +--- + +## LUT generation guidance + +The LUT must be deterministic and reproducible. + +Two workable strategies: + +### Strategy A — Commit the table as data (recommended) + +1. Write a tiny generator tool (Rust `xtask` or a script under `scripts/`). +2. Use a known-stable reference implementation to generate high-precision values: + - If using Python, pin interpreter + deps and emit u32 bits. + - If using Rust, consider a BigFloat crate or a known “software libm” implementation. +3. Emit `u32` bit patterns into a Rust source file. +4. Commit the generated file so all builds use identical bits. + +### Strategy B — Generate at build time (not recommended initially) + +Generate LUT in `build.rs` and include it. + +Downsides: + +- build times increase +- “reproducible builds” become harder to audit + +--- + +## Pitfalls checklist + +- Off-by-one at `angle == TAU` after range reduction. +- Table wrap-around (especially if using full-wave LUT without symmetry). +- Using `f32::sin/cos` or any platform `libm` in generation or runtime by accident. +- Accidentally introducing `-0.0` at quadrant boundaries (canonicalize via `F32Scalar::new`). +- Depending on subnormal behavior in intermediate math (prefer to canonicalize at the end; if needed, consider using `F32Scalar` ops internally). + +--- + +## “Done” criteria (for the eventual finish) + +- `F32Scalar::sin/cos/sin_cos` no longer call hardware/libc trig. +- `cargo test -p warp-core --test deterministic_sin_cos_tests` passes with **no ignored tests**. +- Determinism policy docs are updated and explain the chosen approximation + error budget. diff --git a/scripts/check_no_raw_trig.sh b/scripts/check_no_raw_trig.sh new file mode 100755 index 00000000..60f110c4 --- /dev/null +++ b/scripts/check_no_raw_trig.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$ROOT" + +# Policy: runtime math modules must not call platform/libm transcendentals +# directly. Trig should flow through `warp_core::math::trig` and be surfaced via +# `F32Scalar` (or future fixed-point scalar types). +# +# We enforce this narrowly within `warp-core`'s math module, excluding: +# - scalar.rs: the sanctioned wrapper surface +# - trig.rs / trig_lut.rs: the deterministic backend + data +target_dir="crates/warp-core/src/math" +# Match method calls like `.sin(`, allowing optional whitespace before the `(`. +# Use explicit character classes to keep the regex compatible across `rg` and `grep`. +pattern='[.](sin|cos|sin_cos)[[:space:]]*[(]' + +if [[ ! -d "$target_dir" ]]; then + echo "Error: determinism guard target directory not found: $target_dir" >&2 + echo "If the warp-core math module moved, update scripts/check_no_raw_trig.sh accordingly." >&2 + exit 1 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: this script must run inside a git work tree (for deterministic file enumeration)." >&2 + exit 1 +fi + +files=() +while IFS= read -r -d '' path; do + case "$path" in + *.rs) + base="${path##*/}" + if [[ "$base" == "scalar.rs" || "$base" == "trig.rs" || "$base" == "trig_lut.rs" ]]; then + continue + fi + files+=("$path") + ;; + esac +done < <(git ls-files -z -- "$target_dir") + +if [[ ${#files[@]} -eq 0 ]]; then + echo "Error: no Rust source files found under $target_dir (did paths change?)" >&2 + exit 1 +fi + +if command -v rg >/dev/null 2>&1; then + set +e + matches="$(rg -n --no-heading --color never "$pattern" -- "${files[@]}")" + status=$? + set -e + if [[ $status -gt 1 ]]; then + echo "$matches" >&2 + exit $status + fi +else + # CI runners may not have ripgrep installed by default; fall back to `grep`. + # Both lanes use the same `git ls-files` input set to avoid drift. + set +e + matches="$(grep -nE "$pattern" -- "${files[@]}")" + status=$? + set -e + if [[ $status -gt 1 ]]; then + echo "$matches" >&2 + exit $status + fi +fi + +if [[ -n "$matches" ]]; then + echo "Error: raw trig calls found in warp-core math module (use math::trig or F32Scalar wrappers):" >&2 + echo "$matches" >&2 + exit 1 +fi + +tool="grep" +if command -v rg >/dev/null 2>&1; then + tool="rg" +fi +echo "ok: no raw trig calls found in $target_dir (scanned ${#files[@]} files; tool=$tool)"