Skip to main content

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}