xingo_sparse/partition.rs
1//! # Chapter 5.8.5 — Block partitions
2//!
3//! *New with task L1.7a (PLAN.md trilha L): the contract's
4//! `BlockPartition`.*
5//!
6//! ## Purpose
7//! The matrices of optimization are assembled from blocks, and the code
8//! around them keeps asking where a block begins: the simplex method prices
9//! the structural columns of `[A | ±I]` and treats the logical ones apart
10//! (Chapter 5.1); the interior-point method stacks `[−D Aᵀ; A δI]` and
11//! must know that row `n + i` is the dual of constraint `i`
12//! (Chapter 5.14, [`KktForm::Augmented`](crate::KktForm::Augmented));
13//! a decomposition (Dantzig–Wolfe, Benders) names coupling rows and
14//! subproblem blocks. The matrix stays one [`CscMatrix`](crate::CscMatrix); a
15//! [`BlockPartition`] is the small companion object that records the
16//! *boundaries* of its row and column blocks, so that assembly with
17//! [`Triplets`](crate::Triplets), extraction and bookkeeping agree on them
18//! instead of each recomputing `n + i`.
19//!
20//! ## Notation
21//! An `m × n` matrix is partitioned into `p ≥ 0` row blocks and `q ≥ 0`
22//! column blocks by the *boundary lists*
23//!
24//! ```text
25//! row_blocks = [r_0, r_1, …, r_p], 0 = r_0 < r_1 < … < r_p = m,
26//! col_blocks = [c_0, c_1, …, c_q], 0 = c_0 < c_1 < … < c_q = n.
27//! ```
28//!
29//! Row block `k` is the half-open range `r_k..r_{k+1}`; column block `l` is
30//! `c_l..c_{l+1}`; block `(k, l)` is their product. Strict increase means a
31//! block is never empty; a dimension of size `0` has the single boundary
32//! list `[0]` and no block. Two partitions of the same matrix are equal iff
33//! their lists are.
34//!
35//! The two examples of the contract:
36//!
37//! * the computational form `[A | ±I]` of the simplex, `A ∈ R^{m×n}`: one
38//! row block, `row_blocks = [0, m]`, and two column blocks,
39//! `col_blocks = [0, n, n + m]` — column `j` is structural iff
40//! `col_block_of(j) = 0`;
41//! * the augmented system `K = [−D Aᵀ; A δI]` of order `n + m`:
42//! `row_blocks = col_blocks = [0, n, n + m]` — the primal block first, as
43//! [`KktForm::Augmented`](crate::KktForm::Augmented) requires.
44//!
45//! ## Algorithm B (Block of an index)
46//! Given a boundary list `b = [b_0, …, b_p]` and an index `0 ≤ i < b_p`.
47//!
48//! B1. Find, by binary search, the number `k` of boundaries `≤ i`
49//! (`partition_point`): `k ≥ 1` since `b_0 = 0 ≤ i`, and `k ≤ p` since
50//! `i < b_p`.
51//! B2. Return `k − 1`: the block `b_{k−1}..b_k` contains `i`.
52//!
53//! Cost `O(log p)`.
54//!
55//! ## Invariants
56//! (B1) `row_blocks` is non-empty, starts at `0` and is strictly
57//! increasing; its last element is `m`.
58//! (B2) The same for `col_blocks` and `n`.
59//! (B3) `m ≤ u32::MAX` and `n ≤ u32::MAX`: the dimensions fit the index
60//! space of the containers ((C4) of Chapter 5.8).
61//!
62//! Checked by [`BlockPartition::check_invariants`]; both constructors
63//! return the first violation as a [`SparseError`]. The fields are public,
64//! as in the contract; a value edited by hand should be checked again.
65//!
66//! ## References
67//! Maros, *Computational Techniques of the Simplex Method* (2003), §5.1
68//! (the computational form); Wright, *Primal-Dual Interior-Point Methods*
69//! (1997), ch. 11 (the augmented system); the contract of the crate
70//! (`BlockPartition`).
71
72use core::ops::Range;
73
74use crate::SparseError;
75use crate::index::dim_to_u32;
76
77/// Block boundaries of a partitioned matrix: `row_blocks` and `col_blocks`
78/// are boundary lists starting at `0` and ending at `m`, `n` (invariants
79/// (B1)–(B3)).
80///
81/// ```
82/// use xingo_sparse::BlockPartition;
83/// // [A | I] with A 3 × 5: one row block, structural then logical columns.
84/// let p = BlockPartition::from_sizes(&[3], &[5, 3]).unwrap();
85/// assert_eq!((p.n_rows(), p.n_cols()), (3, 8));
86/// assert_eq!(p.col_block(1), 5..8);
87/// assert_eq!(p.col_block_of(6), 1); // a logical column
88/// // The augmented system [−D Aᵀ; A δI] of the same A: order 5 + 3.
89/// let k = BlockPartition::new(vec![0, 5, 8], vec![0, 5, 8]).unwrap();
90/// assert_eq!(k.row_block(1).start, 5); // the dual of constraint i is row 5 + i
91/// ```
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct BlockPartition {
94 /// Row boundaries `[0 = r_0 < r_1 < … < r_p = m]`.
95 pub row_blocks: Vec<usize>,
96 /// Column boundaries `[0 = c_0 < c_1 < … < c_q = n]`.
97 pub col_blocks: Vec<usize>,
98}
99
100impl BlockPartition {
101 /// A partition from its boundary lists, validated ((B1)–(B3)).
102 pub fn new(row_blocks: Vec<usize>, col_blocks: Vec<usize>) -> Result<Self, SparseError> {
103 let p = Self {
104 row_blocks,
105 col_blocks,
106 };
107 p.check_invariants()?;
108 Ok(p)
109 }
110
111 /// A partition from the sizes of its blocks (prefix sums): `[A | I]`
112 /// with `A` of `m × n` is `from_sizes(&[m], &[n, m])`. An error if a
113 /// size is `0` (blocks are never empty) or a dimension exceeds
114 /// `u32::MAX`.
115 pub fn from_sizes(row_sizes: &[usize], col_sizes: &[usize]) -> Result<Self, SparseError> {
116 Self::new(boundaries(row_sizes), boundaries(col_sizes))
117 }
118
119 /// Checks (B1)–(B3).
120 pub fn check_invariants(&self) -> Result<(), SparseError> {
121 check_boundaries("row", &self.row_blocks)?;
122 check_boundaries("column", &self.col_blocks)
123 }
124
125 /// Number of rows `m` (the last row boundary).
126 #[inline]
127 pub fn n_rows(&self) -> usize {
128 *self
129 .row_blocks
130 .last()
131 .expect("(B1): non-empty boundary list")
132 }
133
134 /// Number of columns `n` (the last column boundary).
135 #[inline]
136 pub fn n_cols(&self) -> usize {
137 *self
138 .col_blocks
139 .last()
140 .expect("(B2): non-empty boundary list")
141 }
142
143 /// Number of row blocks `p`.
144 #[inline]
145 pub fn n_row_blocks(&self) -> usize {
146 self.row_blocks.len() - 1
147 }
148
149 /// Number of column blocks `q`.
150 #[inline]
151 pub fn n_col_blocks(&self) -> usize {
152 self.col_blocks.len() - 1
153 }
154
155 /// The rows `r_k..r_{k+1}` of row block `k`.
156 ///
157 /// # Panics
158 /// If `k ≥ p`.
159 #[inline]
160 pub fn row_block(&self, k: usize) -> Range<usize> {
161 assert!(k < self.n_row_blocks(), "row block {k} out of range");
162 self.row_blocks[k]..self.row_blocks[k + 1]
163 }
164
165 /// The columns `c_l..c_{l+1}` of column block `l`.
166 ///
167 /// # Panics
168 /// If `l ≥ q`.
169 #[inline]
170 pub fn col_block(&self, l: usize) -> Range<usize> {
171 assert!(l < self.n_col_blocks(), "column block {l} out of range");
172 self.col_blocks[l]..self.col_blocks[l + 1]
173 }
174
175 /// The row block containing row `i` (Algorithm B).
176 ///
177 /// # Panics
178 /// If `i ≥ m`.
179 pub fn row_block_of(&self, i: usize) -> usize {
180 assert!(i < self.n_rows(), "row {i} out of range");
181 block_of(&self.row_blocks, i)
182 }
183
184 /// The column block containing column `j` (Algorithm B).
185 ///
186 /// # Panics
187 /// If `j ≥ n`.
188 pub fn col_block_of(&self, j: usize) -> usize {
189 assert!(j < self.n_cols(), "column {j} out of range");
190 block_of(&self.col_blocks, j)
191 }
192}
193
194/// Prefix sums of `sizes`, starting at `0`.
195fn boundaries(sizes: &[usize]) -> Vec<usize> {
196 let mut b = Vec::with_capacity(sizes.len() + 1);
197 let mut acc = 0;
198 b.push(acc);
199 for &s in sizes {
200 acc += s;
201 b.push(acc);
202 }
203 b
204}
205
206/// (B1)/(B2) and (B3) for one boundary list; `what` is `"row"` or
207/// `"column"`.
208fn check_boundaries(what: &'static str, b: &[usize]) -> Result<(), SparseError> {
209 let Some((&first, _)) = b.split_first() else {
210 return Err(SparseError::Malformed(format!(
211 "{what} block boundaries are empty (expected at least [0])"
212 )));
213 };
214 if first != 0 {
215 return Err(SparseError::Malformed(format!(
216 "{what} block boundaries start at {first}, not 0"
217 )));
218 }
219 if let Some(k) = b.windows(2).position(|w| w[0] >= w[1]) {
220 return Err(SparseError::Malformed(format!(
221 "{what} block boundaries not strictly increasing at block {k}: {} ≥ {}",
222 b[k],
223 b[k + 1]
224 )));
225 }
226 // (B3): the last element is the dimension.
227 dim_to_u32(what, b[b.len() - 1]).map(|_| ())
228}
229
230/// Algorithm B on a valid boundary list, `i` below its last element.
231#[inline]
232fn block_of(b: &[usize], i: usize) -> usize {
233 let k = b.partition_point(|&x| x <= i);
234 debug_assert!(
235 k >= 1 && k < b.len(),
236 "Algorithm B: index {i} outside {b:?}"
237 );
238 k - 1
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use proptest::prelude::*;
245
246 #[test]
247 fn ex_5_8_5_simplex_and_kkt_partitions() {
248 // [A | ±I], A 4 × 6.
249 let p = BlockPartition::from_sizes(&[4], &[6, 4]).unwrap();
250 assert_eq!(p.row_blocks, vec![0, 4]);
251 assert_eq!(p.col_blocks, vec![0, 6, 10]);
252 assert_eq!((p.n_rows(), p.n_cols()), (4, 10));
253 assert_eq!((p.n_row_blocks(), p.n_col_blocks()), (1, 2));
254 assert_eq!(p.row_block(0), 0..4);
255 assert_eq!(p.col_block(0), 0..6);
256 assert_eq!(p.col_block(1), 6..10);
257 for j in 0..10 {
258 assert_eq!(p.col_block_of(j), usize::from(j >= 6));
259 assert_eq!(p.row_block_of(j % 4), 0);
260 }
261 // [−D Aᵀ; A δI], n = 6, m = 4.
262 let k = BlockPartition::new(vec![0, 6, 10], vec![0, 6, 10]).unwrap();
263 assert_eq!(k, BlockPartition::from_sizes(&[6, 4], &[6, 4]).unwrap());
264 assert_eq!(k.row_block(1), 6..10);
265 assert_eq!(k.row_block_of(5), 0);
266 assert_eq!(k.row_block_of(6), 1);
267 assert_eq!(k.col_block_of(9), 1);
268 // An empty dimension: the single boundary [0] and no block.
269 let e = BlockPartition::new(vec![0], vec![0, 2]).unwrap();
270 assert_eq!((e.n_rows(), e.n_row_blocks()), (0, 0));
271 assert_eq!(BlockPartition::from_sizes(&[], &[2]).unwrap(), e);
272 }
273
274 #[test]
275 fn rejects_bad_boundaries() {
276 let bad = |r: Vec<usize>, c: Vec<usize>| BlockPartition::new(r, c).unwrap_err();
277 assert!(matches!(bad(vec![], vec![0]), SparseError::Malformed(_)));
278 assert!(matches!(
279 bad(vec![1, 2], vec![0]),
280 SparseError::Malformed(_)
281 ));
282 assert!(matches!(
283 bad(vec![0, 2, 2], vec![0]),
284 SparseError::Malformed(_)
285 ));
286 assert!(matches!(
287 bad(vec![0], vec![0, 3, 1]),
288 SparseError::Malformed(_)
289 ));
290 // A zero block size.
291 assert!(matches!(
292 BlockPartition::from_sizes(&[3, 0], &[1]),
293 Err(SparseError::Malformed(_))
294 ));
295 #[cfg(target_pointer_width = "64")]
296 assert!(matches!(
297 bad(vec![0, 1 << 33], vec![0]),
298 SparseError::TooLarge { what: "row", .. }
299 ));
300 // A value edited by hand fails the check.
301 let mut p = BlockPartition::from_sizes(&[2], &[2]).unwrap();
302 p.col_blocks[0] = 1;
303 assert!(p.check_invariants().is_err());
304 }
305
306 #[test]
307 #[should_panic(expected = "row 4 out of range")]
308 fn block_of_rejects_an_index_beyond_the_dimension() {
309 let p = BlockPartition::from_sizes(&[4], &[1]).unwrap();
310 let _ = p.row_block_of(4);
311 }
312
313 #[test]
314 #[should_panic(expected = "column block 1 out of range")]
315 fn block_range_rejects_a_block_beyond_the_count() {
316 let p = BlockPartition::from_sizes(&[4], &[1]).unwrap();
317 let _ = p.col_block(1);
318 }
319
320 proptest! {
321 /// `from_sizes` tiles `0..m` with the blocks in order; Algorithm B
322 /// agrees with a linear scan on every index.
323 #[test]
324 fn blocks_tile_the_dimension(
325 rows in proptest::collection::vec(1_usize..6, 0..6),
326 cols in proptest::collection::vec(1_usize..6, 0..6),
327 ) {
328 let p = BlockPartition::from_sizes(&rows, &cols).unwrap();
329 prop_assert!(p.check_invariants().is_ok());
330 prop_assert_eq!(p.n_row_blocks(), rows.len());
331 prop_assert_eq!(p.n_col_blocks(), cols.len());
332 prop_assert_eq!(p.n_rows(), rows.iter().sum::<usize>());
333 let mut next = 0;
334 for (k, &s) in rows.iter().enumerate() {
335 let r = p.row_block(k);
336 prop_assert_eq!(r.clone(), next..next + s);
337 for i in r {
338 prop_assert_eq!(p.row_block_of(i), k);
339 }
340 next += s;
341 }
342 prop_assert_eq!(next, p.n_rows());
343 for j in 0..p.n_cols() {
344 let scan = (0..cols.len()).find(|&l| p.col_block(l).contains(&j)).unwrap();
345 prop_assert_eq!(p.col_block_of(j), scan);
346 }
347 }
348 }
349}