nibli_types/arithmetic.rs
1//! Built-in arithmetic for the engine's three native compute predicates.
2//!
3//! `pilji` (multiply), `sumji` (add), and `dilcu` (divide) each assert the
4//! relation `x1 = x2 op x3`. This is the SINGLE shared evaluation used by both
5//! the nibli-reason engine fast path (compiled into the WASM guest) and the nibli-host host
6//! fast path — `nibli-types` is the one crate both depend on. The Python
7//! reference backend (`python/nibli_backend.py`) mirrors the same semantics.
8
9/// Evaluate a built-in arithmetic predicate `x1 = x2 op x3` over three numbers.
10///
11/// Returns `Some(true|false)` for `pilji`/`sumji`/`dilcu` given at least three
12/// arguments, and `None` otherwise (unknown relation or too few args) so the
13/// caller falls through to the external compute backend.
14///
15/// Equality is TOLERANT (`isclose`, rel_tol `1e-9`), so decimal queries such as
16/// `0.3 = 0.1 + 0.2` answer TRUE despite IEEE-754 rounding — the engine, the
17/// host, and the Python backend all agree. The `dilcu` divide-by-zero check is an
18/// exact `== 0.0` guard (a guard, not a result comparison).
19pub fn eval_arithmetic(relation: &str, args: &[f64]) -> Option<bool> {
20 let (&x1, &x2, &x3) = (args.first()?, args.get(1)?, args.get(2)?);
21 // A non-finite operand (a literal too large for an f64 overflows to ±inf) makes the
22 // relation meaningless — DECLINE (None) rather than return a confident TRUE/FALSE.
23 // nibli-reason turns this into `Unknown(NonFinite)`; the nibli-host host fast path declines too.
24 let nonfinite_operand = !x1.is_finite() || !x2.is_finite() || !x3.is_finite();
25 let result = match relation {
26 "product" => x2 * x3,
27 "sum" => x2 + x3,
28 "quotient" => {
29 if x3 == 0.0 {
30 // Divide-by-zero (exact guard): never equal — but only DECIDE that for
31 // finite operands; with a non-finite operand it is the undetermined case.
32 return if nonfinite_operand { None } else { Some(false) };
33 }
34 x2 / x3
35 }
36 _ => return None,
37 };
38 // Even finite operands can overflow (e.g. `1e200 * 1e200 -> inf`); an out-of-range
39 // result is equally undetermined.
40 if nonfinite_operand || !result.is_finite() {
41 return None;
42 }
43 Some(isclose(x1, result))
44}
45
46/// Tolerant float equality, mirroring Python `math.isclose(a, b, rel_tol=1e-9,
47/// abs_tol=0.0)`: `|a - b| <= 1e-9 * max(|a|, |b|)`. (With `abs_tol = 0`, `0.0`
48/// is close only to exactly `0.0`, matching the reference.)
49fn isclose(a: f64, b: f64) -> bool {
50 (a - b).abs() <= 1e-9 * a.abs().max(b.abs())
51}
52
53#[cfg(test)]
54mod tests {
55 use super::eval_arithmetic;
56
57 #[test]
58 fn integer_cases_exact() {
59 // Conformance: the evaluator's domain is exactly relations::BUILTIN_ARITHMETIC
60 // (the single-source name sets) — comparisons are NOT tolerant arithmetic.
61 for r in crate::relations::BUILTIN_ARITHMETIC {
62 assert!(
63 eval_arithmetic(r, &[6.0, 2.0, 3.0]).is_some(),
64 "{r} must be evaluable"
65 );
66 }
67 for r in crate::relations::NUMERIC_COMPARISONS {
68 assert!(
69 eval_arithmetic(r, &[6.0, 2.0, 3.0]).is_none(),
70 "{r} must not be tolerant arithmetic"
71 );
72 }
73 assert_eq!(eval_arithmetic("product", &[6.0, 2.0, 3.0]), Some(true)); // 6 = 2*3
74 assert_eq!(eval_arithmetic("product", &[7.0, 2.0, 3.0]), Some(false));
75 assert_eq!(eval_arithmetic("sum", &[5.0, 2.0, 3.0]), Some(true)); // 5 = 2+3
76 assert_eq!(eval_arithmetic("sum", &[4.0, 2.0, 3.0]), Some(false));
77 assert_eq!(eval_arithmetic("quotient", &[3.0, 6.0, 2.0]), Some(true)); // 3 = 6/2
78 }
79
80 #[test]
81 fn float_tolerance_headline() {
82 // 0.1 + 0.2 = 0.30000000000000004 in IEEE-754; exact `==` would say
83 // FALSE, but the user means 0.3 — isclose makes it TRUE.
84 assert_eq!(eval_arithmetic("sum", &[0.3, 0.1, 0.2]), Some(true));
85 // A genuinely-wrong claim is still FALSE (the tolerance is tiny).
86 assert_eq!(eval_arithmetic("sum", &[0.4, 0.1, 0.2]), Some(false));
87 // Product with rounding: 0.1 * 0.1 = 0.010000000000000002.
88 assert_eq!(eval_arithmetic("product", &[0.01, 0.1, 0.1]), Some(true));
89 }
90
91 #[test]
92 fn dilcu_divide_by_zero_is_false_not_none() {
93 assert_eq!(eval_arithmetic("quotient", &[0.0, 5.0, 0.0]), Some(false));
94 }
95
96 #[test]
97 fn non_finite_operand_or_result_declines() {
98 let inf = f64::INFINITY;
99 // A non-finite operand makes the relation undetermined → None (nibli-reason surfaces
100 // Unknown(NonFinite)); never a confident TRUE/FALSE.
101 assert_eq!(eval_arithmetic("sum", &[inf, inf, 1.0]), None);
102 assert_eq!(eval_arithmetic("product", &[1.0, inf, 2.0]), None);
103 assert_eq!(eval_arithmetic("quotient", &[1.0, inf, 2.0]), None);
104 assert_eq!(eval_arithmetic("sum", &[f64::NAN, 1.0, 2.0]), None);
105 // Finite operands whose product overflows to ±inf are equally undetermined.
106 assert_eq!(eval_arithmetic("product", &[1.0, 1e200, 1e200]), None);
107 // A divide-by-zero with FINITE operands is still a decided false (not None).
108 assert_eq!(eval_arithmetic("quotient", &[0.0, 5.0, 0.0]), Some(false));
109 // A non-finite operand on a divide-by-zero is undetermined, not a decided false.
110 assert_eq!(eval_arithmetic("quotient", &[inf, 5.0, 0.0]), None);
111 }
112
113 #[test]
114 fn unknown_relation_and_short_args_are_none() {
115 assert_eq!(eval_arithmetic("exponential", &[8.0, 2.0, 3.0]), None);
116 assert_eq!(eval_arithmetic("sum", &[5.0, 2.0]), None);
117 }
118}