Skip to main content

cobre_core/model/resolved/
generic.rs

1//! Pre-resolved RHS bound table for user-defined generic linear constraints.
2//!
3//! Unlike the dense entity bound tables in `bounds`, generic-constraint bounds are
4//! sparse, so absent `(constraint, stage)` pairs report inactive rather than
5//! panicking. Populated by `cobre-io`; never modified after construction.
6
7use std::collections::HashMap;
8use std::ops::Range;
9
10/// Pre-resolved RHS bound table for user-defined generic linear constraints.
11///
12/// Sparse `(constraint_index, stage_id)` index with O(1) lookup. Absent pairs
13/// report [`is_active`] `false` and [`bounds_for_stage`] empty — no panic.
14///
15/// # Examples
16///
17/// ```
18/// use cobre_core::ResolvedGenericConstraintBounds;
19///
20/// let empty = ResolvedGenericConstraintBounds::empty();
21/// assert!(!empty.is_active(0, 0));
22/// assert!(empty.bounds_for_stage(0, 0).is_empty());
23/// ```
24///
25/// [`is_active`]: ResolvedGenericConstraintBounds::is_active
26/// [`bounds_for_stage`]: ResolvedGenericConstraintBounds::bounds_for_stage
27#[derive(Debug, Clone, PartialEq)]
28pub struct ResolvedGenericConstraintBounds {
29    /// Maps `(constraint_idx, stage_id)` to a contiguous range in `entries`.
30    /// `stage_id` is `i32` to match domain-level stage IDs, which may be negative.
31    index: HashMap<(usize, i32), Range<usize>>,
32    /// Flat entries; each key's entries occupy the range `index` records.
33    entries: Vec<GenericConstraintBoundEntry>,
34}
35
36/// One resolved `(block_id, bound_lower, bound_upper)` entry for a `(constraint, stage)`
37/// key. Shape is derived from which endpoints are present: lower-only, upper-only, or
38/// both (a band, degenerate to an equality when the two are equal).
39///
40/// A named record rather than a tuple: `bound_lower` and `bound_upper` are both
41/// `Option<f64>`-shaped, so a bare positional tuple invites a silent field swap at a
42/// construction site.
43#[derive(Debug, Clone, Copy, PartialEq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub struct GenericConstraintBoundEntry {
46    /// Block index (`None` = all blocks).
47    pub block_id: Option<i32>,
48    /// Lower RHS bound; `None` when the row is upper-only.
49    pub bound_lower: Option<f64>,
50    /// Upper RHS bound; `None` when the row is lower-only.
51    pub bound_upper: Option<f64>,
52}
53
54#[cfg(feature = "serde")]
55mod serde_generic_bounds {
56    use serde::{Deserialize, Deserializer, Serialize, Serializer};
57
58    use super::{GenericConstraintBoundEntry, ResolvedGenericConstraintBounds};
59
60    /// Wire format for serde: a list of `(constraint_idx, stage_id, pairs)` groups,
61    /// because `HashMap<(usize, i32), Range<usize>>` cannot serialize directly
62    /// (composite tuple keys are not strings).
63    #[derive(Serialize, Deserialize)]
64    struct WireEntry {
65        constraint_idx: usize,
66        stage_id: i32,
67        pairs: Vec<GenericConstraintBoundEntry>,
68    }
69
70    impl Serialize for ResolvedGenericConstraintBounds {
71        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
72            // Sort keys for deterministic output regardless of HashMap iteration order.
73            let mut keys: Vec<(usize, i32)> = self.index.keys().copied().collect();
74            keys.sort_unstable();
75
76            let wire: Vec<WireEntry> = keys
77                .into_iter()
78                .map(|(constraint_idx, stage_id)| {
79                    let range = self.index[&(constraint_idx, stage_id)].clone();
80                    WireEntry {
81                        constraint_idx,
82                        stage_id,
83                        pairs: self.entries[range].to_vec(),
84                    }
85                })
86                .collect();
87
88            wire.serialize(serializer)
89        }
90    }
91
92    impl<'de> Deserialize<'de> for ResolvedGenericConstraintBounds {
93        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
94            let wire = Vec::<WireEntry>::deserialize(deserializer)?;
95
96            let mut index = std::collections::HashMap::new();
97            let mut entries = Vec::new();
98
99            for entry in wire {
100                let start = entries.len();
101                entries.extend_from_slice(&entry.pairs);
102                let end = entries.len();
103                // Drop empty-pairs groups (no key) — inserting an empty range would
104                // make `is_active` report `true` for a stage with no bounds. Mirrors `new`.
105                if end > start {
106                    index.insert((entry.constraint_idx, entry.stage_id), start..end);
107                }
108            }
109
110            Ok(ResolvedGenericConstraintBounds { index, entries })
111        }
112    }
113}
114
115impl ResolvedGenericConstraintBounds {
116    /// Return an empty table with no constraints and no bounds.
117    ///
118    /// The default value in [`System`](crate::System) when no generic constraints
119    /// are loaded; all queries return `false` / empty slices.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use cobre_core::ResolvedGenericConstraintBounds;
125    ///
126    /// let t = ResolvedGenericConstraintBounds::empty();
127    /// assert!(!t.is_active(0, 0));
128    /// assert!(t.bounds_for_stage(99, 5).is_empty());
129    /// ```
130    #[must_use]
131    pub fn empty() -> Self {
132        Self {
133            index: HashMap::new(),
134            entries: Vec::new(),
135        }
136    }
137
138    /// Build a resolved table from sorted bound rows.
139    ///
140    /// `constraint_id_to_idx` maps domain `constraint_id: i32` to positional index;
141    /// rows with an absent `constraint_id` are silently skipped (caught upstream by
142    /// referential validation). `raw_bounds` must be sorted by `(constraint_id,
143    /// stage_id, block_id)` ascending — the grouping into contiguous ranges relies on it.
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use std::collections::HashMap;
149    /// use cobre_core::ResolvedGenericConstraintBounds;
150    ///
151    /// // Two constraints with IDs 10 and 20, mapped to positions 0 and 1.
152    /// let id_map: HashMap<i32, usize> = [(10, 0), (20, 1)].into_iter().collect();
153    ///
154    /// // One bound row: constraint 10 at stage 3, block_id = None, bound_lower = 500.0,
155    /// // bound_upper = None.
156    /// let rows = vec![(10i32, 3i32, None::<i32>, Some(500.0f64), None::<f64>)];
157    ///
158    /// let table = ResolvedGenericConstraintBounds::new(
159    ///     &id_map,
160    ///     rows.iter().map(|(cid, sid, bid, bl, bu)| (*cid, *sid, *bid, *bl, *bu)),
161    /// );
162    ///
163    /// assert!(table.is_active(0, 3));
164    /// assert!(!table.is_active(1, 3));
165    ///
166    /// let slice = table.bounds_for_stage(0, 3);
167    /// assert_eq!(slice.len(), 1);
168    /// assert_eq!(slice[0].block_id, None);
169    /// assert_eq!(slice[0].bound_lower, Some(500.0));
170    /// assert_eq!(slice[0].bound_upper, None);
171    /// ```
172    #[must_use]
173    pub fn new<I>(constraint_id_to_idx: &HashMap<i32, usize>, raw_bounds: I) -> Self
174    where
175        I: Iterator<Item = (i32, i32, Option<i32>, Option<f64>, Option<f64>)>,
176    {
177        let mut index: HashMap<(usize, i32), Range<usize>> = HashMap::new();
178        let mut entries: Vec<GenericConstraintBoundEntry> = Vec::new();
179
180        let mut current_key: Option<(usize, i32)> = None;
181        let mut range_start: usize = 0;
182
183        for (constraint_id, stage_id, block_id, bound_lower, bound_upper) in raw_bounds {
184            let Some(&constraint_idx) = constraint_id_to_idx.get(&constraint_id) else {
185                continue;
186            };
187
188            let key = (constraint_idx, stage_id);
189
190            if current_key != Some(key) {
191                if let Some(prev_key) = current_key {
192                    let range_end = entries.len();
193                    if range_end > range_start {
194                        index.insert(prev_key, range_start..range_end);
195                    }
196                }
197                range_start = entries.len();
198                current_key = Some(key);
199            }
200
201            entries.push(GenericConstraintBoundEntry {
202                block_id,
203                bound_lower,
204                bound_upper,
205            });
206        }
207
208        if let Some(last_key) = current_key {
209            let range_end = entries.len();
210            if range_end > range_start {
211                index.insert(last_key, range_start..range_end);
212            }
213        }
214
215        Self { index, entries }
216    }
217
218    /// Return `true` if at least one bound entry exists for this constraint at the given stage.
219    ///
220    /// Returns `false` for any unknown `(constraint_idx, stage_id)` pair.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// use cobre_core::ResolvedGenericConstraintBounds;
226    ///
227    /// let empty = ResolvedGenericConstraintBounds::empty();
228    /// assert!(!empty.is_active(0, 0));
229    /// ```
230    #[inline]
231    #[must_use]
232    pub fn is_active(&self, constraint_idx: usize, stage_id: i32) -> bool {
233        self.index.contains_key(&(constraint_idx, stage_id))
234    }
235
236    /// Return the bound entries for a constraint at the given stage.
237    ///
238    /// Returns an empty slice when no bounds exist for the `(constraint_idx, stage_id)` pair.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// use cobre_core::ResolvedGenericConstraintBounds;
244    ///
245    /// let empty = ResolvedGenericConstraintBounds::empty();
246    /// assert!(empty.bounds_for_stage(0, 0).is_empty());
247    /// ```
248    #[inline]
249    #[must_use]
250    pub fn bounds_for_stage(
251        &self,
252        constraint_idx: usize,
253        stage_id: i32,
254    ) -> &[GenericConstraintBoundEntry] {
255        match self.index.get(&(constraint_idx, stage_id)) {
256            Some(range) => &self.entries[range.clone()],
257            None => &[],
258        }
259    }
260}
261
262// ─── Tests ────────────────────────────────────────────────────────────────────
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    /// `empty()` returns a table where all queries return false/empty.
269    #[test]
270    fn test_generic_bounds_empty() {
271        let t = ResolvedGenericConstraintBounds::empty();
272        assert!(!t.is_active(0, 0));
273        assert!(!t.is_active(99, -1));
274        assert!(t.bounds_for_stage(0, 0).is_empty());
275        assert!(t.bounds_for_stage(99, 5).is_empty());
276    }
277
278    /// `new()` with 2 constraints, sparse bounds: constraint 0 at stage 0 is active;
279    /// constraint 1 at stage 0 is not active.
280    #[test]
281    fn test_generic_bounds_sparse_active() {
282        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
283
284        let rows = vec![(0i32, 0i32, None::<i32>, Some(100.0f64), None::<f64>)];
285        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
286
287        assert!(t.is_active(0, 0), "constraint 0 at stage 0 must be active");
288        assert!(
289            !t.is_active(1, 0),
290            "constraint 1 at stage 0 must not be active"
291        );
292        assert!(
293            !t.is_active(0, 1),
294            "constraint 0 at stage 1 must not be active"
295        );
296    }
297
298    /// `bounds_for_stage()` with `block_id=None` returns the correct single-entry slice.
299    #[test]
300    fn test_generic_bounds_single_block_none() {
301        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
302        let rows = vec![(0i32, 0i32, None::<i32>, Some(100.0f64), None::<f64>)];
303        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
304
305        let slice = t.bounds_for_stage(0, 0);
306        assert_eq!(slice.len(), 1);
307        assert_eq!(
308            slice[0],
309            GenericConstraintBoundEntry {
310                block_id: None,
311                bound_lower: Some(100.0),
312                bound_upper: None,
313            }
314        );
315    }
316
317    /// Multiple (`block_id`, `bound_lower`) pairs for the same (constraint, stage).
318    #[test]
319    fn test_generic_bounds_multiple_blocks() {
320        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
321        let rows = vec![
322            (0i32, 2i32, None::<i32>, Some(50.0f64), None::<f64>),
323            (0i32, 2i32, Some(0i32), Some(60.0f64), None::<f64>),
324            (0i32, 2i32, Some(1i32), Some(70.0f64), None::<f64>),
325        ];
326        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
327
328        assert!(t.is_active(0, 2));
329        let slice = t.bounds_for_stage(0, 2);
330        assert_eq!(slice.len(), 3);
331        assert_eq!(
332            slice[0],
333            GenericConstraintBoundEntry {
334                block_id: None,
335                bound_lower: Some(50.0),
336                bound_upper: None,
337            }
338        );
339        assert_eq!(
340            slice[1],
341            GenericConstraintBoundEntry {
342                block_id: Some(0),
343                bound_lower: Some(60.0),
344                bound_upper: None,
345            }
346        );
347        assert_eq!(
348            slice[2],
349            GenericConstraintBoundEntry {
350                block_id: Some(1),
351                bound_lower: Some(70.0),
352                bound_upper: None,
353            }
354        );
355    }
356
357    /// `bound_upper`, when supplied, round-trips through `new()` into
358    /// `bounds_for_stage()` alongside `bound_lower` — the actual widened-pipe contract.
359    #[test]
360    fn test_generic_bounds_bound_upper_round_trips() {
361        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
362        let rows = vec![
363            (0i32, 0i32, None::<i32>, Some(50.0f64), Some(90.0f64)),
364            (0i32, 0i32, Some(0i32), Some(60.0f64), None::<f64>),
365        ];
366        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
367
368        let slice = t.bounds_for_stage(0, 0);
369        assert_eq!(slice.len(), 2);
370        assert_eq!(
371            slice[0],
372            GenericConstraintBoundEntry {
373                block_id: None,
374                bound_lower: Some(50.0),
375                bound_upper: Some(90.0),
376            }
377        );
378        assert_eq!(
379            slice[1],
380            GenericConstraintBoundEntry {
381                block_id: Some(0),
382                bound_lower: Some(60.0),
383                bound_upper: None,
384            }
385        );
386    }
387
388    /// A lower-`None`, upper-only row round-trips through `new()`.
389    #[test]
390    fn test_generic_bounds_upper_only_round_trips() {
391        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
392        let rows = vec![(0i32, 0i32, None::<i32>, None::<f64>, Some(10.0f64))];
393        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
394
395        let slice = t.bounds_for_stage(0, 0);
396        assert_eq!(slice.len(), 1);
397        assert_eq!(
398            slice[0],
399            GenericConstraintBoundEntry {
400                block_id: None,
401                bound_lower: None,
402                bound_upper: Some(10.0),
403            }
404        );
405    }
406
407    /// Rows with unknown `constraint_id` are silently skipped.
408    #[test]
409    fn test_generic_bounds_unknown_constraint_id_skipped() {
410        let id_map: HashMap<i32, usize> = [(0, 0)].into_iter().collect();
411        let rows = vec![(99i32, 0i32, None::<i32>, Some(1000.0f64), None::<f64>)];
412        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
413
414        assert!(!t.is_active(0, 0), "unknown constraint_id must be skipped");
415        assert!(t.bounds_for_stage(0, 0).is_empty());
416    }
417
418    /// Empty `raw_bounds` produces a table identical to `empty()`.
419    #[test]
420    fn test_generic_bounds_no_rows() {
421        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
422        let t = ResolvedGenericConstraintBounds::new(&id_map, std::iter::empty());
423
424        assert!(!t.is_active(0, 0));
425        assert!(!t.is_active(1, 0));
426        assert!(t.bounds_for_stage(0, 0).is_empty());
427    }
428
429    /// Bounds for constraint 0 at stages 0 and 1; constraint 1 has no bounds.
430    #[test]
431    fn test_generic_bounds_two_stages_one_constraint() {
432        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
433        let rows = vec![
434            (0i32, 0i32, None::<i32>, Some(100.0f64), None::<f64>),
435            (0i32, 1i32, None::<i32>, Some(200.0f64), None::<f64>),
436        ];
437        let t = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
438
439        assert!(t.is_active(0, 0));
440        assert!(t.is_active(0, 1));
441        assert!(!t.is_active(1, 0));
442        assert!(!t.is_active(1, 1));
443
444        let s0 = t.bounds_for_stage(0, 0);
445        assert_eq!(s0.len(), 1);
446        assert!((s0[0].bound_lower.expect("lower present") - 100.0).abs() < f64::EPSILON);
447
448        let s1 = t.bounds_for_stage(0, 1);
449        assert_eq!(s1.len(), 1);
450        assert!((s1[0].bound_lower.expect("lower present") - 200.0).abs() < f64::EPSILON);
451    }
452
453    #[test]
454    #[cfg(feature = "serde")]
455    fn test_generic_bounds_serde_roundtrip() {
456        let id_map: HashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
457        let rows = vec![
458            (0i32, 0i32, None::<i32>, Some(100.0f64), None::<f64>),
459            (0i32, 0i32, Some(1i32), Some(150.0f64), Some(175.0f64)),
460            (1i32, 2i32, None::<i32>, Some(300.0f64), None::<f64>),
461        ];
462        let original = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
463        let json = serde_json::to_string(&original).expect("serialize");
464        let restored: ResolvedGenericConstraintBounds =
465            serde_json::from_str(&json).expect("deserialize");
466        assert_eq!(original, restored);
467    }
468}