Skip to main content

nx_value/
math.rs

1//! Closed numerical operations used by compiler-owned standard-library bodies.
2//! This is runtime implementation support, not a host registration API.
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5#[repr(u8)]
6pub enum MathOp {
7    Sin = 0,
8    Cos = 1,
9    Tan = 2,
10    Asin = 3,
11    Acos = 4,
12    Atan = 5,
13    Atan2 = 6,
14    Sqrt = 7,
15    Hypot = 8,
16    Abs = 9,
17    Sign = 10,
18    Min = 11,
19    Max = 12,
20    Clamp = 13,
21    Floor = 14,
22    Ceil = 15,
23    Round = 16,
24    RoundTiesToEven = 17,
25    Trunc = 18,
26    DegreesToRadians = 19,
27    RadiansToDegrees = 20,
28    Lerp = 21,
29}
30
31impl MathOp {
32    pub const ALL: [Self; 22] = [
33        Self::Sin,
34        Self::Cos,
35        Self::Tan,
36        Self::Asin,
37        Self::Acos,
38        Self::Atan,
39        Self::Atan2,
40        Self::Sqrt,
41        Self::Hypot,
42        Self::Abs,
43        Self::Sign,
44        Self::Min,
45        Self::Max,
46        Self::Clamp,
47        Self::Floor,
48        Self::Ceil,
49        Self::Round,
50        Self::RoundTiesToEven,
51        Self::Trunc,
52        Self::DegreesToRadians,
53        Self::RadiansToDegrees,
54        Self::Lerp,
55    ];
56    pub fn from_code(code: u32) -> Option<Self> {
57        Self::ALL.get(code as usize).copied()
58    }
59    pub fn name(self) -> &'static str {
60        match self {
61            Self::Sin => "sin",
62            Self::Cos => "cos",
63            Self::Tan => "tan",
64            Self::Asin => "asin",
65            Self::Acos => "acos",
66            Self::Atan => "atan",
67            Self::Atan2 => "atan2",
68            Self::Sqrt => "sqrt",
69            Self::Hypot => "hypot",
70            Self::Abs => "abs",
71            Self::Sign => "sign",
72            Self::Min => "min",
73            Self::Max => "max",
74            Self::Clamp => "clamp",
75            Self::Floor => "floor",
76            Self::Ceil => "ceil",
77            Self::Round => "round",
78            Self::RoundTiesToEven => "roundTiesToEven",
79            Self::Trunc => "trunc",
80            Self::DegreesToRadians => "degreesToRadians",
81            Self::RadiansToDegrees => "radiansToDegrees",
82            Self::Lerp => "lerp",
83        }
84    }
85    pub fn params(self) -> &'static [&'static str] {
86        match self {
87            Self::Sin => &["x"],
88            Self::Cos => &["x"],
89            Self::Tan => &["x"],
90            Self::Asin => &["x"],
91            Self::Acos => &["x"],
92            Self::Atan => &["x"],
93            Self::Atan2 => &["y", "x"],
94            Self::Sqrt => &["x"],
95            Self::Hypot => &["x", "y"],
96            Self::Abs => &["x"],
97            Self::Sign => &["x"],
98            Self::Min => &["a", "b"],
99            Self::Max => &["a", "b"],
100            Self::Clamp => &["value", "min", "max"],
101            Self::Floor => &["x"],
102            Self::Ceil => &["x"],
103            Self::Round => &["x"],
104            Self::RoundTiesToEven => &["x"],
105            Self::Trunc => &["x"],
106            Self::DegreesToRadians => &["x"],
107            Self::RadiansToDegrees => &["x"],
108            Self::Lerp => &["a", "b", "t"],
109        }
110    }
111    pub fn arity(self) -> usize {
112        self.params().len()
113    }
114    /// Canonical descriptor includes all signature fields and a semantic revision.
115    pub fn descriptor(self, owner_module: &str) -> String {
116        format!(
117            "{owner_module}::{}({}):float64={}:v1",
118            self.name(),
119            self.params()
120                .iter()
121                .map(|p| format!("{p}:float64"))
122                .collect::<Vec<_>>()
123                .join(","),
124            self as u8
125        )
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct MathError {
131    pub code: &'static str,
132    pub message: String,
133}
134
135fn minimum(a: f64, b: f64) -> f64 {
136    if a == 0.0 && b == 0.0 {
137        if a.is_sign_negative() || b.is_sign_negative() {
138            -0.0
139        } else {
140            0.0
141        }
142    } else {
143        a.min(b)
144    }
145}
146fn maximum(a: f64, b: f64) -> f64 {
147    if a == 0.0 && b == 0.0 {
148        if a.is_sign_positive() || b.is_sign_positive() {
149            0.0
150        } else {
151            -0.0
152        }
153    } else {
154        a.max(b)
155    }
156}
157
158/// Evaluate one bounded operation. Existing general arithmetic is unaffected.
159pub fn evaluate(op: MathOp, args: &[f64]) -> Result<f64, MathError> {
160    let error = |code, detail| MathError {
161        code,
162        message: format!("{}: {detail}", op.name()),
163    };
164    if args.len() != op.arity() {
165        return Err(error("nx-math-domain", "wrong argument count"));
166    }
167    if args.iter().any(|x| !x.is_finite()) {
168        return Err(error(
169            "nx-math-non-finite-input",
170            "arguments must be finite",
171        ));
172    }
173    let x = args[0];
174    let y = args.get(1).copied().unwrap_or(0.0);
175    use MathOp::*;
176    if (op == Sqrt && x < 0.0)
177        || (matches!(op, Asin | Acos) && x.abs() > 1.0)
178        || (op == Clamp && y > args[2])
179    {
180        return Err(error("nx-math-domain", "invalid domain or bounds"));
181    }
182    let result = match op {
183        Sin => x.sin(),
184        Cos => x.cos(),
185        Tan => x.tan(),
186        Asin => x.asin(),
187        Acos => x.acos(),
188        Atan => x.atan(),
189        Atan2 => x.atan2(y),
190        Sqrt => x.sqrt(),
191        Hypot => x.hypot(y),
192        Abs => x.abs(),
193        Sign => {
194            if x == 0.0 {
195                x
196            } else {
197                x.signum()
198            }
199        }
200        Min => minimum(x, y),
201        Max => maximum(x, y),
202        Clamp => minimum(maximum(x, y), args[2]),
203        Floor => x.floor(),
204        Ceil => x.ceil(),
205        Trunc => x.trunc(),
206        RoundTiesToEven => x.round_ties_even(),
207        Round => {
208            // Compare the fractional distance without rounding x + 0.5 first.
209            let f = x.floor();
210            let r = if x - f < 0.5 { f } else { f + 1.0 };
211            if r == 0.0 {
212                0.0_f64.copysign(x)
213            } else {
214                r
215            }
216        }
217        DegreesToRadians => x * (std::f64::consts::PI / 180.0),
218        RadiansToDegrees => x * (180.0 / std::f64::consts::PI),
219        Lerp => {
220            let t = args[2];
221            if t == 0.0 {
222                x
223            } else if t == 1.0 {
224                y
225            } else if x == y {
226                x
227            } else if (x <= 0.0 && y >= 0.0) || (x >= 0.0 && y <= 0.0) {
228                (1.0 - t) * x + t * y
229            } else {
230                let result = x + t * (y - x);
231                if result.is_finite() {
232                    result
233                } else {
234                    // Extrapolation can overflow the difference product before cancellation.
235                    let alternative = (1.0 - t) * x + t * y;
236                    if alternative.is_finite() {
237                        alternative
238                    } else {
239                        // Both forms can overflow before cancellation; scale first.
240                        (x * 0.5 + t * (y * 0.5 - x * 0.5)) * 2.0
241                    }
242                }
243            }
244        }
245    };
246    if !result.is_finite() {
247        return Err(error("nx-math-overflow", "result is not finite"));
248    }
249    Ok(result)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    #[test]
256    fn shared_reference_corpus() {
257        let cases: serde_json::Value =
258            serde_json::from_str(include_str!("math-cases.json")).unwrap();
259        for case in cases.as_array().unwrap() {
260            let op = MathOp::ALL
261                .into_iter()
262                .find(|op| op.name() == case["op"].as_str().unwrap())
263                .unwrap();
264            let args = case["args"]
265                .as_array()
266                .unwrap()
267                .iter()
268                .map(|x| x.as_str().unwrap().parse::<f64>().unwrap())
269                .collect::<Vec<_>>();
270            let actual = evaluate(op, &args);
271            if let Some(code) = case["error"].as_str() {
272                assert_eq!(actual.unwrap_err().code, code, "{}", case["name"]);
273            } else {
274                let actual = actual.unwrap();
275                let expected = case["expected"].as_str().unwrap().parse::<f64>().unwrap();
276                if case["exact"] == true {
277                    assert_eq!(actual.to_bits(), expected.to_bits(), "{}", case["name"]);
278                } else {
279                    assert!(
280                        (actual - expected).abs() <= 1e-12 * expected.abs().max(1.0),
281                        "{}: {actual} vs {expected}",
282                        case["name"]
283                    );
284                }
285            }
286        }
287    }
288}