Skip to main content

oximo_expr/
eval.rs

1use std::cell::RefCell;
2
3use thiserror::Error;
4
5use crate::arena::{ExprArena, ExprId, ExprNode, ParamId, VarId};
6
7#[derive(Debug, Error)]
8pub enum EvalError {
9    #[error("variable {0:?} has no value bound in the evaluation context")]
10    UnboundVar(VarId),
11    #[error("parameter {0:?} has no value bound in the evaluation context")]
12    UnboundParam(ParamId),
13}
14
15/// Source of variable and parameter values during expression evaluation.
16pub trait EvalContext {
17    fn var(&self, v: VarId) -> Option<f64>;
18    fn param(&self, p: ParamId) -> Option<f64>;
19}
20
21impl EvalContext for &[f64] {
22    fn var(&self, v: VarId) -> Option<f64> {
23        self.get(v.index()).copied()
24    }
25    fn param(&self, _p: ParamId) -> Option<f64> {
26        None
27    }
28}
29
30/// Evaluate `id` to an `f64`, pulling variable / parameter values from `ctx`.
31///
32/// The traversal is iterative and visits each reachable node once, so deep
33/// chains cannot overflow the call stack and heavily shared DAGs are
34/// evaluated in time proportional to the number of distinct nodes.
35///
36/// # Errors
37///
38/// Returns an [`EvalError`] if a needed variable or parameter is missing from the context.
39#[inline]
40pub fn evaluate<C: EvalContext>(arena: &ExprArena, id: ExprId, ctx: &C) -> Result<f64, EvalError> {
41    match arena.get(id) {
42        ExprNode::Const(c) => Ok(*c),
43        ExprNode::Var(v) => ctx.var(*v).ok_or(EvalError::UnboundVar(*v)),
44        ExprNode::Param(p) => {
45            ctx.param(*p).or_else(|| arena.try_param_value(*p)).ok_or(EvalError::UnboundParam(*p))
46        }
47        ExprNode::Linear { coeffs, constant } => eval_linear(coeffs, *constant, ctx),
48        _ => eval_compound(arena, id, ctx),
49    }
50}
51
52#[inline]
53fn eval_linear<C: EvalContext>(
54    coeffs: &[(VarId, f64)],
55    mut value: f64,
56    ctx: &C,
57) -> Result<f64, EvalError> {
58    for &(v, c) in coeffs {
59        value += c * ctx.var(v).ok_or(EvalError::UnboundVar(v))?;
60    }
61    Ok(value)
62}
63
64#[inline]
65fn eval_leaf<C: EvalContext>(
66    arena: &ExprArena,
67    node: &ExprNode,
68    ctx: &C,
69) -> Option<Result<f64, EvalError>> {
70    Some(match node {
71        ExprNode::Const(c) => Ok(*c),
72        ExprNode::Var(v) => ctx.var(*v).ok_or(EvalError::UnboundVar(*v)),
73        ExprNode::Param(p) => {
74            ctx.param(*p).or_else(|| arena.try_param_value(*p)).ok_or(EvalError::UnboundParam(*p))
75        }
76        ExprNode::Linear { coeffs, constant } => eval_linear(coeffs, *constant, ctx),
77        _ => return None,
78    })
79}
80
81fn eval_compound<C: EvalContext>(
82    arena: &ExprArena,
83    mut id: ExprId,
84    ctx: &C,
85) -> Result<f64, EvalError> {
86    // A negation spine has no branching or sharing to memoize.
87    let mut negate = false;
88    while let ExprNode::Unary(crate::UnaryOp::Neg, inner) = arena.get(id) {
89        negate = !negate;
90        id = *inner;
91    }
92    let value = eval_shallow(arena, id, ctx).unwrap_or_else(|| eval_general(arena, id, ctx))?;
93    Ok(if negate { -value } else { value })
94}
95
96fn eval_shallow<C: EvalContext>(
97    arena: &ExprArena,
98    id: ExprId,
99    ctx: &C,
100) -> Option<Result<f64, EvalError>> {
101    let node = arena.get(id);
102    if let Some(value) = eval_leaf(arena, node, ctx) {
103        return Some(value);
104    }
105    if let ExprNode::Unary(op, inner) = node {
106        return eval_leaf(arena, arena.get(*inner), ctx).map(|v| v.map(|v| op.apply(v)));
107    }
108    let (children, mut value) = match node {
109        ExprNode::Add(c) => (c, 0.0),
110        ExprNode::Mul(c) => (c, 1.0),
111        ExprNode::Min(c) => (c, f64::INFINITY),
112        ExprNode::Max(c) => (c, f64::NEG_INFINITY),
113        _ => return None,
114    };
115    // Ascending IDs prove these leaves are distinct without a hash set.
116    // We do the shape check before querying the context, so fallback never
117    // repeats context calls or changes which missing value is reported first.
118    let mut previous = None;
119    if !children.iter().all(|&child| {
120        let distinct = previous.is_none_or(|p| child.index() > p);
121        previous = Some(child.index());
122        distinct
123            && matches!(
124                arena.get(child),
125                ExprNode::Const(_)
126                    | ExprNode::Var(_)
127                    | ExprNode::Param(_)
128                    | ExprNode::Linear { .. }
129            )
130    }) {
131        return None;
132    }
133    for &child in children {
134        let x = match eval_leaf(arena, arena.get(child), ctx)? {
135            Ok(x) => x,
136            Err(error) => return Some(Err(error)),
137        };
138        value = match node {
139            ExprNode::Add(_) => value + x,
140            ExprNode::Mul(_) => value * x,
141            ExprNode::Min(_) => value.min(x),
142            ExprNode::Max(_) => value.max(x),
143            _ => unreachable!(),
144        };
145    }
146    Some(Ok(value))
147}
148
149fn eval_general<C: EvalContext>(arena: &ExprArena, id: ExprId, ctx: &C) -> Result<f64, EvalError> {
150    if let Some(result) = eval_array(arena, id, ctx) {
151        return result;
152    }
153    eval_hashed(arena, id, ctx)
154}
155
156#[derive(Default)]
157struct EvalScratch {
158    values: Vec<f64>,
159    seen: Vec<u32>,
160    stack: Vec<(ExprId, usize)>,
161    generation: u32,
162}
163
164thread_local! {
165    static SCRATCH: RefCell<EvalScratch> = const { RefCell::new(EvalScratch {
166        values: Vec::new(), seen: Vec::new(), stack: Vec::new(), generation: 0,
167    }) };
168}
169
170const ARRAY_PATH_MAX_NODES: usize = 1 << 16;
171
172/// Stack-safe, sharing-aware evaluation using arena-indexed scratch buffers.
173///
174/// Returns `None` when the caller should use [`eval_hashed`] instead (arena
175/// too large or scratch re-entered).
176fn eval_array<C: EvalContext>(
177    arena: &ExprArena,
178    id: ExprId,
179    ctx: &C,
180) -> Option<Result<f64, EvalError>> {
181    let n = arena.len();
182    if n > ARRAY_PATH_MAX_NODES || id.index() >= n {
183        return None;
184    }
185    SCRATCH.with(|scratch| {
186        // A nested evaluation uses hashed storage.
187        let mut scratch = scratch.try_borrow_mut().ok()?;
188        let EvalScratch { values, seen, stack, generation } = &mut *scratch;
189        values.resize(values.len().max(n), 0.0);
190        seen.resize(seen.len().max(n), 0);
191        stack.clear();
192        *generation = generation.wrapping_add(1);
193        if *generation == 0 {
194            seen.fill(0);
195            *generation = 1;
196        }
197        stack.push((id, 0));
198        seen[id.index()] = *generation;
199        while let Some((cur, next)) = stack.last_mut() {
200            let cur = *cur;
201            if let Some(child) = eval_child(arena, cur, *next) {
202                *next += 1;
203                if child.index() >= n {
204                    return None;
205                }
206                if seen[child.index()] != *generation {
207                    seen[child.index()] = *generation;
208                    stack.push((child, 0));
209                }
210            } else {
211                match eval_node_array(arena, ctx, values, cur) {
212                    Ok(value) => values[cur.index()] = value,
213                    Err(error) => return Some(Err(error)),
214                }
215                stack.pop();
216            }
217        }
218        Some(Ok(values[id.index()]))
219    })
220}
221
222/// Value of a single node from already-computed child values.
223fn eval_node_array<C: EvalContext>(
224    arena: &ExprArena,
225    ctx: &C,
226    values: &[f64],
227    cur: ExprId,
228) -> Result<f64, EvalError> {
229    Ok(match arena.get(cur) {
230        ExprNode::Const(c) => *c,
231        ExprNode::Var(v) => ctx.var(*v).ok_or(EvalError::UnboundVar(*v))?,
232        ExprNode::Param(p) => ctx
233            .param(*p)
234            .or_else(|| arena.try_param_value(*p))
235            .ok_or(EvalError::UnboundParam(*p))?,
236        ExprNode::Add(children) => {
237            let mut acc = 0.0;
238            for c in children {
239                acc += values[c.index()];
240            }
241            acc
242        }
243        ExprNode::Mul(children) => {
244            let mut acc = 1.0;
245            for c in children {
246                acc *= values[c.index()];
247            }
248            acc
249        }
250        ExprNode::Unary(op, inner) => op.apply(values[inner.index()]),
251        ExprNode::Pow(base, exp) => values[base.index()].powf(values[exp.index()]),
252        ExprNode::Div(num, den) => values[num.index()] / values[den.index()],
253        ExprNode::Atan2(y, x) => values[y.index()].atan2(values[x.index()]),
254        ExprNode::Min(children) => {
255            let mut acc = f64::INFINITY;
256            for c in children {
257                acc = acc.min(values[c.index()]);
258            }
259            acc
260        }
261        ExprNode::Max(children) => {
262            let mut acc = f64::NEG_INFINITY;
263            for c in children {
264                acc = acc.max(values[c.index()]);
265            }
266            acc
267        }
268        ExprNode::Linear { coeffs, constant } => {
269            let mut acc = *constant;
270            for (v, c) in coeffs {
271                acc += c * ctx.var(*v).ok_or(EvalError::UnboundVar(*v))?;
272            }
273            acc
274        }
275    })
276}
277
278/// General fallback: same post-order, but `seen`/`values` live in hash maps
279/// so only reachable nodes are touched no matter how large the arena is.
280fn eval_hashed<C: EvalContext>(arena: &ExprArena, id: ExprId, ctx: &C) -> Result<f64, EvalError> {
281    let mut seen = rustc_hash::FxHashSet::default();
282    let mut order = Vec::new();
283    seen.insert(id);
284    let mut stack: Vec<(ExprId, usize)> = vec![(id, 0)];
285    while let Some((cur, next)) = stack.last_mut() {
286        let cur = *cur;
287        let Some(child) = eval_child(arena, cur, *next) else {
288            order.push(cur);
289            stack.pop();
290            continue;
291        };
292        *next += 1;
293        if seen.insert(child) {
294            stack.push((child, 0));
295        }
296    }
297
298    let mut values =
299        rustc_hash::FxHashMap::with_capacity_and_hasher(order.len(), rustc_hash::FxBuildHasher);
300    for cur in order {
301        let value = match arena.get(cur) {
302            ExprNode::Const(c) => *c,
303            ExprNode::Var(v) => ctx.var(*v).ok_or(EvalError::UnboundVar(*v))?,
304            ExprNode::Param(p) => ctx
305                .param(*p)
306                .or_else(|| arena.try_param_value(*p))
307                .ok_or(EvalError::UnboundParam(*p))?,
308            ExprNode::Add(children) => {
309                let mut acc = 0.0;
310                for c in children {
311                    acc += values[c];
312                }
313                acc
314            }
315            ExprNode::Mul(children) => {
316                let mut acc = 1.0;
317                for c in children {
318                    acc *= values[c];
319                }
320                acc
321            }
322            ExprNode::Unary(op, inner) => op.apply(values[inner]),
323            ExprNode::Pow(base, exp) => values[base].powf(values[exp]),
324            ExprNode::Div(num, den) => values[num] / values[den],
325            ExprNode::Atan2(y, x) => values[y].atan2(values[x]),
326            ExprNode::Min(children) => {
327                let mut acc = f64::INFINITY;
328                for c in children {
329                    acc = acc.min(values[c]);
330                }
331                acc
332            }
333            ExprNode::Max(children) => {
334                let mut acc = f64::NEG_INFINITY;
335                for c in children {
336                    acc = acc.max(values[c]);
337                }
338                acc
339            }
340            ExprNode::Linear { coeffs, constant } => {
341                let mut acc = *constant;
342                for (v, c) in coeffs {
343                    acc += c * ctx.var(*v).ok_or(EvalError::UnboundVar(*v))?;
344                }
345                acc
346            }
347        };
348        values.insert(cur, value);
349    }
350    Ok(values[&id])
351}
352
353/// The `next`-th lowering dependency of `id` (the `next`-th child), or `None`
354/// when all children have been yielded.
355fn eval_child(arena: &ExprArena, id: ExprId, next: usize) -> Option<ExprId> {
356    match arena.get(id) {
357        ExprNode::Add(children)
358        | ExprNode::Mul(children)
359        | ExprNode::Min(children)
360        | ExprNode::Max(children) => children.get(next).copied(),
361        ExprNode::Unary(_, inner) => (next == 0).then_some(*inner),
362        ExprNode::Pow(base, exp) | ExprNode::Div(base, exp) | ExprNode::Atan2(base, exp) => {
363            match next {
364                0 => Some(*base),
365                1 => Some(*exp),
366                _ => None,
367            }
368        }
369        ExprNode::Const(_) | ExprNode::Var(_) | ExprNode::Param(_) | ExprNode::Linear { .. } => {
370            None
371        }
372    }
373}