epiphany/crates/epiphany-determinism/src/float.rs

227 lines
7.8 KiB
Rust

//! Floating-point hygiene for canonical state.
//!
//! Appendix D §"Floating-Point Values in Canonical State":
//!
//! * Canonical stored `f64` values **must** be finite IEEE 754 binary64.
//! NaN and infinity must never appear in canonical chunks.
//! * `-0.0` **must** be canonicalized to `+0.0` before storage; two values
//! that differ only in zero sign are canonically equal.
//! * Canonical `f64` values are serialized as little-endian IEEE 754 octets
//! *after* the `-0.0 -> +0.0` canonicalization, and canonical equality is
//! byte equality of that representation.
//!
//! Floating point is admissible only in advisory/acoustic/tuning/tempo/
//! layout/quality contexts. Identity (ids, ordering, membership, hashes)
//! never uses it.
use core::cmp::Ordering;
use core::hash::{Hash, Hasher};
/// Maps `-0.0` to `+0.0`, leaving every other value (including NaN and
/// infinities) unchanged. This is the `-0.0 -> +0.0` canonicalization the
/// spec requires before any canonical comparison or serialization.
///
/// Implemented with an equality test rather than `x + 0.0` so it is immune to
/// the ambient rounding mode (Appendix D §"Rounding and CPU Behavior").
#[inline]
pub fn canonicalize_zero(x: f64) -> f64 {
// `-0.0 == 0.0` is true in IEEE 754, so this branch catches both zeros
// and yields the canonical `+0.0`; all non-zero values pass through.
if x == 0.0 {
0.0
} else {
x
}
}
/// Debug-only guard that a value is *already* in canonical form: finite, not
/// NaN/inf, and not negative zero. Use it to assert an invariant on a value
/// you believe a prior step has already canonicalized — not on a raw value
/// you are about to canonicalize (for that, the canonicalizing functions
/// accept `-0.0` and fix it). In release builds this compiles away; the hard
/// rejection of NaN/inf happens at the typed boundary ([`CanonicalF64`]) and
/// at decode time, not on every arithmetic step.
#[inline]
#[track_caller]
pub fn debug_assert_canonical(x: f64) {
debug_assert!(
x.is_finite(),
"non-finite f64 ({x:?}) in canonical state (Appendix D: NaN/inf forbidden)"
);
debug_assert!(
!(x == 0.0 && x.is_sign_negative()),
"-0.0 in canonical state without canonicalization (Appendix D: -0.0 -> +0.0)"
);
}
/// The canonical 8-byte little-endian representation of a finite `f64`, or
/// `None` if `x` is NaN or infinite.
///
/// Rejection is at runtime in *all* build profiles: Appendix D §"Permitted
/// Forms" requires implementations to *reject* NaN/infinity at serialization
/// time, so a debug-only assertion would not suffice. `-0.0` is accepted and
/// canonicalized to `+0.0`. This is the fallible convenience form of
/// [`CanonicalF64::new`] followed by [`CanonicalF64::to_le_bytes`].
#[inline]
pub fn canonical_f64_bytes(x: f64) -> Option<[u8; 8]> {
CanonicalF64::new(x).map(|c| c.to_le_bytes())
}
/// A finite `f64` in canonical form: never NaN, never infinite, never `-0.0`.
///
/// Construction is the only way to get one, and it enforces the invariants, so
/// every `CanonicalF64` is admissible in canonical state by construction.
/// Equality and hashing are defined over the canonical serialized bytes (so
/// `+0.0 == -0.0`'s canonicalization is already absorbed); ordering is IEEE
/// 754 ordered comparison, which is total here because the value is finite.
#[derive(Copy, Clone, Debug, Default)]
pub struct CanonicalF64(f64);
impl CanonicalF64 {
/// Wraps `x` if it is finite, applying `-0.0 -> +0.0`. Returns `None` for
/// NaN or infinity (which the spec forbids from canonical state).
#[inline]
pub fn new(x: f64) -> Option<Self> {
if x.is_finite() {
Some(CanonicalF64(canonicalize_zero(x)))
} else {
None
}
}
/// The wrapped finite value (with `+0.0` for any zero).
#[inline]
pub fn get(self) -> f64 {
self.0
}
/// Canonical little-endian serialization (8 bytes). Infallible: the
/// wrapped value is finite and `+0.0`-normalized by construction.
#[inline]
pub fn to_le_bytes(self) -> [u8; 8] {
self.0.to_le_bytes()
}
/// Decodes canonical little-endian bytes, rejecting NaN/inf and
/// canonicalizing `-0.0`. Returns `None` on a non-finite payload, which
/// the spec instructs readers to treat as data corruption.
#[inline]
pub fn from_le_bytes(bytes: [u8; 8]) -> Option<Self> {
Self::new(f64::from_le_bytes(bytes))
}
}
impl PartialEq for CanonicalF64 {
#[inline]
fn eq(&self, other: &Self) -> bool {
// Byte equality of the canonical representation (Appendix D §Equality).
self.0.to_le_bytes() == other.0.to_le_bytes()
}
}
impl Eq for CanonicalF64 {}
impl Hash for CanonicalF64 {
#[inline]
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.to_le_bytes().hash(state);
}
}
impl PartialOrd for CanonicalF64 {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CanonicalF64 {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
// Finite + zero-canonicalized, so `total_cmp` agrees with numeric
// order and is consistent with the byte-equality `Eq` above.
self.0.total_cmp(&other.0)
}
}
impl TryFrom<f64> for CanonicalF64 {
type Error = NonFiniteError;
#[inline]
fn try_from(x: f64) -> Result<Self, Self::Error> {
Self::new(x).ok_or(NonFiniteError)
}
}
/// Returned when a non-finite `f64` is offered where canonical state requires
/// a finite value.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct NonFiniteError;
impl core::fmt::Display for NonFiniteError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("non-finite f64 is inadmissible in canonical state")
}
}
impl std::error::Error for NonFiniteError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn negative_zero_canonicalizes_to_positive_zero() {
assert_eq!(canonicalize_zero(-0.0).to_bits(), 0.0f64.to_bits());
assert_eq!(canonical_f64_bytes(-0.0), Some(0.0f64.to_le_bytes()));
assert_eq!(canonical_f64_bytes(f64::NAN), None);
assert_eq!(canonical_f64_bytes(f64::INFINITY), None);
let neg = CanonicalF64::new(-0.0).unwrap();
let pos = CanonicalF64::new(0.0).unwrap();
assert_eq!(neg, pos);
assert_eq!(neg.to_le_bytes(), pos.to_le_bytes());
assert_eq!(neg.get().to_bits(), 0.0f64.to_bits()); // truly +0.0
}
#[test]
fn non_finite_is_rejected() {
assert!(CanonicalF64::new(f64::NAN).is_none());
assert!(CanonicalF64::new(f64::INFINITY).is_none());
assert!(CanonicalF64::new(f64::NEG_INFINITY).is_none());
assert!(CanonicalF64::try_from(f64::NAN).is_err());
assert!(CanonicalF64::from_le_bytes(f64::NAN.to_le_bytes()).is_none());
}
#[test]
fn ordering_is_numeric_and_consistent_with_equality() {
let a = CanonicalF64::new(-1.5).unwrap();
let b = CanonicalF64::new(0.0).unwrap();
let c = CanonicalF64::new(2.25).unwrap();
assert!(a < b && b < c);
assert_eq!(b.cmp(&CanonicalF64::new(-0.0).unwrap()), Ordering::Equal);
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "-0.0 in canonical state")]
fn debug_assert_canonical_rejects_negative_zero() {
debug_assert_canonical(-0.0);
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "NaN/inf forbidden")]
fn debug_assert_canonical_rejects_nan() {
debug_assert_canonical(f64::NAN);
}
#[test]
fn ordinary_values_round_trip_bytes() {
for v in [1.0, -1.0, 123.456, -2.5, 1e300, -1e-300, f64::MIN, f64::MAX] {
let c = CanonicalF64::new(v).unwrap();
assert_eq!(CanonicalF64::from_le_bytes(c.to_le_bytes()).unwrap(), c);
}
}
}