1#![allow(
2 clippy::cast_possible_truncation,
3 clippy::cast_sign_loss,
4 clippy::cast_precision_loss,
5 clippy::too_many_arguments,
6 clippy::doc_markdown,
7 clippy::needless_range_loop,
8 clippy::too_many_lines,
9 clippy::many_single_char_names,
10 clippy::cast_lossless
11)]
12
13use crate::core::{Graph, IgraphError, IgraphResult};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SpinglassUpdateRule {
47 Simple,
49 Config,
51}
52
53#[derive(Debug, Clone)]
55pub struct SpinglassResult {
56 pub membership: Vec<u32>,
58 pub modularity: f64,
60 pub temperature: f64,
62 pub nb_clusters: u32,
64 pub csize: Vec<u32>,
66}
67
68#[derive(Debug, Clone)]
70pub struct SpinglassOptions {
71 pub spins: u32,
73 pub parallel_update: bool,
75 pub start_temp: f64,
77 pub stop_temp: f64,
79 pub cool_fact: f64,
81 pub update_rule: SpinglassUpdateRule,
83 pub gamma: f64,
85 pub seed: u64,
87}
88
89impl Default for SpinglassOptions {
90 fn default() -> Self {
91 Self {
92 spins: 25,
93 parallel_update: false,
94 start_temp: 1.0,
95 stop_temp: 0.01,
96 cool_fact: 0.99,
97 update_rule: SpinglassUpdateRule::Config,
98 gamma: 1.0,
99 seed: 42,
100 }
101 }
102}
103
104pub const SPINGLASS_DEFAULT_SPINS: u32 = 25;
106
107struct SplitMix64(u64);
111
112impl SplitMix64 {
113 fn next_u64(&mut self) -> u64 {
114 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
115 let mut z = self.0;
116 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
117 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
118 z ^ (z >> 31)
119 }
120
121 fn gen_range(&mut self, lo: u64, hi: u64) -> u64 {
122 if lo >= hi {
123 return lo;
124 }
125 let range = hi - lo;
126 lo + self.next_u64() % range
127 }
128
129 fn gen_f64(&mut self) -> f64 {
130 (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
131 }
132
133 fn gen_f64_range(&mut self, lo: f64, hi: f64) -> f64 {
134 lo + self.gen_f64() * (hi - lo)
135 }
136}
137
138struct PottsModel {
141 adj: Vec<Vec<(usize, f64)>>,
143 n: usize,
145 q: usize,
147 operation_mode: usize,
149 spin: Vec<u32>,
151 color_field: Vec<f64>,
155 total_degree_sum: f64,
157 vertex_weight: Vec<f64>,
159 q_matrix: Vec<Vec<f64>>,
161 qa: Vec<f64>,
163 sum_weights: f64,
165 acceptance: f64,
167 rng: SplitMix64,
169}
170
171impl PottsModel {
172 fn new(
173 graph: &Graph,
174 weights: Option<&[f64]>,
175 q: usize,
176 operation_mode: usize,
177 seed: u64,
178 ) -> IgraphResult<Self> {
179 let n = graph.vcount() as usize;
180 let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
181 let mut sum_weights = 0.0;
182
183 for eid in 0..graph.ecount() {
184 let (u, v) = graph.edge(eid as u32)?;
185 let w = weights.map_or(1.0, |ws| ws[eid]);
186 adj[u as usize].push((v as usize, w));
187 if u != v {
188 adj[v as usize].push((u as usize, w));
189 }
190 sum_weights += w;
191 }
192
193 let mut vertex_weight = vec![0.0; n];
194 for v in 0..n {
195 for &(_, w) in &adj[v] {
196 vertex_weight[v] += w;
197 }
198 }
199
200 Ok(Self {
201 adj,
202 n,
203 q,
204 operation_mode,
205 spin: vec![0; n],
206 color_field: vec![0.0; q + 1],
207 total_degree_sum: 0.0,
208 vertex_weight,
209 q_matrix: vec![vec![0.0; q + 1]; q + 1],
210 qa: vec![0.0; q + 1],
211 sum_weights,
212 acceptance: 0.0,
213 rng: SplitMix64(seed),
214 })
215 }
216
217 fn assign_initial_conf(&mut self) {
218 for i in 0..=self.q {
219 self.color_field[i] = 0.0;
220 }
221 self.total_degree_sum = 0.0;
222 for v in 0..self.n {
223 let s = (self.rng.gen_range(1, self.q as u64 + 1)) as u32;
224 self.spin[v] = s;
225 let s_idx = s as usize;
226 let deg = self.vertex_weight[v];
227 if self.operation_mode == 0 {
228 self.color_field[s_idx] += 1.0;
229 } else {
230 self.color_field[s_idx] += deg;
231 }
232 self.total_degree_sum += deg;
233 }
234 }
235
236 fn initialize_q_matrix(&mut self) -> f64 {
237 for i in 0..=self.q {
238 self.qa[i] = 0.0;
239 for j in 0..=self.q {
240 self.q_matrix[i][j] = 0.0;
241 }
242 }
243 for v in 0..self.n {
253 let sv = self.spin[v] as usize;
254 for &(u, w) in &self.adj[v] {
255 let su = self.spin[u] as usize;
256 self.q_matrix[sv][su] += w;
257 }
258 }
259 for i in 0..=self.q {
265 for j in 0..=self.q {
266 self.qa[i] += self.q_matrix[i][j];
267 }
268 }
269 self.calculate_q()
270 }
271
272 fn calculate_q(&self) -> f64 {
273 let two_m = 2.0 * self.sum_weights;
274 if two_m == 0.0 {
275 return 0.0;
276 }
277 let mut q_val = 0.0;
278 for i in 0..=self.q {
279 q_val += self.q_matrix[i][i] - self.qa[i] * self.qa[i] / two_m;
280 }
281 q_val / two_m
282 }
283
284 fn find_start_temp(&mut self, gamma: f64, prob: f64, ts: f64) -> f64 {
285 let mut kt = ts;
286 self.assign_initial_conf();
287 self.initialize_q_matrix();
288 let threshold = (1.0 - 1.0 / self.q as f64) * 0.95;
289 while self.acceptance < threshold {
290 kt *= 1.1;
291 self.heat_bath_parallel_lookup(gamma, prob, kt, 50);
292 }
293 kt * 1.1
294 }
295
296 fn heat_bath_lookup(&mut self, gamma: f64, prob_base: f64, kt: f64, max_sweeps: u32) -> f64 {
298 let mut changes: u64 = 0;
299 let mut neighbours = vec![0.0; self.q + 1];
300 let mut weights_arr = vec![0.0; self.q + 1];
301
302 for _sweep in 0..max_sweeps {
303 for _step in 0..self.n {
304 let rn = self.rng.gen_range(0, self.n as u64) as usize;
305 for i in 0..=self.q {
307 neighbours[i] = 0.0;
308 weights_arr[i] = 0.0;
309 }
310 let degree = self.vertex_weight[rn];
311 for &(nbr, w) in &self.adj[rn] {
312 neighbours[self.spin[nbr] as usize] += w;
313 }
314
315 let old_spin = self.spin[rn] as usize;
316 let (prob, delta) = match self.operation_mode {
317 0 => (prob_base, 1.0),
318 _ => (degree / self.total_degree_sum, degree),
319 };
320
321 let beta = 1.0 / kt;
322 let mut minweight = 0.0_f64;
323 weights_arr[old_spin] = 0.0;
324 for spin in 1..=self.q {
325 if spin != old_spin {
326 let h = self.color_field[spin] - (self.color_field[old_spin] - delta);
327 weights_arr[spin] =
328 neighbours[old_spin] - neighbours[spin] + gamma * prob * h;
329 if weights_arr[spin] < minweight {
330 minweight = weights_arr[spin];
331 }
332 }
333 }
334 let mut norm = 0.0;
335 for spin in 1..=self.q {
336 weights_arr[spin] -= minweight;
337 weights_arr[spin] = (-beta * weights_arr[spin]).exp();
338 norm += weights_arr[spin];
339 }
340
341 let mut r = self.rng.gen_f64_range(0.0, norm);
342 let mut spin_opt = old_spin;
343 for spin in 1..=self.q {
344 if r <= weights_arr[spin] {
345 spin_opt = spin;
346 break;
347 }
348 r -= weights_arr[spin];
349 }
350
351 let new_spin = spin_opt;
352 if new_spin != old_spin {
353 changes += 1;
354 self.spin[rn] = new_spin as u32;
355 self.color_field[old_spin] -= delta;
356 self.color_field[new_spin] += delta;
357 for &(nbr, w) in &self.adj[rn] {
359 let sn = self.spin[nbr] as usize;
360 self.q_matrix[old_spin][sn] -= w;
361 self.q_matrix[new_spin][sn] += w;
362 self.q_matrix[sn][old_spin] -= w;
363 self.q_matrix[sn][new_spin] += w;
364 self.qa[old_spin] -= w;
365 self.qa[new_spin] += w;
366 }
367 }
368 }
369 }
370 self.acceptance = changes as f64 / self.n as f64 / max_sweeps as f64;
371 self.acceptance
372 }
373
374 fn heat_bath_parallel_lookup(
376 &mut self,
377 gamma: f64,
378 prob_base: f64,
379 kt: f64,
380 max_sweeps: u32,
381 ) -> i64 {
382 let mut changes: i64 = 1;
383 let mut neighbours = vec![0.0; self.q + 1];
384 let mut weights_arr = vec![0.0; self.q + 1];
385 let mut new_spins = vec![0u32; self.n];
386 let mut prev_spins = vec![0u32; self.n];
387
388 let mut sweep = 0u32;
389 while sweep < max_sweeps && changes > 0 {
390 let mut cyclic = true;
391 sweep += 1;
392 changes = 0;
393
394 for v in 0..self.n {
396 for i in 0..=self.q {
397 neighbours[i] = 0.0;
398 weights_arr[i] = 0.0;
399 }
400 let degree = self.vertex_weight[v];
401 for &(nbr, w) in &self.adj[v] {
402 neighbours[self.spin[nbr] as usize] += w;
403 }
404
405 let old_spin = self.spin[v] as usize;
406 let (prob, delta) = match self.operation_mode {
407 0 => (prob_base, 1.0),
408 _ => (degree / self.total_degree_sum, degree),
409 };
410
411 let beta = 1.0 / kt;
412 let mut minweight = 0.0_f64;
413 weights_arr[old_spin] = 0.0;
414 for spin in 1..=self.q {
415 if spin != old_spin {
416 let h = self.color_field[spin] + delta - self.color_field[old_spin];
417 weights_arr[spin] =
418 neighbours[old_spin] - neighbours[spin] + gamma * prob * h;
419 if weights_arr[spin] < minweight {
420 minweight = weights_arr[spin];
421 }
422 }
423 }
424 let mut norm = 0.0;
425 for spin in 1..=self.q {
426 weights_arr[spin] -= minweight;
427 weights_arr[spin] = (-beta * weights_arr[spin]).exp();
428 norm += weights_arr[spin];
429 }
430
431 let mut r = self.rng.gen_f64_range(0.0, norm);
432 let mut spin_opt = old_spin;
433 for spin in 1..=self.q {
434 if r <= weights_arr[spin] {
435 spin_opt = spin;
436 break;
437 }
438 r -= weights_arr[spin];
439 }
440 new_spins[v] = spin_opt as u32;
441 }
442
443 for v in 0..self.n {
445 let old_spin = self.spin[v] as usize;
446 let new_spin = new_spins[v] as usize;
447 if new_spin != old_spin {
448 changes += 1;
449 self.spin[v] = new_spin as u32;
450 if new_spins[v] != prev_spins[v] {
451 cyclic = false;
452 }
453 prev_spins[v] = old_spin as u32;
454 let delta = if self.operation_mode == 0 {
455 1.0
456 } else {
457 self.vertex_weight[v]
458 };
459 self.color_field[old_spin] -= delta;
460 self.color_field[new_spin] += delta;
461 for &(nbr, w) in &self.adj[v] {
462 let sn = self.spin[nbr] as usize;
463 self.q_matrix[old_spin][sn] -= w;
464 self.q_matrix[new_spin][sn] += w;
465 self.q_matrix[sn][old_spin] -= w;
466 self.q_matrix[sn][new_spin] += w;
467 self.qa[old_spin] -= w;
468 self.qa[new_spin] += w;
469 }
470 }
471 }
472
473 if cyclic {
474 self.acceptance = 0.0;
475 return 0;
476 }
477 }
478 self.acceptance = changes as f64 / self.n as f64;
479 changes
480 }
481
482 fn heat_bath_lookup_zero_temp(&mut self, gamma: f64, prob_base: f64, max_sweeps: u32) -> f64 {
484 let mut changes: u64 = 0;
485 let mut neighbours = vec![0.0; self.q + 1];
486
487 for _sweep in 0..max_sweeps {
488 for _step in 0..self.n {
489 let rn = self.rng.gen_range(0, self.n as u64) as usize;
490 for i in 0..=self.q {
491 neighbours[i] = 0.0;
492 }
493 let degree = self.vertex_weight[rn];
494 for &(nbr, w) in &self.adj[rn] {
495 neighbours[self.spin[nbr] as usize] += w;
496 }
497
498 let old_spin = self.spin[rn] as usize;
499 let (prob, delta) = match self.operation_mode {
500 0 => (prob_base, 1.0),
501 _ => (degree / self.total_degree_sum, degree),
502 };
503
504 let mut spin_opt = old_spin;
505 let mut delta_e_min = 0.0_f64;
506 for spin in 1..=self.q {
507 if spin != old_spin {
508 let h = self.color_field[spin] + delta - self.color_field[old_spin];
509 let delta_e = neighbours[old_spin] - neighbours[spin] + gamma * prob * h;
510 if delta_e < delta_e_min {
511 spin_opt = spin;
512 delta_e_min = delta_e;
513 }
514 }
515 }
516
517 let new_spin = spin_opt;
518 if new_spin != old_spin {
519 changes += 1;
520 self.spin[rn] = new_spin as u32;
521 self.color_field[old_spin] -= delta;
522 self.color_field[new_spin] += delta;
523 for &(nbr, w) in &self.adj[rn] {
524 let sn = self.spin[nbr] as usize;
525 self.q_matrix[old_spin][sn] -= w;
526 self.q_matrix[new_spin][sn] += w;
527 self.q_matrix[sn][old_spin] -= w;
528 self.q_matrix[sn][new_spin] += w;
529 self.qa[old_spin] -= w;
530 self.qa[new_spin] += w;
531 }
532 }
533 }
534 }
535 self.acceptance = changes as f64 / self.n as f64 / max_sweeps as f64;
536 self.acceptance
537 }
538
539 fn heat_bath_parallel_lookup_zero_temp(
541 &mut self,
542 gamma: f64,
543 prob_base: f64,
544 max_sweeps: u32,
545 ) -> i64 {
546 let mut changes: i64 = 1;
547 let mut neighbours = vec![0.0; self.q + 1];
548 let mut new_spins = vec![0u32; self.n];
549 let mut prev_spins = vec![0u32; self.n];
550
551 let mut sweep = 0u32;
552 while sweep < max_sweeps && changes > 0 {
553 let mut cyclic = true;
554 sweep += 1;
555 changes = 0;
556
557 for v in 0..self.n {
558 for i in 0..=self.q {
559 neighbours[i] = 0.0;
560 }
561 let degree = self.vertex_weight[v];
562 for &(nbr, w) in &self.adj[v] {
563 neighbours[self.spin[nbr] as usize] += w;
564 }
565
566 let old_spin = self.spin[v] as usize;
567 let (prob, delta) = match self.operation_mode {
568 0 => (prob_base, 1.0),
569 _ => (degree / self.total_degree_sum, degree),
570 };
571
572 let mut spin_opt = old_spin;
573 let mut delta_e_min = 0.0_f64;
574 for spin in 1..=self.q {
575 if spin != old_spin {
576 let h = self.color_field[spin] + delta - self.color_field[old_spin];
577 let delta_e = neighbours[old_spin] - neighbours[spin] + gamma * prob * h;
578 if delta_e < delta_e_min {
579 spin_opt = spin;
580 delta_e_min = delta_e;
581 }
582 }
583 }
584 new_spins[v] = spin_opt as u32;
585 }
586
587 for v in 0..self.n {
588 let old_spin = self.spin[v] as usize;
589 let new_spin = new_spins[v] as usize;
590 if new_spin != old_spin {
591 changes += 1;
592 self.spin[v] = new_spin as u32;
593 if new_spins[v] != prev_spins[v] {
594 cyclic = false;
595 }
596 prev_spins[v] = old_spin as u32;
597 self.color_field[old_spin] -= 1.0;
598 self.color_field[new_spin] += 1.0;
599 for &(nbr, w) in &self.adj[v] {
600 let sn = self.spin[nbr] as usize;
601 self.q_matrix[old_spin][sn] -= w;
602 self.q_matrix[new_spin][sn] += w;
603 self.q_matrix[sn][old_spin] -= w;
604 self.q_matrix[sn][new_spin] += w;
605 self.qa[old_spin] -= w;
606 self.qa[new_spin] += w;
607 }
608 }
609 }
610
611 if cyclic {
612 self.acceptance = 0.0;
613 return 0;
614 }
615 }
616 self.acceptance = changes as f64 / self.n as f64;
617 changes
618 }
619
620 fn write_clusters(&self, gamma: f64) -> SpinglassResult {
621 let mut nodes_per_spin = vec![0u32; self.q + 1];
623 for v in 0..self.n {
624 nodes_per_spin[self.spin[v] as usize] += 1;
625 }
626
627 let mut spin_to_community = vec![0u32; self.q + 1];
629 let mut nb_clusters = 0u32;
630 let mut csize = Vec::new();
631 for spin in 1..=self.q {
632 if nodes_per_spin[spin] > 0 {
633 spin_to_community[spin] = nb_clusters;
634 nb_clusters += 1;
635 csize.push(nodes_per_spin[spin]);
636 }
637 }
638
639 let mut membership = vec![0u32; self.n];
640 for v in 0..self.n {
641 membership[v] = spin_to_community[self.spin[v] as usize];
642 }
643
644 let modularity = if self.sum_weights > 0.0 {
646 let mut q_val = 0.0;
647 for spin in 1..=self.q {
648 if nodes_per_spin[spin] > 0 {
649 let mut inner = 0.0;
651 let mut total = 0.0;
652 for v in 0..self.n {
653 if self.spin[v] as usize == spin {
654 for &(nbr, w) in &self.adj[v] {
655 if self.spin[nbr] as usize == spin {
656 inner += w;
657 }
658 total += w;
659 }
660 }
661 }
662 let t1 = inner / (2.0 * self.sum_weights);
663 let t2 = total / (2.0 * self.sum_weights);
664 q_val += t1;
665 q_val -= gamma * t2 * t2;
666 }
667 }
668 q_val
669 } else {
670 0.0
671 };
672
673 SpinglassResult {
674 membership,
675 modularity,
676 temperature: 0.0, nb_clusters,
678 csize,
679 }
680 }
681}
682
683pub fn spinglass(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<SpinglassResult> {
694 spinglass_with_options(graph, weights, &SpinglassOptions::default())
695}
696
697pub fn spinglass_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<SpinglassResult> {
704 spinglass_with_options(graph, Some(weights), &SpinglassOptions::default())
705}
706
707pub fn spinglass_with_options(
714 graph: &Graph,
715 weights: Option<&[f64]>,
716 options: &SpinglassOptions,
717) -> IgraphResult<SpinglassResult> {
718 let n = graph.vcount();
719
720 if options.spins < 2 {
722 return Err(IgraphError::InvalidArgument(
723 "Number of spins must be at least 2".into(),
724 ));
725 }
726 if let Some(ws) = weights {
727 if ws.len() != graph.ecount() {
728 return Err(IgraphError::InvalidArgument(
729 "Weight vector length must equal edge count".into(),
730 ));
731 }
732 if ws.iter().any(|&w| w < 0.0) {
733 return Err(IgraphError::InvalidArgument(
734 "Edge weights must be non-negative".into(),
735 ));
736 }
737 }
738 if options.cool_fact <= 0.0 || options.cool_fact >= 1.0 {
739 return Err(IgraphError::InvalidArgument(
740 "Cooling factor must be in (0, 1)".into(),
741 ));
742 }
743 if options.gamma < 0.0 {
744 return Err(IgraphError::InvalidArgument(
745 "Gamma must be non-negative".into(),
746 ));
747 }
748
749 let zero_t = options.start_temp == 0.0 && options.stop_temp == 0.0;
750 if !zero_t {
751 if options.start_temp <= 0.0 || options.stop_temp <= 0.0 {
752 return Err(IgraphError::InvalidArgument(
753 "Starting and stopping temperatures must be both positive or both zero".into(),
754 ));
755 }
756 if options.start_temp <= options.stop_temp {
757 return Err(IgraphError::InvalidArgument(
758 "Starting temperature must be larger than stopping temperature".into(),
759 ));
760 }
761 }
762
763 if n < 2 {
765 let membership = (0..n).map(|_| 0u32).collect::<Vec<_>>();
766 let nb_clusters = u32::from(n > 0);
767 let csize = if n == 0 { vec![] } else { vec![1] };
768 return Ok(SpinglassResult {
769 membership,
770 modularity: 0.0,
771 temperature: options.stop_temp,
772 nb_clusters,
773 csize,
774 });
775 }
776
777 if !is_weakly_connected(graph) {
779 return Err(IgraphError::InvalidArgument(
780 "Spinglass community detection requires a connected graph".into(),
781 ));
782 }
783
784 let operation_mode = match options.update_rule {
785 SpinglassUpdateRule::Simple => 0,
786 SpinglassUpdateRule::Config => 1,
787 };
788
789 let mut pm = PottsModel::new(
790 graph,
791 weights,
792 options.spins as usize,
793 operation_mode,
794 options.seed,
795 )?;
796
797 let prob = if pm.n > 1 {
798 2.0 * pm.sum_weights / pm.n as f64 / (pm.n as f64 - 1.0)
799 } else {
800 0.0
801 };
802
803 let mut kt = if zero_t {
804 options.stop_temp
805 } else {
806 pm.find_start_temp(options.gamma, prob, options.start_temp)
807 };
808
809 pm.assign_initial_conf();
811 pm.initialize_q_matrix();
812
813 let mut changes: i64 = 1;
814 let mut runs = 0u64;
815 let spins_f = options.spins as f64;
816 let threshold = (1.0 - 1.0 / spins_f) * 0.01;
817
818 while changes > 0 && (kt / options.stop_temp > 1.0 || (zero_t && runs < 150)) {
819 runs += 1;
820 if zero_t {
821 if options.parallel_update {
822 changes = pm.heat_bath_parallel_lookup_zero_temp(options.gamma, prob, 50);
823 } else {
824 let acc = pm.heat_bath_lookup_zero_temp(options.gamma, prob, 50);
825 changes = i64::from(acc >= threshold);
826 }
827 } else {
828 kt *= options.cool_fact;
829 if options.parallel_update {
830 changes = pm.heat_bath_parallel_lookup(options.gamma, prob, kt, 50);
831 } else {
832 let acc = pm.heat_bath_lookup(options.gamma, prob, kt, 50);
833 changes = i64::from(acc >= threshold);
834 }
835 }
836 }
837
838 let mut result = pm.write_clusters(options.gamma);
839 result.temperature = kt;
840 Ok(result)
841}
842
843fn is_weakly_connected(graph: &Graph) -> bool {
845 let n = graph.vcount() as usize;
846 if n <= 1 {
847 return true;
848 }
849 let mut visited = vec![false; n];
850 let mut queue = std::collections::VecDeque::new();
851 visited[0] = true;
852 queue.push_back(0u32);
853 let mut count = 1usize;
854 while let Some(v) = queue.pop_front() {
855 if let Ok(nbrs) = graph.neighbors(v) {
856 for &nbr in &nbrs {
857 let ni = nbr as usize;
858 if !visited[ni] {
859 visited[ni] = true;
860 count += 1;
861 queue.push_back(nbr);
862 }
863 }
864 }
865 }
866 count == n
867}
868
869#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
876 fn basic_two_communities() {
877 let g = Graph::from_edges(
879 &[
880 (0, 1),
881 (0, 2),
882 (0, 3),
883 (1, 2),
884 (1, 3),
885 (2, 3),
886 (4, 5),
887 (4, 6),
888 (4, 7),
889 (5, 6),
890 (5, 7),
891 (6, 7),
892 (3, 4), ],
894 false,
895 None,
896 )
897 .unwrap();
898
899 let result = spinglass(&g, None).unwrap();
900 assert_eq!(result.membership.len(), 8);
901 assert!(
902 result.nb_clusters >= 2,
903 "Expected at least 2 clusters, got {}",
904 result.nb_clusters
905 );
906 assert!(
907 result.modularity > 0.0,
908 "Expected positive modularity, got {}",
909 result.modularity
910 );
911 let total: u32 = result.csize.iter().sum();
913 assert_eq!(total, 8);
914 }
915
916 #[test]
917 fn single_vertex() {
918 let g = Graph::from_edges(&[] as &[(u32, u32)], false, Some(1)).unwrap();
919 let result = spinglass(&g, None).unwrap();
920 assert_eq!(result.membership, vec![0]);
921 assert_eq!(result.nb_clusters, 1);
922 }
923
924 #[test]
925 fn empty_graph() {
926 let g = Graph::from_edges(&[] as &[(u32, u32)], false, Some(0)).unwrap();
927 let result = spinglass(&g, None).unwrap();
928 assert!(result.membership.is_empty());
929 assert_eq!(result.nb_clusters, 0);
930 }
931
932 #[test]
933 fn disconnected_graph_error() {
934 let g = Graph::from_edges(&[(0, 1), (2, 3)], false, Some(4)).unwrap();
935 let result = spinglass(&g, None);
936 assert!(result.is_err());
937 }
938
939 #[test]
940 fn deterministic_with_seed() {
941 let g = Graph::from_edges(
942 &[
943 (0, 1),
944 (0, 2),
945 (0, 3),
946 (1, 2),
947 (1, 3),
948 (2, 3),
949 (4, 5),
950 (4, 6),
951 (4, 7),
952 (5, 6),
953 (5, 7),
954 (6, 7),
955 (3, 4),
956 ],
957 false,
958 None,
959 )
960 .unwrap();
961
962 let opts = SpinglassOptions {
963 seed: 12345,
964 ..SpinglassOptions::default()
965 };
966 let r1 = spinglass_with_options(&g, None, &opts).unwrap();
967 let r2 = spinglass_with_options(&g, None, &opts).unwrap();
968 assert_eq!(r1.membership, r2.membership);
969 }
970
971 #[test]
972 fn weighted_edges() {
973 let g = Graph::from_edges(
975 &[(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)],
976 false,
977 None,
978 )
979 .unwrap();
980 let weights = vec![5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 0.1];
981 let result = spinglass_weighted(&g, &weights).unwrap();
982 assert_eq!(result.membership.len(), 6);
983 assert!(result.nb_clusters >= 2);
984 }
985
986 #[test]
987 fn invalid_spins() {
988 let g = Graph::from_edges(&[(0, 1)], false, None).unwrap();
989 let opts = SpinglassOptions {
990 spins: 1,
991 ..SpinglassOptions::default()
992 };
993 let result = spinglass_with_options(&g, None, &opts);
994 assert!(result.is_err());
995 }
996
997 #[test]
998 fn invalid_cool_fact() {
999 let g = Graph::from_edges(&[(0, 1)], false, None).unwrap();
1000 let opts = SpinglassOptions {
1001 cool_fact: 1.5,
1002 ..SpinglassOptions::default()
1003 };
1004 let result = spinglass_with_options(&g, None, &opts);
1005 assert!(result.is_err());
1006 }
1007
1008 #[test]
1009 fn invalid_gamma() {
1010 let g = Graph::from_edges(&[(0, 1)], false, None).unwrap();
1011 let opts = SpinglassOptions {
1012 gamma: -1.0,
1013 ..SpinglassOptions::default()
1014 };
1015 let result = spinglass_with_options(&g, None, &opts);
1016 assert!(result.is_err());
1017 }
1018
1019 #[test]
1020 fn negative_weights_error() {
1021 let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 0)], false, None).unwrap();
1022 let result = spinglass(&g, Some(&[1.0, -1.0, 1.0]));
1023 assert!(result.is_err());
1024 }
1025
1026 #[test]
1027 fn simple_update_rule() {
1028 let g = Graph::from_edges(
1029 &[
1030 (0, 1),
1031 (0, 2),
1032 (0, 3),
1033 (1, 2),
1034 (1, 3),
1035 (2, 3),
1036 (4, 5),
1037 (4, 6),
1038 (4, 7),
1039 (5, 6),
1040 (5, 7),
1041 (6, 7),
1042 (3, 4),
1043 ],
1044 false,
1045 None,
1046 )
1047 .unwrap();
1048
1049 let opts = SpinglassOptions {
1050 update_rule: SpinglassUpdateRule::Simple,
1051 ..SpinglassOptions::default()
1052 };
1053 let result = spinglass_with_options(&g, None, &opts).unwrap();
1054 assert!(result.nb_clusters >= 2);
1055 }
1056
1057 #[test]
1058 fn parallel_update() {
1059 let g = Graph::from_edges(
1060 &[
1061 (0, 1),
1062 (0, 2),
1063 (0, 3),
1064 (1, 2),
1065 (1, 3),
1066 (2, 3),
1067 (4, 5),
1068 (4, 6),
1069 (4, 7),
1070 (5, 6),
1071 (5, 7),
1072 (6, 7),
1073 (3, 4),
1074 ],
1075 false,
1076 None,
1077 )
1078 .unwrap();
1079
1080 let opts = SpinglassOptions {
1081 parallel_update: true,
1082 ..SpinglassOptions::default()
1083 };
1084 let result = spinglass_with_options(&g, None, &opts).unwrap();
1085 assert!(result.nb_clusters >= 1);
1086 }
1087
1088 #[test]
1089 fn zero_temperature() {
1090 let g = Graph::from_edges(
1091 &[
1092 (0, 1),
1093 (0, 2),
1094 (0, 3),
1095 (1, 2),
1096 (1, 3),
1097 (2, 3),
1098 (4, 5),
1099 (4, 6),
1100 (4, 7),
1101 (5, 6),
1102 (5, 7),
1103 (6, 7),
1104 (3, 4),
1105 ],
1106 false,
1107 None,
1108 )
1109 .unwrap();
1110
1111 let opts = SpinglassOptions {
1112 start_temp: 0.0,
1113 stop_temp: 0.0,
1114 ..SpinglassOptions::default()
1115 };
1116 let result = spinglass_with_options(&g, None, &opts).unwrap();
1117 assert!(result.nb_clusters >= 1);
1118 }
1119
1120 #[test]
1121 fn karate_club() {
1122 let edges: &[(u32, u32)] = &[
1123 (0, 1),
1124 (0, 2),
1125 (0, 3),
1126 (0, 4),
1127 (0, 5),
1128 (0, 6),
1129 (0, 7),
1130 (0, 8),
1131 (0, 10),
1132 (0, 11),
1133 (0, 12),
1134 (0, 13),
1135 (0, 17),
1136 (0, 19),
1137 (0, 21),
1138 (0, 31),
1139 (1, 2),
1140 (1, 3),
1141 (1, 7),
1142 (1, 13),
1143 (1, 17),
1144 (1, 19),
1145 (1, 21),
1146 (1, 30),
1147 (2, 3),
1148 (2, 7),
1149 (2, 8),
1150 (2, 9),
1151 (2, 13),
1152 (2, 27),
1153 (2, 28),
1154 (2, 32),
1155 (3, 7),
1156 (3, 12),
1157 (3, 13),
1158 (4, 6),
1159 (4, 10),
1160 (5, 6),
1161 (5, 10),
1162 (5, 16),
1163 (6, 16),
1164 (8, 30),
1165 (8, 32),
1166 (8, 33),
1167 (9, 33),
1168 (13, 33),
1169 (14, 32),
1170 (14, 33),
1171 (15, 32),
1172 (15, 33),
1173 (18, 32),
1174 (18, 33),
1175 (19, 33),
1176 (20, 32),
1177 (20, 33),
1178 (22, 32),
1179 (22, 33),
1180 (23, 25),
1181 (23, 27),
1182 (23, 29),
1183 (23, 32),
1184 (23, 33),
1185 (24, 25),
1186 (24, 27),
1187 (24, 31),
1188 (25, 31),
1189 (26, 29),
1190 (26, 33),
1191 (27, 33),
1192 (28, 31),
1193 (28, 33),
1194 (29, 32),
1195 (29, 33),
1196 (30, 32),
1197 (30, 33),
1198 (31, 32),
1199 (31, 33),
1200 (32, 33),
1201 ];
1202 let g = Graph::from_edges(edges, false, None).unwrap();
1203 let result = spinglass(&g, None).unwrap();
1204 assert_eq!(result.membership.len(), 34);
1205 assert!(
1207 result.nb_clusters >= 2 && result.nb_clusters <= 10,
1208 "Unexpected number of clusters: {}",
1209 result.nb_clusters
1210 );
1211 assert!(
1213 result.modularity > 0.2,
1214 "Modularity too low: {}",
1215 result.modularity
1216 );
1217 }
1218}