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}