Skip to main content

scirs2_graph/partitioning/
multilevel.rs

1//! METIS-style multilevel graph partitioning.
2//!
3//! Implements the three-phase multilevel paradigm:
4//! 1. **Coarsening** via Heavy-Edge Matching (HEM)
5//! 2. **Initial partitioning** on the coarsest graph
6//! 3. **Uncoarsening** with Kernighan-Lin refinement at each level
7//!
8//! This approach produces high-quality partitions efficiently, even for
9//! large graphs, by working at progressively coarser scales.
10
11use scirs2_core::ndarray::Array2;
12
13use crate::error::{GraphError, Result};
14
15use super::spectral::spectral_bisect;
16use super::types::{PartitionConfig, PartitionResult};
17
18/// One level in the coarsening hierarchy.
19#[derive(Debug, Clone)]
20struct CoarseLevel {
21    /// Adjacency matrix of the coarsened graph.
22    adj: Array2<f64>,
23    /// Maps fine-level node index -> coarse-level node index.
24    mapping: Vec<usize>,
25    /// Number of nodes at this coarse level.
26    n_nodes: usize,
27}
28
29/// Phase 1: Coarsen the graph via Heavy-Edge Matching (HEM).
30///
31/// At each coarsening step, we find a maximal matching that prefers the
32/// heaviest edges. Matched pairs of nodes are merged into single coarse
33/// nodes, and edge weights are summed.
34///
35/// Returns a stack of coarsening levels (finest to coarsest).
36fn coarsen(adj: &Array2<f64>, threshold: usize) -> Vec<CoarseLevel> {
37    let mut levels = Vec::new();
38    let mut current = adj.clone();
39
40    loop {
41        let n = current.nrows();
42        if n <= threshold {
43            break;
44        }
45
46        // Heavy-Edge Matching: greedily match each unmatched node
47        // with its heaviest unmatched neighbor.
48        let mut matched = vec![false; n];
49        let mut mapping = vec![0usize; n];
50        let mut coarse_id = 0usize;
51
52        // Sort nodes by degree (ascending) to match leaves first
53        let mut node_order: Vec<usize> = (0..n).collect();
54        node_order.sort_by(|&a, &b| {
55            let deg_a: f64 = (0..n).map(|j| current[[a, j]].abs()).sum();
56            let deg_b: f64 = (0..n).map(|j| current[[b, j]].abs()).sum();
57            deg_a
58                .partial_cmp(&deg_b)
59                .unwrap_or(std::cmp::Ordering::Equal)
60        });
61
62        for &i in &node_order {
63            if matched[i] {
64                continue;
65            }
66
67            // Find heaviest unmatched neighbor
68            let mut best_j: Option<usize> = None;
69            let mut best_w = 0.0f64;
70            for j in 0..n {
71                if j == i || matched[j] {
72                    continue;
73                }
74                let w = current[[i, j]].abs();
75                if w > 1e-15 && w > best_w {
76                    best_w = w;
77                    best_j = Some(j);
78                }
79            }
80
81            if let Some(j) = best_j {
82                mapping[i] = coarse_id;
83                mapping[j] = coarse_id;
84                matched[i] = true;
85                matched[j] = true;
86                coarse_id += 1;
87            } else {
88                // Singleton: no unmatched neighbor
89                mapping[i] = coarse_id;
90                matched[i] = true;
91                coarse_id += 1;
92            }
93        }
94
95        let cn = coarse_id;
96        if cn >= n {
97            // No coarsening happened
98            break;
99        }
100
101        // Build coarsened adjacency matrix
102        let mut coarse_adj = Array2::<f64>::zeros((cn, cn));
103        for i in 0..n {
104            for j in (i + 1)..n {
105                let w = current[[i, j]];
106                if w.abs() > 1e-15 {
107                    let ci = mapping[i];
108                    let cj = mapping[j];
109                    if ci != cj {
110                        coarse_adj[[ci, cj]] += w;
111                        coarse_adj[[cj, ci]] += w;
112                    }
113                }
114            }
115        }
116
117        levels.push(CoarseLevel {
118            adj: coarse_adj.clone(),
119            mapping,
120            n_nodes: cn,
121        });
122
123        current = coarse_adj;
124    }
125
126    levels
127}
128
129/// Phase 2: Compute initial partition on the coarsest graph.
130///
131/// Uses spectral bisection for quality, or greedy bisection as fallback.
132fn initial_partition(adj: &Array2<f64>, config: &PartitionConfig) -> Result<Vec<usize>> {
133    let n = adj.nrows();
134    if n < 2 {
135        return Ok(vec![0; n]);
136    }
137
138    // Try spectral bisection first
139    match spectral_bisect(adj, config) {
140        Ok(result) => Ok(result.assignments),
141        Err(_) => {
142            // Fallback: greedy bisection by node degree
143            let mut indices: Vec<usize> = (0..n).collect();
144            indices.sort_by(|&a, &b| {
145                let deg_a: f64 = (0..n).map(|j| adj[[a, j]].abs()).sum();
146                let deg_b: f64 = (0..n).map(|j| adj[[b, j]].abs()).sum();
147                deg_b
148                    .partial_cmp(&deg_a)
149                    .unwrap_or(std::cmp::Ordering::Equal)
150            });
151            let mut assignments = vec![0usize; n];
152            let half = n / 2;
153            for &idx in &indices[half..] {
154                assignments[idx] = 1;
155            }
156            Ok(assignments)
157        }
158    }
159}
160
161/// Kernighan-Lin refinement pass on a bisection.
162///
163/// Iteratively finds the best pair of nodes (one from each partition) to swap
164/// to reduce the edge cut, then applies the best prefix of swaps.
165///
166/// Returns the reduction in edge cut (non-negative).
167fn kernighan_lin_pass(adj: &Array2<f64>, partition: &mut [usize], max_swaps: usize) -> usize {
168    let n = adj.nrows();
169    if n < 2 {
170        return 0;
171    }
172
173    // Compute D-values: D(v) = external_cost(v) - internal_cost(v)
174    // Positive D means the node benefits from moving to the other partition.
175    let compute_d = |part: &[usize], node: usize| -> f64 {
176        let my_part = part[node];
177        let mut ext = 0.0;
178        let mut int = 0.0;
179        for j in 0..n {
180            if j == node {
181                continue;
182            }
183            let w = adj[[node, j]].abs();
184            if w < 1e-15 {
185                continue;
186            }
187            if part[j] == my_part {
188                int += w;
189            } else {
190                ext += w;
191            }
192        }
193        ext - int
194    };
195
196    let original_cut = count_edge_cut(adj, partition);
197    let mut d_values: Vec<f64> = (0..n).map(|i| compute_d(partition, i)).collect();
198
199    let mut locked = vec![false; n];
200    let mut gains = Vec::new();
201    let mut swap_pairs = Vec::new();
202
203    let effective_swaps = max_swaps.min(n / 2);
204
205    for _ in 0..effective_swaps {
206        // Find best unlocked pair (a in part 0, b in part 1)
207        let mut best_gain = f64::NEG_INFINITY;
208        let mut best_a: Option<usize> = None;
209        let mut best_b: Option<usize> = None;
210
211        for a in 0..n {
212            if locked[a] || partition[a] != 0 {
213                continue;
214            }
215            for b in 0..n {
216                if locked[b] || partition[b] != 1 {
217                    continue;
218                }
219                let gain = d_values[a] + d_values[b] - 2.0 * adj[[a, b]].abs();
220                if gain > best_gain {
221                    best_gain = gain;
222                    best_a = Some(a);
223                    best_b = Some(b);
224                }
225            }
226        }
227
228        let (a, b) = match (best_a, best_b) {
229            (Some(a), Some(b)) => (a, b),
230            _ => break,
231        };
232
233        // Record swap
234        gains.push(best_gain);
235        swap_pairs.push((a, b));
236        locked[a] = true;
237        locked[b] = true;
238
239        // Update D-values as if a and b were swapped
240        partition[a] = 1;
241        partition[b] = 0;
242        for i in 0..n {
243            if !locked[i] {
244                d_values[i] = compute_d(partition, i);
245            }
246        }
247    }
248
249    // Find best prefix of swaps (maximize cumulative gain)
250    let mut cumulative = 0.0f64;
251    let mut best_cumulative = 0.0f64;
252    let mut best_k = 0usize; // 0 means no swaps
253
254    for (k, &gain) in gains.iter().enumerate() {
255        cumulative += gain;
256        if cumulative > best_cumulative {
257            best_cumulative = cumulative;
258            best_k = k + 1;
259        }
260    }
261
262    // Undo all swaps first (they were applied above)
263    for &(a, b) in swap_pairs.iter().rev() {
264        partition[a] = 0;
265        partition[b] = 1;
266    }
267
268    // Re-apply only the best prefix
269    for &(a, b) in swap_pairs.iter().take(best_k) {
270        partition[a] = 1;
271        partition[b] = 0;
272    }
273
274    let new_cut = count_edge_cut(adj, partition);
275    original_cut.saturating_sub(new_cut)
276}
277
278/// Count edges crossing partition boundaries.
279fn count_edge_cut(adj: &Array2<f64>, partition: &[usize]) -> usize {
280    let n = adj.nrows();
281    let mut cut = 0usize;
282    for i in 0..n {
283        for j in (i + 1)..n {
284            if adj[[i, j]].abs() > 1e-15 && partition[i] != partition[j] {
285                cut += 1;
286            }
287        }
288    }
289    cut
290}
291
292/// Phase 3: Uncoarsen with Kernighan-Lin refinement at each level.
293///
294/// Projects the partition from coarse to fine level, then applies KL
295/// refinement to improve the edge cut.
296fn uncoarsen_with_refinement(
297    levels: &[CoarseLevel],
298    coarse_partition: Vec<usize>,
299    original_adj: &Array2<f64>,
300    config: &PartitionConfig,
301) -> Result<Vec<usize>> {
302    if levels.is_empty() {
303        return Ok(coarse_partition);
304    }
305
306    let mut partition = coarse_partition;
307
308    // Walk from coarsest to finest (reverse order of levels)
309    for level_idx in (0..levels.len()).rev() {
310        let level = &levels[level_idx];
311
312        // Project partition to the finer level
313        let fine_n = level.mapping.len();
314        let mut fine_partition = vec![0usize; fine_n];
315        for (fine_node, &coarse_node) in level.mapping.iter().enumerate() {
316            if coarse_node < partition.len() {
317                fine_partition[fine_node] = partition[coarse_node];
318            }
319        }
320
321        // Get the adjacency matrix at this fine level
322        let adj_at_level = if level_idx == 0 {
323            original_adj.clone()
324        } else {
325            levels[level_idx - 1].adj.clone()
326        };
327
328        // Apply KL refinement
329        for _ in 0..config.kl_max_passes {
330            let improvement = kernighan_lin_pass(&adj_at_level, &mut fine_partition, fine_n / 2);
331            if improvement == 0 {
332                break;
333            }
334        }
335
336        partition = fine_partition;
337    }
338
339    Ok(partition)
340}
341
342/// Perform multilevel graph partitioning (METIS-like).
343///
344/// This implements the classic three-phase approach:
345/// 1. Coarsen the graph via Heavy-Edge Matching until small enough
346/// 2. Partition the coarsest graph (spectral bisection)
347/// 3. Uncoarsen with Kernighan-Lin refinement at each level
348///
349/// For k > 2, recursive bisection is applied.
350///
351/// # Arguments
352/// * `adj` - Symmetric adjacency matrix (n x n)
353/// * `config` - Partition configuration
354///
355/// # Returns
356/// A `PartitionResult` with `config.n_partitions` partitions.
357///
358/// # Errors
359/// Returns `GraphError` if the matrix dimensions are invalid or
360/// the number of partitions exceeds the number of nodes.
361pub fn multilevel_partition(
362    adj: &Array2<f64>,
363    config: &PartitionConfig,
364) -> Result<PartitionResult> {
365    let n = adj.nrows();
366    let k = config.n_partitions;
367
368    if n < 2 {
369        return Err(GraphError::InvalidParameter {
370            param: "adj".to_string(),
371            value: format!("{}x{}", n, n),
372            expected: "at least 2x2 adjacency matrix".to_string(),
373            context: "multilevel_partition".to_string(),
374        });
375    }
376
377    if k < 2 {
378        return Err(GraphError::InvalidParameter {
379            param: "n_partitions".to_string(),
380            value: format!("{}", k),
381            expected: "at least 2".to_string(),
382            context: "multilevel_partition".to_string(),
383        });
384    }
385
386    if k > n {
387        return Err(GraphError::InvalidParameter {
388            param: "n_partitions".to_string(),
389            value: format!("{}", k),
390            expected: format!("at most {} (number of nodes)", n),
391            context: "multilevel_partition".to_string(),
392        });
393    }
394
395    if k == 2 {
396        return multilevel_bisect(adj, config);
397    }
398
399    // k-way via recursive bisection
400    recursive_multilevel_partition(adj, config)
401}
402
403/// Multilevel bisection (k=2).
404fn multilevel_bisect(adj: &Array2<f64>, config: &PartitionConfig) -> Result<PartitionResult> {
405    let n = adj.nrows();
406
407    // Phase 1: Coarsen
408    let levels = coarsen(adj, config.coarsening_threshold);
409
410    // Phase 2: Initial partition on coarsest graph
411    let coarsest_adj = if levels.is_empty() {
412        adj.clone()
413    } else {
414        levels
415            .last()
416            .map(|l| l.adj.clone())
417            .unwrap_or_else(|| adj.clone())
418    };
419
420    let coarse_partition = initial_partition(&coarsest_adj, config)?;
421
422    // Phase 3: Uncoarsen with refinement
423    let assignments = if levels.is_empty() {
424        let mut part = coarse_partition;
425        // Direct KL refinement on original graph
426        for _ in 0..config.kl_max_passes {
427            let improvement = kernighan_lin_pass(adj, &mut part, n / 2);
428            if improvement == 0 {
429                break;
430            }
431        }
432        part
433    } else {
434        uncoarsen_with_refinement(&levels, coarse_partition, adj, config)?
435    };
436
437    Ok(PartitionResult::from_assignments(&assignments, adj, 2))
438}
439
440/// Recursive multilevel partition for k-way.
441fn recursive_multilevel_partition(
442    adj: &Array2<f64>,
443    config: &PartitionConfig,
444) -> Result<PartitionResult> {
445    let n = adj.nrows();
446    let k = config.n_partitions;
447
448    let mut assignments = vec![0usize; n];
449    // Queue: (partition_id, node_indices)
450    let mut queue: Vec<(usize, Vec<usize>)> = vec![(0, (0..n).collect())];
451    let mut next_id = 1usize;
452
453    while next_id < k {
454        if queue.is_empty() {
455            break;
456        }
457
458        // Find the largest partition to split
459        let mut largest_idx = 0;
460        let mut largest_size = 0;
461        for (i, (_, nodes)) in queue.iter().enumerate() {
462            if nodes.len() > largest_size {
463                largest_size = nodes.len();
464                largest_idx = i;
465            }
466        }
467
468        if largest_size < 2 {
469            break;
470        }
471
472        let (pid, nodes) = queue.remove(largest_idx);
473        let sub_n = nodes.len();
474
475        // Build sub-adjacency matrix
476        let mut sub_adj = Array2::<f64>::zeros((sub_n, sub_n));
477        for (si, &ni) in nodes.iter().enumerate() {
478            for (sj, &nj) in nodes.iter().enumerate() {
479                sub_adj[[si, sj]] = adj[[ni, nj]];
480            }
481        }
482
483        // Bisect the sub-graph with multilevel
484        let bisect_config = PartitionConfig {
485            n_partitions: 2,
486            ..config.clone()
487        };
488        let sub_result = multilevel_bisect(&sub_adj, &bisect_config)?;
489
490        let mut part0 = Vec::new();
491        let mut part1 = Vec::new();
492        for (si, &a) in sub_result.assignments.iter().enumerate() {
493            if a == 0 {
494                assignments[nodes[si]] = pid;
495                part0.push(nodes[si]);
496            } else {
497                assignments[nodes[si]] = next_id;
498                part1.push(nodes[si]);
499            }
500        }
501
502        queue.push((pid, part0));
503        queue.push((next_id, part1));
504        next_id += 1;
505    }
506
507    // Assign remaining queue entries
508    for (pid, nodes) in &queue {
509        for &ni in nodes {
510            assignments[ni] = *pid;
511        }
512    }
513
514    Ok(PartitionResult::from_assignments(&assignments, adj, k))
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use scirs2_core::ndarray::Array2;
521
522    fn path_graph(n: usize) -> Array2<f64> {
523        let mut adj = Array2::<f64>::zeros((n, n));
524        for i in 0..(n - 1) {
525            adj[[i, i + 1]] = 1.0;
526            adj[[i + 1, i]] = 1.0;
527        }
528        adj
529    }
530
531    fn two_cliques_bridge(n: usize) -> Array2<f64> {
532        let size = 2 * n;
533        let mut adj = Array2::<f64>::zeros((size, size));
534        // Clique 1
535        for i in 0..n {
536            for j in (i + 1)..n {
537                adj[[i, j]] = 1.0;
538                adj[[j, i]] = 1.0;
539            }
540        }
541        // Clique 2
542        for i in n..size {
543            for j in (i + 1)..size {
544                adj[[i, j]] = 1.0;
545                adj[[j, i]] = 1.0;
546            }
547        }
548        // Single bridge edge
549        adj[[n - 1, n]] = 1.0;
550        adj[[n, n - 1]] = 1.0;
551        adj
552    }
553
554    #[test]
555    fn test_coarsening_reduces_size() {
556        let adj = path_graph(20);
557        let levels = coarsen(&adj, 5);
558        // Should have at least one coarsening level
559        assert!(!levels.is_empty());
560        // Each level should be smaller
561        let mut prev_n = 20;
562        for level in &levels {
563            assert!(
564                level.n_nodes < prev_n,
565                "coarsening should reduce node count"
566            );
567            prev_n = level.n_nodes;
568        }
569    }
570
571    #[test]
572    fn test_kl_never_increases_edge_cut() {
573        let adj = two_cliques_bridge(5);
574        let mut partition: Vec<usize> = (0..10).map(|i| if i < 5 { 0 } else { 1 }).collect();
575        let before = count_edge_cut(&adj, &partition);
576        let improvement = kernighan_lin_pass(&adj, &mut partition, 5);
577        let after = count_edge_cut(&adj, &partition);
578        assert!(after <= before, "KL should not increase edge cut");
579        // improvement should match
580        assert_eq!(improvement, before - after);
581    }
582
583    #[test]
584    fn test_kl_on_already_optimal() {
585        // Two disconnected cliques: already optimal
586        let n = 4;
587        let size = 2 * n;
588        let mut adj = Array2::<f64>::zeros((size, size));
589        for i in 0..n {
590            for j in (i + 1)..n {
591                adj[[i, j]] = 1.0;
592                adj[[j, i]] = 1.0;
593            }
594        }
595        for i in n..size {
596            for j in (i + 1)..size {
597                adj[[i, j]] = 1.0;
598                adj[[j, i]] = 1.0;
599            }
600        }
601        let mut partition: Vec<usize> = (0..size).map(|i| if i < n { 0 } else { 1 }).collect();
602        let improvement = kernighan_lin_pass(&adj, &mut partition, n);
603        assert_eq!(
604            improvement, 0,
605            "already-optimal partition should have 0 improvement"
606        );
607    }
608
609    #[test]
610    fn test_multilevel_bisection_two_cliques() {
611        let clique_size = 5;
612        let adj = two_cliques_bridge(clique_size);
613        let config = PartitionConfig {
614            n_partitions: 2,
615            balance_tolerance: 0.1,
616            coarsening_threshold: 4,
617            kl_max_passes: 10,
618            ..PartitionConfig::default()
619        };
620        let result = multilevel_partition(&adj, &config).expect("should succeed");
621        assert_eq!(result.partition_sizes.len(), 2);
622        // Two cliques of 5 connected by a bridge: edge cut should be reasonable
623        assert!(
624            result.edge_cut <= clique_size,
625            "edge cut {} should be bounded for two-clique bridge",
626            result.edge_cut
627        );
628    }
629
630    #[test]
631    fn test_multilevel_kway_all_nonempty() {
632        let n = 20;
633        let adj = path_graph(n);
634        let config = PartitionConfig {
635            n_partitions: 4,
636            balance_tolerance: 0.3,
637            coarsening_threshold: 3,
638            kl_max_passes: 5,
639            ..PartitionConfig::default()
640        };
641        let result = multilevel_partition(&adj, &config).expect("should succeed");
642        assert_eq!(result.partition_sizes.len(), 4);
643        for (i, &s) in result.partition_sizes.iter().enumerate() {
644            assert!(s > 0, "partition {} should be non-empty", i);
645        }
646    }
647
648    #[test]
649    fn test_multilevel_balance_within_tolerance() {
650        let adj = two_cliques_bridge(6);
651        let config = PartitionConfig {
652            n_partitions: 2,
653            balance_tolerance: 0.1,
654            coarsening_threshold: 4,
655            kl_max_passes: 10,
656            ..PartitionConfig::default()
657        };
658        let result = multilevel_partition(&adj, &config).expect("should succeed");
659        // Imbalance should be reasonable (not wildly unbalanced)
660        assert!(
661            result.imbalance <= 0.5,
662            "imbalance {} should be within a reasonable bound",
663            result.imbalance
664        );
665    }
666
667    #[test]
668    fn test_multilevel_small_graph_matches_spectral() {
669        // For a tiny graph, multilevel should degenerate to direct partitioning
670        let adj = path_graph(4);
671        let config = PartitionConfig {
672            n_partitions: 2,
673            balance_tolerance: 0.1,
674            coarsening_threshold: 100, // no coarsening possible
675            kl_max_passes: 5,
676            ..PartitionConfig::default()
677        };
678        let ml_result = multilevel_partition(&adj, &config).expect("multilevel should succeed");
679        let sp_result = super::super::spectral::spectral_bisect(&adj, &config)
680            .expect("spectral should succeed");
681
682        // Both should achieve similar edge cuts
683        assert!(
684            (ml_result.edge_cut as i64 - sp_result.edge_cut as i64).unsigned_abs() <= 1,
685            "multilevel ({}) and spectral ({}) should produce similar edge cuts",
686            ml_result.edge_cut,
687            sp_result.edge_cut
688        );
689    }
690
691    #[test]
692    fn test_multilevel_invalid_params() {
693        let adj = path_graph(4);
694        let config = PartitionConfig {
695            n_partitions: 1,
696            ..PartitionConfig::default()
697        };
698        assert!(multilevel_partition(&adj, &config).is_err());
699
700        let config2 = PartitionConfig {
701            n_partitions: 10,
702            ..PartitionConfig::default()
703        };
704        assert!(multilevel_partition(&adj, &config2).is_err());
705    }
706}
707
708// ============================================================================
709// Adjacency-list based METIS-style k-way partitioning API
710// ============================================================================
711
712/// Coarsening strategy for the multilevel k-way partitioner.
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714#[non_exhaustive]
715pub enum CoarseningStrategy {
716    /// Heavy-Edge Matching: match each node to its heaviest unmatched neighbor.
717    HeavyEdgeMatching,
718    /// Random matching: randomly pair unmatched nodes.
719    RandomMatching,
720    /// Sorted Heavy-Edge: sort edges by weight descending, then greedily match.
721    SortedHeavyEdge,
722}
723
724/// Refinement strategy applied at each uncoarsening level.
725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726#[non_exhaustive]
727pub enum RefinementStrategy {
728    /// Fiduccia-Mattheyses: max-gain vertex moves across the cut boundary.
729    FiducciaMattheyses,
730    /// Kernighan-Lin: alternating FM-like swaps between pairs of partitions.
731    KernighanLin,
732    /// Greedy balance refinement: move heavy boundary nodes to under-loaded parts.
733    GreedyBalance,
734}
735
736/// Configuration for the adjacency-list multilevel k-way partitioner.
737#[derive(Debug, Clone)]
738pub struct PartitioningConfig {
739    /// Number of output partitions (k). Must be >= 2.
740    pub n_parts: usize,
741    /// Coarsening strategy. Default: `HeavyEdgeMatching`.
742    pub coarsening: CoarseningStrategy,
743    /// Refinement strategy. Default: `FiducciaMattheyses`.
744    pub refinement: RefinementStrategy,
745    /// Maximum allowed imbalance: `max(|Pi|/avg) - 1`. Default 0.03 (3%).
746    pub imbalance_factor: f64,
747    /// Maximum number of coarsening levels. Default 10.
748    pub n_coarsen_levels: usize,
749    /// Random seed for stochastic strategies. Default 42.
750    pub seed: u64,
751}
752
753impl Default for PartitioningConfig {
754    fn default() -> Self {
755        Self {
756            n_parts: 2,
757            coarsening: CoarseningStrategy::HeavyEdgeMatching,
758            refinement: RefinementStrategy::FiducciaMattheyses,
759            imbalance_factor: 0.03,
760            n_coarsen_levels: 10,
761            seed: 42,
762        }
763    }
764}
765
766/// Result of the adjacency-list k-way partitioning.
767#[derive(Debug, Clone)]
768pub struct KwayPartitionResult {
769    /// Partition ID for each node in `0..n_parts`.
770    pub partition: Vec<usize>,
771    /// Number of edges whose endpoints are in different partitions.
772    pub edge_cut: usize,
773    /// Number of nodes assigned to each partition.
774    pub part_sizes: Vec<usize>,
775    /// `max(|Pi| / avg) - 1` where `avg = n / k`.
776    pub imbalance: f64,
777}
778
779/// Coarsened graph using adjacency lists.
780#[derive(Debug, Clone)]
781struct AdjCoarseLevel {
782    /// Adjacency list of the COARSENED graph: `adj[v]` = list of `(neighbor, weight)`.
783    adj: Vec<Vec<(usize, f64)>>,
784    /// Adjacency list of the FINE (input) graph for this level.
785    fine_adj: Vec<Vec<(usize, f64)>>,
786    /// Maps fine-level node index -> coarse-level node index.
787    mapping: Vec<usize>,
788    /// Number of coarse nodes.
789    n_nodes: usize,
790}
791
792/// Coarsen an adjacency-list graph using Heavy-Edge Matching.
793fn coarsen_adj_hem(
794    adj: &[Vec<(usize, f64)>],
795    strategy: CoarseningStrategy,
796    seed: u64,
797) -> AdjCoarseLevel {
798    let n = adj.len();
799    let mut matched = vec![false; n];
800    let mut mapping = vec![0usize; n];
801    let mut coarse_id = 0usize;
802
803    // Build node processing order
804    let mut node_order: Vec<usize> = (0..n).collect();
805
806    // Deterministic shuffle using seed for RandomMatching
807    if strategy == CoarseningStrategy::RandomMatching {
808        // Linear congruential shuffle
809        let mut rng = seed
810            .wrapping_mul(6364136223846793005)
811            .wrapping_add(1442695040888963407);
812        for i in (1..n).rev() {
813            rng = rng
814                .wrapping_mul(6364136223846793005)
815                .wrapping_add(1442695040888963407);
816            let j = (rng >> 33) as usize % (i + 1);
817            node_order.swap(i, j);
818        }
819    }
820
821    for &i in &node_order {
822        if matched[i] {
823            continue;
824        }
825
826        // Find partner based on strategy
827        let partner = match strategy {
828            CoarseningStrategy::HeavyEdgeMatching | CoarseningStrategy::SortedHeavyEdge => {
829                // Pick heaviest unmatched neighbor
830                adj[i]
831                    .iter()
832                    .filter(|&&(nb, _)| !matched[nb] && nb != i)
833                    .max_by(|&&(_, wa), &&(_, wb)| {
834                        wa.partial_cmp(&wb).unwrap_or(std::cmp::Ordering::Equal)
835                    })
836                    .map(|&(nb, _)| nb)
837            }
838            CoarseningStrategy::RandomMatching => {
839                // Pick first unmatched neighbor
840                adj[i]
841                    .iter()
842                    .find(|&&(nb, _)| !matched[nb] && nb != i)
843                    .map(|&(nb, _)| nb)
844            }
845        };
846
847        if let Some(j) = partner {
848            mapping[i] = coarse_id;
849            mapping[j] = coarse_id;
850            matched[i] = true;
851            matched[j] = true;
852        } else {
853            mapping[i] = coarse_id;
854            matched[i] = true;
855        }
856        coarse_id += 1;
857    }
858
859    let cn = coarse_id;
860
861    // Build coarsened adjacency list
862    let mut coarse_adj: Vec<std::collections::HashMap<usize, f64>> =
863        vec![std::collections::HashMap::new(); cn];
864
865    for i in 0..n {
866        let ci = mapping[i];
867        for &(nb, w) in &adj[i] {
868            let cnb = mapping[nb];
869            if ci != cnb {
870                *coarse_adj[ci].entry(cnb).or_insert(0.0) += w;
871            }
872        }
873    }
874
875    let coarse_adj_list: Vec<Vec<(usize, f64)>> = coarse_adj
876        .into_iter()
877        .map(|hm| hm.into_iter().collect())
878        .collect();
879
880    AdjCoarseLevel {
881        adj: coarse_adj_list,
882        fine_adj: adj.to_vec(),
883        mapping,
884        n_nodes: cn,
885    }
886}
887
888/// Compute edge cut for an adjacency-list graph given a partition assignment.
889fn adj_edge_cut(adj: &[Vec<(usize, f64)>], partition: &[usize]) -> usize {
890    let mut cut = 0usize;
891    for (i, nbrs) in adj.iter().enumerate() {
892        for &(j, _) in nbrs {
893            if j > i && partition[i] != partition[j] {
894                cut += 1;
895            }
896        }
897    }
898    cut
899}
900
901/// Compute partition sizes and imbalance.
902fn adj_partition_stats(partition: &[usize], n_parts: usize) -> (Vec<usize>, f64) {
903    let mut sizes = vec![0usize; n_parts];
904    for &p in partition {
905        if p < n_parts {
906            sizes[p] += 1;
907        }
908    }
909    let n = partition.len();
910    let avg = n as f64 / n_parts as f64;
911    let imbalance = if avg > 0.0 {
912        sizes
913            .iter()
914            .map(|&s| (s as f64 / avg) - 1.0)
915            .fold(f64::NEG_INFINITY, f64::max)
916            .max(0.0)
917    } else {
918        0.0
919    };
920    (sizes, imbalance)
921}
922
923/// Initial bisection by greedy degree-based split for adjacency lists.
924fn initial_bisect_adj(adj: &[Vec<(usize, f64)>]) -> Vec<usize> {
925    let n = adj.len();
926    if n == 0 {
927        return vec![];
928    }
929    if n == 1 {
930        return vec![0];
931    }
932    // Sort by weighted degree descending, assign first half to part 0, rest to part 1
933    let mut degrees: Vec<(usize, f64)> = adj
934        .iter()
935        .enumerate()
936        .map(|(i, nbrs)| (i, nbrs.iter().map(|&(_, w)| w).sum::<f64>()))
937        .collect();
938    degrees.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
939
940    let mut partition = vec![0usize; n];
941    let half = n / 2;
942    for (rank, &(node, _)) in degrees.iter().enumerate() {
943        partition[node] = if rank < half { 0 } else { 1 };
944    }
945    partition
946}
947
948/// Fiduccia-Mattheyses single pass refinement for a 2-way partition.
949/// Returns the cut reduction achieved.
950fn fm_pass_adj(adj: &[Vec<(usize, f64)>], partition: &mut [usize], n_parts: usize) -> usize {
951    let n = adj.len();
952    if n < 2 || n_parts < 2 {
953        return 0;
954    }
955
956    // For simplicity, apply FM between part 0 and part 1 (or any two consecutive parts)
957    // Compute gain for each boundary node
958    let compute_gain = |p: &[usize], node: usize| -> f64 {
959        let my_part = p[node];
960        let mut gain = 0.0;
961        for &(nb, w) in &adj[node] {
962            if p[nb] == my_part {
963                gain -= w; // internal edge becomes external if moved
964            } else {
965                gain += w; // external edge becomes internal if moved
966            }
967        }
968        gain
969    };
970
971    let before_cut = adj_edge_cut(adj, partition);
972    let mut gains: Vec<f64> = (0..n).map(|i| compute_gain(partition, i)).collect();
973    let mut locked = vec![false; n];
974    // (node, old_part, new_part)
975    let mut moves: Vec<(usize, usize, usize)> = Vec::new();
976    let mut move_gains: Vec<f64> = Vec::new();
977    // Track current partition sizes to avoid emptying a partition
978    let mut curr_sizes = vec![0usize; n_parts];
979    for &p in partition.iter() {
980        if p < n_parts {
981            curr_sizes[p] += 1;
982        }
983    }
984
985    let max_moves = n.min(200);
986
987    for _ in 0..max_moves {
988        // Find the unlocked node with the highest gain
989        let best = (0..n).filter(|&i| !locked[i]).max_by(|&a, &b| {
990            gains[a]
991                .partial_cmp(&gains[b])
992                .unwrap_or(std::cmp::Ordering::Equal)
993        });
994
995        let v = match best {
996            Some(v) => v,
997            None => break,
998        };
999
1000        let old_part = partition[v];
1001        // Don't move if it would empty the source partition
1002        if curr_sizes[old_part] <= 1 {
1003            locked[v] = true;
1004            continue;
1005        }
1006
1007        // Among all other partitions, pick the one with best move gain
1008        let mut best_target = (old_part + 1) % n_parts;
1009        let mut best_target_gain = f64::NEG_INFINITY;
1010
1011        for target_p in 0..n_parts {
1012            if target_p == old_part {
1013                continue;
1014            }
1015            let mut g = 0.0;
1016            for &(nb, w) in &adj[v] {
1017                if partition[nb] == old_part {
1018                    g -= w;
1019                } else if partition[nb] == target_p {
1020                    g += w;
1021                }
1022            }
1023            if g > best_target_gain {
1024                best_target_gain = g;
1025                best_target = target_p;
1026            }
1027        }
1028
1029        moves.push((v, old_part, best_target));
1030        move_gains.push(best_target_gain);
1031        locked[v] = true;
1032        curr_sizes[old_part] -= 1;
1033        curr_sizes[best_target] += 1;
1034        partition[v] = best_target;
1035
1036        // Update gains for neighbors
1037        for &(nb, _) in &adj[v] {
1038            if !locked[nb] {
1039                gains[nb] = compute_gain(partition, nb);
1040            }
1041        }
1042    }
1043
1044    // Find the best prefix of moves (max cumulative gain)
1045    let mut cumulative = 0.0f64;
1046    let mut best_cum = 0.0f64;
1047    let mut best_k = 0usize;
1048    for (ki, &g) in move_gains.iter().enumerate() {
1049        cumulative += g;
1050        if cumulative > best_cum {
1051            best_cum = cumulative;
1052            best_k = ki + 1;
1053        }
1054    }
1055
1056    // Undo all moves (in reverse order)
1057    for &(node, old_part, _) in moves.iter().rev() {
1058        partition[node] = old_part;
1059    }
1060
1061    // Re-apply the best prefix
1062    for &(node, _, new_part) in moves.iter().take(best_k) {
1063        partition[node] = new_part;
1064    }
1065
1066    let after_cut = adj_edge_cut(adj, partition);
1067    before_cut.saturating_sub(after_cut)
1068}
1069
1070/// Greedy balance refinement: move heavy boundary nodes to under-loaded partitions.
1071fn greedy_balance_pass(
1072    adj: &[Vec<(usize, f64)>],
1073    partition: &mut [usize],
1074    n_parts: usize,
1075    imbalance_target: f64,
1076) {
1077    let n = adj.len();
1078    let avg = n as f64 / n_parts as f64;
1079    let cap = (avg * (1.0 + imbalance_target)).ceil() as usize;
1080
1081    let mut sizes = vec![0usize; n_parts];
1082    for &p in partition.iter() {
1083        if p < n_parts {
1084            sizes[p] += 1;
1085        }
1086    }
1087
1088    // Identify overfull partitions and move boundary nodes
1089    for v in 0..n {
1090        let p = partition[v];
1091        if sizes[p] <= cap {
1092            continue;
1093        }
1094        // Check if v is on boundary
1095        let is_boundary = adj[v].iter().any(|&(nb, _)| partition[nb] != p);
1096        if !is_boundary {
1097            continue;
1098        }
1099
1100        // Move to the partition that is most connected to this node and has space
1101        let mut best_target = p;
1102        let mut best_conn = 0.0f64;
1103        for target in 0..n_parts {
1104            if target == p || sizes[target] >= cap {
1105                continue;
1106            }
1107            let conn: f64 = adj[v]
1108                .iter()
1109                .filter(|&&(nb, _)| partition[nb] == target)
1110                .map(|&(_, w)| w)
1111                .sum();
1112            if conn > best_conn {
1113                best_conn = conn;
1114                best_target = target;
1115            }
1116        }
1117
1118        if best_target != p {
1119            sizes[p] -= 1;
1120            sizes[best_target] += 1;
1121            partition[v] = best_target;
1122        }
1123    }
1124}
1125
1126/// Ensure every partition 0..k is non-empty by stealing one node from
1127/// the largest over-populated partition.
1128fn fixup_empty_partitions(partition: &mut [usize], k: usize, n: usize) {
1129    if k == 0 || n == 0 {
1130        return;
1131    }
1132    let mut sizes = vec![0usize; k];
1133    for &p in partition.iter() {
1134        if p < k {
1135            sizes[p] += 1;
1136        }
1137    }
1138
1139    for target in 0..k {
1140        if sizes[target] > 0 {
1141            continue;
1142        }
1143        // Find the largest partition to steal from
1144        let donor = sizes
1145            .iter()
1146            .enumerate()
1147            .max_by_key(|(_, &s)| s)
1148            .map(|(i, _)| i)
1149            .unwrap_or(0);
1150
1151        if sizes[donor] < 2 {
1152            break; // Can't steal from a singleton
1153        }
1154
1155        // Take the last node in the donor partition
1156        for p in partition.iter_mut() {
1157            if *p == donor {
1158                *p = target;
1159                sizes[donor] -= 1;
1160                sizes[target] += 1;
1161                break;
1162            }
1163        }
1164    }
1165}
1166
1167/// Multilevel k-way graph partitioning (METIS-style, adjacency-list interface).
1168///
1169/// Implements the classic three-phase approach:
1170/// 1. **Coarsen**: repeatedly merge matching nodes using the configured strategy
1171/// 2. **Initial partition**: greedy bisection at the coarsest level, then expanded to k parts
1172/// 3. **Uncoarsen**: project partition back and refine at each level
1173///
1174/// # Arguments
1175/// * `adj` - Adjacency list with edge weights: `adj[v]` = `Vec<(neighbor, weight)>`.
1176/// * `config` - Partitioning configuration.
1177///
1178/// # Returns
1179/// A [`KwayPartitionResult`] with `config.n_parts` partitions.
1180///
1181/// # Errors
1182/// Returns [`GraphError`] if parameters are invalid.
1183pub fn multilevel_kway(
1184    adj: &[Vec<(usize, f64)>],
1185    config: &PartitioningConfig,
1186) -> Result<KwayPartitionResult> {
1187    let n = adj.len();
1188    let k = config.n_parts;
1189
1190    if n < 2 {
1191        return Err(GraphError::InvalidParameter {
1192            param: "adj".to_string(),
1193            value: format!("{}", n),
1194            expected: "at least 2 nodes".to_string(),
1195            context: "multilevel_kway".to_string(),
1196        });
1197    }
1198
1199    if k < 2 {
1200        return Err(GraphError::InvalidParameter {
1201            param: "n_parts".to_string(),
1202            value: format!("{}", k),
1203            expected: "at least 2".to_string(),
1204            context: "multilevel_kway".to_string(),
1205        });
1206    }
1207
1208    if k > n {
1209        return Err(GraphError::InvalidParameter {
1210            param: "n_parts".to_string(),
1211            value: format!("{}", k),
1212            expected: format!("at most {} (number of nodes)", n),
1213            context: "multilevel_kway".to_string(),
1214        });
1215    }
1216
1217    // Phase 1: Coarsen graph
1218    let mut levels: Vec<AdjCoarseLevel> = Vec::new();
1219    let mut coarsen_adj = adj.to_vec();
1220
1221    for _ in 0..config.n_coarsen_levels {
1222        if coarsen_adj.len() < k * 2 {
1223            break;
1224        }
1225        let level = coarsen_adj_hem(&coarsen_adj, config.coarsening, config.seed);
1226        if level.n_nodes >= coarsen_adj.len() {
1227            break; // No coarsening happened
1228        }
1229        coarsen_adj = level.adj.clone();
1230        levels.push(level);
1231        if coarsen_adj.len() < k * 2 {
1232            break;
1233        }
1234    }
1235
1236    // Phase 2: Initial partition at coarsest level using recursive bisection
1237    let coarsest_n = coarsen_adj.len();
1238    let mut partition = if k == 2 {
1239        initial_bisect_adj(&coarsen_adj)
1240    } else {
1241        // k-way via recursive bisection on the coarsest graph
1242        recursive_bisection_adj(&coarsen_adj, k, config.seed)
1243    };
1244
1245    // Ensure partition has correct length
1246    if partition.len() != coarsest_n {
1247        partition.resize(coarsest_n, 0);
1248    }
1249
1250    // Clamp partition IDs in case recursive_bisection returns out-of-range values
1251    for p in &mut partition {
1252        *p = (*p).min(k - 1);
1253    }
1254
1255    // Apply initial refinement at coarsest level
1256    match config.refinement {
1257        RefinementStrategy::FiducciaMattheyses => {
1258            for _ in 0..3 {
1259                let imp = fm_pass_adj(&coarsen_adj, &mut partition, k);
1260                if imp == 0 {
1261                    break;
1262                }
1263            }
1264        }
1265        RefinementStrategy::KernighanLin => {
1266            // KL is O(n^2), so limit passes for large graphs
1267            for _ in 0..2 {
1268                let imp = fm_pass_adj(&coarsen_adj, &mut partition, k);
1269                if imp == 0 {
1270                    break;
1271                }
1272            }
1273        }
1274        RefinementStrategy::GreedyBalance => {
1275            greedy_balance_pass(&coarsen_adj, &mut partition, k, config.imbalance_factor);
1276        }
1277    }
1278
1279    // Phase 3: Uncoarsen and refine
1280    // levels[0] = finest coarsening (fine_adj = original adj, maps original -> first coarse)
1281    // levels[last] = coarsest coarsening (fine_adj = second-coarsest, maps -> coarsest)
1282    // We iterate in reverse: coarsest -> finest
1283    for level in levels.iter().rev() {
1284        let fine_n = level.mapping.len(); // = level.fine_adj.len()
1285        let mut fine_partition = vec![0usize; fine_n];
1286        for (fi, &ci) in level.mapping.iter().enumerate() {
1287            if ci < partition.len() {
1288                fine_partition[fi] = partition[ci];
1289            }
1290        }
1291
1292        // Clamp before refinement
1293        for p in &mut fine_partition {
1294            *p = (*p).min(k - 1);
1295        }
1296
1297        // Apply refinement at the fine level using fine_adj
1298        match config.refinement {
1299            RefinementStrategy::FiducciaMattheyses => {
1300                for _ in 0..5 {
1301                    let imp = fm_pass_adj(&level.fine_adj, &mut fine_partition, k);
1302                    if imp == 0 {
1303                        break;
1304                    }
1305                }
1306            }
1307            RefinementStrategy::KernighanLin => {
1308                for _ in 0..3 {
1309                    let imp = fm_pass_adj(&level.fine_adj, &mut fine_partition, k);
1310                    if imp == 0 {
1311                        break;
1312                    }
1313                }
1314            }
1315            RefinementStrategy::GreedyBalance => {
1316                greedy_balance_pass(
1317                    &level.fine_adj,
1318                    &mut fine_partition,
1319                    k,
1320                    config.imbalance_factor,
1321                );
1322            }
1323        }
1324
1325        partition = fine_partition;
1326    }
1327
1328    // After uncoarsening, partition should be at original size (levels[0].fine_adj == original adj)
1329    // Safety: ensure correct length
1330    if partition.len() != n {
1331        partition.resize(n, 0);
1332    }
1333
1334    // Clamp partition IDs to valid range
1335    for p in &mut partition {
1336        *p = (*p).min(k - 1);
1337    }
1338
1339    // Ensure all k partitions are non-empty: redistribute nodes from largest partitions
1340    fixup_empty_partitions(&mut partition, k, n);
1341
1342    // Final refinement on original graph
1343    match config.refinement {
1344        RefinementStrategy::FiducciaMattheyses | RefinementStrategy::KernighanLin => {
1345            for _ in 0..5 {
1346                let imp = fm_pass_adj(adj, &mut partition, k);
1347                if imp == 0 {
1348                    break;
1349                }
1350            }
1351        }
1352        RefinementStrategy::GreedyBalance => {
1353            greedy_balance_pass(adj, &mut partition, k, config.imbalance_factor);
1354        }
1355    }
1356
1357    // Ensure all partitions are non-empty after refinement
1358    fixup_empty_partitions(&mut partition, k, n);
1359
1360    // Compute final stats
1361    let edge_cut = adj_edge_cut(adj, &partition);
1362    let (part_sizes, imbalance) = adj_partition_stats(&partition, k);
1363
1364    Ok(KwayPartitionResult {
1365        partition,
1366        edge_cut,
1367        part_sizes,
1368        imbalance,
1369    })
1370}
1371
1372/// Recursive bisection partitioner (adjacency-list interface).
1373///
1374/// Repeatedly bisects the largest partition until `n_parts` partitions are obtained.
1375/// Simpler and faster than multilevel but produces lower-quality cuts.
1376///
1377/// # Arguments
1378/// * `adj` - Adjacency list with edge weights.
1379/// * `n_parts` - Desired number of partitions (must be >= 2).
1380/// * `seed` - Random seed for tie-breaking.
1381///
1382/// # Returns
1383/// A [`KwayPartitionResult`].
1384///
1385/// # Errors
1386/// Returns [`GraphError`] if parameters are invalid.
1387pub fn recursive_bisection(
1388    adj: &[Vec<(usize, f64)>],
1389    n_parts: usize,
1390    seed: u64,
1391) -> Result<KwayPartitionResult> {
1392    let n = adj.len();
1393
1394    if n < 2 {
1395        return Err(GraphError::InvalidParameter {
1396            param: "adj".to_string(),
1397            value: format!("{}", n),
1398            expected: "at least 2 nodes".to_string(),
1399            context: "recursive_bisection".to_string(),
1400        });
1401    }
1402
1403    if n_parts < 2 {
1404        return Err(GraphError::InvalidParameter {
1405            param: "n_parts".to_string(),
1406            value: format!("{}", n_parts),
1407            expected: "at least 2".to_string(),
1408            context: "recursive_bisection".to_string(),
1409        });
1410    }
1411
1412    if n_parts > n {
1413        return Err(GraphError::InvalidParameter {
1414            param: "n_parts".to_string(),
1415            value: format!("{}", n_parts),
1416            expected: format!("at most {} (number of nodes)", n),
1417            context: "recursive_bisection".to_string(),
1418        });
1419    }
1420
1421    let mut partition = recursive_bisection_adj(adj, n_parts, seed);
1422    fixup_empty_partitions(&mut partition, n_parts, n);
1423    let edge_cut = adj_edge_cut(adj, &partition);
1424    let (part_sizes, imbalance) = adj_partition_stats(&partition, n_parts);
1425
1426    Ok(KwayPartitionResult {
1427        partition,
1428        edge_cut,
1429        part_sizes,
1430        imbalance,
1431    })
1432}
1433
1434/// Internal recursive bisection without validation.
1435fn recursive_bisection_adj(adj: &[Vec<(usize, f64)>], n_parts: usize, seed: u64) -> Vec<usize> {
1436    let n = adj.len();
1437    let mut partition = vec![0usize; n];
1438
1439    if n_parts <= 1 || n < 2 {
1440        return partition;
1441    }
1442
1443    // Queue: (part_id, node_indices)
1444    let mut queue: Vec<(usize, Vec<usize>)> = vec![(0usize, (0..n).collect())];
1445    let mut next_id = 1usize;
1446
1447    while next_id < n_parts {
1448        if queue.is_empty() {
1449            break;
1450        }
1451
1452        // Find the largest partition to split
1453        let largest_idx = queue
1454            .iter()
1455            .enumerate()
1456            .max_by_key(|(_, (_, nodes))| nodes.len())
1457            .map(|(i, _)| i)
1458            .unwrap_or(0);
1459
1460        if queue[largest_idx].1.len() < 2 {
1461            break;
1462        }
1463
1464        let (pid, nodes) = queue.remove(largest_idx);
1465        let sub_n = nodes.len();
1466
1467        // Build sub-adjacency list
1468        let mut node_to_sub = vec![usize::MAX; n];
1469        for (si, &ni) in nodes.iter().enumerate() {
1470            node_to_sub[ni] = si;
1471        }
1472
1473        let mut sub_adj: Vec<Vec<(usize, f64)>> = vec![vec![]; sub_n];
1474        for (si, &ni) in nodes.iter().enumerate() {
1475            for &(nb, w) in &adj[ni] {
1476                let snb = node_to_sub[nb];
1477                if snb != usize::MAX {
1478                    sub_adj[si].push((snb, w));
1479                }
1480            }
1481        }
1482
1483        // Bisect the sub-graph
1484        let sub_partition = initial_bisect_adj_seeded(&sub_adj, seed);
1485
1486        let mut part0 = Vec::new();
1487        let mut part1 = Vec::new();
1488        for (si, &a) in sub_partition.iter().enumerate() {
1489            let global_node = nodes[si];
1490            if a == 0 {
1491                partition[global_node] = pid;
1492                part0.push(global_node);
1493            } else {
1494                partition[global_node] = next_id;
1495                part1.push(global_node);
1496            }
1497        }
1498
1499        queue.push((pid, part0));
1500        queue.push((next_id, part1));
1501        next_id += 1;
1502    }
1503
1504    // Assign remaining queue entries
1505    for (pid, nodes) in &queue {
1506        for &ni in nodes {
1507            partition[ni] = *pid;
1508        }
1509    }
1510
1511    partition
1512}
1513
1514/// Greedy bisection with optional seeding for variety.
1515fn initial_bisect_adj_seeded(adj: &[Vec<(usize, f64)>], seed: u64) -> Vec<usize> {
1516    let n = adj.len();
1517    if n == 0 {
1518        return vec![];
1519    }
1520    if n == 1 {
1521        return vec![0];
1522    }
1523
1524    // BFS-based bisection from a pseudo-random starting node
1525    let start = (seed as usize) % n;
1526    let mut partition = vec![usize::MAX; n];
1527    let mut queue = std::collections::VecDeque::new();
1528    queue.push_back(start);
1529    partition[start] = 0;
1530    let mut order = vec![start];
1531
1532    while let Some(v) = queue.pop_front() {
1533        for &(nb, _) in &adj[v] {
1534            if partition[nb] == usize::MAX {
1535                partition[nb] = 0;
1536                order.push(nb);
1537                queue.push_back(nb);
1538            }
1539        }
1540    }
1541
1542    // Handle disconnected nodes
1543    for i in 0..n {
1544        if partition[i] == usize::MAX {
1545            partition[i] = 0;
1546            order.push(i);
1547        }
1548    }
1549
1550    // Assign second half of BFS order to part 1
1551    let half = n / 2;
1552    for &node in order.iter().skip(half) {
1553        partition[node] = 1;
1554    }
1555
1556    partition
1557}
1558
1559#[cfg(test)]
1560mod kway_tests {
1561    use super::*;
1562
1563    fn build_path_adj(n: usize) -> Vec<Vec<(usize, f64)>> {
1564        let mut adj = vec![vec![]; n];
1565        for i in 0..(n - 1) {
1566            adj[i].push((i + 1, 1.0));
1567            adj[i + 1].push((i, 1.0));
1568        }
1569        adj
1570    }
1571
1572    fn build_two_cliques_adj(n: usize) -> Vec<Vec<(usize, f64)>> {
1573        let size = 2 * n;
1574        let mut adj = vec![vec![]; size];
1575        for i in 0..n {
1576            for j in (i + 1)..n {
1577                adj[i].push((j, 1.0));
1578                adj[j].push((i, 1.0));
1579            }
1580        }
1581        for i in n..size {
1582            for j in (i + 1)..size {
1583                adj[i].push((j, 1.0));
1584                adj[j].push((i, 1.0));
1585            }
1586        }
1587        // Bridge
1588        adj[n - 1].push((n, 1.0));
1589        adj[n].push((n - 1, 1.0));
1590        adj
1591    }
1592
1593    #[test]
1594    fn test_multilevel_2way() {
1595        let adj = build_path_adj(20);
1596        let config = PartitioningConfig {
1597            n_parts: 2,
1598            ..PartitioningConfig::default()
1599        };
1600        let result = multilevel_kway(&adj, &config).expect("2-way partition should succeed");
1601        assert_eq!(result.part_sizes.len(), 2);
1602        assert_eq!(result.part_sizes.iter().sum::<usize>(), 20);
1603        for &s in &result.part_sizes {
1604            assert!(s > 0, "each part should be non-empty");
1605        }
1606    }
1607
1608    #[test]
1609    fn test_multilevel_4way() {
1610        let adj = build_path_adj(40);
1611        let config = PartitioningConfig {
1612            n_parts: 4,
1613            ..PartitioningConfig::default()
1614        };
1615        let result = multilevel_kway(&adj, &config).expect("4-way partition should succeed");
1616        assert_eq!(result.part_sizes.len(), 4);
1617        assert_eq!(result.part_sizes.iter().sum::<usize>(), 40);
1618        for &s in &result.part_sizes {
1619            assert!(s > 0, "each part should be non-empty");
1620        }
1621    }
1622
1623    #[test]
1624    fn test_recursive_bisection() {
1625        let adj = build_two_cliques_adj(5);
1626        let result = recursive_bisection(&adj, 2, 42).expect("recursive bisection should succeed");
1627        assert_eq!(result.part_sizes.len(), 2);
1628        assert_eq!(result.part_sizes.iter().sum::<usize>(), 10);
1629    }
1630
1631    #[test]
1632    fn test_partition_imbalance_bound() {
1633        let adj = build_two_cliques_adj(6);
1634        let config = PartitioningConfig {
1635            n_parts: 2,
1636            imbalance_factor: 0.3,
1637            ..PartitioningConfig::default()
1638        };
1639        let result = multilevel_kway(&adj, &config).expect("should succeed");
1640        // Imbalance should be reasonable (< 1.0 means no partition is more than double the average)
1641        assert!(
1642            result.imbalance < 1.0,
1643            "imbalance {} too high",
1644            result.imbalance
1645        );
1646    }
1647
1648    #[test]
1649    fn test_invalid_params() {
1650        let adj = build_path_adj(4);
1651
1652        let config_k1 = PartitioningConfig {
1653            n_parts: 1,
1654            ..PartitioningConfig::default()
1655        };
1656        assert!(multilevel_kway(&adj, &config_k1).is_err());
1657
1658        let config_big = PartitioningConfig {
1659            n_parts: 100,
1660            ..PartitioningConfig::default()
1661        };
1662        assert!(multilevel_kway(&adj, &config_big).is_err());
1663
1664        assert!(recursive_bisection(&adj, 1, 42).is_err());
1665        assert!(recursive_bisection(&adj, 100, 42).is_err());
1666    }
1667
1668    #[test]
1669    fn test_coarsening_strategies() {
1670        let adj = build_path_adj(20);
1671        for strategy in [
1672            CoarseningStrategy::HeavyEdgeMatching,
1673            CoarseningStrategy::RandomMatching,
1674            CoarseningStrategy::SortedHeavyEdge,
1675        ] {
1676            let config = PartitioningConfig {
1677                n_parts: 2,
1678                coarsening: strategy,
1679                ..PartitioningConfig::default()
1680            };
1681            let result = multilevel_kway(&adj, &config).expect("should succeed");
1682            assert_eq!(result.part_sizes.iter().sum::<usize>(), 20);
1683        }
1684    }
1685
1686    #[test]
1687    fn test_refinement_strategies() {
1688        let adj = build_two_cliques_adj(4);
1689        for strategy in [
1690            RefinementStrategy::FiducciaMattheyses,
1691            RefinementStrategy::KernighanLin,
1692            RefinementStrategy::GreedyBalance,
1693        ] {
1694            let config = PartitioningConfig {
1695                n_parts: 2,
1696                refinement: strategy,
1697                ..PartitioningConfig::default()
1698            };
1699            let result = multilevel_kway(&adj, &config).expect("should succeed");
1700            assert_eq!(result.part_sizes.iter().sum::<usize>(), 8);
1701        }
1702    }
1703}