Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion crates/rmg-core/src/math/scalar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,34 @@ pub trait Scalar:
#[derive(Debug, Copy, Clone)]
pub struct F32Scalar {
/// The wrapped f32 value
///
/// # Invariant
/// This field is private to enforce canonicalization via `new()`.
/// It must NEVER contain `-0.0`, non-canonical NaNs, or subnormals (future).
value: f32,
}

#[cfg(feature = "serde")]
impl serde::Serialize for F32Scalar {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.value.serialize(serializer)
}
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for F32Scalar {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = f32::deserialize(deserializer)?;
Ok(Self::new(v))
}
}

impl F32Scalar {
/// Nil value
pub const ZERO: Self = Self::new(0.0);
Expand All @@ -112,7 +137,7 @@ impl F32Scalar {

impl PartialEq for F32Scalar {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
self.cmp(other) == Ordering::Equal
}
}

Expand Down
68 changes: 68 additions & 0 deletions crates/rmg-core/tests/determinism_policy_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: Apache-2.0
// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>

#![allow(missing_docs)]

// use rmg_core::math::scalar::F32Scalar;
// use rmg_core::math::Scalar;

// NOTE: These tests describe the intended strict determinism policy.
// They currently fail because F32Scalar only canonicalizes -0.0.
// They are commented out until the "CanonicalF32" work (Issue #XXX) is landed.

/*
#[test]
fn test_policy_nan_canonicalization() {
// Construct different NaNs
let pos_qnan = f32::from_bits(0x7fc00000);
let neg_qnan = f32::from_bits(0xffc00000);
let signaling_nan = f32::from_bits(0x7f800001); // Exponent all 1s, MSB mantissa 0
let payload_nan = f32::from_bits(0x7f80dead);

let s1 = F32Scalar::new(pos_qnan);
let s2 = F32Scalar::new(neg_qnan);
let s3 = F32Scalar::new(signaling_nan);
let s4 = F32Scalar::new(payload_nan);

// All must be bitwise identical to the canonical NaN
let canonical_bits = 0x7fc00000; // Standard positive quiet NaN

assert_eq!(s1.to_f32().to_bits(), canonical_bits, "Positive qNaN not canonicalized");
assert_eq!(s2.to_f32().to_bits(), canonical_bits, "Negative qNaN not canonicalized");
assert_eq!(s3.to_f32().to_bits(), canonical_bits, "Signaling NaN not canonicalized");
assert_eq!(s4.to_f32().to_bits(), canonical_bits, "Payload NaN not canonicalized");
}

#[test]
fn test_policy_subnormal_flushing() {
// Smallest positive subnormal: 1 bit set in mantissa, 0 exponent
let small_sub = f32::from_bits(1);
// Largest subnormal
let large_sub = f32::from_bits(0x007fffff);

let s1 = F32Scalar::new(small_sub);
let s2 = F32Scalar::new(large_sub);

// Must be flushed to positive zero
assert_eq!(s1.to_f32().to_bits(), 0, "Small subnormal not flushed to +0.0");
assert_eq!(s2.to_f32().to_bits(), 0, "Large subnormal not flushed to +0.0");

// Verify negative subnormals go to +0.0 too (since -0.0 -> +0.0)
let neg_sub = f32::from_bits(0x80000001);
let s3 = F32Scalar::new(neg_sub);
assert_eq!(s3.to_f32().to_bits(), 0, "Negative subnormal not flushed to +0.0");
}

#[test]
#[cfg(feature = "serde")]
fn test_policy_serialization_guard() {
// Manually construct JSON with -0.0
let json = r#"{ "value": -0.0 }"#;

// If Deserialize is derived, this will put -0.0 into the struct, violating the invariant.
// If implemented manually via new(), it should be +0.0.
let s: F32Scalar = serde_json::from_str(json).expect("Failed to deserialize");

assert_eq!(s.to_f32().to_bits(), 0, "Deserialized -0.0 was not canonicalized!");
}
*/
13 changes: 13 additions & 0 deletions crates/rmg-core/tests/math_scalar_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,16 @@ fn test_f32_traits() {
// So z == nz and z.cmp(nz) == Equal.
assert_eq!(z.cmp(&nz), std::cmp::Ordering::Equal);
}

#[test]
fn test_f32_nan_reflexivity() {
let nan = F32Scalar::new(f32::NAN);

// Eq requires reflexivity: x == x must be true.
// Standard f32 is not Eq because NaN != NaN.
// Since we claim F32Scalar: Eq, we MUST guarantee nan == nan.
assert_eq!(nan, nan);

// Also ensure cmp is consistent with eq
assert_eq!(nan.cmp(&nan), std::cmp::Ordering::Equal);
}
69 changes: 69 additions & 0 deletions docs/DETERMINISTIC_MATH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0
// SPDX-License-Identifier: Apache-2.0
// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>

//! Deterministic math hazards and mitigation strategies.
//!
//! This document outlines the specific challenges of cross-platform deterministic floating-point
//! arithmetic (IEEE 754) and the strategies used in Echo to mitigate them.

# Deterministic Math Hazards

Achieving bit-perfect determinism across disparate hardware architectures (x86_64, AArch64, WASM32)
is difficult due to loosely defined behaviors in the IEEE 754 specification. While basic arithmetic
is largely standardized, "freaky numbers" (NaN, Subnormals, Signed Zero) introduce divergence.

## 1. NaN Payloads
**The Hazard:** IEEE 754 standardizes that `0.0 / 0.0` produces `NaN`, but it does *not* mandate
the exact bit pattern of that `NaN`.
* **Sign Bit:** Some FPUs produce positive NaN, others negative.
* **Payload Bits:** The mantissa can contain arbitrary diagnostic information ("payload").
* **Signaling vs Quiet:** Operations might quiet a signaling NaN (sNaN -> qNaN) differently.

**Impact:** If a simulation produces a NaN, the exact bits may differ between a player on Mac (ARM)
and a player on Windows (x86). Hashing this state (`blake3(mem)`) will result in a fork (desync).

**Mitigation:**
* **Canonicalization:** All NaNs must be clamped to a single canonical bit pattern (e.g., `0x7fc00000`)
at the boundary of the deterministic simulation (input/output) and potentially after every operation.
* **Avoidance:** Ideally, gameplay logic should never produce NaN.

## 2. Subnormal Numbers (Denormals)
**The Hazard:** Subnormal numbers are very small numbers close to zero (e.g., `1e-40`).
* **Hardware Diversity:** Some CPUs (or modes like DAZ/FTZ on x86) flush these to zero for performance.
Others (WASM, modern ARM) compute them precisely.
* **The Fork:** A calculation `1e-40 + 0.0` yields `1e-40` on Machine A and `0.0` on Machine B.
This tiny difference butterfly-effects into a major desync.

**Mitigation:**
* **Software Flush-to-Zero:** The `F32Scalar` wrapper should detect subnormals and force them to
`0.0` (with proper sign canonicalization) to ensure consistent behavior regardless of CPU flags.

## 3. Signed Zero
**The Hazard:** IEEE 754 distinguishes `+0.0` and `-0.0`.
* **Arithmetic:** `-1.0 * 0.0 = -0.0`. `(-0.0) + (-0.0) = -0.0`.
* **Comparison:** `+0.0 == -0.0` is true.
* **Hashing:** `hash(+0.0) != hash(-0.0)`.
* **Impact:** If logic relies on bits (hashing) or strict ordering (`total_cmp`), `-0.0` is a distinct
value.

**Mitigation:**
* **Canonicalization:** `F32Scalar` converts `-0.0` to `+0.0` on construction.

## 4. Fused Multiply-Add (FMA)
**The Hazard:** `a * b + c` can be computed as two ops (round intermediate) or one FMA op (single round).
* **Result:** The least significant bit often differs.
* **Compiler:** Rust/LLVM might optimize `mul` + `add` into `fma` depending on target features.

**Mitigation:**
* **Strict Ops:** Rely on `rmg-core` wrappers which enforce distinct operations.
* **Compiler Flags:** Ensure builds do not aggressively fuse ops unless explicitly safe.

## 5. Transmutation & Zerocopy
**The Hazard:** Casting raw bytes to `f32` (`zerocopy::FromBytes`) bypasses constructor logic.
* **Attack Vector:** A network packet contains non-canonical bytes (e.g., `-0.0` or weird `NaN`).
If interpreted directly as `F32Scalar`, the invariant is violated.

**Mitigation:**
* **Validation:** `FromBytes` implementations must validate or canonicalize data.
* **Opaque Types:** Prefer opaque serialization that routes through `new()`.
55 changes: 55 additions & 0 deletions docs/SPEC_DETERMINISTIC_MATH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0
// SPDX-License-Identifier: Apache-2.0
// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>

//! Math Determinism Specification & Policy.
//!
//! Defines the strict policies Echo enforces to guarantee bit-perfect determinism across all
//! supported platforms. This spec takes precedence over hardware defaults.

# Policy: Strictly Deterministic Math

All math within the simulation loop (`rmg-core`) must adhere to these rules.

## 1. Floating Point (f32)

We wrap `f32` in `F32Scalar` to enforce these invariants.

| Feature | Policy | Implementation Strategy |
| :--- | :--- | :--- |
| **Signed Zero** | **Strict (+0.0)** | `new()` maps `-0.0` to `+0.0`. |
| **NaN Payloads** | **Strict (Canonical)** | All `NaN` values are mapped to `0x7fc00000` (Positive Quiet NaN). |
| **Subnormals** | **Flush-to-Zero** | Inputs with biased exponent `0` are flushed to `+0.0`. |
| **Rounding** | **Ties-to-Even** | Standard IEEE 754 default (Rust default). |
| **Transcendental** | **Software / LUT** | `sin`/`cos` must use software approximation (e.g., `fdlibm` port or LUT), never hardware instructions which vary by uLP. |

### Reflexivity Note
Implementations of `Eq` for floating-point types **must** be reflexive.
* `NaN == NaN` must be **TRUE**.
* Use `total_cmp` or check `is_nan()`.
* This prevents logic errors in collections (`HashSet`, `BTreeMap`) which rely on `x == x`.

## 2. Zerocopy & Serialization

* **No Direct Casts:** `F32Scalar` must **not** implement `zerocopy::FromBytes` blindly. Raw bytes could contain non-canonical values (`-0.0`, `sNaN`).
* **Deserialize:** Must route through `F32Scalar::new()` or a validator that applies canonicalization.
* **Serialize:** Safe to dump bytes *if* the value is already canonical.

## 3. Audit Findings (2025-11-30)

An audit of `rmg-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).
* **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.
* **NaN Propagation:** `f32` ops produce hardware-specific NaN payloads.
* *Action:* `F32Scalar::new` must sanitize NaNs.

## 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).
1 change: 1 addition & 0 deletions docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
| Date | Context | Decision | Rationale | Consequence |
| ---- | ------- | -------- | --------- | ----------- |
| 2025-11-29 | LICENSE | Add SPDX headers to all files | LEGAL PROTECTION 🛡️✨ |
| 2025-11-30 | `F32Scalar` NaN reflexivity | Update `PartialEq` implementation to use `total_cmp` (via `Ord`) instead of `f32::eq`. | Ensures `Eq` reflexivity holds even for NaN (`NaN == NaN`), consistent with `Ord`. Prevents violations of the `Eq` contract in collections. | `F32Scalar` now behaves as a totally ordered type; NaN values are considered equal to themselves and comparable. |
| 2025-11-30 | `F32Scalar` canonicalization | Enforce bitwise determinism by canonicalizing `-0.0` to `+0.0` for all `F32Scalar` instances; implement `PartialEq`, `Eq`, `PartialOrd`, `Ord`, `Display`. Make `value` field private. | Essential for bit-perfect cross-platform determinism in math operations and comparisons, especially for hashing and serialization. Prevents accidental introduction of `-0.0` by direct field access. | Guarantees consistent numerical behavior for `F32Scalar`; all public API methods and constructors now ensure canonical zero. |
| 2025-11-29 | `F32Scalar` | Add `rmg-core::math::scalar::F32Scalar` type | Now we have it. |
| 2025-11-03 | Scalar foundation | Add `rmg-core::math::Scalar` trait (operator supertraits + sin/cos) | Arithmetic via `Add/Sub/Mul/Div/Neg` supertraits for ergonomic `+ - * /`; `sin/cos` methods declared; canonicalization/LUTs deferred | Unblocks F32Scalar and DFix64 implementations; math code can target a stable trait |
Expand Down
Loading