//! What a sparse factorisation of the joint would actually buy (#52). //! //! Run explicitly: //! //! ```text //! cargo test --release --features approx,measure-sparsity \ //! --test sparsity_measurement -- --ignored --nocapture //! ``` //! //! The whole file is gated: it reaches for the joint's sparsity pattern, which //! is exposed only under `measure-sparsity`. #![cfg(feature = "measure-sparsity")] use std::collections::HashSet; use trueskill_tt::{ConvergenceOptions, History}; /// A history shaped like the issue's fixture: many slices, scored duels, /// competitors reappearing across slices so the drift links are long. fn fitted(slices: i64, duels: usize, competitors: usize) -> History { let mut h: History = History::builder() .key_type::() .mu(0.0) .sigma(6.0) .beta(1.0) .score_sigma(2.0) .gamma(0.05) .convergence(ConvergenceOptions { max_iter: trueskill_tt::ITERATIONS, epsilon: 1e-8, alpha: 1.0, }) .build(); let mut k = 0usize; for t in 0..slices { for _ in 0..duels { k += 1; h.event(t) .team([format!("p{}", k % competitors)]) .team([format!("p{}", (k + 37) % competitors)]) .scores([ (k as f64 * 0.3).sin().abs() * 20.0, (k as f64 * 0.3).cos().abs() * 20.0, ]) .commit() .expect("ingests"); } } h.converge().expect("converges"); h } /// Symbolic Cholesky by row-merge: returns (nnz(L), flops). /// /// Fill-in is simulated directly — for each column, the set of rows below the /// diagonal that are nonzero — which is exact and easily checked, at the cost /// of being O(n * nnz(L)) rather than the linear elimination-tree method. fn symbolic(n: usize, adj: &[HashSet], perm_of: &[usize]) -> (usize, f64) { // `perm_of[old] = new`. Build the permuted lower-triangle pattern. let mut cols: Vec> = vec![HashSet::new(); n]; for (old, nbrs) in adj.iter().enumerate() { let i = perm_of[old]; for &old_j in nbrs { let j = perm_of[old_j]; if j < i { cols[j].insert(i); } } } let mut nnz = 0usize; let mut flops = 0.0f64; for j in 0..n { // Column j's pattern is final once every earlier column has merged in. let rows: Vec = cols[j].iter().copied().collect(); let c = rows.len(); nnz += c + 1; // below-diagonal entries plus the diagonal // Cholesky work for this column: one outer product over its pattern. flops += (c as f64 + 1.0) * (c as f64 + 1.0); // Fill-in: every pair in column j becomes an edge in the remaining graph. for (a_idx, &a) in rows.iter().enumerate() { for &b in &rows[a_idx + 1..] { let (lo, hi) = if a < b { (a, b) } else { (b, a) }; cols[lo].insert(hi); } } } (nnz, flops) } #[test] #[ignore = "measurement, run explicitly"] fn what_sparsity_would_buy() { for (slices, duels, competitors) in [(30, 8, 100), (76, 13, 200)] { let h = fitted(slices, duels, competitors); let (n, pattern) = h.joint_pattern_for_measurement(); let nnz_a: usize = pattern.iter().map(HashSet::len).sum::() + n; let dense_flops = (n as f64).powi(3) / 3.0; let natural: Vec = (0..n).collect(); let (nnz_nat, flops_nat) = symbolic(n, &pattern, &natural); // AMD returns `perm[new] = old`; invert it. let (col_ptr, row_idx) = csc(n, &pattern); let p = feral_amd::amd_order( &feral_amd::CscPattern::new(n, &col_ptr, &row_idx).expect("valid pattern"), ) .expect("amd"); let mut perm_of = vec![0usize; n]; for (new, &old) in p.iter().enumerate() { perm_of[old as usize] = new; } let (nnz_amd, flops_amd) = symbolic(n, &pattern, &perm_of); println!( "\n=== {slices} slices x {duels} duels, {competitors} competitors ===\n\ n = {n}\n\ nnz(A) = {nnz_a} ({:.4}% dense)\n\ dense flops = {:.3e}\n\ nnz(L) natural = {nnz_nat} flops = {:.3e} ({:.1}x vs dense)\n\ nnz(L) AMD = {nnz_amd} flops = {:.3e} ({:.1}x vs dense)", 100.0 * nnz_a as f64 / (n * n) as f64, dense_flops, flops_nat, dense_flops / flops_nat, flops_amd, dense_flops / flops_amd, ); } } /// Full symmetric pattern to CSC, as `feral-amd` wants it. fn csc(n: usize, adj: &[HashSet]) -> (Vec, Vec) { let mut col_ptr = Vec::with_capacity(n + 1); let mut row_idx = Vec::new(); col_ptr.push(0i32); for (j, nbrs) in adj.iter().enumerate() { let mut rows: Vec = nbrs.iter().map(|&i| i as i32).collect(); rows.push(j as i32); rows.sort_unstable(); rows.dedup(); row_idx.extend_from_slice(&rows); col_ptr.push(row_idx.len() as i32); } (col_ptr, row_idx) } /// End-to-end factorisation time at the scale #52 was opened about. #[test] #[ignore = "measurement, run explicitly"] fn factorisation_time_at_scale() { use std::time::Instant; for (slices, duels, competitors) in [(30, 8, 100), (76, 13, 200), (150, 26, 400)] { let h = fitted(slices, duels, competitors); let (n, _) = h.joint_pattern_for_measurement(); // Warm, then time. let _ = h.joint().expect("scored history"); let t = Instant::now(); let joint = h.joint().expect("scored history"); let factor = t.elapsed(); let a = "p0".to_string(); let b = "p1".to_string(); let t = Instant::now(); let _ = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).expect("known"); let query = t.elapsed(); println!( "n = {n:5} factorise = {factor:>12?} query = {query:>10?} \ (dense was O(n^3): {:.3e} flops)", (n as f64).powi(3) / 3.0 ); } }