Skip to main content

oximo_expr/
linear.rs

1use rustc_hash::{FxBuildHasher, FxHashMap};
2use smallvec::smallvec;
3
4use crate::arena::{ExprArena, ExprId, ExprNode, VarId};
5
6/// Coefficients of a linear expression: `sum(coeff * var) + constant`.
7#[derive(Clone, Debug, Default)]
8pub struct LinearTerms {
9    pub coeffs: Vec<(VarId, f64)>,
10    pub constant: f64,
11}
12
13/// Accumulator that merges duplicate `(VarId, coeff)` terms while
14/// preserving the order each variable is first seen.
15struct CoeffAccum {
16    coeffs: Vec<(VarId, f64)>,
17    slot: FxHashMap<VarId, usize>,
18}
19
20impl CoeffAccum {
21    fn with_capacity(n: usize) -> Self {
22        Self {
23            coeffs: Vec::with_capacity(n),
24            slot: FxHashMap::with_capacity_and_hasher(n, FxBuildHasher),
25        }
26    }
27
28    /// Add `c` to `v`'s running coefficient, appending `v` the first time it is
29    /// seen.
30    fn add(&mut self, v: VarId, c: f64) {
31        if let Some(&i) = self.slot.get(&v) {
32            self.coeffs[i].1 += c;
33        } else {
34            self.slot.insert(v, self.coeffs.len());
35            self.coeffs.push((v, c));
36        }
37    }
38
39    fn extend(&mut self, terms: impl IntoIterator<Item = (VarId, f64)>) {
40        for (v, c) in terms {
41            self.add(v, c);
42        }
43    }
44
45    fn into_coeffs(self) -> Vec<(VarId, f64)> {
46        self.coeffs
47    }
48}
49
50/// Try to interpret `id` as a linear expression. Returns `None` for any
51/// nonlinear node (Mul of two non-constants, Pow, transcendentals, ...).
52///
53/// When `resolve_params` is set, a [`ExprNode::Param`] folds to its current
54/// arena value and counts as a constant.
55fn as_linear(arena: &ExprArena, id: ExprId, resolve_params: bool) -> Option<LinearTerms> {
56    match arena.get(id) {
57        ExprNode::Const(c) => Some(LinearTerms { coeffs: Vec::new(), constant: *c }),
58        ExprNode::Param(p) if resolve_params => {
59            Some(LinearTerms { coeffs: Vec::new(), constant: arena.param_value(*p) })
60        }
61        ExprNode::Var(v) => Some(LinearTerms { coeffs: vec![(*v, 1.0)], constant: 0.0 }),
62        ExprNode::Linear { coeffs, constant } => {
63            Some(LinearTerms { coeffs: coeffs.clone(), constant: *constant })
64        }
65        ExprNode::Neg(inner) => {
66            let inner = *inner;
67            as_linear(arena, inner, resolve_params).map(|mut t| {
68                t.coeffs.iter_mut().for_each(|(_, c)| *c = -*c);
69                t.constant = -t.constant;
70                t
71            })
72        }
73        ExprNode::Add(children) => {
74            let mut acc = CoeffAccum::with_capacity(children.len() * 4);
75            let mut constant = 0.0;
76            for &child in children {
77                let t = as_linear(arena, child, resolve_params)?;
78                acc.extend(t.coeffs);
79                constant += t.constant;
80            }
81            Some(LinearTerms { coeffs: acc.into_coeffs(), constant })
82        }
83        ExprNode::Mul(children) => {
84            // Linear if and only if exactly one non-const child is linear and the rest are constants.
85            let mut scalar = 1.0;
86            let mut linear: Option<LinearTerms> = None;
87            for &child in children {
88                match arena.get(child) {
89                    ExprNode::Const(c) => scalar *= c,
90                    ExprNode::Param(p) if resolve_params => scalar *= arena.param_value(*p),
91                    _ if linear.is_none() => {
92                        linear = Some(as_linear(arena, child, resolve_params)?);
93                    }
94                    _ => return None,
95                }
96            }
97            Some(match linear {
98                None => LinearTerms { coeffs: Vec::new(), constant: scalar },
99                Some(mut t) => {
100                    t.coeffs.iter_mut().for_each(|(_, c)| *c *= scalar);
101                    t.constant *= scalar;
102                    t
103                }
104            })
105        }
106        _ => None,
107    }
108}
109
110/// Materialize a linear-terms struct into a fresh `Linear` node in the arena.
111fn push_linear(arena: &mut ExprArena, mut t: LinearTerms) -> ExprId {
112    t.coeffs.retain(|(_, c)| *c != 0.0);
113    arena.push(ExprNode::Linear { coeffs: t.coeffs, constant: t.constant })
114}
115
116/// Build `lhs + rhs`, preserving the linear fast-path when both sides are
117/// linear. Falls back to an n-ary `Add` node otherwise.
118pub(crate) fn add_into(arena: &mut ExprArena, lhs: ExprId, rhs: ExprId) -> ExprId {
119    if let (Some(lt), Some(rt)) = (as_linear(arena, lhs, false), as_linear(arena, rhs, false)) {
120        let mut acc = CoeffAccum::with_capacity(lt.coeffs.len() + rt.coeffs.len());
121        acc.extend(lt.coeffs);
122        acc.extend(rt.coeffs);
123        return push_linear(
124            arena,
125            LinearTerms { coeffs: acc.into_coeffs(), constant: lt.constant + rt.constant },
126        );
127    }
128    arena.push(ExprNode::Add(smallvec![lhs, rhs]))
129}
130
131/// Build a flat n-ary sum of `ids` as a single `Add` node.
132/// `as_linear`/`split_linear` collapse the resulting `Add`
133/// in one pass at extraction, so the linear fast-path is preserved.
134///
135/// # Panics
136/// Panics if `ids` is empty (callers supply at least one term).
137pub(crate) fn add_n(arena: &mut ExprArena, ids: &[ExprId]) -> ExprId {
138    match ids {
139        [] => panic!("add_n on an empty term list"),
140        [one] => *one,
141        _ => arena.push(ExprNode::Add(ids.iter().copied().collect())),
142    }
143}
144
145/// Build `lhs - rhs`. Same linear fast-path as `add_into`.
146pub(crate) fn sub_into(arena: &mut ExprArena, lhs: ExprId, rhs: ExprId) -> ExprId {
147    let neg = neg_into(arena, rhs);
148    add_into(arena, lhs, neg)
149}
150
151/// Build `lhs * rhs`. If either side is constant and the other is linear, we
152/// stay on the linear fast-path. Otherwise produce a generic n-ary `Mul`.
153pub(crate) fn mul_into(arena: &mut ExprArena, lhs: ExprId, rhs: ExprId) -> ExprId {
154    if let ExprNode::Const(c) = *arena.get(lhs) {
155        if let Some(mut t) = as_linear(arena, rhs, false) {
156            t.coeffs.iter_mut().for_each(|(_, co)| *co *= c);
157            t.constant *= c;
158            return push_linear(arena, t);
159        }
160    }
161    if let ExprNode::Const(c) = *arena.get(rhs) {
162        if let Some(mut t) = as_linear(arena, lhs, false) {
163            t.coeffs.iter_mut().for_each(|(_, co)| *co *= c);
164            t.constant *= c;
165            return push_linear(arena, t);
166        }
167    }
168    arena.push(ExprNode::Mul(smallvec![lhs, rhs]))
169}
170
171/// Build `num / den`. If `den` is a nonzero constant `c`, fold to `num * (1/c)`
172/// so a constant-denominator division stays on the linear fast-path. Otherwise
173/// produce a `Div` node (always nonlinear, even when the numerator is linear).
174pub(crate) fn div_into(arena: &mut ExprArena, num: ExprId, den: ExprId) -> ExprId {
175    if let ExprNode::Const(c) = *arena.get(den) {
176        if c != 0.0 {
177            if let Some(mut t) = as_linear(arena, num, false) {
178                let inv = 1.0 / c;
179                t.coeffs.iter_mut().for_each(|(_, co)| *co *= inv);
180                t.constant *= inv;
181                return push_linear(arena, t);
182            }
183            let inv = arena.push(ExprNode::Const(1.0 / c));
184            return mul_into(arena, num, inv);
185        }
186    }
187    arena.push(ExprNode::Div(num, den))
188}
189
190/// Build `-rhs`, preserving linearity.
191pub(crate) fn neg_into(arena: &mut ExprArena, rhs: ExprId) -> ExprId {
192    if let Some(mut t) = as_linear(arena, rhs, false) {
193        t.coeffs.iter_mut().for_each(|(_, c)| *c = -*c);
194        t.constant = -t.constant;
195        return push_linear(arena, t);
196    }
197    arena.push(ExprNode::Neg(rhs))
198}
199
200/// Snapshot the linear terms of `id`, if any. Used by solver backends to
201/// extract LP coefficients without walking the tree themselves.
202///
203/// Parameters are folded to their current arena values, so the returned
204/// coefficients reflect the latest [`ExprArena::set_param_value`] binding.
205///
206/// [`ExprArena::set_param_value`]: crate::ExprArena::set_param_value
207pub fn extract_linear(arena: &ExprArena, id: ExprId) -> Option<LinearTerms> {
208    as_linear(arena, id, true)
209}
210
211/// A nonlinear residual summand: the existing arena node `id`, taken with a
212/// leading negation when `neg` is set. Carrying the sign as a flag.
213/// Lets [`split_linear`] run without a mutable arena.
214#[derive(Copy, Clone, Debug, PartialEq, Eq)]
215pub struct SignedExpr {
216    pub id: ExprId,
217    pub neg: bool,
218}
219
220/// Split an expression into its linear part and a nonlinear residual. The
221/// returned `(LinearTerms, Vec<SignedExpr>)` satisfies
222///
223/// ```text
224/// value(id) == sum_i coef_i * var_i + constant + sum_j (-1)^neg_j value(id_j)
225/// ```
226///
227/// where the residual is empty when the whole expression is linear and
228/// otherwise lists the remaining nonlinear summands (each a pre-existing arena
229/// node, optionally negated). `LinearTerms` may have empty `coeffs` and
230/// `constant == 0.0` when the whole expression is purely nonlinear.
231pub fn split_linear(arena: &ExprArena, id: ExprId) -> (LinearTerms, Vec<SignedExpr>) {
232    if let Some(lt) = as_linear(arena, id, true) {
233        return (lt, Vec::new());
234    }
235    let mut lin = CoeffAccum::with_capacity(0);
236    let mut constant = 0.0;
237    let mut residual: Vec<SignedExpr> = Vec::new();
238    let mut sign_stack: smallvec::SmallVec<[(ExprId, f64); 8]> = smallvec![(id, 1.0)];
239    while let Some((cur, sign)) = sign_stack.pop() {
240        match arena.get(cur) {
241            ExprNode::Add(children) => {
242                for c in children.iter().copied() {
243                    sign_stack.push((c, sign));
244                }
245            }
246            ExprNode::Neg(inner) => sign_stack.push((*inner, -sign)),
247            _ => {
248                if let Some(mut t) = as_linear(arena, cur, true) {
249                    if (sign - 1.0).abs() > 0.0 {
250                        t.coeffs.iter_mut().for_each(|(_, c)| *c *= sign);
251                        t.constant *= sign;
252                    }
253                    lin.extend(t.coeffs);
254                    constant += t.constant;
255                } else {
256                    residual.push(SignedExpr { id: cur, neg: sign < 0.0 });
257                }
258            }
259        }
260    }
261    let mut coeffs = lin.into_coeffs();
262    coeffs.retain(|(_, c)| *c != 0.0);
263    (LinearTerms { coeffs, constant }, residual)
264}
265
266/// Render the first nonlinear summand of `id` as a short infix string, resolving
267/// each [`VarId`] to a display name via `resolve`. Returns `None` when `id` is
268/// fully affine (no nonlinear residual).
269pub fn describe_nonlinear_term(
270    arena: &ExprArena,
271    id: ExprId,
272    resolve: &impl Fn(VarId) -> String,
273) -> Option<String> {
274    use crate::render::{PREC_ADD, PREC_UNARY, render_node};
275    let (_, residual) = split_linear(arena, id);
276    residual.first().map(|s| {
277        if s.neg {
278            format!("-{}", render_node(arena, s.id, resolve, PREC_UNARY))
279        } else {
280            render_node(arena, s.id, resolve, PREC_ADD)
281        }
282    })
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::arena::{ExprArena, ExprNode, VarId};
289
290    #[test]
291    fn param_times_var_stays_symbolic_until_extracted() {
292        // Build `price * x` through the operator helper. The parameter must NOT
293        // be folded into a Linear node at build time (so it stays re-bindable)
294        let mut arena = ExprArena::new();
295        let pid = arena.new_param(3.0);
296        let price = arena.param(pid);
297        let xnode = arena.push(ExprNode::Var(VarId(0)));
298        let prod = mul_into(&mut arena, price, xnode);
299        assert!(matches!(arena.get(prod), ExprNode::Mul(_)));
300
301        let terms = extract_linear(&arena, prod).expect("linear");
302        assert_eq!(terms.coeffs, vec![(VarId(0), 3.0)]);
303        assert!(terms.constant.abs() < f64::EPSILON);
304    }
305
306    #[test]
307    fn rebinding_param_updates_extracted_coeff() {
308        let mut arena = ExprArena::new();
309        let pid = arena.new_param(3.0);
310        let price = arena.param(pid);
311        let xnode = arena.push(ExprNode::Var(VarId(0)));
312        let prod = mul_into(&mut arena, price, xnode);
313
314        arena.set_param_value(pid, 10.0);
315        let terms = extract_linear(&arena, prod).expect("linear");
316        assert_eq!(terms.coeffs, vec![(VarId(0), 10.0)]);
317    }
318
319    #[test]
320    fn param_plus_var_resolves_constant() {
321        let mut arena = ExprArena::new();
322        let pid = arena.new_param(5.0);
323        let price = arena.param(pid);
324        let xnode = arena.push(ExprNode::Var(VarId(0)));
325        let sum = add_into(&mut arena, price, xnode);
326        let terms = extract_linear(&arena, sum).expect("linear");
327        assert_eq!(terms.coeffs, vec![(VarId(0), 1.0)]);
328        assert!((terms.constant - 5.0).abs() < f64::EPSILON);
329    }
330
331    #[test]
332    fn add_extraction_is_first_seen_ordered_and_merges() {
333        // `z + x + y + x`: coefficients come out in first-seen order [z, x, y]
334        // and the repeated `x` is merged to coeff 2.
335        let mut arena = ExprArena::new();
336        let z = arena.push(ExprNode::Var(VarId(2)));
337        let x = arena.push(ExprNode::Var(VarId(0)));
338        let y = arena.push(ExprNode::Var(VarId(1)));
339        let sum = arena.push(ExprNode::Add(smallvec::smallvec![z, x, y, x]));
340
341        let terms = extract_linear(&arena, sum).expect("linear");
342        assert_eq!(terms.coeffs, vec![(VarId(2), 1.0), (VarId(0), 2.0), (VarId(1), 1.0)]);
343        assert!(terms.constant.abs() < f64::EPSILON);
344        assert_eq!(extract_linear(&arena, sum).unwrap().coeffs, terms.coeffs);
345    }
346
347    #[test]
348    fn wide_sum_merges_repeated_vars_in_order() {
349        let mut arena = ExprArena::new();
350        let n = 50u32;
351        let mut ids = Vec::new();
352        for _ in 0..3 {
353            for v in 0..n {
354                ids.push(arena.push(ExprNode::Var(VarId(v))));
355            }
356        }
357        let sum = arena.push(ExprNode::Add(ids.into_iter().collect()));
358        let terms = extract_linear(&arena, sum).expect("linear");
359        let expected: Vec<(VarId, f64)> = (0..n).map(|v| (VarId(v), 3.0)).collect();
360        assert_eq!(terms.coeffs, expected);
361    }
362
363    fn names(v: VarId) -> String {
364        match v.0 {
365            0 => "x".to_string(),
366            1 => "y".to_string(),
367            n => format!("v{n}"),
368        }
369    }
370
371    #[test]
372    fn describe_renders_the_first_nonlinear_summand() {
373        let mut arena = ExprArena::new();
374        let x = arena.push(ExprNode::Var(VarId(0)));
375        let y = arena.push(ExprNode::Var(VarId(1)));
376
377        let prod = arena.push(ExprNode::Mul(smallvec::smallvec![x, y]));
378        assert_eq!(describe_nonlinear_term(&arena, prod, &names).as_deref(), Some("x * y"));
379
380        let two = arena.constant(2.0);
381        let pow = arena.push(ExprNode::Pow(x, two));
382        assert_eq!(describe_nonlinear_term(&arena, pow, &names).as_deref(), Some("x^2"));
383
384        let s = arena.push(ExprNode::Sin(x));
385        assert_eq!(describe_nonlinear_term(&arena, s, &names).as_deref(), Some("sin(x)"));
386
387        let div = arena.push(ExprNode::Div(x, y));
388        assert_eq!(describe_nonlinear_term(&arena, div, &names).as_deref(), Some("x / y"));
389    }
390
391    #[test]
392    fn describe_isolates_the_nonlinear_part_of_a_mixed_expression() {
393        let mut arena = ExprArena::new();
394        let x = arena.push(ExprNode::Var(VarId(0)));
395        let y = arena.push(ExprNode::Var(VarId(1)));
396        let z = arena.push(ExprNode::Var(VarId(2)));
397        let two = arena.constant(2.0);
398        let two_z = arena.push(ExprNode::Mul(smallvec::smallvec![two, z]));
399        let prod = arena.push(ExprNode::Mul(smallvec::smallvec![x, y]));
400        let sum = arena.push(ExprNode::Add(smallvec::smallvec![two_z, prod]));
401        assert_eq!(describe_nonlinear_term(&arena, sum, &names).as_deref(), Some("x * y"));
402    }
403
404    #[test]
405    fn describe_returns_none_for_affine() {
406        let mut arena = ExprArena::new();
407        let x = arena.push(ExprNode::Var(VarId(0)));
408        let three = arena.constant(3.0);
409        let sum = arena.push(ExprNode::Add(smallvec::smallvec![x, three]));
410        assert_eq!(describe_nonlinear_term(&arena, sum, &names), None);
411    }
412
413    #[test]
414    fn describe_falls_back_to_index_for_unknown_var() {
415        let mut arena = ExprArena::new();
416        let a = arena.push(ExprNode::Var(VarId(7)));
417        let b = arena.push(ExprNode::Var(VarId(8)));
418        let prod = arena.push(ExprNode::Mul(smallvec::smallvec![a, b]));
419        assert_eq!(describe_nonlinear_term(&arena, prod, &names).as_deref(), Some("v7 * v8"));
420    }
421}