Skip to main content

pounce_nlp/
expression_provider.rs

1//! Constraint expression DAGs for FBBT (issue [#62]).
2//!
3//! The [`ExpressionProvider`] trait gives a presolve pass access to
4//! per-constraint expression trees in a form independent of the
5//! TNLP source. The shape is a *tape* (a `Vec<FbbtOp>` where operand
6//! fields are indices into earlier slots of the same vector), which:
7//!
8//! * Folds common subexpressions naturally (each unique subexpression
9//!   gets a single slot, even if used in many places).
10//! * Lets the forward interval pass be a single linear scan.
11//! * Lets the reverse interval pass (commit 3 of #62) also be a
12//!   single linear scan.
13//!
14//! ## Operator set
15//!
16//! Only the operators FBBT's interval arithmetic can soundly handle
17//! appear in [`FbbtOp`]. Anything else — extern function calls,
18//! integer powers above some safe cap, AMPL's `log10` reduction, the
19//! `Sum` n-ary node — is conveyed as [`FbbtOp::Opaque`], and the
20//! forward / reverse rules treat that slot as
21//! [`crate::expression_provider::FbbtOp::Opaque`] (= `ENTIRE`, no
22//! tightening). Providers MUST emit `Opaque` rather than panicking
23//! on unsupported sub-expressions; a presolve pass can degrade
24//! gracefully across mixed-feature problems that way.
25//!
26//! ## Implementation status
27//!
28//! The trait has a default `constraint_expression` implementation
29//! that returns `None`, meaning "I don't expose any expressions."
30//! That makes the trait safe to require on existing TNLPs (e.g.
31//! `PyTnlp`, `CCallbackTnlp`) — they decline and FBBT silently
32//! becomes a no-op for those problems.
33//!
34//! [#62]: https://github.com/jkitchin/pounce/issues/62
35
36use pounce_common::types::Number;
37
38/// One node in an FBBT-friendly expression tape. Operand fields are
39/// indices into the same tape's `ops` vector, and must reference
40/// strictly earlier slots (`< self`). The result of the whole tape is
41/// the value computed at slot `ops.len() - 1` (the last entry).
42#[derive(Debug, Clone, PartialEq)]
43pub enum FbbtOp {
44    /// Numeric constant.
45    Const(Number),
46    /// Reference to problem variable `i` (0-based).
47    Var(usize),
48
49    // -- Binary --
50    Add(usize, usize),
51    Sub(usize, usize),
52    Mul(usize, usize),
53    Div(usize, usize),
54    /// `x^n` for non-negative integer `n`. Variable-exponent power is
55    /// emitted as [`FbbtOp::Opaque`] (interval `Pow` with a
56    /// non-constant exponent is non-trivial and rare in practice;
57    /// pounce defers it).
58    PowInt(usize, u32),
59
60    // -- Unary --
61    Neg(usize),
62    Sqrt(usize),
63    Exp(usize),
64    Ln(usize),
65    Abs(usize),
66    Sin(usize),
67    Cos(usize),
68
69    /// "Don't reason about this slot." The forward pass assigns
70    /// `ENTIRE` to it; the reverse pass declines to push information
71    /// through it. Providers emit this for operators FBBT doesn't
72    /// support (extern function calls, AMPL log10 / sum, non-integer
73    /// or variable-exponent powers).
74    Opaque,
75}
76
77impl FbbtOp {
78    /// Whether the op reads any predecessor slots. Used by validators.
79    pub fn operand_indices(&self) -> ArrayVec2 {
80        match *self {
81            FbbtOp::Const(_) | FbbtOp::Var(_) | FbbtOp::Opaque => ArrayVec2::new(),
82            FbbtOp::Neg(a)
83            | FbbtOp::Sqrt(a)
84            | FbbtOp::Exp(a)
85            | FbbtOp::Ln(a)
86            | FbbtOp::Abs(a)
87            | FbbtOp::Sin(a)
88            | FbbtOp::Cos(a)
89            | FbbtOp::PowInt(a, _) => ArrayVec2::one(a),
90            FbbtOp::Add(a, b) | FbbtOp::Sub(a, b) | FbbtOp::Mul(a, b) | FbbtOp::Div(a, b) => {
91                ArrayVec2::two(a, b)
92            }
93        }
94    }
95}
96
97/// Tiny stack-allocated 0..=2-element vector — every `FbbtOp` has at
98/// most two operands. Used so [`FbbtOp::operand_indices`] doesn't
99/// allocate.
100#[derive(Debug, Clone, Copy)]
101pub struct ArrayVec2 {
102    data: [usize; 2],
103    len: u8,
104}
105
106impl ArrayVec2 {
107    pub fn new() -> Self {
108        Self {
109            data: [0, 0],
110            len: 0,
111        }
112    }
113    pub fn one(a: usize) -> Self {
114        Self {
115            data: [a, 0],
116            len: 1,
117        }
118    }
119    pub fn two(a: usize, b: usize) -> Self {
120        Self {
121            data: [a, b],
122            len: 2,
123        }
124    }
125    pub fn as_slice(&self) -> &[usize] {
126        &self.data[..self.len as usize]
127    }
128    pub fn len(&self) -> usize {
129        self.len as usize
130    }
131    pub fn is_empty(&self) -> bool {
132        self.len == 0
133    }
134}
135
136impl Default for ArrayVec2 {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142/// Flat expression tape. The root is the **last** slot
143/// (`ops[ops.len() - 1]`).
144#[derive(Debug, Clone, Default)]
145pub struct FbbtTape {
146    pub ops: Vec<FbbtOp>,
147}
148
149impl FbbtTape {
150    /// Empty tape — semantically equivalent to "no information".
151    pub fn new() -> Self {
152        Self { ops: Vec::new() }
153    }
154
155    pub fn is_empty(&self) -> bool {
156        self.ops.is_empty()
157    }
158
159    pub fn len(&self) -> usize {
160        self.ops.len()
161    }
162
163    /// Validate that every operand index is strictly less than its
164    /// owner's slot index. Returns the slot of the first offender or
165    /// `None` if the tape is well-formed.
166    pub fn first_invalid_slot(&self) -> Option<usize> {
167        for (i, op) in self.ops.iter().enumerate() {
168            for &operand in op.operand_indices().as_slice() {
169                if operand >= i {
170                    return Some(i);
171                }
172            }
173        }
174        None
175    }
176}
177
178/// Optional capability hooked onto a TNLP: per-constraint expression
179/// trees in tape form, used by presolve passes like FBBT.
180///
181/// Default impls return `None` / empty so existing TNLPs without
182/// structural expression access (callback-based bridges, AD-tape-only
183/// problems) compile against the trait but contribute nothing —
184/// downstream passes degrade silently.
185pub trait ExpressionProvider {
186    /// Expression tape for constraint `i` (0-based, in the same order
187    /// as the TNLP's `eval_g`). Returns `None` to indicate "no
188    /// structural information" — a constraint expressed only via the
189    /// numerical `eval_g` callback. Bounds for the constraint
190    /// (`g_l[i]`, `g_u[i]`) are read separately via the parent TNLP's
191    /// `get_bounds_info`.
192    fn constraint_expression(&self, _i: usize) -> Option<FbbtTape> {
193        None
194    }
195
196    /// Expression tape for the objective. Optional in the same sense
197    /// as [`Self::constraint_expression`]; FBBT does not use the
198    /// objective today, but a future OBBT-style pass might.
199    fn objective_expression(&self) -> Option<FbbtTape> {
200        None
201    }
202
203    /// Human-readable name for variable `i` (0-based, original problem
204    /// order), if the model carries one. `None` ⇒ the caller should fall
205    /// back to an index label like `x[i]`.
206    ///
207    /// Names turn index-level diagnostics into model-level ones — e.g.
208    /// reporting that a near-singular Jacobian row is the `mass_balance`
209    /// equation rather than "row 3". Lee et al. (2024,
210    /// <https://doi.org/10.69997/sct.147875>) call out that gap as a key
211    /// roadblock for debugging equation-oriented models; this method is
212    /// the seam the debugger reads names through.
213    fn variable_name(&self, _i: usize) -> Option<&str> {
214        None
215    }
216
217    /// Human-readable name for constraint `i` (0-based, original problem
218    /// order), if the model carries one. `None` ⇒ fall back to an index
219    /// label like `c[i]`. See [`Self::variable_name`].
220    fn constraint_name(&self, _i: usize) -> Option<&str> {
221        None
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn const_tape(c: Number) -> FbbtTape {
230        FbbtTape {
231            ops: vec![FbbtOp::Const(c)],
232        }
233    }
234
235    #[test]
236    fn operand_indices_match_op_arity() {
237        assert!(FbbtOp::Const(1.0).operand_indices().is_empty());
238        assert!(FbbtOp::Var(0).operand_indices().is_empty());
239        assert!(FbbtOp::Opaque.operand_indices().is_empty());
240        assert_eq!(FbbtOp::Neg(3).operand_indices().as_slice(), &[3]);
241        assert_eq!(FbbtOp::PowInt(2, 4).operand_indices().as_slice(), &[2]);
242        assert_eq!(FbbtOp::Add(1, 2).operand_indices().as_slice(), &[1, 2]);
243    }
244
245    #[test]
246    fn validate_well_formed_tape() {
247        // (x0 + x1) * 2
248        let tape = FbbtTape {
249            ops: vec![
250                FbbtOp::Var(0),
251                FbbtOp::Var(1),
252                FbbtOp::Add(0, 1),
253                FbbtOp::Const(2.0),
254                FbbtOp::Mul(2, 3),
255            ],
256        };
257        assert_eq!(tape.first_invalid_slot(), None);
258    }
259
260    #[test]
261    fn validate_catches_forward_reference() {
262        // Slot 0 references slot 1 → invalid.
263        let tape = FbbtTape {
264            ops: vec![FbbtOp::Neg(1), FbbtOp::Const(0.0)],
265        };
266        assert_eq!(tape.first_invalid_slot(), Some(0));
267    }
268
269    #[test]
270    fn validate_catches_self_reference() {
271        let tape = FbbtTape {
272            ops: vec![FbbtOp::Neg(0)],
273        };
274        assert_eq!(tape.first_invalid_slot(), Some(0));
275    }
276
277    #[test]
278    fn default_trait_returns_none() {
279        struct NoExpr;
280        impl ExpressionProvider for NoExpr {}
281        let p = NoExpr;
282        assert!(p.constraint_expression(0).is_none());
283        assert!(p.objective_expression().is_none());
284    }
285
286    /// A trivial provider that returns the same constant for every
287    /// constraint — checks the trait can be implemented and used.
288    #[test]
289    fn custom_provider_returns_tape() {
290        struct Always(Number);
291        impl ExpressionProvider for Always {
292            fn constraint_expression(&self, _i: usize) -> Option<FbbtTape> {
293                Some(const_tape(self.0))
294            }
295        }
296        let p = Always(3.5);
297        let t = p.constraint_expression(7).unwrap();
298        assert_eq!(t.ops, vec![FbbtOp::Const(3.5)]);
299    }
300}