Skip to main content

rust_igraph/algorithms/constructors/
weighted_adjacency.rs

1//! Dense weighted adjacency-matrix constructor (ALGO-CN-030).
2//!
3//! Counterpart of `igraph_weighted_adjacency()` in
4//! `references/igraph/src/constructors/adjacency.c:785-878`.
5//!
6//! Builds a [`Graph`] and a parallel weight vector from a square
7//! `n × n` real-valued matrix. Differences from the integer
8//! [`adjacency`](crate::adjacency) variant:
9//!
10//! * Entries are **edge weights**, not multiplicities: every non-zero
11//!   cell becomes exactly **one** edge whose weight equals the cell
12//!   value (the integer variant duplicates edges by cell value).
13//! * Negative weights are **allowed**.
14//! * `NaN` propagation rules apply to [`AdjacencyMode::Max`] and
15//!   [`AdjacencyMode::Min`]; [`AdjacencyMode::Undirected`] uses a
16//!   NaN-tolerant symmetry check (`NaN == NaN` on the same diagonal
17//!   reflection is treated as symmetric, matching upstream's
18//!   `! (isnan(M1) && isnan(M2))` predicate).
19//!
20//! The shape of the resulting graph (directed / undirected, which
21//! triangle drives the edge count, how the diagonal becomes self-loops)
22//! is controlled by the two enums that [`adjacency`](crate::adjacency)
23//! exposes — [`AdjacencyMode`] and [`LoopsMode`]. They are re-exported
24//! here for convenience.
25//!
26//! For consistency with upstream igraph the `Twice` request is silently
27//! collapsed to `Once` for the `Directed`, `Upper` and `Lower` modes —
28//! the matrix only stores one copy of each loop in those layouts (see
29//! the issue cited in the upstream source, igraph#2501).
30//!
31//! For [`LoopsMode::NoLoops`], the diagonal contribution is zeroed and
32//! consequently skipped by the post-adjust `!= 0.0` filter so no
33//! self-loops are emitted.
34//!
35//! Matrix layout: `&[&[f64]]` — a slice of equal-length rows in
36//! row-major form. Every row must have the same length as the outer
37//! slice; ragged input is rejected. A `0 × 0` matrix produces an empty
38//! graph and an empty weight vector (matches the C semantics for an
39//! `IGRAPH_MATRIX_NULL` of shape `0 × 0`).
40//!
41//! Time complexity: `O(|V|² + |E|)`.
42
43// The double-loop traversals over the square matrix are clearer with
44// explicit `i, j` indices than with iterator+enumerate chains, and the
45// `i as VertexId` casts are safe because the caller validates
46// `nrow ≤ u32::MAX` before any index is produced.
47#![allow(clippy::cast_possible_truncation, clippy::needless_range_loop)]
48
49use crate::algorithms::constructors::adjacency::{AdjacencyMode, LoopsMode};
50use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
51
52/// Build a graph and a per-edge weight vector from a dense real-valued
53/// adjacency matrix.
54///
55/// Matches `igraph_weighted_adjacency()` semantics exactly. The matrix
56/// is passed as `&[&[f64]]` — outer slice indexed by row, inner slice
57/// by column. The matrix must be square (each row length equal to the
58/// outer length). Negative entries are allowed.
59///
60/// Returns `(graph, weights)` where `weights[k]` is the weight of the
61/// `k`-th emitted edge (`graph.edge(k)`).
62///
63/// # Errors
64///
65/// * [`IgraphError::InvalidArgument`] — the matrix is non-square (a
66///   row's length differs from the row count), or
67///   ([`AdjacencyMode::Undirected`] only) is not NaN-tolerantly
68///   symmetric, or the vertex count exceeds [`u32::MAX`].
69///
70/// # Examples
71///
72/// ```
73/// use rust_igraph::{weighted_adjacency, AdjacencyMode, LoopsMode};
74///
75/// // Directed K₃ with no loops, weights = 0.5 on every arc.
76/// let m: &[&[f64]] = &[&[0.0, 0.5, 0.5], &[0.5, 0.0, 0.5], &[0.5, 0.5, 0.0]];
77/// let (g, w) = weighted_adjacency(m, AdjacencyMode::Directed, LoopsMode::NoLoops).unwrap();
78/// assert_eq!(g.vcount(), 3);
79/// assert_eq!(g.ecount(), 6);
80/// assert!(g.is_directed());
81/// assert_eq!(w.len(), 6);
82/// for &x in &w {
83///     assert!((x - 0.5).abs() < 1e-12);
84/// }
85/// ```
86pub fn weighted_adjacency(
87    matrix: &[&[f64]],
88    mode: AdjacencyMode,
89    loops: LoopsMode,
90) -> IgraphResult<(Graph, Vec<f64>)> {
91    let nrow = matrix.len();
92
93    // Square check — every row must have exactly `nrow` columns.
94    if matrix.iter().any(|row| row.len() != nrow) {
95        return Err(IgraphError::InvalidArgument(
96            "weighted_adjacency: matrix must be square (every row has the same length as the row count)"
97                .into(),
98        ));
99    }
100
101    if nrow == 0 {
102        let directed = matches!(mode, AdjacencyMode::Directed);
103        return Ok((Graph::new(0, directed)?, Vec::new()));
104    }
105
106    let no_of_nodes = u32::try_from(nrow).map_err(|_| {
107        IgraphError::InvalidArgument("weighted_adjacency: vertex count exceeds u32::MAX".into())
108    })?;
109
110    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
111    let mut weights: Vec<f64> = Vec::new();
112
113    match mode {
114        AdjacencyMode::Directed => {
115            emit_directed(matrix, loops, &mut edges, &mut weights);
116        }
117        AdjacencyMode::Max => {
118            emit_max(matrix, loops, &mut edges, &mut weights);
119        }
120        AdjacencyMode::Undirected => {
121            emit_undirected(matrix, loops, &mut edges, &mut weights)?;
122        }
123        AdjacencyMode::Upper => {
124            emit_upper(matrix, loops, &mut edges, &mut weights);
125        }
126        AdjacencyMode::Lower => {
127            emit_lower(matrix, loops, &mut edges, &mut weights);
128        }
129        AdjacencyMode::Min => {
130            emit_min(matrix, loops, &mut edges, &mut weights);
131        }
132        AdjacencyMode::Plus => {
133            emit_plus(matrix, loops, &mut edges, &mut weights);
134        }
135    }
136
137    let directed = matches!(mode, AdjacencyMode::Directed);
138    let mut graph = Graph::new(no_of_nodes, directed)?;
139    graph.add_edges(edges)?;
140    Ok((graph, weights))
141}
142
143/// Per-mode `Twice → Once` collapse for the modes that store the loop
144/// only once in the matrix (Directed, Upper, Lower).
145fn effective_loops(mode_collapses_twice: bool, loops: LoopsMode) -> LoopsMode {
146    if mode_collapses_twice && matches!(loops, LoopsMode::Twice) {
147        LoopsMode::Once
148    } else {
149        loops
150    }
151}
152
153/// Diagonal entry → emitted self-loop weight.
154///
155/// Mirrors `igraph_i_adjust_loop_edge_weight()`:
156/// * `NoLoops` → 0.0  (caller filters out 0.0)
157/// * `Twice`   → `weight / 2`
158/// * `Once`    → `weight`
159fn adjust_loop_weight(weight: f64, loops: LoopsMode) -> f64 {
160    match loops {
161        LoopsMode::NoLoops => 0.0,
162        LoopsMode::Twice => weight / 2.0,
163        LoopsMode::Once => weight,
164    }
165}
166
167fn emit_directed(
168    matrix: &[&[f64]],
169    loops: LoopsMode,
170    edges: &mut Vec<(VertexId, VertexId)>,
171    weights: &mut Vec<f64>,
172) {
173    let n = matrix.len();
174    let loops = effective_loops(true, loops);
175
176    // Upstream walks j (column) outer, i (row) inner; we mirror so the
177    // emitted edge order matches the .out fixtures.
178    for j in 0..n {
179        for i in 0..n {
180            let mut m = matrix[i][j];
181            if m == 0.0 {
182                continue;
183            }
184            if i == j {
185                m = adjust_loop_weight(m, loops);
186                if m == 0.0 {
187                    continue;
188                }
189            }
190            edges.push((i as VertexId, j as VertexId));
191            weights.push(m);
192        }
193    }
194}
195
196fn emit_plus(
197    matrix: &[&[f64]],
198    loops: LoopsMode,
199    edges: &mut Vec<(VertexId, VertexId)>,
200    weights: &mut Vec<f64>,
201) {
202    let n = matrix.len();
203    for i in 0..n {
204        if !matches!(loops, LoopsMode::NoLoops) {
205            let m = matrix[i][i];
206            if m != 0.0 {
207                let adj = adjust_loop_weight(m, loops);
208                edges.push((i as VertexId, i as VertexId));
209                weights.push(adj);
210            }
211        }
212        for j in (i + 1)..n {
213            let m = matrix[i][j] + matrix[j][i];
214            if m != 0.0 {
215                edges.push((i as VertexId, j as VertexId));
216                weights.push(m);
217            }
218        }
219    }
220}
221
222fn emit_max(
223    matrix: &[&[f64]],
224    loops: LoopsMode,
225    edges: &mut Vec<(VertexId, VertexId)>,
226    weights: &mut Vec<f64>,
227) {
228    let n = matrix.len();
229    for i in 0..n {
230        if !matches!(loops, LoopsMode::NoLoops) {
231            let m1 = matrix[i][i];
232            if m1 != 0.0 {
233                let adj = adjust_loop_weight(m1, loops);
234                edges.push((i as VertexId, i as VertexId));
235                weights.push(adj);
236            }
237        }
238        for j in (i + 1)..n {
239            let mut m1 = matrix[i][j];
240            let m2 = matrix[j][i];
241            // Upstream rule: `if (M1 < M2 || isnan(M2)) M1 = M2;`
242            // i.e. NaN on the j,i side propagates and wins.
243            if m1 < m2 || m2.is_nan() {
244                m1 = m2;
245            }
246            if m1 != 0.0 {
247                edges.push((i as VertexId, j as VertexId));
248                weights.push(m1);
249            }
250        }
251    }
252}
253
254fn emit_min(
255    matrix: &[&[f64]],
256    loops: LoopsMode,
257    edges: &mut Vec<(VertexId, VertexId)>,
258    weights: &mut Vec<f64>,
259) {
260    let n = matrix.len();
261    for i in 0..n {
262        if !matches!(loops, LoopsMode::NoLoops) {
263            let m1 = matrix[i][i];
264            if m1 != 0.0 {
265                let adj = adjust_loop_weight(m1, loops);
266                edges.push((i as VertexId, i as VertexId));
267                weights.push(adj);
268            }
269        }
270        for j in (i + 1)..n {
271            let mut m1 = matrix[i][j];
272            let m2 = matrix[j][i];
273            // Upstream rule: `if (M1 > M2 || isnan(M2)) M1 = M2;`
274            // i.e. NaN on the j,i side propagates and wins.
275            if m1 > m2 || m2.is_nan() {
276                m1 = m2;
277            }
278            if m1 != 0.0 {
279                edges.push((i as VertexId, j as VertexId));
280                weights.push(m1);
281            }
282        }
283    }
284}
285
286fn emit_undirected(
287    matrix: &[&[f64]],
288    loops: LoopsMode,
289    edges: &mut Vec<(VertexId, VertexId)>,
290    weights: &mut Vec<f64>,
291) -> IgraphResult<()> {
292    let n = matrix.len();
293    // Upstream walks `for i in 0..n; for j in 0..i` — lower triangle,
294    // row-major. NaN-tolerant symmetry: a pair where both sides are
295    // NaN counts as symmetric (NaN == NaN is false, so a plain
296    // `matrix[i][j] != matrix[j][i]` would over-reject).
297    for i in 0..n {
298        if !matches!(loops, LoopsMode::NoLoops) {
299            let m = matrix[i][i];
300            if m != 0.0 {
301                let adj = adjust_loop_weight(m, loops);
302                edges.push((i as VertexId, i as VertexId));
303                weights.push(adj);
304            }
305        }
306        for j in 0..i {
307            let m1 = matrix[i][j];
308            let m2 = matrix[j][i];
309            // Bit-equality match (NaN-aware) matches upstream C's
310            // `M1 != M2` semantics: NaN == NaN counts as symmetric.
311            #[allow(clippy::float_cmp)]
312            let asymmetric = m1 != m2 && !(m1.is_nan() && m2.is_nan());
313            if asymmetric {
314                return Err(IgraphError::InvalidArgument(
315                    "weighted_adjacency: matrix must be symmetric for AdjacencyMode::Undirected"
316                        .into(),
317                ));
318            }
319            if m1 != 0.0 {
320                // Note upstream emits (i, j) — i is the outer loop, so
321                // we push the larger index first (i.e. lower triangle
322                // emission order).
323                edges.push((i as VertexId, j as VertexId));
324                weights.push(m1);
325            }
326        }
327    }
328    Ok(())
329}
330
331fn emit_upper(
332    matrix: &[&[f64]],
333    loops: LoopsMode,
334    edges: &mut Vec<(VertexId, VertexId)>,
335    weights: &mut Vec<f64>,
336) {
337    let n = matrix.len();
338    let loops = effective_loops(true, loops);
339    // Upstream walks j outer, i < j inner, then the diagonal at the
340    // end of each column.
341    for j in 0..n {
342        for i in 0..j {
343            let m = matrix[i][j];
344            if m != 0.0 {
345                edges.push((i as VertexId, j as VertexId));
346                weights.push(m);
347            }
348        }
349        if !matches!(loops, LoopsMode::NoLoops) {
350            let m = matrix[j][j];
351            if m != 0.0 {
352                let adj = adjust_loop_weight(m, loops);
353                edges.push((j as VertexId, j as VertexId));
354                weights.push(adj);
355            }
356        }
357    }
358}
359
360fn emit_lower(
361    matrix: &[&[f64]],
362    loops: LoopsMode,
363    edges: &mut Vec<(VertexId, VertexId)>,
364    weights: &mut Vec<f64>,
365) {
366    let n = matrix.len();
367    let loops = effective_loops(true, loops);
368    // Upstream walks j outer; diagonal first then i > j inner.
369    for j in 0..n {
370        if !matches!(loops, LoopsMode::NoLoops) {
371            let m = matrix[j][j];
372            if m != 0.0 {
373                let adj = adjust_loop_weight(m, loops);
374                edges.push((j as VertexId, j as VertexId));
375                weights.push(adj);
376            }
377        }
378        for i in (j + 1)..n {
379            let m = matrix[i][j];
380            if m != 0.0 {
381                edges.push((i as VertexId, j as VertexId));
382                weights.push(m);
383            }
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn approx_eq(a: f64, b: f64) -> bool {
393        (a - b).abs() < 1e-12
394    }
395
396    fn edges_in_order(g: &Graph) -> Vec<(VertexId, VertexId)> {
397        let m = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
398        (0..m)
399            .map(|e| g.edge(e).expect("edge id in bounds"))
400            .collect()
401    }
402
403    /// 3x3 sample used to exercise asymmetric modes.
404    const M3: &[&[f64]] = &[&[2.0, 0.5, 0.0], &[1.5, 0.0, 2.0], &[0.0, 2.5, 3.0]];
405
406    /// 3x3 symmetric sample for Undirected.
407    const M3_SYM: &[&[f64]] = &[&[2.0, 0.5, 0.0], &[0.5, 0.0, 2.0], &[0.0, 2.0, 3.0]];
408
409    #[test]
410    fn empty_matrix_yields_empty_graph() {
411        let m: &[&[f64]] = &[];
412        let (g, w) = weighted_adjacency(m, AdjacencyMode::Directed, LoopsMode::Once).unwrap();
413        assert_eq!(g.vcount(), 0);
414        assert_eq!(g.ecount(), 0);
415        assert!(g.is_directed());
416        assert!(w.is_empty());
417
418        let (g2, w2) = weighted_adjacency(m, AdjacencyMode::Undirected, LoopsMode::Once).unwrap();
419        assert_eq!(g2.vcount(), 0);
420        assert!(!g2.is_directed());
421        assert!(w2.is_empty());
422    }
423
424    #[test]
425    fn one_by_one_directed_no_loops() {
426        let m: &[&[f64]] = &[&[1.25]];
427        let (g, w) = weighted_adjacency(m, AdjacencyMode::Directed, LoopsMode::NoLoops).unwrap();
428        assert_eq!(g.vcount(), 1);
429        assert_eq!(g.ecount(), 0);
430        assert!(w.is_empty());
431    }
432
433    #[test]
434    fn one_by_one_directed_loops_once() {
435        let m: &[&[f64]] = &[&[1.25]];
436        let (g, w) = weighted_adjacency(m, AdjacencyMode::Directed, LoopsMode::Once).unwrap();
437        assert_eq!(edges_in_order(&g), vec![(0, 0)]);
438        assert_eq!(w.len(), 1);
439        assert!(approx_eq(w[0], 1.25));
440    }
441
442    #[test]
443    fn one_by_one_directed_loops_twice_collapses() {
444        // Twice → Once for Directed: weight passes through unhalved.
445        let m: &[&[f64]] = &[&[1.25]];
446        let (g, w) = weighted_adjacency(m, AdjacencyMode::Directed, LoopsMode::Twice).unwrap();
447        assert_eq!(edges_in_order(&g), vec![(0, 0)]);
448        assert!(approx_eq(w[0], 1.25));
449    }
450
451    #[test]
452    fn directed_no_loops_emits_in_column_major_order() {
453        let (g, w) = weighted_adjacency(M3, AdjacencyMode::Directed, LoopsMode::NoLoops).unwrap();
454        assert_eq!(g.vcount(), 3);
455        // M3 has four non-zero off-diagonal entries:
456        // (1,0)=1.5, (0,1)=0.5, (2,1)=2.5, (1,2)=2.0. Column-major
457        // walk: j=0 -> i=1 (1.5); j=1 -> i=0 (0.5), i=2 (2.5); j=2 ->
458        // i=1 (2.0). Diagonal entries (2.0 and 3.0) skipped by
459        // NoLoops.
460        assert_eq!(edges_in_order(&g), vec![(1, 0), (0, 1), (2, 1), (1, 2)]);
461        assert_eq!(w.len(), 4);
462        assert!(approx_eq(w[0], 1.5));
463        assert!(approx_eq(w[1], 0.5));
464        assert!(approx_eq(w[2], 2.5));
465        assert!(approx_eq(w[3], 2.0));
466    }
467
468    #[test]
469    fn directed_loops_once_includes_diagonal() {
470        let (g, w) = weighted_adjacency(M3, AdjacencyMode::Directed, LoopsMode::Once).unwrap();
471        // Column-major: j=0 -> (0,0)=2.0 (loop, adjusted from 2 → 2),
472        // (1,0)=1.5; j=1 -> (0,1)=0.5, (2,1)=2.5; j=2 -> (1,2)=2.0,
473        // (2,2)=3.0. M[1][1]=0 stays skipped.
474        assert_eq!(
475            edges_in_order(&g),
476            vec![(0, 0), (1, 0), (0, 1), (2, 1), (1, 2), (2, 2)]
477        );
478        assert_eq!(w, vec![2.0, 1.5, 0.5, 2.5, 2.0, 3.0]);
479    }
480
481    #[test]
482    fn undirected_lower_triangle_walk() {
483        let (g, w) =
484            weighted_adjacency(M3_SYM, AdjacencyMode::Undirected, LoopsMode::Once).unwrap();
485        // Upstream walks i outer, j < i inner. Diagonal first per i.
486        // i=0: diag 2.0 -> (0,0)=2.0.
487        // i=1: diag 0 (skip); j=0 -> M[1][0]=0.5 -> push (1,0)=0.5.
488        // i=2: diag 3.0 -> (2,2)=3.0; j=0 -> 0 (skip); j=1 -> 2.0 -> (2,1)=2.0.
489        // Graph canonicalises undirected edges so `from <= to`, hence
490        // (1,0) → (0,1) and (2,1) → (1,2) on read-back.
491        assert_eq!(edges_in_order(&g), vec![(0, 0), (0, 1), (2, 2), (1, 2)]);
492        assert_eq!(w, vec![2.0, 0.5, 3.0, 2.0]);
493        assert!(!g.is_directed());
494    }
495
496    #[test]
497    fn undirected_rejects_asymmetric() {
498        let res = weighted_adjacency(M3, AdjacencyMode::Undirected, LoopsMode::NoLoops);
499        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
500    }
501
502    #[test]
503    fn undirected_accepts_nan_symmetric() {
504        // Both (i, j) and (j, i) are NaN — counts as symmetric, the
505        // emit path skips them because `NaN != 0.0` is true so the
506        // edge would be pushed BUT the upstream rule emits the
507        // (lower-index, upper-index) pair with weight = NaN. We test
508        // that the build does not error and that the weights vector
509        // contains the NaN.
510        let m: &[&[f64]] = &[
511            &[0.0, f64::NAN, 0.0],
512            &[f64::NAN, 0.0, 1.0],
513            &[0.0, 1.0, 0.0],
514        ];
515        let (g, w) = weighted_adjacency(m, AdjacencyMode::Undirected, LoopsMode::NoLoops).unwrap();
516        assert_eq!(g.ecount(), 2);
517        assert!(w[0].is_nan());
518        assert!(approx_eq(w[1], 1.0));
519    }
520
521    #[test]
522    fn undirected_rejects_one_sided_nan() {
523        // One side NaN, other side numeric — NOT symmetric.
524        let m: &[&[f64]] = &[&[0.0, f64::NAN, 0.0], &[1.0, 0.0, 0.0], &[0.0, 0.0, 0.0]];
525        let res = weighted_adjacency(m, AdjacencyMode::Undirected, LoopsMode::NoLoops);
526        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
527    }
528
529    #[test]
530    fn max_picks_larger_of_two_sides() {
531        let (g, w) = weighted_adjacency(M3, AdjacencyMode::Max, LoopsMode::NoLoops).unwrap();
532        // For each (i,j) with i < j: M = max(M[i][j], M[j][i]).
533        // (0,1): max(0.5, 1.5) = 1.5
534        // (0,2): max(0.0, 0.0) = 0.0 -> skip
535        // (1,2): max(2.0, 2.5) = 2.5
536        assert_eq!(edges_in_order(&g), vec![(0, 1), (1, 2)]);
537        assert_eq!(w, vec![1.5, 2.5]);
538    }
539
540    #[test]
541    fn max_nan_propagates() {
542        // (j, i) is NaN -> M2.is_nan() triggers M1 = M2 = NaN.
543        let m: &[&[f64]] = &[&[0.0, 1.0], &[f64::NAN, 0.0]];
544        let (_, w) = weighted_adjacency(m, AdjacencyMode::Max, LoopsMode::NoLoops).unwrap();
545        // M1 = 1.0, M2 = NaN -> M1 < M2 is false but M2.is_nan() is
546        // true -> M1 = NaN; then `M1 != 0.0` (NaN != 0.0 is true) so
547        // we emit.
548        assert_eq!(w.len(), 1);
549        assert!(w[0].is_nan());
550    }
551
552    #[test]
553    fn min_picks_smaller_of_two_sides() {
554        let (g, w) = weighted_adjacency(M3, AdjacencyMode::Min, LoopsMode::NoLoops).unwrap();
555        // (0,1): min(0.5, 1.5) = 0.5
556        // (0,2): min(0.0, 0.0) = 0.0 -> skip
557        // (1,2): min(2.0, 2.5) = 2.0
558        assert_eq!(edges_in_order(&g), vec![(0, 1), (1, 2)]);
559        assert_eq!(w, vec![0.5, 2.0]);
560    }
561
562    #[test]
563    fn min_nan_propagates() {
564        let m: &[&[f64]] = &[&[0.0, 1.0], &[f64::NAN, 0.0]];
565        let (_, w) = weighted_adjacency(m, AdjacencyMode::Min, LoopsMode::NoLoops).unwrap();
566        assert_eq!(w.len(), 1);
567        assert!(w[0].is_nan());
568    }
569
570    #[test]
571    fn plus_sums_both_sides() {
572        let (g, w) = weighted_adjacency(M3, AdjacencyMode::Plus, LoopsMode::NoLoops).unwrap();
573        // (0,1): 0.5 + 1.5 = 2.0
574        // (0,2): 0.0 + 0.0 = 0.0 -> skip
575        // (1,2): 2.0 + 2.5 = 4.5
576        assert_eq!(edges_in_order(&g), vec![(0, 1), (1, 2)]);
577        assert_eq!(w, vec![2.0, 4.5]);
578    }
579
580    #[test]
581    fn plus_loops_once_includes_diagonal() {
582        let (_, w) = weighted_adjacency(M3, AdjacencyMode::Plus, LoopsMode::Once).unwrap();
583        // i=0: diag 2.0 (Once -> 2.0); off (0,1)=2.0, (0,2)=0 skip.
584        // i=1: diag 0 skip; off (1,2)=4.5.
585        // i=2: diag 3.0; no more off-diagonal.
586        assert_eq!(w, vec![2.0, 2.0, 4.5, 3.0]);
587    }
588
589    #[test]
590    fn plus_loops_twice_halves_diagonal() {
591        let (_, w) = weighted_adjacency(M3, AdjacencyMode::Plus, LoopsMode::Twice).unwrap();
592        // Diagonals: 2.0 -> 1.0 (halved), 3.0 -> 1.5 (halved).
593        assert!(approx_eq(w[0], 1.0));
594        assert!(approx_eq(w[3], 1.5));
595    }
596
597    #[test]
598    fn upper_column_major_walk() {
599        let (g, w) = weighted_adjacency(M3, AdjacencyMode::Upper, LoopsMode::NoLoops).unwrap();
600        // j=0: no i<0 inner; diag NoLoops skip.
601        // j=1: i=0 -> M[0][1]=0.5; diag 0 skip.
602        // j=2: i=0 -> M[0][2]=0 skip; i=1 -> M[1][2]=2.0; diag NoLoops skip.
603        assert_eq!(edges_in_order(&g), vec![(0, 1), (1, 2)]);
604        assert_eq!(w, vec![0.5, 2.0]);
605    }
606
607    #[test]
608    fn upper_loops_twice_collapses_to_once() {
609        // Upper + Twice -> Once: diagonal NOT halved.
610        let (_, w) = weighted_adjacency(M3, AdjacencyMode::Upper, LoopsMode::Twice).unwrap();
611        // Same emission order as Once. Diagonals 2.0 and 3.0 emitted
612        // un-halved.
613        // j=0: diag 2.0
614        // j=1: i=0 -> 0.5; diag 0 skip
615        // j=2: i=0 -> 0 skip; i=1 -> 2.0; diag 3.0
616        assert_eq!(w, vec![2.0, 0.5, 2.0, 3.0]);
617    }
618
619    #[test]
620    fn lower_column_major_walk() {
621        let (g, w) = weighted_adjacency(M3, AdjacencyMode::Lower, LoopsMode::NoLoops).unwrap();
622        // j=0: diag skip; i=1 -> M[1][0]=1.5; i=2 -> M[2][0]=0 skip.
623        // j=1: diag skip; i=2 -> M[2][1]=2.5.
624        // j=2: diag skip; no i>2.
625        // Lower yields an undirected graph, so Graph canonicalises
626        // each (i, j) with i > j to (j, i) on read-back.
627        assert_eq!(edges_in_order(&g), vec![(0, 1), (1, 2)]);
628        assert_eq!(w, vec![1.5, 2.5]);
629    }
630
631    #[test]
632    fn lower_loops_twice_collapses_to_once() {
633        let (_, w) = weighted_adjacency(M3, AdjacencyMode::Lower, LoopsMode::Twice).unwrap();
634        // Diagonals 2.0 and 3.0 emitted un-halved (Twice -> Once
635        // collapse for Lower).
636        // j=0: diag 2.0; i=1 -> 1.5; i=2 -> 0 skip.
637        // j=1: diag 0 skip; i=2 -> 2.5.
638        // j=2: diag 3.0; no more inner.
639        assert_eq!(w, vec![2.0, 1.5, 2.5, 3.0]);
640    }
641
642    #[test]
643    fn negative_weights_pass_through() {
644        let m: &[&[f64]] = &[&[0.0, -1.0], &[-2.0, 0.0]];
645        let (g, w) = weighted_adjacency(m, AdjacencyMode::Directed, LoopsMode::NoLoops).unwrap();
646        assert_eq!(g.ecount(), 2);
647        // Column-major: j=0 -> (1,0)=-2.0; j=1 -> (0,1)=-1.0.
648        assert_eq!(w, vec![-2.0, -1.0]);
649    }
650
651    #[test]
652    fn ragged_matrix_rejected() {
653        let m: &[&[f64]] = &[&[1.0, 2.0], &[3.0]];
654        let res = weighted_adjacency(m, AdjacencyMode::Directed, LoopsMode::NoLoops);
655        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
656    }
657
658    #[test]
659    fn weights_length_equals_ecount_always() {
660        for mode in [
661            AdjacencyMode::Directed,
662            AdjacencyMode::Max,
663            AdjacencyMode::Min,
664            AdjacencyMode::Plus,
665            AdjacencyMode::Upper,
666            AdjacencyMode::Lower,
667        ] {
668            for loops in [LoopsMode::NoLoops, LoopsMode::Once, LoopsMode::Twice] {
669                let (g, w) = weighted_adjacency(M3, mode, loops).unwrap();
670                assert_eq!(g.ecount(), w.len(), "mode={mode:?}, loops={loops:?}");
671            }
672        }
673    }
674}
675
676#[cfg(all(test, feature = "proptest-harness"))]
677mod proptests {
678    use super::*;
679    use proptest::prelude::*;
680
681    fn arb_dense_matrix(max_n: usize) -> impl Strategy<Value = Vec<Vec<f64>>> {
682        (0..=max_n).prop_flat_map(|n| {
683            // Each cell in [-2.0, 2.0], discrete steps to keep
684            // arithmetic exact.
685            let cell = (-4..=4i32).prop_map(|x| f64::from(x) * 0.5);
686            prop::collection::vec(prop::collection::vec(cell, n), n)
687        })
688    }
689
690    fn rows_view(matrix: &[Vec<f64>]) -> Vec<&[f64]> {
691        matrix.iter().map(Vec::as_slice).collect()
692    }
693
694    proptest! {
695        #[test]
696        fn vcount_equals_matrix_side(matrix in arb_dense_matrix(6)) {
697            let rows = rows_view(&matrix);
698            for mode in [
699                AdjacencyMode::Directed,
700                AdjacencyMode::Max,
701                AdjacencyMode::Min,
702                AdjacencyMode::Plus,
703                AdjacencyMode::Upper,
704                AdjacencyMode::Lower,
705            ] {
706                for loops in [LoopsMode::NoLoops, LoopsMode::Once, LoopsMode::Twice] {
707                    let (g, _) = weighted_adjacency(&rows, mode, loops).unwrap();
708                    prop_assert_eq!(g.vcount() as usize, matrix.len());
709                }
710            }
711        }
712
713        #[test]
714        fn weights_length_equals_ecount(matrix in arb_dense_matrix(6)) {
715            let rows = rows_view(&matrix);
716            for mode in [
717                AdjacencyMode::Directed,
718                AdjacencyMode::Max,
719                AdjacencyMode::Min,
720                AdjacencyMode::Plus,
721                AdjacencyMode::Upper,
722                AdjacencyMode::Lower,
723            ] {
724                for loops in [LoopsMode::NoLoops, LoopsMode::Once, LoopsMode::Twice] {
725                    let (g, w) = weighted_adjacency(&rows, mode, loops).unwrap();
726                    prop_assert_eq!(g.ecount(), w.len());
727                }
728            }
729        }
730
731        #[test]
732        fn upper_lower_twice_equals_once(matrix in arb_dense_matrix(5)) {
733            // Twice collapses to Once for Upper / Lower / Directed
734            // -> same edge multiset & same weights.
735            let rows = rows_view(&matrix);
736            for mode in [
737                AdjacencyMode::Upper,
738                AdjacencyMode::Lower,
739                AdjacencyMode::Directed,
740            ] {
741                let (g_once, w_once) =
742                    weighted_adjacency(&rows, mode, LoopsMode::Once).unwrap();
743                let (g_twice, w_twice) =
744                    weighted_adjacency(&rows, mode, LoopsMode::Twice).unwrap();
745                prop_assert_eq!(g_once.ecount(), g_twice.ecount());
746                prop_assert_eq!(w_once, w_twice);
747            }
748        }
749
750        #[test]
751        fn plus_twice_halves_diagonal(matrix in arb_dense_matrix(5)) {
752            // Plus is a non-collapsing mode -> Twice halves the
753            // diagonal weights.
754            let rows = rows_view(&matrix);
755            let (g_once, w_once) =
756                weighted_adjacency(&rows, AdjacencyMode::Plus, LoopsMode::Once).unwrap();
757            let (g_twice, w_twice) =
758                weighted_adjacency(&rows, AdjacencyMode::Plus, LoopsMode::Twice).unwrap();
759            prop_assert_eq!(g_once.ecount(), g_twice.ecount());
760            // Walk emitted edges in the same order; loop edges should
761            // be halved, off-diagonal weights identical.
762            for k in 0..g_once.ecount() {
763                let (u, v) = g_once.edge(u32::try_from(k).unwrap()).unwrap();
764                if u == v {
765                    let diff = w_once[k] - 2.0 * w_twice[k];
766                    prop_assert!(diff.abs() < 1e-9);
767                } else {
768                    let diff = w_once[k] - w_twice[k];
769                    prop_assert!(diff.abs() < 1e-12);
770                }
771            }
772        }
773
774        #[test]
775        fn directed_no_loops_excludes_diagonal(matrix in arb_dense_matrix(5)) {
776            let rows = rows_view(&matrix);
777            let (g, _) =
778                weighted_adjacency(&rows, AdjacencyMode::Directed, LoopsMode::NoLoops).unwrap();
779            for k in 0..g.ecount() {
780                let (u, v) = g.edge(u32::try_from(k).unwrap()).unwrap();
781                prop_assert!(u != v, "self-loop emitted under NoLoops");
782            }
783        }
784
785        #[test]
786        fn max_min_bounded_by_off_diagonal_pairs(matrix in arb_dense_matrix(5)) {
787            // The only universally true MAX/MIN invariant for signed
788            // weights is the off-diagonal cap: each pair contributes
789            // at most one edge under NoLoops, so ecount <= n*(n-1)/2.
790            // (Element-wise domination breaks with mixed signs: e.g.
791            // pair (-0.5, 0) -> MAX=0 skipped, MIN=-0.5 emitted, so
792            // MIN can emit more edges than MAX.)
793            let rows = rows_view(&matrix);
794            let n = matrix.len();
795            let cap = n * n.saturating_sub(1) / 2;
796            let (g_max, _) =
797                weighted_adjacency(&rows, AdjacencyMode::Max, LoopsMode::NoLoops).unwrap();
798            let (g_min, _) =
799                weighted_adjacency(&rows, AdjacencyMode::Min, LoopsMode::NoLoops).unwrap();
800            prop_assert!(g_max.ecount() <= cap);
801            prop_assert!(g_min.ecount() <= cap);
802        }
803    }
804}