Skip to main content

cobre_core/model/resolved/
generic.rs

1//! Pre-resolved RHS bound table for user-defined generic linear constraints.
2//!
3//! Holds `ResolvedGenericConstraintBounds`, a sparse `(constraint_index,
4//! stage_id)`-indexed table backed by a flat `Vec<(Option<i32>, f64)>` of
5//! `(block_id, bound)` pairs. Unlike the dense entity bound tables in `bounds`,
6//! generic-constraint bounds are sparse, so absent `(constraint, stage)` pairs
7//! report inactive rather than panicking. Populated by `cobre-io`; never modified
8//! after construction. The private `serde_generic_bounds` child module owns the
9//! deterministic wire format (sorted keys) because the composite tuple key cannot
10//! be serialized directly.
11
12use std::collections::HashMap;
13use std::ops::Range;
14
15// ─── Generic constraint bounds ────────────────────────────────────────────────
16
17/// Pre-resolved RHS bound table for user-defined generic linear constraints.
18///
19/// Indexed by `(constraint_index, stage_id)` using a sparse `HashMap`. Provides O(1)
20/// lookup of the active bounds for LP row construction.
21///
22/// Entries are stored in a flat `Vec<(Option<i32>, f64)>` of `(block_id, bound)` pairs.
23/// Each `(constraint_index, stage_id)` key maps to a contiguous `Range<usize>` slice
24/// within that flat vec.
25///
26/// When no bounds exist for a `(constraint_index, stage_id)` pair, [`is_active`]
27/// returns `false` and [`bounds_for_stage`] returns an empty slice — there is no
28/// panic or error.
29///
30/// # Construction
31///
32/// Use [`ResolvedGenericConstraintBounds::empty`] as the default (no generic constraints),
33/// or [`ResolvedGenericConstraintBounds::new`] to build from parsed bound rows.
34/// `cobre-io` is responsible for populating the table.
35///
36/// # Examples
37///
38/// ```
39/// use cobre_core::ResolvedGenericConstraintBounds;
40///
41/// let empty = ResolvedGenericConstraintBounds::empty();
42/// assert!(!empty.is_active(0, 0));
43/// assert!(empty.bounds_for_stage(0, 0).is_empty());
44/// ```
45///
46/// [`is_active`]: ResolvedGenericConstraintBounds::is_active
47/// [`bounds_for_stage`]: ResolvedGenericConstraintBounds::bounds_for_stage
48#[derive(Debug, Clone, PartialEq)]
49pub struct ResolvedGenericConstraintBounds {
50    /// Sparse index: maps `(constraint_idx, stage_id)` to a range in `entries`.
51    ///
52    /// Using `i32` for `stage_id` because domain-level stage IDs are `i32` and may
53    /// be negative for pre-study stages (though generic constraint bounds should only
54    /// reference study stages).
55    index: HashMap<(usize, i32), Range<usize>>,
56    /// Flat storage of `(block_id, bound)` pairs, grouped by `(constraint_idx, stage_id)`.
57    ///
58    /// Entries for each key occupy a contiguous region; the [`index`] map provides
59    /// the `Range<usize>` slice boundaries.
60    ///
61    /// [`index`]: Self::index
62    entries: Vec<(Option<i32>, f64)>,
63}
64
65#[cfg(feature = "serde")]
66mod serde_generic_bounds {
67    use serde::{Deserialize, Deserializer, Serialize, Serializer};
68
69    use super::ResolvedGenericConstraintBounds;
70
71    /// Wire format for serde: a list of `(constraint_idx, stage_id, pairs)` groups.
72    ///
73    /// JSON/postcard cannot serialize `HashMap<(usize, i32), Range<usize>>` directly
74    /// because composite tuple keys are not strings. This wire format avoids that
75    /// by encoding each group as a tagged list of entries.
76    #[derive(Serialize, Deserialize)]
77    struct WireEntry {
78        constraint_idx: usize,
79        stage_id: i32,
80        pairs: Vec<(Option<i32>, f64)>,
81    }
82
83    impl Serialize for ResolvedGenericConstraintBounds {
84        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
85            // Sort keys for deterministic output regardless of HashMap iteration order.
86            let mut keys: Vec<(usize, i32)> = self.index.keys().copied().collect();
87            keys.sort_unstable();
88
89            let wire: Vec<WireEntry> = keys
90                .into_iter()
91                .map(|(constraint_idx, stage_id)| {
92                    let range = self.index[&(constraint_idx, stage_id)].clone();
93                    WireEntry {
94                        constraint_idx,
95                        stage_id,
96                        pairs: self.entries[range].to_vec(),
97                    }
98                })
99                .collect();
100
101            wire.serialize(serializer)
102        }
103    }
104
105    impl<'de> Deserialize<'de> for ResolvedGenericConstraintBounds {
106        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
107            let wire = Vec::<WireEntry>::deserialize(deserializer)?;
108
109            let mut index = std::collections::HashMap::new();
110            let mut entries = Vec::new();
111
112            for entry in wire {
113                let start = entries.len();
114                entries.extend_from_slice(&entry.pairs);
115                let end = entries.len();
116                // An empty-pairs group indexes no entries and is intentionally
117                // dropped (no key inserted) — mirroring `new`, which likewise
118                // commits a key only when its range is non-empty. Inserting an
119                // empty range would make `is_active` report `true` for a stage
120                // with no bounds, contradicting the sparse-table contract.
121                if end > start {
122                    index.insert((entry.constraint_idx, entry.stage_id), start..end);
123                }
124            }
125
126            Ok(ResolvedGenericConstraintBounds { index, entries })
127        }
128    }
129}
130
131impl ResolvedGenericConstraintBounds {
132    /// Return an empty table with no constraints and no bounds.
133    ///
134    /// Used as the default value in [`System`](crate::System) when no generic constraints
135    /// are loaded. All queries on the empty table return `false` / empty slices.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use cobre_core::ResolvedGenericConstraintBounds;
141    ///
142    /// let t = ResolvedGenericConstraintBounds::empty();
143    /// assert!(!t.is_active(0, 0));
144    /// assert!(t.bounds_for_stage(99, 5).is_empty());
145    /// ```
146    #[must_use]
147    pub fn empty() -> Self {
148        Self {
149            index: HashMap::new(),
150            entries: Vec::new(),
151        }
152    }
153
154    /// Build a resolved table from sorted bound rows.
155    ///
156    /// `constraint_id_to_idx` maps domain-level `constraint_id: i32` values to
157    /// positional indices in the constraint collection. Rows whose `constraint_id`
158    /// is not present in that map are silently skipped (they would have been caught
159    /// by referential validation upstream).
160    ///
161    /// `raw_bounds` must be sorted by `(constraint_id, stage_id, block_id)` ascending
162    /// (the ordering produced by `parse_generic_constraint_bounds`).
163    ///
164    /// # Arguments
165    ///
166    /// * `constraint_id_to_idx` — maps domain `constraint_id` to positional index
167    /// * `raw_bounds` — sorted rows from `constraints/generic_constraint_bounds.parquet`
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use std::collections::HashMap;
173    /// use cobre_core::ResolvedGenericConstraintBounds;
174    ///
175    /// // Two constraints with IDs 10 and 20, mapped to positions 0 and 1.
176    /// let id_map: HashMap<i32, usize> = [(10, 0), (20, 1)].into_iter().collect();
177    ///
178    /// // One bound row: constraint 10 at stage 3, block_id = None, bound = 500.0.
179    /// let rows = vec![(10i32, 3i32, None::<i32>, 500.0f64)];
180    ///
181    /// let table = ResolvedGenericConstraintBounds::new(
182    ///     &id_map,
183    ///     rows.iter().map(|(cid, sid, bid, b)| (*cid, *sid, *bid, *b)),
184    /// );
185    ///
186    /// assert!(table.is_active(0, 3));
187    /// assert!(!table.is_active(1, 3));
188    ///
189    /// let slice = table.bounds_for_stage(0, 3);
190    /// assert_eq!(slice.len(), 1);
191    /// assert_eq!(slice[0], (None, 500.0));
192    /// ```
193    #[must_use]
194    pub fn new<I>(constraint_id_to_idx: &HashMap<i32, usize>, raw_bounds: I) -> Self
195    where
196        I: Iterator<Item = (i32, i32, Option<i32>, f64)>,
197    {
198        let mut index: HashMap<(usize, i32), Range<usize>> = HashMap::new();
199        let mut entries: Vec<(Option<i32>, f64)> = Vec::new();
200
201        // The input rows are sorted by (constraint_id, stage_id, block_id).
202        // We group consecutive rows with the same (constraint_idx, stage_id) key
203        // into a contiguous range in `entries`.
204
205        let mut current_key: Option<(usize, i32)> = None;
206        let mut range_start: usize = 0;
207
208        for (constraint_id, stage_id, block_id, bound) in raw_bounds {
209            let Some(&constraint_idx) = constraint_id_to_idx.get(&constraint_id) else {
210                // Unknown constraint ID — silently skip (referential validation concern).
211                continue;
212            };
213
214            let key = (constraint_idx, stage_id);
215
216            // When the key changes, commit the range for the previous key.
217            if current_key != Some(key) {
218                if let Some(prev_key) = current_key {
219                    let range_end = entries.len();
220                    if range_end > range_start {
221                        index.insert(prev_key, range_start..range_end);
222                    }
223                }
224                range_start = entries.len();
225                current_key = Some(key);
226            }
227
228            entries.push((block_id, bound));
229        }
230
231        // Commit the final key.
232        if let Some(last_key) = current_key {
233            let range_end = entries.len();
234            if range_end > range_start {
235                index.insert(last_key, range_start..range_end);
236            }
237        }
238
239        Self { index, entries }
240    }
241
242    /// Return `true` if at least one bound entry exists for this constraint at the given stage.
243    ///
244    /// Returns `false` for any unknown `(constraint_idx, stage_id)` pair.
245    ///
246    /// # Examples
247    ///
248    /// ```
249    /// use cobre_core::ResolvedGenericConstraintBounds;
250    ///
251    /// let empty = ResolvedGenericConstraintBounds::empty();
252    /// assert!(!empty.is_active(0, 0));
253    /// ```
254    #[inline]
255    #[must_use]
256    pub fn is_active(&self, constraint_idx: usize, stage_id: i32) -> bool {
257        self.index.contains_key(&(constraint_idx, stage_id))
258    }
259
260    /// Return the `(block_id, bound)` pairs for a constraint at the given stage.
261    ///
262    /// Returns an empty slice when no bounds exist for the `(constraint_idx, stage_id)` pair.
263    ///
264    /// # Examples
265    ///
266    /// ```
267    /// use cobre_core::ResolvedGenericConstraintBounds;
268    ///
269    /// let empty = ResolvedGenericConstraintBounds::empty();
270    /// assert!(empty.bounds_for_stage(0, 0).is_empty());
271    /// ```
272    #[inline]
273    #[must_use]
274    pub fn bounds_for_stage(&self, constraint_idx: usize, stage_id: i32) -> &[(Option<i32>, f64)] {
275        match self.index.get(&(constraint_idx, stage_id)) {
276            Some(range) => &self.entries[range.clone()],
277            None => &[],
278        }
279    }
280}
281
282// ─── Tests ────────────────────────────────────────────────────────────────────
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    // ─── ResolvedGenericConstraintBounds tests ────────────────────────────────
289
290    /// `empty()` returns a table where all queries return false/empty.
291    #[test]
292    fn test_generic_bounds_empty() {
293        let t = ResolvedGenericConstraintBounds::empty();
294        assert!(!t.is_active(0, 0));
295        assert!(!t.is_active(99, -1));
296        assert!(t.bounds_for_stage(0, 0).is_empty());
297        assert!(t.bounds_for_stage(99, 5).is_empty());
298    }
299
300    /// `new()` with 2 constraints, sparse bounds: constraint 0 at stage 0 is active;
301    /// constraint 1 at stage 0 is not active.
302    #[test]
303    fn test_generic_bounds_sparse_active() {
304        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
305
306        // One row: constraint_id=0, stage_id=0, block_id=None, bound=100.0
307        let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
308        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
309
310        assert!(t.is_active(0, 0), "constraint 0 at stage 0 must be active");
311        assert!(
312            !t.is_active(1, 0),
313            "constraint 1 at stage 0 must not be active"
314        );
315        assert!(
316            !t.is_active(0, 1),
317            "constraint 0 at stage 1 must not be active"
318        );
319    }
320
321    /// `bounds_for_stage()` with `block_id=None` returns the correct single-entry slice.
322    #[test]
323    fn test_generic_bounds_single_block_none() {
324        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
325        let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
326        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
327
328        let slice = t.bounds_for_stage(0, 0);
329        assert_eq!(slice.len(), 1);
330        assert_eq!(slice[0], (None, 100.0));
331    }
332
333    /// Multiple (`block_id`, bound) pairs for the same (constraint, stage).
334    #[test]
335    fn test_generic_bounds_multiple_blocks() {
336        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
337        // Three rows for constraint 0 at stage 2: block None, block 0, block 1.
338        let rows = vec![
339            (0i32, 2i32, None::<i32>, 50.0f64),
340            (0i32, 2i32, Some(0i32), 60.0f64),
341            (0i32, 2i32, Some(1i32), 70.0f64),
342        ];
343        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
344
345        assert!(t.is_active(0, 2));
346        let slice = t.bounds_for_stage(0, 2);
347        assert_eq!(slice.len(), 3);
348        assert_eq!(slice[0], (None, 50.0));
349        assert_eq!(slice[1], (Some(0), 60.0));
350        assert_eq!(slice[2], (Some(1), 70.0));
351    }
352
353    /// Rows with unknown `constraint_id` are silently skipped.
354    #[test]
355    fn test_generic_bounds_unknown_constraint_id_skipped() {
356        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
357        // Row with constraint_id=99 not in id_map.
358        let rows = vec![(99i32, 0i32, None::<i32>, 1000.0f64)];
359        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
360
361        assert!(!t.is_active(0, 0), "unknown constraint_id must be skipped");
362        assert!(t.bounds_for_stage(0, 0).is_empty());
363    }
364
365    /// Empty `raw_bounds` produces a table identical to `empty()`.
366    #[test]
367    fn test_generic_bounds_no_rows() {
368        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
369        let t = ResolvedGenericConstraintBounds::new(&id_map, std::iter::empty());
370
371        assert!(!t.is_active(0, 0));
372        assert!(!t.is_active(1, 0));
373        assert!(t.bounds_for_stage(0, 0).is_empty());
374    }
375
376    /// Bounds for constraint 0 at stages 0 and 1; constraint 1 has no bounds.
377    #[test]
378    fn test_generic_bounds_two_stages_one_constraint() {
379        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
380        let rows = vec![
381            (0i32, 0i32, None::<i32>, 100.0f64),
382            (0i32, 1i32, None::<i32>, 200.0f64),
383        ];
384        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
385
386        assert!(t.is_active(0, 0));
387        assert!(t.is_active(0, 1));
388        assert!(!t.is_active(1, 0));
389        assert!(!t.is_active(1, 1));
390
391        let s0 = t.bounds_for_stage(0, 0);
392        assert_eq!(s0.len(), 1);
393        assert!((s0[0].1 - 100.0).abs() < f64::EPSILON);
394
395        let s1 = t.bounds_for_stage(0, 1);
396        assert_eq!(s1.len(), 1);
397        assert!((s1[0].1 - 200.0).abs() < f64::EPSILON);
398    }
399
400    #[test]
401    #[cfg(feature = "serde")]
402    fn test_generic_bounds_serde_roundtrip() {
403        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
404        let rows = vec![
405            (0i32, 0i32, None::<i32>, 100.0f64),
406            (0i32, 0i32, Some(1i32), 150.0f64),
407            (1i32, 2i32, None::<i32>, 300.0f64),
408        ];
409        let original = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
410        let json = serde_json::to_string(&original).expect("serialize");
411        let restored: ResolvedGenericConstraintBounds =
412            serde_json::from_str(&json).expect("deserialize");
413        assert_eq!(original, restored);
414    }
415}