Skip to main content

rust_igraph/algorithms/community/
spinglass.rs

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
13//! Spinglass community detection (Reichardt-Bornholdt 2006).
14//!
15//! Implements the community structure detection algorithm based on the
16//! Potts model with simulated annealing. Vertices are assigned spin states
17//! (1..q) and the Hamiltonian is minimized via heat-bath updates at
18//! decreasing temperatures.
19//!
20//! Two null models:
21//! - **Simple** (G(n,p)): baseline is a random graph with edge probability p
22//! - **Config** (configuration model): baseline respects the degree sequence
23//!
24//! Two update modes:
25//! - **Sequential**: randomly pick one node per step, update immediately
26//! - **Parallel**: compute optimal spins for all nodes, then update all at once
27//!
28//! Reference: Reichardt & Bornholdt, "Statistical Mechanics of Community
29//! Detection", Phys. Rev. E 74, 016110 (2006).
30//!
31//! # Example
32//!
33//! ```
34//! use rust_igraph::{Graph, spinglass};
35//!
36//! // Two triangles connected by a single edge
37//! let g = Graph::from_edges(&[(0,1),(1,2),(2,0),(3,4),(4,5),(5,3),(2,3)], false, None).unwrap();
38//! let result = spinglass(&g, None).unwrap();
39//! assert!(result.nb_clusters >= 2);
40//! ```
41
42use crate::core::{Graph, IgraphError, IgraphResult};
43
44/// Update rule for the spinglass null model.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SpinglassUpdateRule {
47    /// G(n,p) random graph null model.
48    Simple,
49    /// Configuration model (degree-preserving) null model.
50    Config,
51}
52
53/// Result of spinglass community detection.
54#[derive(Debug, Clone)]
55pub struct SpinglassResult {
56    /// Community membership for each vertex (0-indexed).
57    pub membership: Vec<u32>,
58    /// Generalized modularity of the partition.
59    pub modularity: f64,
60    /// Final temperature of the simulated annealing.
61    pub temperature: f64,
62    /// Number of communities found.
63    pub nb_clusters: u32,
64    /// Size of each community.
65    pub csize: Vec<u32>,
66}
67
68/// Options for spinglass community detection.
69#[derive(Debug, Clone)]
70pub struct SpinglassOptions {
71    /// Number of spins (maximum number of communities). Default: 25.
72    pub spins: u32,
73    /// Whether to use parallel update mode. Default: false.
74    pub parallel_update: bool,
75    /// Starting temperature. Default: 1.0.
76    pub start_temp: f64,
77    /// Stopping temperature. Default: 0.01.
78    pub stop_temp: f64,
79    /// Cooling factor for simulated annealing. Default: 0.99.
80    pub cool_fact: f64,
81    /// Update rule (null model). Default: Config.
82    pub update_rule: SpinglassUpdateRule,
83    /// Resolution parameter gamma. Default: 1.0.
84    pub gamma: f64,
85    /// Random seed for reproducibility. Default: 42.
86    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
104/// Default number of spins for spinglass.
105pub const SPINGLASS_DEFAULT_SPINS: u32 = 25;
106
107// ── Internal PRNG ──────────────────────────────────────────────────
108// Same SplitMix64 used in other community algorithms.
109
110struct 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
138// ── Internal Potts Model ───────────────────────────────────────────
139
140struct PottsModel {
141    /// Adjacency list: for each vertex, list of (neighbor, weight).
142    adj: Vec<Vec<(usize, f64)>>,
143    /// Number of vertices.
144    n: usize,
145    /// Number of spins (q).
146    q: usize,
147    /// Operation mode: 0 = Simple, 1 = Config.
148    operation_mode: usize,
149    /// Spin assignment per vertex (1..q).
150    spin: Vec<u32>,
151    /// Per-spin count/weight ("color_field"):
152    /// mode 0: count of vertices per spin
153    /// mode 1: sum of vertex degrees per spin
154    color_field: Vec<f64>,
155    /// Total degree sum.
156    total_degree_sum: f64,
157    /// Vertex weights (sum of incident edge weights).
158    vertex_weight: Vec<f64>,
159    /// Qmatrix[i][j] = sum of edge weights between spin-i and spin-j vertices.
160    q_matrix: Vec<Vec<f64>>,
161    /// Qa[i] = sum over j of Qmatrix[i][j].
162    qa: Vec<f64>,
163    /// Sum of all edge weights (each edge counted once).
164    sum_weights: f64,
165    /// Acceptance ratio.
166    acceptance: f64,
167    /// PRNG.
168    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        // Walk all edges once via the adjacency representation
244        // To avoid double-counting, we walk graph edges directly
245        // adj has both directions, so we need to be careful
246        // Actually we stored adj symmetrically, so each edge appears twice.
247        // We accumulate from adj but divide by 2... no, the C code also
248        // counts each edge twice in Qmatrix (both [i][j] and [j][i]).
249        // Let's replicate: for each directed (u->v) entry in adj, add to Qmatrix.
250        // Since adj is symmetric, each undirected edge gives two entries,
251        // which fills both Qmatrix[si][sj] and Qmatrix[sj][si].
252        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        // But wait - in the C code, each edge contributes to BOTH [i][j] and [j][i].
260        // Our adj is already symmetric (each edge stored twice), so each undirected
261        // edge contributes w to q_matrix[si][sj] from v's perspective and
262        // w to q_matrix[sj][si] from u's perspective. This matches the C behavior.
263
264        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    /// Sequential (random node) heat-bath update at temperature kT.
297    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                // Count neighbours per spin
306                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                    // Qmatrix update
358                    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    /// Parallel heat-bath update at temperature kT.
375    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            // Phase 1: compute new spins for all nodes
395            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            // Phase 2: apply all spin changes
444            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    /// Sequential (random node) heat-bath update at zero temperature (greedy).
483    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    /// Parallel heat-bath update at zero temperature.
540    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        // Count nodes per spin and build densified mapping
622        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        // Build mapping from spin -> dense community id
628        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        // Compute generalized modularity
645        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                    // Count inner and outer links for this spin
650                    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, // set by caller
677            nb_clusters,
678            csize,
679        }
680    }
681}
682
683// ── Public API ─────────────────────────────────────────────────────
684
685/// Spinglass community detection with default options.
686///
687/// The graph must be connected. Direction of edges is ignored.
688///
689/// # Errors
690///
691/// Returns an error if the graph is disconnected, has fewer than 2 vertices,
692/// or if weights contain negative values.
693pub fn spinglass(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<SpinglassResult> {
694    spinglass_with_options(graph, weights, &SpinglassOptions::default())
695}
696
697/// Spinglass community detection with edge weights.
698///
699/// # Errors
700///
701/// Returns an error if the graph is disconnected, has fewer than 2 vertices,
702/// or if weights contain non-positive values.
703pub fn spinglass_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<SpinglassResult> {
704    spinglass_with_options(graph, Some(weights), &SpinglassOptions::default())
705}
706
707/// Spinglass community detection with full options.
708///
709/// # Errors
710///
711/// Returns an error if the graph is disconnected, has fewer than 2 vertices,
712/// or if invalid options are provided.
713pub fn spinglass_with_options(
714    graph: &Graph,
715    weights: Option<&[f64]>,
716    options: &SpinglassOptions,
717) -> IgraphResult<SpinglassResult> {
718    let n = graph.vcount();
719
720    // Validation
721    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    // Trivial cases
764    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    // Check connectivity
778    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    // Re-assign initial configuration
810    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
843/// Check weak connectivity (simplified BFS).
844fn 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// ── Tests ──────────────────────────────────────────────────────────
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    #[test]
876    fn basic_two_communities() {
877        // Two K4 cliques connected by a single bridge edge
878        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), // bridge
893            ],
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        // Cluster sizes should sum to 8
912        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        // Two communities with strong internal edges and weak bridge
974        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        // Karate club typically yields 3-5 communities
1206        assert!(
1207            result.nb_clusters >= 2 && result.nb_clusters <= 10,
1208            "Unexpected number of clusters: {}",
1209            result.nb_clusters
1210        );
1211        // Modularity should be positive
1212        assert!(
1213            result.modularity > 0.2,
1214            "Modularity too low: {}",
1215            result.modularity
1216        );
1217    }
1218}