Skip to main content

oximo_solver/
prepare.rs

1//! Solver-independent model views.
2
3use std::cell::Ref;
4use std::collections::VecDeque;
5use std::ops::Deref;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex, OnceLock};
8
9use oximo_core::{
10    Constraint, Model, ModelConstraints, ModelKind, Objective, ObjectiveSense, Sense,
11    SocConstraint, SocForm, Variable,
12};
13use oximo_expr::{
14    ExprArena, ExprId, ExprNode, LinearTerms, QuadraticTerms, extract_linear, extract_quadratic,
15};
16use rustc_hash::FxHashMap;
17
18use crate::SolverError;
19
20/// Affine coefficients borrowed from the arena or shared with the preparation cache.
21#[derive(Clone, Debug)]
22pub enum AffineTerms<'a> {
23    /// Direct terms, were the inner `Cow` borrows affine nodes or owns uncached folds.
24    Borrowed(LinearTerms<'a>),
25    Shared(Arc<LinearTerms<'static>>),
26}
27
28impl<'a> Deref for AffineTerms<'a> {
29    type Target = LinearTerms<'a>;
30    #[inline]
31    fn deref(&self) -> &Self::Target {
32        match self {
33            Self::Borrowed(t) => t,
34            Self::Shared(t) => t,
35        }
36    }
37}
38
39impl AffineTerms<'_> {
40    #[inline]
41    pub fn into_owned(self) -> LinearTerms<'static> {
42        match self {
43            Self::Borrowed(t) => t.into_owned(),
44            Self::Shared(t) => Arc::unwrap_or_clone(t),
45        }
46    }
47}
48
49/// A one-use decomposition, or a shared decomposition after observed reuse.
50#[derive(Clone, Debug)]
51pub enum Extracted<T> {
52    Owned(T),
53    Shared(Arc<T>),
54}
55
56/// A direct affine node or an owned/shared degree-at-most-two decomposition.
57#[derive(Clone, Debug)]
58pub enum PolynomialTerms<'a> {
59    Affine(LinearTerms<'a>),
60    Quadratic(Extracted<QuadraticTerms>),
61}
62
63impl<T> Deref for Extracted<T> {
64    type Target = T;
65    #[inline]
66    fn deref(&self) -> &T {
67        match self {
68            Self::Owned(value) => value,
69            Self::Shared(value) => value,
70        }
71    }
72}
73
74/// One solve's immutable model metadata and lazy expression decompositions.
75///
76/// Original IDs, expressions, bounds and activity flags are retained.
77/// Native ordering, capability checks and generated entities belong to
78/// the adapter. Create a fresh preparation after model or parameter changes.
79/// Resident solver handles should retain their native state, not this borrowed view.
80#[derive(Debug)]
81pub struct LoweringContext<'a> {
82    expressions: PreparedExpressions,
83    variables: Ref<'a, Vec<Variable>>,
84    constraints: ModelConstraints<'a>,
85    objective: Ref<'a, Option<Objective>>,
86    kind: ModelKind,
87}
88
89/// A shareable arena snapshot and bounded, reuse-admitted extraction caches.
90/// Each cache has 16 independent shards, each retaining at most 16 expressions
91/// and 1,024 coefficient entries, evicting in insertion order. Before cache
92/// initialization, first encounters return owned terms without locking or
93/// allocating cache storage. Only repeated roots are admitted. A small
94/// direct-mapped history recognizes reuse across interleaved roots, and
95/// collisions may delay admission. Once initialized, cached entries are
96/// always checked independently of admission history.
97/// Larger decompositions are returned uncached.
98#[derive(Debug)]
99pub struct PreparedExpressions {
100    arena: ExprArena,
101    linear: ExtractionCache<LinearTerms<'static>>,
102    quadratic: ExtractionCache<QuadraticTerms>,
103}
104
105const CACHE_EXPRESSIONS: usize = 256;
106const CACHE_COEFFICIENTS: usize = 16_384;
107const CACHE_SHARDS: usize = 16;
108
109#[derive(Debug)]
110struct CacheEntries<T> {
111    values: FxHashMap<ExprId, Option<Arc<T>>>,
112    order: VecDeque<(ExprId, usize)>,
113    coefficients: usize,
114}
115
116#[derive(Debug)]
117struct ExtractionCache<T> {
118    recent: [AtomicU64; CACHE_SHARDS],
119    shards: OnceLock<Box<[CacheShard<T>; CACHE_SHARDS]>>,
120}
121
122// Keep unrelated workers' cache locks on independent cache lines.
123#[derive(Debug)]
124#[repr(align(64))]
125struct CacheShard<T> {
126    entries: Mutex<CacheEntries<T>>,
127}
128
129impl<T> Default for ExtractionCache<T> {
130    fn default() -> Self {
131        Self { recent: std::array::from_fn(|_| AtomicU64::new(u64::MAX)), shards: OnceLock::new() }
132    }
133}
134
135fn cache_shards<T>() -> Box<[CacheShard<T>; CACHE_SHARDS]> {
136    Box::new(std::array::from_fn(|_| CacheShard {
137        entries: Mutex::new(CacheEntries {
138            values: FxHashMap::default(),
139            order: VecDeque::new(),
140            coefficients: 0,
141        }),
142    }))
143}
144
145impl<T> ExtractionCache<T> {
146    fn extract(
147        &self,
148        expr: ExprId,
149        extract: impl FnOnce() -> Option<T>,
150        size: impl FnOnce(&T) -> usize,
151    ) -> Option<Extracted<T>> {
152        let key = u64::from(expr.0);
153        // Low bits distribute admission slots independently of cache shards.
154        let admit =
155            || self.recent[expr.0 as usize % CACHE_SHARDS].swap(key, Ordering::Relaxed) == key;
156        let Some(shards) = self.shards.get() else {
157            if !admit() {
158                return extract().map(Extracted::Owned);
159            }
160            let shards = self.shards.get_or_init(cache_shards);
161            let shard = &shards[(expr.0.wrapping_mul(0x9e37_79b9) >> 28) as usize];
162            return shard.extract(expr, extract, size, || true);
163        };
164        let shard = &shards[(expr.0.wrapping_mul(0x9e37_79b9) >> 28) as usize];
165        shard.extract(expr, extract, size, admit)
166    }
167}
168
169impl<T> CacheShard<T> {
170    fn extract(
171        &self,
172        expr: ExprId,
173        extract: impl FnOnce() -> Option<T>,
174        size: impl FnOnce(&T) -> usize,
175        admit: impl FnOnce() -> bool,
176    ) -> Option<Extracted<T>> {
177        if let Some(value) =
178            self.entries.lock().expect("preparation cache poisoned").values.get(&expr)
179        {
180            return value.clone().map(Extracted::Shared);
181        }
182        // Admission hints should not hide an already cached value.
183        if !admit() {
184            return extract().map(Extracted::Owned);
185        }
186        // Independent misses can be evaluated concurrently. Concurrent misses
187        // for the same expression may repeat work, but publish one shared value.
188        let value = extract();
189        let coefficients = value.as_ref().map_or(0, size);
190        if coefficients > CACHE_COEFFICIENTS / CACHE_SHARDS {
191            return value.map(Extracted::Owned);
192        }
193        let mut cache = self.entries.lock().expect("preparation cache poisoned");
194        if let Some(existing) = cache.values.get(&expr) {
195            return existing.clone().map(Extracted::Shared);
196        }
197        while cache.values.len() >= CACHE_EXPRESSIONS / CACHE_SHARDS
198            || cache.coefficients + coefficients > CACHE_COEFFICIENTS / CACHE_SHARDS
199        {
200            let (old, size) = cache.order.pop_front().expect("nonempty cache");
201            cache.values.remove(&old);
202            cache.coefficients -= size;
203        }
204        cache.coefficients += coefficients;
205        cache.order.push_back((expr, coefficients));
206        let value = value.map(Arc::new);
207        cache.values.insert(expr, value.clone());
208        value.map(Extracted::Shared)
209    }
210}
211
212impl<'a> LoweringContext<'a> {
213    /// Validate the objective declaration and capture the current parameter values.
214    ///
215    /// # Errors
216    /// Returns a core error if neither an objective nor feasibility was declared.
217    pub fn new(model: &'a Model) -> Result<Self, SolverError> {
218        model.ensure_objective_declared()?;
219        Ok(Self {
220            expressions: PreparedExpressions::new((*model.arena()).clone()),
221            variables: model.variables(),
222            constraints: model.constraints(),
223            objective: model.objective(),
224            kind: model.kind(),
225        })
226    }
227
228    pub fn variables(&self) -> &[Variable] {
229        &self.variables
230    }
231    pub fn constraints(&self) -> &ModelConstraints<'a> {
232        &self.constraints
233    }
234    pub fn objective(&self) -> Option<&Objective> {
235        self.objective.as_ref()
236    }
237    pub fn kind(&self) -> ModelKind {
238        self.kind
239    }
240    pub fn sense(&self) -> ObjectiveSense {
241        self.objective().map_or(ObjectiveSense::Minimize, |o| o.sense)
242    }
243
244    /// Explain an unsupported expression using original variable names.
245    #[cold]
246    #[inline(never)]
247    pub fn nonlinear_error(&self, expr: ExprId, location: impl Into<String>) -> SolverError {
248        SolverError::Nonlinear {
249            location: location.into(),
250            term: oximo_expr::describe_nonlinear_term(self.arena(), expr, &|id| {
251                oximo_core::var_name(self.variables(), id)
252            })
253            .unwrap_or_else(|| "<nonlinear>".into()),
254        }
255    }
256
257    /// Require an affine expression.
258    ///
259    /// # Errors
260    /// Returns a named nonlinear-expression error when extraction fails.
261    #[inline]
262    pub fn require_linear(
263        &self,
264        expr: ExprId,
265        location: impl FnOnce() -> String,
266    ) -> Result<AffineTerms<'_>, SolverError> {
267        self.linear(expr).ok_or_else(|| self.nonlinear_error(expr, location()))
268    }
269
270    /// Require an affine expression for a one-use streaming consumer.
271    ///
272    /// This bypasses reuse admission and returns the expression crate's native
273    /// borrowed/owned representation. Prefer [`Self::require_linear`] when the
274    /// same root is likely to be requested again during this preparation.
275    ///
276    /// # Errors
277    /// Returns a lazily named nonlinear-expression error when extraction fails.
278    #[expect(clippy::inline_always, reason = "called once per streamed backend row")]
279    #[inline(always)]
280    pub fn require_linear_once(
281        &self,
282        expr: ExprId,
283        location: impl FnOnce() -> String,
284    ) -> Result<LinearTerms<'_>, SolverError> {
285        extract_linear(&self.arena, expr).ok_or_else(|| self.nonlinear_error(expr, location()))
286    }
287
288    /// Require a polynomial of degree at most two.
289    ///
290    /// # Errors
291    /// Returns a named nonlinear-expression error when extraction fails.
292    #[inline]
293    pub fn require_quadratic(
294        &self,
295        expr: ExprId,
296        location: impl FnOnce() -> String,
297    ) -> Result<Extracted<QuadraticTerms>, SolverError> {
298        self.quadratic(expr).ok_or_else(|| self.nonlinear_error(expr, location()))
299    }
300
301    /// Require a polynomial while retaining the direct-affine borrowed path.
302    /// Non-direct nodes are traversed once as quadratics, avoiding a failed
303    /// affine pass before quadratic/SOC handling.
304    ///
305    /// # Errors
306    /// Returns a lazily named nonlinear-expression error when extraction fails.
307    pub fn require_polynomial(
308        &self,
309        expr: ExprId,
310        location: impl FnOnce() -> String,
311    ) -> Result<PolynomialTerms<'_>, SolverError> {
312        if let ExprNode::Linear { coeffs, constant } = self.arena.get(expr) {
313            return Ok(PolynomialTerms::Affine(LinearTerms::borrowed(coeffs, *constant)));
314        }
315        self.quadratic(expr)
316            .map(PolynomialTerms::Quadratic)
317            .ok_or_else(|| self.nonlinear_error(expr, location()))
318    }
319
320    /// An optional alternative, never a replacement for the original row.
321    pub fn detected_soc(&self, row: &Constraint) -> Option<SocForm> {
322        self.expressions.detected_soc(self.variables(), row)
323    }
324}
325
326/// Bounds for a polynomial body with its constant removed. No row is dropped,
327/// split, relaxed or reclassified as feasible/infeasible by this operation.
328#[inline]
329pub fn shifted_bounds(row: &Constraint, constant: f64) -> (f64, f64) {
330    (row.lower - constant, row.upper - constant)
331}
332
333/// Optional single-sided view, in lower/upper order for a range. Free rows
334/// produce no sides. The caller owns native row ordering and provenance.
335#[inline]
336pub fn row_sides(row: &Constraint) -> [Option<(Sense, f64)>; 2] {
337    match row.as_single() {
338        Some(single) => [Some(single), None],
339        None if row.is_range() => [Some((Sense::Ge, row.lower)), Some((Sense::Le, row.upper))],
340        None => [None, None],
341    }
342}
343
344impl Deref for LoweringContext<'_> {
345    type Target = PreparedExpressions;
346    #[inline]
347    fn deref(&self) -> &Self::Target {
348        &self.expressions
349    }
350}
351
352impl Deref for PreparedExpressions {
353    type Target = ExprArena;
354    #[inline]
355    fn deref(&self) -> &Self::Target {
356        &self.arena
357    }
358}
359
360impl PreparedExpressions {
361    pub fn new(arena: ExprArena) -> Self {
362        Self { arena, linear: ExtractionCache::default(), quadratic: ExtractionCache::default() }
363    }
364    pub fn arena(&self) -> &ExprArena {
365        &self.arena
366    }
367    /// Direct affine nodes retain their zero-copy path. Reused compound roots
368    /// may be cached, including failed extractions, within this snapshot's budget.
369    ///
370    /// # Panics
371    /// Panics for an expression outside this arena or a poisoned cache.
372    #[inline]
373    pub fn linear(&self, expr: ExprId) -> Option<AffineTerms<'_>> {
374        if let ExprNode::Linear { coeffs, constant } = self.arena.get(expr) {
375            return Some(AffineTerms::Borrowed(LinearTerms::borrowed(coeffs, *constant)));
376        }
377        self.compound_linear(expr)
378    }
379
380    // Keep cache admission out of the direct-affine hot loop in each adapter.
381    #[inline(never)]
382    fn compound_linear(&self, expr: ExprId) -> Option<AffineTerms<'_>> {
383        self.linear
384            .extract(
385                expr,
386                || extract_linear(&self.arena, expr).map(LinearTerms::into_owned),
387                |t| t.coeffs.len(),
388            )
389            .map(|terms| match terms {
390                Extracted::Owned(terms) => AffineTerms::Borrowed(terms),
391                Extracted::Shared(terms) => AffineTerms::Shared(terms),
392            })
393    }
394
395    /// Extract a quadratic polynomial, sharing terms only after observed reuse.
396    ///
397    /// # Panics
398    /// Panics for an expression outside this arena or a poisoned cache.
399    pub fn quadratic(&self, expr: ExprId) -> Option<Extracted<QuadraticTerms>> {
400        self.quadratic.extract(
401            expr,
402            || extract_quadratic(&self.arena, expr),
403            |t| t.linear.len() + t.hessian.len(),
404        )
405    }
406
407    /// Detect an optional cone representation using cached polynomial terms.
408    pub fn detected_soc(&self, variables: &[Variable], row: &Constraint) -> Option<SocForm> {
409        let q = self.quadratic(row.lhs)?;
410        oximo_core::__detect_soc_from_quadratic(variables, row, &q)
411    }
412
413    /// Extract an explicit cone in one streaming pass over its affine members.
414    ///
415    /// # Errors
416    /// Returns a backend error for invalid affine cone members.
417    pub fn explicit_soc(&self, soc: &SocConstraint) -> Result<SocForm, SolverError> {
418        let affine = |expr| {
419            extract_linear(&self.arena, expr).map(LinearTerms::into_owned).ok_or_else(|| {
420                SolverError::Backend(format!(
421                    "invalid affine expressions in SOC constraint {:?}",
422                    soc.name
423                ))
424            })
425        };
426        Ok(SocForm {
427            terms: soc.terms.iter().map(|&expr| affine(expr)).collect::<Result<_, _>>()?,
428            bound: affine(soc.bound)?,
429        })
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn interleaved_roots_are_admitted_and_cached_hits_ignore_history() {
439        let cache = ExtractionCache::default();
440        let shard = |id: u32| id.wrapping_mul(0x9e37_79b9) >> 28;
441        let a = 0;
442        let b = (1..1000).find(|&id| shard(id) == shard(a) && id % 16 != a % 16).unwrap();
443        for id in [a, b] {
444            assert!(matches!(
445                cache.extract(ExprId(id), || Some(vec![id]), Vec::len),
446                Some(Extracted::Owned(_))
447            ));
448        }
449        for id in [a, b, a, b] {
450            assert!(matches!(
451                cache.extract(ExprId(id), || Some(vec![id]), Vec::len),
452                Some(Extracted::Shared(_))
453            ));
454        }
455        // Change the admission history for a without evicting its cached value.
456        cache.extract(ExprId(a + 16), || Some(vec![16]), Vec::len);
457        let hit =
458            cache.extract(ExprId(a), || panic!("cached root was re-extracted"), Vec::len).unwrap();
459        assert_eq!(hit.as_slice(), &[a]);
460    }
461
462    #[test]
463    fn unique_roots_do_not_allocate_cache_entries() {
464        let cache = ExtractionCache::default();
465        for index in 0..1000 {
466            assert!(matches!(
467                cache.extract(ExprId(index), || Some(vec![1]), Vec::len),
468                Some(Extracted::Owned(_))
469            ));
470        }
471        assert!(cache.shards.get().is_none());
472    }
473
474    #[test]
475    fn cache_bounds_storage_and_keeps_returned_terms_alive() {
476        let cache = ExtractionCache::default();
477        let extract =
478            |id, size| cache.extract(ExprId(id), || Some(vec![1; size]), Vec::len).unwrap();
479        extract(0, 1);
480        let first = extract(0, 1);
481        assert!(matches!(first, Extracted::Shared(_)));
482        for index in 1..1000 {
483            extract(index, 100);
484            extract(index, 100);
485        }
486        assert_eq!(first.len(), 1);
487        let shards = cache.shards.get().unwrap();
488        let entries: usize = shards.iter().map(|s| s.entries.lock().unwrap().values.len()).sum();
489        let coefficients: usize =
490            shards.iter().map(|s| s.entries.lock().unwrap().coefficients).sum();
491        assert!(entries <= CACHE_EXPRESSIONS);
492        assert!(coefficients <= CACHE_COEFFICIENTS);
493        extract(1001, CACHE_COEFFICIENTS + 1);
494        assert!(matches!(extract(1001, CACHE_COEFFICIENTS + 1), Extracted::Owned(_)));
495        for index in 2000..3000 {
496            for _ in 0..2 {
497                assert!(cache.extract(ExprId(index), || None, Vec::len).is_none());
498            }
499        }
500        assert!(shards
501            .iter()
502            .all(|s| s.entries.lock().unwrap().values.len() <= CACHE_EXPRESSIONS / CACHE_SHARDS));
503    }
504
505    #[test]
506    fn cache_extracts_outside_lock_and_shares_concurrent_publication() {
507        let single = ExtractionCache::default();
508        single.extract(ExprId(0), || Some(vec![1]), Vec::len);
509        single.extract(
510            ExprId(0),
511            || {
512                assert!(single.shards.get().unwrap()[0].entries.try_lock().is_ok());
513                Some(vec![1])
514            },
515            Vec::len,
516        );
517        let cache = ExtractionCache::default();
518        cache.extract(ExprId(0), || Some(vec![1]), Vec::len);
519        let barrier = std::sync::Barrier::new(2);
520        std::thread::scope(|scope| {
521            let extract = || {
522                let terms = cache
523                    .extract(
524                        ExprId(0),
525                        || {
526                            barrier.wait();
527                            Some(vec![1])
528                        },
529                        Vec::len,
530                    )
531                    .unwrap();
532                let Extracted::Shared(value) = terms else { panic!("reuse not admitted") };
533                value
534            };
535            let a = scope.spawn(extract);
536            let b = scope.spawn(extract);
537            assert!(Arc::ptr_eq(&a.join().unwrap(), &b.join().unwrap()));
538        });
539    }
540}