//! JavaScript arithmetic semantics. //! //! The TypeScript library is the oracle, so this port has to reproduce V8's numbers bit for bit, //! not merely closely. Three classes of difference between Rust and JavaScript matter: //! //! 1. **Storage rounding.** JS computes everything in `f64` and rounds to `f32` only when a value //! is stored into a `Float32Array`. Every such store in the kernel is written here as //! `() as f32`, which is IEEE round-to-nearest-even, exactly what a //! `Float32Array` store does. Never compute in `f32` and never use `mul_add`. //! 2. **`Math.min`/`Math.max` NaN handling.** `f64::max` returns the non-NaN operand; //! `Math.max` propagates NaN. [`js_max`] and [`js_min`] follow JavaScript. //! 3. **Transcendentals.** `Math.exp` and `Math.tanh` in V8 are the fdlibm ports in //! `src/base/ieee754.cc`, not the platform libm. glibc's `exp` disagrees with V8 by 1 ulp on //! roughly 10% of the arguments this kernel uses, and glibc's `tanh` by up to 3 ulp, which is //! enough to move a Float32 gain and, through synaptic propagation, a spike. See [`exp`] and //! [`tanh`]. /// `Math.fround`: round an `f64` to the nearest `f32` and back. #[inline] pub fn fround(value: f64) -> f64 { value as f32 as f64 } /// `Math.max(a, b)`: NaN-propagating, unlike [`f64::max`]. #[inline] pub fn js_max(a: f64, b: f64) -> f64 { if a.is_nan() || b.is_nan() { return f64::NAN; } if a > b { a } else if b > a { b } else if a == 0.0 && b == 0.0 { // Math.max(-0, +0) is +0. if a.is_sign_positive() { a } else { b } } else { a } } /// `Math.min(a, b)`: NaN-propagating, unlike [`f64::min`]. #[inline] pub fn js_min(a: f64, b: f64) -> f64 { if a.is_nan() || b.is_nan() { return f64::NAN; } if a < b { a } else if b < a { b } else if a == 0.0 && b == 0.0 { // Math.min(-0, +0) is -0. if a.is_sign_negative() { a } else { b } } else { a } } /// `Math.round`: half away from zero towards +infinity, with the `0.49999999999999994` correction /// that a naive `floor(x + 0.5)` gets wrong. #[inline] pub fn js_round(x: f64) -> f64 { if !x.is_finite() || x == 0.0 { return x; } if x > 0.0 && x < 0.5 { return 0.0; } if (-0.5..0.0).contains(&x) { return -0.0; } let rounded = (x + 0.5).floor(); if rounded - x > 0.5 { rounded - 1.0 } else { rounded } } /// `Math.exp`. /// /// `libm::exp` is the MUSL port of the same fdlibm `__ieee754_exp` that V8 carries in /// `src/base/ieee754.cc`. Verified bit-identical to V8 over every argument this kernel produces: /// `exp(-k/5000)` for integer `k` in `0..=2_000_000` (the eligibility-trace decay), `exp(-dt/20)` /// for integer `dt` in `1..=100` (the spike-pair window), and `exp(-1/decayMs)` for the default /// and a spread of non-default decay constants. The platform `f64::exp` (glibc) is *not* /// interchangeable: it differs from V8 by 1 ulp on 19,758 of those 200,001 trace-decay arguments. #[inline] pub fn exp(x: f64) -> f64 { libm::exp(x) } /// `Math.tanh`, transcribed from fdlibm `s_tanh.c` as V8 carries it in `src/base/ieee754.cc`. /// /// Neither `f64::tanh` (glibc) nor `libm::tanh` reproduces V8 here: measured over 1,400,000 seeded /// arguments they disagree with V8 on 7,095 and 139,693 of them respectively, by up to 3 ulp. /// fdlibm's `tanh` is a thin wrapper around `expm1`, and `libm::expm1` *is* bit-identical to V8's, /// so only the wrapper needs transcribing; this form matches V8 on all 1,400,000. pub fn tanh(x: f64) -> f64 { let jx = (x.to_bits() >> 32) as u32 as i32; let ix = jx & 0x7fff_ffff; // x is +-inf or NaN. if ix >= 0x7ff0_0000 { return if jx >= 0 { 1.0 / x + 1.0 } else { 1.0 / x - 1.0 }; } let z = if ix < 0x4036_0000 { // |x| < 22 if ix < 0x3e30_0000 && HUGE + x > 1.0 { // |x| < 2^-28: tanh(tiny) = tiny. return x; } if ix >= 0x3ff0_0000 { // |x| >= 1 let t = libm::expm1(2.0 * x.abs()); 1.0 - 2.0 / (t + 2.0) } else { let t = libm::expm1(-2.0 * x.abs()); -t / (t + 2.0) } } else { // |x| > 22: +-1, with the inexact flag raised. 1.0 - TINY }; if jx >= 0 { z } else { -z } } const HUGE: f64 = 1.0e300; const TINY: f64 = 1.0e-300; /// `String(number)`: the ECMAScript `Number::toString` algorithm. /// /// Used only by the version hashes, which join numeric parameters with `,` and hash the resulting /// string, so "0.005" must not become "5e-3" and "20" must not become "20.0". pub fn number_to_string(value: f64) -> String { if value.is_nan() { return "NaN".to_string(); } if value.is_infinite() { return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); } if value == 0.0 { return "0".to_string(); } let mut buffer = ryu_js::Buffer::new(); buffer.format(value).to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn fround_matches_the_default_decay_constant() { // Math.fround(Math.exp(-1 / 20)) === 0.95122945308685303 in V8; docs/model.md quotes the // f64 print of that f32 as 0.951229453086853. assert_eq!(fround(exp(-1.0 / 20.0)), 0.951229453086853); assert_eq!(exp(-1.0 / 20.0), 0.951229424500714); } #[test] fn js_min_max_propagate_nan_unlike_std() { assert!(js_max(-2.0, f64::NAN).is_nan()); assert!(js_min(1.0, f64::NAN).is_nan()); assert_eq!((-2.0f64).max(f64::NAN), -2.0, "std::max swallows NaN"); assert_eq!(js_max(-2.0, -1.5), -1.5); assert_eq!(js_min(1.0, 0.25), 0.25); assert!(js_max(-0.0, 0.0).is_sign_positive()); assert!(js_min(-0.0, 0.0).is_sign_negative()); } #[test] fn js_round_is_half_up_with_the_awkward_case() { assert_eq!(js_round(0.5), 1.0); assert_eq!(js_round(1.5), 2.0); assert_eq!(js_round(-0.5), 0.0); assert!(js_round(-0.5).is_sign_negative()); assert_eq!(js_round(2.4), 2.0); assert_eq!(js_round(0.499_999_999_999_999_94), 0.0); } #[test] fn number_to_string_matches_javascript() { for (value, text) in [ (20.0, "20"), (1.0, "1"), (0.005, "0.005"), (0.06, "0.06"), (300.0, "300"), (0.42, "0.42"), (1.0 / 25.0, "0.04"), (-2.0, "-2"), (22_222.0, "22222"), (0.2, "0.2"), (160.0, "160"), (144.0, "144"), (0.0001, "0.0001"), (0.000_001, "0.000001"), (0.000_000_1, "1e-7"), (1e21, "1e+21"), (5000.0, "5000"), (0.1, "0.1"), (0.05, "0.05"), (0.002, "0.002"), (0.9, "0.9"), (1.1, "1.1"), ] { assert_eq!(number_to_string(value), text, "String({value})"); } } #[test] fn tanh_matches_v8_on_the_documented_values() { // Spot values cross-checked against Node: these are the ones the reward path produces. assert_eq!(tanh(1.0), 0.7615941559557649); assert_eq!(tanh(-0.5), -0.46211715726000974); assert_eq!(tanh(0.85), 0.6910694698329305); assert_eq!(tanh(0.0), 0.0); assert_eq!(tanh(f64::INFINITY), 1.0); assert!(tanh(f64::NAN).is_nan()); } }