Skip to main content

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}