Skip to main content

bicmath_core/
eval.rs

1//! Shared restricted-expression evaluators.
2//!
3//! `bicmath-core` owns the expression AST and parser. This module provides the
4//! one shared evaluation walk used by modules that must evaluate expressions
5//! they receive as data (verification, optimization, interval analysis).
6//! Function resolution is delegated to a caller-supplied table, so each module
7//! controls exactly which functions are reachable. No arbitrary code is ever
8//! evaluated.
9
10use std::collections::BTreeMap;
11
12use crate::error::{EngineError, ErrorCode};
13use crate::expr::{BinaryOp, Expr, UnaryOp};
14use crate::limits::Limits;
15use crate::number::{Number, NumericContext};
16
17/// Function table for exact-number evaluation.
18pub type NumberCalls<'a> = dyn Fn(&str, &[Number]) -> Result<Number, EngineError> + 'a;
19
20/// Function table for float64 evaluation.
21pub type FloatCalls<'a> = dyn Fn(&str, &[f64]) -> Result<f64, EngineError> + 'a;
22
23struct Budget {
24    ops: u64,
25    max_ops: u64,
26    max_depth: usize,
27}
28
29impl Budget {
30    fn new(limits: &Limits) -> Budget {
31        Budget {
32            ops: 0,
33            max_ops: limits.max_operations,
34            max_depth: limits.max_ast_depth,
35        }
36    }
37
38    fn tick(&mut self, depth: usize) -> Result<(), EngineError> {
39        if depth > self.max_depth {
40            return Err(EngineError::new(
41                ErrorCode::ResourceLimit,
42                format!(
43                    "expression nesting depth {depth} exceeds the limit of {}",
44                    self.max_depth
45                ),
46            ));
47        }
48        self.ops += 1;
49        if self.ops > self.max_ops {
50            return Err(EngineError::new(
51                ErrorCode::ResourceLimit,
52                format!("expression operation budget of {} exceeded", self.max_ops),
53            ));
54        }
55        Ok(())
56    }
57}
58
59/// Evaluate an expression exactly, using the shared numeric contract.
60///
61/// Supported: integer/decimal/rational literals, bindings, unary sign,
62/// `+ - * / % ^`, and calls resolved by `calls`. Comparisons, booleans, arrays,
63/// records, and text are rejected with a clear error.
64pub fn evaluate_number(
65    expr: &Expr,
66    bindings: &BTreeMap<String, Number>,
67    numeric: &NumericContext,
68    limits: &Limits,
69    calls: &NumberCalls<'_>,
70) -> Result<Number, EngineError> {
71    let mut budget = Budget::new(limits);
72    eval_number(expr, bindings, numeric, limits, calls, &mut budget, 0)
73}
74
75fn eval_number(
76    expr: &Expr,
77    bindings: &BTreeMap<String, Number>,
78    numeric: &NumericContext,
79    limits: &Limits,
80    calls: &NumberCalls<'_>,
81    budget: &mut Budget,
82    depth: usize,
83) -> Result<Number, EngineError> {
84    budget.tick(depth)?;
85    match expr {
86        Expr::Number(number) => Ok(number.clone()),
87        Expr::Ident(name) => bindings.get(name).cloned().ok_or_else(|| {
88            let mut available: Vec<&String> = bindings.keys().collect();
89            available.sort();
90            EngineError::new(
91                ErrorCode::NotFound,
92                format!("unknown binding {name:?}; available: {available:?}"),
93            )
94        }),
95        Expr::Unary { op, expr } => {
96            let value = eval_number(expr, bindings, numeric, limits, calls, budget, depth + 1)?;
97            match op {
98                UnaryOp::Neg => Ok(value.neg()),
99                UnaryOp::Pos => Ok(value),
100                UnaryOp::Not => Err(EngineError::malformed(
101                    "logical negation is not valid in a numeric expression",
102                )),
103            }
104        }
105        Expr::Binary { op, left, right } => {
106            let a = eval_number(left, bindings, numeric, limits, calls, budget, depth + 1)?;
107            let b = eval_number(right, bindings, numeric, limits, calls, budget, depth + 1)?;
108            let result = match op {
109                BinaryOp::Add => a.add(&b, numeric, limits)?,
110                BinaryOp::Sub => a.sub(&b, numeric, limits)?,
111                BinaryOp::Mul => a.mul(&b, numeric, limits)?,
112                BinaryOp::Div => a.div(&b, numeric, limits)?,
113                BinaryOp::Rem => a.rem(&b, numeric, limits)?,
114                BinaryOp::Pow => a.pow(&b, numeric, limits)?,
115                BinaryOp::And | BinaryOp::Or => {
116                    return Err(EngineError::malformed(
117                        "logical operators are not valid in a numeric expression",
118                    ));
119                }
120            };
121            Ok(result.value)
122        }
123        Expr::Call { name, args } => {
124            let mut values = Vec::with_capacity(args.len());
125            for arg in args {
126                if arg.name.is_some() {
127                    return Err(EngineError::malformed(
128                        "named arguments are not supported in this expression context",
129                    ));
130                }
131                values.push(eval_number(
132                    &arg.value,
133                    bindings,
134                    numeric,
135                    limits,
136                    calls,
137                    budget,
138                    depth + 1,
139                )?);
140            }
141            calls(name, &values)
142        }
143        Expr::Compare { .. } => Err(EngineError::malformed(
144            "comparisons are not valid in a numeric expression",
145        )),
146        Expr::Bool(_) | Expr::Text(_) | Expr::Array(_) | Expr::Record(_) => Err(
147            EngineError::malformed("expression must produce a single numeric value"),
148        ),
149    }
150}
151
152/// Evaluate an expression in binary64, using a caller-supplied function table.
153pub fn evaluate_f64(
154    expr: &Expr,
155    bindings: &BTreeMap<String, f64>,
156    limits: &Limits,
157    calls: &FloatCalls<'_>,
158) -> Result<f64, EngineError> {
159    let mut budget = Budget::new(limits);
160    eval_f64(expr, bindings, calls, &mut budget, 0)
161}
162
163fn eval_f64(
164    expr: &Expr,
165    bindings: &BTreeMap<String, f64>,
166    calls: &FloatCalls<'_>,
167    budget: &mut Budget,
168    depth: usize,
169) -> Result<f64, EngineError> {
170    budget.tick(depth)?;
171    match expr {
172        Expr::Number(number) => number
173            .to_f64()
174            .ok_or_else(|| EngineError::domain("numeric literal is not representable as float64")),
175        Expr::Ident(name) => bindings.get(name).copied().ok_or_else(|| {
176            let mut available: Vec<&String> = bindings.keys().collect();
177            available.sort();
178            EngineError::new(
179                ErrorCode::NotFound,
180                format!("unknown binding {name:?}; available: {available:?}"),
181            )
182        }),
183        Expr::Unary { op, expr } => {
184            let value = eval_f64(expr, bindings, calls, budget, depth + 1)?;
185            match op {
186                UnaryOp::Neg => Ok(-value),
187                UnaryOp::Pos => Ok(value),
188                UnaryOp::Not => Err(EngineError::malformed(
189                    "logical negation is not valid in a numeric expression",
190                )),
191            }
192        }
193        Expr::Binary { op, left, right } => {
194            let a = eval_f64(left, bindings, calls, budget, depth + 1)?;
195            let b = eval_f64(right, bindings, calls, budget, depth + 1)?;
196            match op {
197                BinaryOp::Add => Ok(a + b),
198                BinaryOp::Sub => Ok(a - b),
199                BinaryOp::Mul => Ok(a * b),
200                BinaryOp::Div => {
201                    if b == 0.0 {
202                        Err(EngineError::division_by_zero("float division by zero"))
203                    } else {
204                        Ok(a / b)
205                    }
206                }
207                BinaryOp::Rem => {
208                    if b == 0.0 {
209                        Err(EngineError::division_by_zero("float remainder by zero"))
210                    } else {
211                        Ok(a % b)
212                    }
213                }
214                BinaryOp::Pow => {
215                    let value = a.powf(b);
216                    if value.is_finite() {
217                        Ok(value)
218                    } else {
219                        Err(EngineError::domain(format!("power {a}^{b} is not finite")))
220                    }
221                }
222                BinaryOp::And | BinaryOp::Or => Err(EngineError::malformed(
223                    "logical operators are not valid in a numeric expression",
224                )),
225            }
226        }
227        Expr::Call { name, args } => {
228            let mut values = Vec::with_capacity(args.len());
229            for arg in args {
230                if arg.name.is_some() {
231                    return Err(EngineError::malformed(
232                        "named arguments are not supported in this expression context",
233                    ));
234                }
235                values.push(eval_f64(&arg.value, bindings, calls, budget, depth + 1)?);
236            }
237            let value = calls(name, &values)?;
238            if value.is_finite() {
239                Ok(value)
240            } else {
241                Err(EngineError::domain(format!(
242                    "function {name} produced a non-finite result"
243                )))
244            }
245        }
246        Expr::Compare { .. } => Err(EngineError::malformed(
247            "comparisons are not valid in a numeric expression",
248        )),
249        Expr::Bool(_) | Expr::Text(_) | Expr::Array(_) | Expr::Record(_) => Err(
250            EngineError::malformed("expression must produce a single numeric value"),
251        ),
252    }
253}
254
255/// A function table built from a list of `(name, arity, fn)` entries.
256/// One entry in a restricted function table.
257type FloatCall = Box<dyn Fn(&[f64]) -> Result<f64, EngineError>>;
258
259pub struct SimpleCalls {
260    entries: Vec<(String, usize, FloatCall)>,
261}
262
263impl SimpleCalls {
264    pub fn new() -> SimpleCalls {
265        SimpleCalls {
266            entries: Vec::new(),
267        }
268    }
269
270    pub fn add<F>(mut self, name: &str, arity: usize, function: F) -> SimpleCalls
271    where
272        F: Fn(&[f64]) -> Result<f64, EngineError> + 'static,
273    {
274        self.entries
275            .push((name.to_string(), arity, Box::new(function)));
276        self
277    }
278
279    pub fn dispatch(&self, name: &str, args: &[f64]) -> Result<f64, EngineError> {
280        for (candidate, arity, function) in &self.entries {
281            if candidate == name {
282                if args.len() != *arity {
283                    return Err(EngineError::malformed(format!(
284                        "function {name} expects {arity} argument(s), found {}",
285                        args.len()
286                    )));
287                }
288                return function(args);
289            }
290        }
291        Err(EngineError::new(
292            ErrorCode::UnknownFunction,
293            format!("unknown function {name:?} in restricted expression"),
294        ))
295    }
296
297    pub fn as_table(&self) -> impl Fn(&str, &[f64]) -> Result<f64, EngineError> + '_ {
298        move |name, args| self.dispatch(name, args)
299    }
300}
301
302impl Default for SimpleCalls {
303    fn default() -> Self {
304        SimpleCalls::new()
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::expr::parse_expression;
312
313    fn limits() -> Limits {
314        Limits::conservative()
315    }
316
317    #[test]
318    fn exact_evaluation_uses_shared_arithmetic() {
319        let expr = parse_expression("0.1 + 0.2 * 2", &limits()).unwrap();
320        let result = evaluate_number(
321            &expr,
322            &BTreeMap::new(),
323            &NumericContext::default(),
324            &limits(),
325            &|name, _| Err(EngineError::new(ErrorCode::UnknownFunction, name)),
326        )
327        .unwrap();
328        assert_eq!(result.to_string(), "0.5");
329    }
330
331    #[test]
332    fn exact_evaluation_is_rational_for_division() {
333        let expr = parse_expression("1 / 3 + 1 / 6", &limits()).unwrap();
334        let result = evaluate_number(
335            &expr,
336            &BTreeMap::new(),
337            &NumericContext::default(),
338            &limits(),
339            &|name, _| Err(EngineError::new(ErrorCode::UnknownFunction, name)),
340        )
341        .unwrap();
342        assert_eq!(result.to_string(), "1/2");
343    }
344
345    #[test]
346    fn float_evaluation_calls_provider() {
347        let calls = SimpleCalls::new().add("double", 1, |args| Ok(args[0] * 2.0));
348        let expr = parse_expression("double(x) + 1", &limits()).unwrap();
349        let mut bindings = BTreeMap::new();
350        bindings.insert("x".to_string(), 3.0);
351        let result = evaluate_f64(&expr, &bindings, &limits(), &calls.as_table()).unwrap();
352        assert_eq!(result, 7.0);
353    }
354
355    #[test]
356    fn unknown_function_is_structured() {
357        let expr = parse_expression("nope(1)", &limits()).unwrap();
358        let error = evaluate_f64(
359            &expr,
360            &BTreeMap::new(),
361            &limits(),
362            &SimpleCalls::new().as_table(),
363        )
364        .unwrap_err();
365        assert_eq!(error.code, ErrorCode::UnknownFunction);
366    }
367}