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}