xingo_sparse/triplets.rs
1//! # Chapter 5.8.4 — The coordinate builder
2//!
3//! *New with task L1.7a (PLAN.md trilha L): the contract's `Triplets<T>`,
4//! the last container of v0.1 after [`Pattern`](crate::Pattern) (L1.3).*
5//!
6//! ## Purpose
7//! A compressed matrix (Chapter 5.8.1) is awkward to *build*: its entries
8//! must arrive column by column, rows sorted, duplicates merged. Code that
9//! assembles a matrix — a model reader, the interior-point method stacking
10//! `[−D Aᵀ; A δI]` block by block, a test writing a small example — knows
11//! its entries in no particular order and sometimes twice. [`Triplets`] is
12//! that coordinate (COO) form: three parallel arrays `(row, col, val)` that
13//! grow by [`Triplets::push`] in any order, and one conversion,
14//! [`Triplets::to_csc`], that sorts, sums the duplicates and validates.
15//!
16//! [`CscMatrix::from_triplets`] (Chapter 5.8.1) does the same for a slice
17//! of `(Row, Col, T)` tuples and is what the solver uses; it is unchanged.
18//! The builder is the *incremental* form for code outside the solver —
19//! plain `u32` indices, the arrays public as in the contract, buffers that
20//! can be cleared and refilled at every iteration of a method whose
21//! pattern is fixed while its values change.
22//!
23//! ## Notation
24//! A builder of an `m × n` matrix holds `K` triplets `(i_k, j_k, v_k)`,
25//! `k = 0, …, K−1`, in *insertion order* (the order of the `push` calls).
26//! The matrix it denotes has entry `a_ij = Σ_{k : i_k = i, j_k = j} v_k`,
27//! the sum taken in insertion order.
28//!
29//! ## Algorithm C′ (Compression of a coordinate list)
30//! Algorithm C of Chapter 5.8.1 on the three parallel arrays of the
31//! builder (the same four steps, so that the two constructors agree bit
32//! for bit on the same list).
33//!
34//! C′1. [Validate.] Reject arrays of unequal length ((D1) below), a
35//! dimension above `u32::MAX`, an index `i_k ≥ m` or `j_k ≥ n`, and a
36//! non-finite `v_k`. Nothing is validated earlier: `push` only
37//! appends, so an index out of range is reported here, by
38//! [`SparseError::IndexOutOfRange`], never panicked on.
39//! C′2. [Bucket by row.] Counting-sort the triplets by row, stably: row
40//! `i` receives the pairs `(j_k, v_k)` in insertion order.
41//! C′3. [Transpose.] Algorithm T of Chapter 5.8 on the arrays of C′2. Each
42//! column now lists its rows in non-decreasing order, and the
43//! duplicates of `(i, j)` are adjacent and still in insertion order.
44//! C′4. [Sum and compact.] Sweep each column once, replacing every run of
45//! equal row indices by the sum of its values, accumulated left to
46//! right — that is, *in insertion order, without reassociation*: the
47//! result of `push(0, 0, 1e16); push(0, 0, 1.0); push(0, 0, −1e16)`
48//! is `(1e16 + 1) − 1e16 = 0` in `f64`, while pushing `−1e16` before
49//! `1.0` gives `1`. Reject a sum that overflows to `±∞`. A sum that is
50//! exactly zero is **not stored**: the output must satisfy (C3) of
51//! Chapter 5.8, which forbids explicit zeros in a [`CscMatrix`]. Zeros
52//! are therefore *accepted as input* — `push(i, j, 0.0)` is legal and
53//! takes part in the sum — and absent from the output exactly when the
54//! sum cancels. Rebuild `col_ptr`.
55//!
56//! Every step is a stable counting sort or a left-to-right sweep, so the
57//! output, rounding of duplicate sums included, depends only on the
58//! sequence of pushes — never on hashing, allocation or thread. Cost
59//! `O(K + m + n)` time and space.
60//!
61//! *Divergence from the contract.* The contract's `to_csc` "drops nothing
62//! (zeros are kept)", under its invariant (S1), which admits stored zeros.
63//! The implementation of L1.2 kept the first edition's stricter (C3) —
64//! no explicit zeros, because the solver's counts rely on it (D-0088, the
65//! containers' invariants) — so a `CscMatrix` with a stored zero would
66//! fail its own [`CscMatrix::check_invariants`]. `to_csc` keeps (C3);
67//! what survives of the contract's sentence is that a zero *input* is not
68//! an error. Listed for ratification (task L1.7a).
69//!
70//! ## Invariants
71//! (D1) `row.len() = col.len() = val.len()`: the number of triplets,
72//! [`Triplets::len`]. Maintained by every method; a value built from
73//! the public fields is checked by C′1.
74//! (D2) `n_rows ≤ u32::MAX` and `n_cols ≤ u32::MAX` ((C4) of Chapter 5.8);
75//! [`Triplets::new`] asserts it, as [`CscMatrix::new`] does.
76//! (D3) After a successful `to_csc`: every `row[k] < n_rows`, every
77//! `col[k] < n_cols`, every `val[k]` finite. Not maintained by `push`
78//! (checked in C′1); [`Triplets::check_invariants`] checks (D1)–(D3)
79//! on demand.
80//! (D4) `to_csc` returns a matrix satisfying (C1)–(C4) of Chapter 5.8
81//! whose entry `(i, j)` is the insertion-order sum of the pushes at
82//! `(i, j)`, or an error; asserted in debug builds.
83//!
84//! ## References
85//! Davis, *Direct Methods for Sparse Linear Systems* (2006), §2.1
86//! (the triplet form) and §2.5 (`cs_compress`, `cs_dupl`); Gustavson
87//! (1978); Chapter 5.8.1 (Algorithm C); the contract of the crate
88//! (`Triplets<T>`).
89
90use crate::compressed::transpose;
91use crate::index::dim_to_u32;
92use crate::real::{Real, is_exact_zero};
93use crate::{CscMatrix, SparseError};
94
95/// Coordinate builder: push `(i, j, v)` in any order, duplicates summed in
96/// insertion order, then [`Triplets::to_csc`] (Algorithm C′).
97///
98/// The fields are public, as in the contract, so a list assembled by
99/// other means can be converted; `to_csc` validates them ((D1)–(D3)).
100#[derive(Clone, Debug, Default)]
101pub struct Triplets<T: Real = f64> {
102 /// Number of rows `m`.
103 pub n_rows: usize,
104 /// Number of columns `n`.
105 pub n_cols: usize,
106 /// Row index `i_k` of each triplet.
107 pub row: Vec<u32>,
108 /// Column index `j_k` of each triplet.
109 pub col: Vec<u32>,
110 /// Value `v_k` of each triplet.
111 pub val: Vec<T>,
112}
113
114impl<T: Real> Triplets<T> {
115 /// An empty builder of an `m × n` matrix.
116 ///
117 /// # Panics
118 /// If `m` or `n` exceeds `u32::MAX` ((D2); as [`CscMatrix::new`]).
119 pub fn new(n_rows: usize, n_cols: usize) -> Self {
120 assert!(
121 dim_to_u32("rows", n_rows).is_ok(),
122 "row count exceeds u32::MAX"
123 );
124 assert!(
125 dim_to_u32("columns", n_cols).is_ok(),
126 "column count exceeds u32::MAX"
127 );
128 Self {
129 n_rows,
130 n_cols,
131 row: Vec::new(),
132 col: Vec::new(),
133 val: Vec::new(),
134 }
135 }
136
137 /// The stored entries of `a`, one triplet each, column by column: a
138 /// builder that [`Triplets::to_csc`] maps back to `a` exactly, and to
139 /// which further entries can be pushed.
140 pub fn from_csc(a: &CscMatrix<T>) -> Self {
141 let mut t = Self::new(a.n_rows(), a.n_cols());
142 t.reserve(a.nnz());
143 for (j, w) in a.col_ptr.windows(2).enumerate() {
144 let jj = u32::try_from(j).expect("column count exceeds u32::MAX");
145 for p in w[0]..w[1] {
146 t.row.push(a.row_idx[p]);
147 t.col.push(jj);
148 t.val.push(a.val[p]);
149 }
150 }
151 t
152 }
153
154 /// Appends the triplet `(i, j, v)`. Indices are not checked here (they
155 /// are in `to_csc`, step C′1); a zero `v` is legal (step C′4).
156 #[inline]
157 pub fn push(&mut self, i: u32, j: u32, v: T) {
158 self.row.push(i);
159 self.col.push(j);
160 self.val.push(v);
161 }
162
163 /// Number of triplets pushed so far (`K`; duplicates counted).
164 #[inline]
165 pub fn len(&self) -> usize {
166 self.val.len()
167 }
168
169 /// `true` iff no triplet has been pushed.
170 #[inline]
171 pub fn is_empty(&self) -> bool {
172 self.val.is_empty()
173 }
174
175 /// Removes every triplet, keeping the dimensions and the allocated
176 /// buffers (for a matrix rebuilt with new values at every iteration).
177 pub fn clear(&mut self) {
178 self.row.clear();
179 self.col.clear();
180 self.val.clear();
181 }
182
183 /// Reserves room for `additional` more triplets.
184 pub fn reserve(&mut self, additional: usize) {
185 self.row.reserve(additional);
186 self.col.reserve(additional);
187 self.val.reserve(additional);
188 }
189
190 /// Checks (D1)–(D3): equal lengths, dimensions in `u32`, indices in
191 /// range, values finite. The validation step C′1 of [`Triplets::to_csc`].
192 pub fn check_invariants(&self) -> Result<(), SparseError> {
193 // (D1)
194 let k = self.row.len();
195 if self.col.len() != k {
196 return Err(SparseError::Dimension {
197 what: "triplet column array",
198 expected: k,
199 found: self.col.len(),
200 });
201 }
202 if self.val.len() != k {
203 return Err(SparseError::Dimension {
204 what: "triplet value array",
205 expected: k,
206 found: self.val.len(),
207 });
208 }
209 // (D2)
210 dim_to_u32("rows", self.n_rows)?;
211 dim_to_u32("columns", self.n_cols)?;
212 // (D3)
213 for (k, (&i, &j)) in self.row.iter().zip(&self.col).enumerate() {
214 if i as usize >= self.n_rows {
215 return Err(SparseError::IndexOutOfRange {
216 what: "row",
217 index: i as usize,
218 bound: self.n_rows,
219 });
220 }
221 if j as usize >= self.n_cols {
222 return Err(SparseError::IndexOutOfRange {
223 what: "column",
224 index: j as usize,
225 bound: self.n_cols,
226 });
227 }
228 let v = self.val[k];
229 if !v.is_finite() {
230 return Err(SparseError::InvalidValue {
231 what: "triplet value",
232 index: k,
233 value: v.to_f64(),
234 });
235 }
236 }
237 Ok(())
238 }
239
240 /// Algorithm C′: the `m × n` matrix the triplets denote — sorted,
241 /// duplicates summed in insertion order, zero sums not stored (C3) —
242 /// or the first violation of (D1)–(D3) found.
243 pub fn to_csc(&self) -> Result<CscMatrix<T>, SparseError> {
244 // C′1. Validate.
245 self.check_invariants()?;
246 let (m, n, k) = (self.n_rows, self.n_cols, self.len());
247
248 // C′2. Stable counting sort by row: CSR with unsorted columns.
249 let mut rptr = vec![0_usize; m + 1];
250 for &i in &self.row {
251 rptr[i as usize + 1] += 1;
252 }
253 for i in 0..m {
254 rptr[i + 1] += rptr[i];
255 }
256 let mut next = rptr[..m].to_vec();
257 let mut cidx = vec![0_u32; k];
258 let mut cval = vec![T::ZERO; k];
259 for ((&i, &j), &v) in self.row.iter().zip(&self.col).zip(&self.val) {
260 let q = next[i as usize];
261 next[i as usize] += 1;
262 cidx[q] = j;
263 cval[q] = v;
264 }
265
266 // C′3. Transpose: rows non-decreasing within each column.
267 let (col_ptr, mut row_idx, mut val) = transpose(n, &rptr, &cidx, &cval);
268
269 // C′4. Sum runs of equal rows left to right, skip exact zeros,
270 // compact in place.
271 let mut out_ptr = Vec::with_capacity(n + 1);
272 out_ptr.push(0);
273 let mut w = 0;
274 for (j, win) in col_ptr.windows(2).enumerate() {
275 let (mut p, end) = (win[0], win[1]);
276 while p < end {
277 let i = row_idx[p];
278 let mut s = val[p];
279 p += 1;
280 while p < end && row_idx[p] == i {
281 s += val[p];
282 p += 1;
283 }
284 if !s.is_finite() {
285 return Err(SparseError::InvalidValue {
286 what: "sum of duplicate entries in column",
287 index: j,
288 value: s.to_f64(),
289 });
290 }
291 if !is_exact_zero(s) {
292 row_idx[w] = i;
293 val[w] = s;
294 w += 1;
295 }
296 }
297 out_ptr.push(w);
298 }
299 row_idx.truncate(w);
300 val.truncate(w);
301
302 let a = CscMatrix {
303 n_rows: m,
304 col_ptr: out_ptr,
305 row_idx,
306 val,
307 };
308 // (D4)
309 debug_assert!(a.check_invariants().is_ok(), "{:?}", a.check_invariants());
310 Ok(a)
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::real::approx_eq;
318 use crate::{Col, Row};
319 use proptest::prelude::*;
320
321 #[test]
322 fn ex_5_8_4_small_example() {
323 // A = [ 1 0 2 ]
324 // [ 0 0 3 ] with (0,2) split as 0.5 + 1.5 and a zero at (1,0).
325 let mut t = Triplets::new(2, 3);
326 t.push(1, 2, 3.0);
327 t.push(0, 2, 0.5);
328 t.push(0, 0, 1.0);
329 t.push(1, 0, 0.0);
330 t.push(0, 2, 1.5);
331 assert_eq!((t.len(), t.is_empty()), (5, false));
332 assert!(t.check_invariants().is_ok());
333 let a = t.to_csc().unwrap();
334 assert_eq!(a.col_ptr, vec![0, 1, 1, 3]);
335 assert_eq!(a.row_idx, vec![0, 0, 1]);
336 assert_eq!(a.val, vec![1.0, 2.0, 3.0]);
337 // The same list through Algorithm C gives the same matrix.
338 let c = CscMatrix::from_triplets(
339 2,
340 3,
341 &[
342 (Row(1), Col(2), 3.0),
343 (Row(0), Col(2), 0.5),
344 (Row(0), Col(0), 1.0),
345 (Row(1), Col(0), 0.0),
346 (Row(0), Col(2), 1.5),
347 ],
348 )
349 .unwrap();
350 assert_eq!(a, c);
351 // Clear keeps the dimensions; the empty builder is the zero matrix.
352 t.clear();
353 assert!(t.is_empty());
354 assert_eq!(t.to_csc().unwrap(), CscMatrix::zeros(2, 3));
355 t.reserve(10);
356 assert!(t.row.capacity() >= 10 && t.col.capacity() >= 10 && t.val.capacity() >= 10);
357 }
358
359 #[test]
360 fn duplicates_are_summed_in_insertion_order_without_reassociation() {
361 // (1e16 + 1) − 1e16 = 0 in f64: the entry cancels and is not stored.
362 let mut t = Triplets::new(1, 1);
363 t.push(0, 0, 1e16);
364 t.push(0, 0, 1.0);
365 t.push(0, 0, -1e16);
366 let a = t.to_csc().unwrap();
367 assert_eq!(a.nnz(), 0);
368 assert!(a.check_invariants().is_ok());
369 // (1e16 − 1e16) + 1 = 1: another order, another sum.
370 let mut t = Triplets::new(1, 1);
371 t.push(0, 0, 1e16);
372 t.push(0, 0, -1e16);
373 t.push(0, 0, 1.0);
374 let a = t.to_csc().unwrap();
375 assert_eq!(a.val, vec![1.0]);
376 }
377
378 #[test]
379 fn zeros_are_accepted_and_not_stored() {
380 let mut t = Triplets::new(2, 2);
381 t.push(0, 0, 0.0);
382 t.push(1, 1, 2.0);
383 t.push(1, 1, 0.0);
384 t.push(0, 1, 1.0);
385 t.push(0, 1, -1.0);
386 let a = t.to_csc().unwrap();
387 assert_eq!(a.col_ptr, vec![0, 0, 1]);
388 assert_eq!(a.row_idx, vec![1]);
389 assert_eq!(a.val, vec![2.0]);
390 }
391
392 #[test]
393 fn rejects_bad_input() {
394 let mut t = Triplets::new(1, 1);
395 t.push(1, 0, 1.0);
396 assert!(matches!(
397 t.to_csc(),
398 Err(SparseError::IndexOutOfRange {
399 what: "row",
400 index: 1,
401 bound: 1
402 })
403 ));
404 let mut t = Triplets::new(1, 1);
405 t.push(0, 3, 1.0);
406 assert!(matches!(
407 t.to_csc(),
408 Err(SparseError::IndexOutOfRange {
409 what: "column",
410 index: 3,
411 bound: 1
412 })
413 ));
414 let mut t = Triplets::new(1, 1);
415 t.push(0, 0, f64::NAN);
416 assert!(matches!(
417 t.to_csc(),
418 Err(SparseError::InvalidValue {
419 what: "triplet value",
420 index: 0,
421 ..
422 })
423 ));
424 let mut t = Triplets::new(1, 1);
425 t.push(0, 0, f64::MAX);
426 t.push(0, 0, f64::MAX);
427 assert!(matches!(
428 t.to_csc(),
429 Err(SparseError::InvalidValue {
430 what: "sum of duplicate entries in column",
431 index: 0,
432 ..
433 })
434 ));
435 // (D1) through the public fields.
436 let t = Triplets {
437 n_rows: 1,
438 n_cols: 1,
439 row: vec![0],
440 col: vec![],
441 val: vec![1.0],
442 };
443 assert!(matches!(
444 t.check_invariants(),
445 Err(SparseError::Dimension {
446 what: "triplet column array",
447 expected: 1,
448 found: 0
449 })
450 ));
451 let t = Triplets {
452 n_rows: 1,
453 n_cols: 1,
454 row: vec![0],
455 col: vec![0],
456 val: Vec::<f64>::new(),
457 };
458 assert!(matches!(
459 t.to_csc(),
460 Err(SparseError::Dimension {
461 what: "triplet value array",
462 ..
463 })
464 ));
465 // (D2) through the public fields.
466 #[cfg(target_pointer_width = "64")]
467 {
468 let t = Triplets::<f64> {
469 n_rows: 1 << 33,
470 ..Triplets::new(0, 0)
471 };
472 assert!(matches!(t.to_csc(), Err(SparseError::TooLarge { .. })));
473 }
474 }
475
476 #[test]
477 #[cfg(target_pointer_width = "64")]
478 #[should_panic(expected = "exceeds u32::MAX")]
479 fn new_rejects_a_dimension_beyond_u32() {
480 let _ = Triplets::<f64>::new(1 << 33, 1);
481 }
482
483 #[test]
484 fn from_csc_lists_the_entries_column_by_column() {
485 let a = CscMatrix::from_raw_parts(2, vec![0, 1, 1, 3], vec![0, 0, 1], vec![1.0, 2.0, 3.0])
486 .unwrap();
487 let t = Triplets::from_csc(&a);
488 assert_eq!((t.n_rows, t.n_cols, t.len()), (2, 3, 3));
489 assert_eq!(t.row, vec![0, 0, 1]);
490 assert_eq!(t.col, vec![0, 2, 2]);
491 assert_eq!(t.val, vec![1.0, 2.0, 3.0]);
492 assert_eq!(t.to_csc().unwrap(), a);
493 }
494
495 /// Strategy for `(m, n, triplets)` with small-integer values (duplicate
496 /// sums exact in any order, frequent exact cancellations).
497 fn triplets() -> impl Strategy<Value = (usize, usize, Vec<(u32, u32, f64)>)> {
498 (0_usize..8, 0_usize..8).prop_flat_map(|(m, n)| {
499 let entries = if m == 0 || n == 0 {
500 Just(Vec::new()).boxed()
501 } else {
502 proptest::collection::vec(
503 (0..m, 0..n, -3_i8..=3).prop_map(|(i, j, v)| {
504 (
505 u32::try_from(i).unwrap(),
506 u32::try_from(j).unwrap(),
507 f64::from(v),
508 )
509 }),
510 0..40,
511 )
512 .boxed()
513 };
514 (Just(m), Just(n), entries)
515 })
516 }
517
518 /// A CSC matrix from the strategy, plus a shuffle of `0..nnz + extra`.
519 fn csc_and_shuffle() -> impl Strategy<Value = (CscMatrix, Vec<usize>, Vec<(u32, u32)>)> {
520 triplets().prop_flat_map(|(m, n, t)| {
521 let mut b = Triplets::new(m, n);
522 for &(i, j, v) in &t {
523 b.push(i, j, v);
524 }
525 let a = b.to_csc().unwrap();
526 // Extra zero pushes at arbitrary positions (in range), which
527 // must leave the matrix unchanged.
528 let extra = if m == 0 || n == 0 {
529 Just(Vec::new()).boxed()
530 } else {
531 proptest::collection::vec(
532 (0..m, 0..n)
533 .prop_map(|(i, j)| (u32::try_from(i).unwrap(), u32::try_from(j).unwrap())),
534 0..6,
535 )
536 .boxed()
537 };
538 let nnz = a.nnz();
539 (Just(a), extra).prop_flat_map(move |(a, extra)| {
540 let order: Vec<usize> = (0..nnz + extra.len()).collect();
541 (Just(a), Just(order).prop_shuffle(), Just(extra))
542 })
543 })
544 }
545
546 proptest! {
547 /// Algorithm C′ agrees with Algorithm C on the same list (same
548 /// sums, same order of summation): the builder is a second entry to
549 /// the same construction.
550 #[test]
551 fn c_prime_agrees_with_c((m, n, t) in triplets()) {
552 let mut b = Triplets::new(m, n);
553 for &(i, j, v) in &t {
554 b.push(i, j, v);
555 }
556 prop_assert_eq!(b.len(), t.len());
557 prop_assert!(b.check_invariants().is_ok());
558 let a = b.to_csc().unwrap();
559 prop_assert!(a.check_invariants().is_ok());
560 let tuples: Vec<(Row, Col, f64)> =
561 t.iter().map(|&(i, j, v)| (Row(i), Col(j), v)).collect();
562 let c = CscMatrix::from_triplets(m, n, &tuples).unwrap();
563 prop_assert_eq!(a, c);
564 }
565
566 /// Round trip: CSC → triplets in a random order, with extra zero
567 /// pushes anywhere → the identical CSC (structure and values).
568 #[test]
569 fn round_trip_in_any_order_with_zeros((a, order, extra) in csc_and_shuffle()) {
570 let t = Triplets::from_csc(&a);
571 prop_assert_eq!(t.to_csc().unwrap(), a.clone());
572 let nnz = a.nnz();
573 let mut shuffled = Triplets::new(a.n_rows(), a.n_cols());
574 for &k in &order {
575 if k < nnz {
576 shuffled.push(t.row[k], t.col[k], t.val[k]);
577 } else {
578 let (i, j) = extra[k - nnz];
579 shuffled.push(i, j, 0.0);
580 }
581 }
582 prop_assert_eq!(shuffled.len(), nnz + extra.len());
583 let back = shuffled.to_csc().unwrap();
584 prop_assert!(back.check_invariants().is_ok());
585 prop_assert_eq!(back, a);
586 }
587
588 /// The matrix denoted equals the dense accumulation entry by entry
589 /// (values are small integers, so the order of summation is moot).
590 #[test]
591 fn matches_the_dense_reference((m, n, t) in triplets()) {
592 let mut d = vec![vec![0.0; n]; m];
593 let mut b = Triplets::new(m, n);
594 for &(i, j, v) in &t {
595 d[i as usize][j as usize] += v;
596 b.push(i, j, v);
597 }
598 let a = b.to_csc().unwrap();
599 let mut nnz = 0;
600 for (i, row) in d.iter().enumerate() {
601 for (j, &dij) in row.iter().enumerate() {
602 let aij = a.get(Row::from_usize(i), Col::from_usize(j));
603 prop_assert!(approx_eq(aij, dij, 0.0));
604 nnz += usize::from(!is_exact_zero(dij));
605 }
606 }
607 prop_assert_eq!(a.nnz(), nnz);
608 }
609 }
610}