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
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn const_tape(c: Number) -> FbbtTape {
209        FbbtTape {
210            ops: vec![FbbtOp::Const(c)],
211        }
212    }
213
214    #[test]
215    fn operand_indices_match_op_arity() {
216        assert!(FbbtOp::Const(1.0).operand_indices().is_empty());
217        assert!(FbbtOp::Var(0).operand_indices().is_empty());
218        assert!(FbbtOp::Opaque.operand_indices().is_empty());
219        assert_eq!(FbbtOp::Neg(3).operand_indices().as_slice(), &[3]);
220        assert_eq!(FbbtOp::PowInt(2, 4).operand_indices().as_slice(), &[2]);
221        assert_eq!(FbbtOp::Add(1, 2).operand_indices().as_slice(), &[1, 2]);
222    }
223
224    #[test]
225    fn validate_well_formed_tape() {
226        // (x0 + x1) * 2
227        let tape = FbbtTape {
228            ops: vec![
229                FbbtOp::Var(0),
230                FbbtOp::Var(1),
231                FbbtOp::Add(0, 1),
232                FbbtOp::Const(2.0),
233                FbbtOp::Mul(2, 3),
234            ],
235        };
236        assert_eq!(tape.first_invalid_slot(), None);
237    }
238
239    #[test]
240    fn validate_catches_forward_reference() {
241        // Slot 0 references slot 1 → invalid.
242        let tape = FbbtTape {
243            ops: vec![FbbtOp::Neg(1), FbbtOp::Const(0.0)],
244        };
245        assert_eq!(tape.first_invalid_slot(), Some(0));
246    }
247
248    #[test]
249    fn validate_catches_self_reference() {
250        let tape = FbbtTape {
251            ops: vec![FbbtOp::Neg(0)],
252        };
253        assert_eq!(tape.first_invalid_slot(), Some(0));
254    }
255
256    #[test]
257    fn default_trait_returns_none() {
258        struct NoExpr;
259        impl ExpressionProvider for NoExpr {}
260        let p = NoExpr;
261        assert!(p.constraint_expression(0).is_none());
262        assert!(p.objective_expression().is_none());
263    }
264
265    /// A trivial provider that returns the same constant for every
266    /// constraint — checks the trait can be implemented and used.
267    #[test]
268    fn custom_provider_returns_tape() {
269        struct Always(Number);
270        impl ExpressionProvider for Always {
271            fn constraint_expression(&self, _i: usize) -> Option<FbbtTape> {
272                Some(const_tape(self.0))
273            }
274        }
275        let p = Always(3.5);
276        let t = p.constraint_expression(7).unwrap();
277        assert_eq!(t.ops, vec![FbbtOp::Const(3.5)]);
278    }
279}