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 children: smallvec::SmallVec<[ExprId; 4]> = children.iter().copied().collect();
75            let mut acc = CoeffAccum::with_capacity(children.len() * 4);
76            let mut constant = 0.0;
77            for child in children {
78                let t = as_linear(arena, child, resolve_params)?;
79                acc.extend(t.coeffs);
80                constant += t.constant;
81            }
82            Some(LinearTerms { coeffs: acc.into_coeffs(), constant })
83        }
84        ExprNode::Mul(children) => {
85            // Linear if and only if exactly one non-const child is linear and the rest are constants.
86            let children: smallvec::SmallVec<[ExprId; 4]> = children.iter().copied().collect();
87            let mut scalar = 1.0;
88            let mut linear: Option<LinearTerms> = None;
89            for child in children {
90                match arena.get(child) {
91                    ExprNode::Const(c) => scalar *= c,
92                    ExprNode::Param(p) if resolve_params => scalar *= arena.param_value(*p),
93                    _ if linear.is_none() => {
94                        linear = Some(as_linear(arena, child, resolve_params)?);
95                    }
96                    _ => return None,
97                }
98            }
99            Some(match linear {
100                None => LinearTerms { coeffs: Vec::new(), constant: scalar },
101                Some(mut t) => {
102                    t.coeffs.iter_mut().for_each(|(_, c)| *c *= scalar);
103                    t.constant *= scalar;
104                    t
105                }
106            })
107        }
108        _ => None,
109    }
110}
111
112/// Materialize a linear-terms struct into a fresh `Linear` node in the arena.
113fn push_linear(arena: &mut ExprArena, mut t: LinearTerms) -> ExprId {
114    t.coeffs.retain(|(_, c)| *c != 0.0);
115    arena.push(ExprNode::Linear { coeffs: t.coeffs, constant: t.constant })
116}
117
118/// Build `lhs + rhs`, preserving the linear fast-path when both sides are
119/// linear. Falls back to an n-ary `Add` node otherwise.
120pub(crate) fn add_into(arena: &mut ExprArena, lhs: ExprId, rhs: ExprId) -> ExprId {
121    if let (Some(lt), Some(rt)) = (as_linear(arena, lhs, false), as_linear(arena, rhs, false)) {
122        let mut acc = CoeffAccum::with_capacity(lt.coeffs.len() + rt.coeffs.len());
123        acc.extend(lt.coeffs);
124        acc.extend(rt.coeffs);
125        return push_linear(
126            arena,
127            LinearTerms { coeffs: acc.into_coeffs(), constant: lt.constant + rt.constant },
128        );
129    }
130    arena.push(ExprNode::Add(smallvec![lhs, rhs]))
131}
132
133/// Build a flat n-ary sum of `ids` as a single `Add` node.
134/// `as_linear`/`split_linear` collapse the resulting `Add`
135/// in one pass at extraction, so the linear fast-path is preserved.
136///
137/// # Panics
138/// Panics if `ids` is empty (callers supply at least one term).
139pub(crate) fn add_n(arena: &mut ExprArena, ids: &[ExprId]) -> ExprId {
140    match ids {
141        [] => panic!("add_n on an empty term list"),
142        [one] => *one,
143        _ => arena.push(ExprNode::Add(ids.iter().copied().collect())),
144    }
145}
146
147/// Build `lhs - rhs`. Same linear fast-path as `add_into`.
148pub(crate) fn sub_into(arena: &mut ExprArena, lhs: ExprId, rhs: ExprId) -> ExprId {
149    let neg = neg_into(arena, rhs);
150    add_into(arena, lhs, neg)
151}
152
153/// Build `lhs * rhs`. If either side is constant and the other is linear, we
154/// stay on the linear fast-path. Otherwise produce a generic n-ary `Mul`.
155pub(crate) fn mul_into(arena: &mut ExprArena, lhs: ExprId, rhs: ExprId) -> ExprId {
156    if let ExprNode::Const(c) = *arena.get(lhs) {
157        if let Some(mut t) = as_linear(arena, rhs, false) {
158            t.coeffs.iter_mut().for_each(|(_, co)| *co *= c);
159            t.constant *= c;
160            return push_linear(arena, t);
161        }
162    }
163    if let ExprNode::Const(c) = *arena.get(rhs) {
164        if let Some(mut t) = as_linear(arena, lhs, false) {
165            t.coeffs.iter_mut().for_each(|(_, co)| *co *= c);
166            t.constant *= c;
167            return push_linear(arena, t);
168        }
169    }
170    arena.push(ExprNode::Mul(smallvec![lhs, rhs]))
171}
172
173/// Build `num / den`. If `den` is a nonzero constant `c`, fold to `num * (1/c)`
174/// so a constant-denominator division stays on the linear fast-path. Otherwise
175/// produce a `Div` node (always nonlinear, even when the numerator is linear).
176pub(crate) fn div_into(arena: &mut ExprArena, num: ExprId, den: ExprId) -> ExprId {
177    if let ExprNode::Const(c) = *arena.get(den) {
178        if c != 0.0 {
179            if let Some(mut t) = as_linear(arena, num, false) {
180                let inv = 1.0 / c;
181                t.coeffs.iter_mut().for_each(|(_, co)| *co *= inv);
182                t.constant *= inv;
183                return push_linear(arena, t);
184            }
185            let inv = arena.push(ExprNode::Const(1.0 / c));
186            return mul_into(arena, num, inv);
187        }
188    }
189    arena.push(ExprNode::Div(num, den))
190}
191
192/// Build `-rhs`, preserving linearity.
193pub(crate) fn neg_into(arena: &mut ExprArena, rhs: ExprId) -> ExprId {
194    if let Some(mut t) = as_linear(arena, rhs, false) {
195        t.coeffs.iter_mut().for_each(|(_, c)| *c = -*c);
196        t.constant = -t.constant;
197        return push_linear(arena, t);
198    }
199    arena.push(ExprNode::Neg(rhs))
200}
201
202/// Snapshot the linear terms of `id`, if any. Used by solver backends to
203/// extract LP coefficients without walking the tree themselves.
204///
205/// Parameters are folded to their current arena values, so the returned
206/// coefficients reflect the latest [`ExprArena::set_param_value`] binding.
207///
208/// [`ExprArena::set_param_value`]: crate::ExprArena::set_param_value
209pub fn extract_linear(arena: &ExprArena, id: ExprId) -> Option<LinearTerms> {
210    as_linear(arena, id, true)
211}
212
213/// A nonlinear residual summand: the existing arena node `id`, taken with a
214/// leading negation when `neg` is set. Carrying the sign as a flag.
215/// Lets [`split_linear`] run without a mutable arena.
216#[derive(Copy, Clone, Debug, PartialEq, Eq)]
217pub struct SignedExpr {
218    pub id: ExprId,
219    pub neg: bool,
220}
221
222/// Split an expression into its linear part and a nonlinear residual. The
223/// returned `(LinearTerms, Vec<SignedExpr>)` satisfies
224///
225/// ```text
226/// value(id) == sum_i coef_i * var_i + constant + sum_j (-1)^neg_j value(id_j)
227/// ```
228///
229/// where the residual is empty when the whole expression is linear and
230/// otherwise lists the remaining nonlinear summands (each a pre-existing arena
231/// node, optionally negated). `LinearTerms` may have empty `coeffs` and
232/// `constant == 0.0` when the whole expression is purely nonlinear.
233pub fn split_linear(arena: &ExprArena, id: ExprId) -> (LinearTerms, Vec<SignedExpr>) {
234    if let Some(lt) = as_linear(arena, id, true) {
235        return (lt, Vec::new());
236    }
237    let mut lin = CoeffAccum::with_capacity(0);
238    let mut constant = 0.0;
239    let mut residual: Vec<SignedExpr> = Vec::new();
240    let mut sign_stack: smallvec::SmallVec<[(ExprId, f64); 8]> = smallvec![(id, 1.0)];
241    while let Some((cur, sign)) = sign_stack.pop() {
242        match arena.get(cur) {
243            ExprNode::Add(children) => {
244                for c in children.iter().copied() {
245                    sign_stack.push((c, sign));
246                }
247            }
248            ExprNode::Neg(inner) => sign_stack.push((*inner, -sign)),
249            _ => {
250                if let Some(mut t) = as_linear(arena, cur, true) {
251                    if (sign - 1.0).abs() > 0.0 {
252                        t.coeffs.iter_mut().for_each(|(_, c)| *c *= sign);
253                        t.constant *= sign;
254                    }
255                    lin.extend(t.coeffs);
256                    constant += t.constant;
257                } else {
258                    residual.push(SignedExpr { id: cur, neg: sign < 0.0 });
259                }
260            }
261        }
262    }
263    let mut coeffs = lin.into_coeffs();
264    coeffs.retain(|(_, c)| *c != 0.0);
265    (LinearTerms { coeffs, constant }, residual)
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::arena::{ExprArena, ExprNode, VarId};
272
273    #[test]
274    fn param_times_var_stays_symbolic_until_extracted() {
275        // Build `price * x` through the operator helper. The parameter must NOT
276        // be folded into a Linear node at build time (so it stays re-bindable)
277        let mut arena = ExprArena::new();
278        let pid = arena.new_param(3.0);
279        let price = arena.param(pid);
280        let xnode = arena.push(ExprNode::Var(VarId(0)));
281        let prod = mul_into(&mut arena, price, xnode);
282        assert!(matches!(arena.get(prod), ExprNode::Mul(_)));
283
284        let terms = extract_linear(&arena, prod).expect("linear");
285        assert_eq!(terms.coeffs, vec![(VarId(0), 3.0)]);
286        assert!(terms.constant.abs() < f64::EPSILON);
287    }
288
289    #[test]
290    fn rebinding_param_updates_extracted_coeff() {
291        let mut arena = ExprArena::new();
292        let pid = arena.new_param(3.0);
293        let price = arena.param(pid);
294        let xnode = arena.push(ExprNode::Var(VarId(0)));
295        let prod = mul_into(&mut arena, price, xnode);
296
297        arena.set_param_value(pid, 10.0);
298        let terms = extract_linear(&arena, prod).expect("linear");
299        assert_eq!(terms.coeffs, vec![(VarId(0), 10.0)]);
300    }
301
302    #[test]
303    fn param_plus_var_resolves_constant() {
304        let mut arena = ExprArena::new();
305        let pid = arena.new_param(5.0);
306        let price = arena.param(pid);
307        let xnode = arena.push(ExprNode::Var(VarId(0)));
308        let sum = add_into(&mut arena, price, xnode);
309        let terms = extract_linear(&arena, sum).expect("linear");
310        assert_eq!(terms.coeffs, vec![(VarId(0), 1.0)]);
311        assert!((terms.constant - 5.0).abs() < f64::EPSILON);
312    }
313
314    #[test]
315    fn add_extraction_is_first_seen_ordered_and_merges() {
316        // `z + x + y + x`: coefficients come out in first-seen order [z, x, y]
317        // and the repeated `x` is merged to coeff 2.
318        let mut arena = ExprArena::new();
319        let z = arena.push(ExprNode::Var(VarId(2)));
320        let x = arena.push(ExprNode::Var(VarId(0)));
321        let y = arena.push(ExprNode::Var(VarId(1)));
322        let sum = arena.push(ExprNode::Add(smallvec::smallvec![z, x, y, x]));
323
324        let terms = extract_linear(&arena, sum).expect("linear");
325        assert_eq!(terms.coeffs, vec![(VarId(2), 1.0), (VarId(0), 2.0), (VarId(1), 1.0)]);
326        assert!(terms.constant.abs() < f64::EPSILON);
327        assert_eq!(extract_linear(&arena, sum).unwrap().coeffs, terms.coeffs);
328    }
329
330    #[test]
331    fn wide_sum_merges_repeated_vars_in_order() {
332        let mut arena = ExprArena::new();
333        let n = 50u32;
334        let mut ids = Vec::new();
335        for _ in 0..3 {
336            for v in 0..n {
337                ids.push(arena.push(ExprNode::Var(VarId(v))));
338            }
339        }
340        let sum = arena.push(ExprNode::Add(ids.into_iter().collect()));
341        let terms = extract_linear(&arena, sum).expect("linear");
342        let expected: Vec<(VarId, f64)> = (0..n).map(|v| (VarId(v), 3.0)).collect();
343        assert_eq!(terms.coeffs, expected);
344    }
345}