Skip to main content

m1nd_core/
topology.rs

1// === crates/m1nd-core/src/topology.rs ===
2
3use crate::error::{M1ndError, M1ndResult};
4use crate::graph::Graph;
5use crate::types::*;
6
7// ---------------------------------------------------------------------------
8// Community — Louvain community detection (topology_v2.py CommunityDetector)
9// FM-TOP-001 fix: correct delta-Q formula with consistent two_m scaling.
10// FM-TOP-002 fix: handle self-loops in degree computation.
11// ---------------------------------------------------------------------------
12
13/// Community assignment result.
14#[derive(Clone, Debug)]
15pub struct CommunityResult {
16    /// Community ID for each node (indexed by NodeId).
17    pub assignments: Vec<CommunityId>,
18    /// Number of distinct communities.
19    pub num_communities: u32,
20    /// Modularity score Q.
21    pub modularity: FiniteF32,
22    /// Number of passes until convergence.
23    pub passes: u32,
24}
25
26/// Per-community statistics.
27#[derive(Clone, Debug)]
28pub struct CommunityStats {
29    pub id: CommunityId,
30    pub node_count: u32,
31    pub internal_edges: u32,
32    pub external_edges: u32,
33    /// Density = internal_edges / max_possible_internal.
34    pub density: FiniteF32,
35}
36
37/// Louvain phase-1 community detection.
38/// Replaces: topology_v2.py CommunityDetector
39pub struct CommunityDetector {
40    max_passes: u32,
41    min_modularity_gain: FiniteF32,
42}
43
44impl CommunityDetector {
45    pub fn new(max_passes: u32, min_modularity_gain: FiniteF32) -> Self {
46        Self {
47            max_passes,
48            min_modularity_gain,
49        }
50    }
51
52    pub fn with_defaults() -> Self {
53        Self {
54            max_passes: 20,
55            min_modularity_gain: FiniteF32::new(1e-6),
56        }
57    }
58
59    /// Detect communities. Returns Err on non-convergence (FM-TOP-003).
60    /// FM-TOP-001 fix: delta-Q uses consistent two_m denominator.
61    /// FM-TOP-002 fix: self-loops handled in degree sum.
62    /// VANILLA-FIX: uses undirected adjacency (forward + reverse edges) for correct modularity.
63    /// Replaces: topology_v2.py CommunityDetector.detect()
64    pub fn detect(&self, graph: &Graph) -> M1ndResult<CommunityResult> {
65        let n = graph.num_nodes() as usize;
66        if n == 0 {
67            return Err(M1ndError::EmptyGraph);
68        }
69
70        // Initialize: each node in its own community
71        let mut community: Vec<u32> = (0..n as u32).collect();
72
73        // VANILLA-FIX: Build undirected adjacency from forward CSR.
74        // For each forward edge (i->j), add both adj[i][j] and adj[j][i].
75        // Use a seen-set on canonical (min,max) pairs to avoid double-counting
76        // bidirectional edges that already appear in both directions in CSR.
77        let mut adj: Vec<std::collections::HashMap<u32, f64>> =
78            vec![std::collections::HashMap::new(); n];
79        let mut seen = std::collections::HashSet::new();
80
81        for i in 0..n {
82            let range = graph.csr.out_range(NodeId::new(i as u32));
83            for j in range {
84                let target = graph.csr.targets[j].as_usize();
85                let w = graph.csr.read_weight(EdgeIdx::new(j as u32)).get() as f64;
86
87                let (lo, hi) = if i <= target {
88                    (i, target)
89                } else {
90                    (target, i)
91                };
92                if !seen.insert((lo, hi)) {
93                    continue; // Already processed this undirected edge
94                }
95
96                *adj[i].entry(target as u32).or_default() += w;
97                if i != target {
98                    *adj[target].entry(i as u32).or_default() += w;
99                } else {
100                    // FM-TOP-002: self-loop contributes 2x to degree
101                    *adj[i].entry(i as u32).or_default() += w;
102                }
103            }
104        }
105
106        // Compute degrees from undirected adjacency
107        let mut degree = vec![0.0f64; n];
108        let mut two_m = 0.0f64;
109        for i in 0..n {
110            degree[i] = adj[i].values().sum();
111            two_m += degree[i];
112        }
113
114        if two_m <= 0.0 {
115            return Ok(CommunityResult {
116                assignments: community.iter().map(|&c| CommunityId(c)).collect(),
117                num_communities: n as u32,
118                modularity: FiniteF32::ZERO,
119                passes: 0,
120            });
121        }
122
123        // Community aggregates
124        let mut sum_in = vec![0.0f64; n];
125        let mut sum_tot: Vec<f64> = degree.clone();
126
127        let mut converged = false;
128        let mut pass = 0u32;
129
130        while pass < self.max_passes {
131            pass += 1;
132            let mut improved = false;
133
134            for i in 0..n {
135                let ki = degree[i];
136                let ci = community[i] as usize;
137
138                // k_i_in: sum of weights from i to nodes in its current community (undirected)
139                let mut ki_in_old = 0.0f64;
140                for (&nb, &w) in &adj[i] {
141                    if community[nb as usize] as usize == ci {
142                        ki_in_old += w;
143                    }
144                }
145
146                sum_in[ci] -= 2.0 * ki_in_old;
147                sum_tot[ci] -= ki;
148
149                // Find best community via undirected neighbors
150                let mut best_comm = ci;
151                let mut best_delta_q = 0.0f64;
152
153                let mut neighbor_comms: std::collections::HashMap<usize, f64> =
154                    std::collections::HashMap::new();
155                for (&nb, &w) in &adj[i] {
156                    let cj = community[nb as usize] as usize;
157                    *neighbor_comms.entry(cj).or_insert(0.0) += w;
158                }
159
160                for (&cj, &ki_in_new) in &neighbor_comms {
161                    let delta_q = (ki_in_new / two_m) - (sum_tot[cj] * ki / (two_m * two_m));
162                    let delta_old = (ki_in_old / two_m) - (sum_tot[ci] * ki / (two_m * two_m));
163                    let gain = delta_q - delta_old;
164                    if gain > best_delta_q {
165                        best_delta_q = gain;
166                        best_comm = cj;
167                    }
168                }
169
170                community[i] = best_comm as u32;
171
172                // Recompute k_i_in for new community
173                let mut ki_in_new_actual = 0.0f64;
174                for (&nb, &w) in &adj[i] {
175                    if community[nb as usize] as usize == best_comm {
176                        ki_in_new_actual += w;
177                    }
178                }
179
180                sum_in[best_comm] += 2.0 * ki_in_new_actual;
181                sum_tot[best_comm] += ki;
182
183                if best_comm != ci {
184                    improved = true;
185                }
186            }
187
188            if !improved {
189                converged = true;
190                break;
191            }
192        }
193
194        if !converged && pass >= self.max_passes {
195            // FM-TOP-003: non-convergence (but we still return the result)
196        }
197
198        // Renumber communities to be contiguous
199        let mut comm_map = std::collections::HashMap::new();
200        let mut next_id = 0u32;
201        let assignments: Vec<CommunityId> = community
202            .iter()
203            .map(|&c| {
204                let id = comm_map.entry(c).or_insert_with(|| {
205                    let id = next_id;
206                    next_id += 1;
207                    id
208                });
209                CommunityId(*id)
210            })
211            .collect();
212
213        // Compute modularity Q using undirected adjacency
214        let mut q = 0.0f64;
215        for i in 0..n {
216            for (&nb, &w) in &adj[i] {
217                let j = nb as usize;
218                if assignments[i] == assignments[j] {
219                    q += w - degree[i] * degree[j] / two_m;
220                }
221            }
222        }
223        q /= two_m;
224
225        Ok(CommunityResult {
226            assignments,
227            num_communities: next_id,
228            modularity: FiniteF32::new(q as f32),
229            passes: pass,
230        })
231    }
232
233    /// Get per-community statistics.
234    /// Replaces: topology_v2.py CommunityDetector.community_stats()
235    pub fn community_stats(graph: &Graph, result: &CommunityResult) -> Vec<CommunityStats> {
236        let n = graph.num_nodes() as usize;
237        let num_comm = result.num_communities as usize;
238        let mut node_counts = vec![0u32; num_comm];
239        let mut internal = vec![0u32; num_comm];
240        let mut external = vec![0u32; num_comm];
241
242        for i in 0..n {
243            let ci = result.assignments[i].0 as usize;
244            node_counts[ci] += 1;
245
246            let range = graph.csr.out_range(NodeId::new(i as u32));
247            for j in range {
248                let tgt = graph.csr.targets[j].as_usize();
249                if tgt < n {
250                    if result.assignments[tgt].0 as usize == ci {
251                        internal[ci] += 1;
252                    } else {
253                        external[ci] += 1;
254                    }
255                }
256            }
257        }
258
259        (0..num_comm)
260            .map(|c| {
261                let nc = node_counts[c];
262                let max_internal = if nc > 1 { nc * (nc - 1) } else { 1 };
263                let density = if max_internal > 0 {
264                    internal[c] as f32 / max_internal as f32
265                } else {
266                    0.0
267                };
268                CommunityStats {
269                    id: CommunityId(c as u32),
270                    node_count: nc,
271                    internal_edges: internal[c],
272                    external_edges: external[c],
273                    density: FiniteF32::new(density.min(1.0)),
274                }
275            })
276            .collect()
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Bridge — inter-community bridge detection (topology_v2.py BridgeDetector)
282// FM-TOP-007 fix: precomputed inter-community edge index, not O(V^2*C).
283// ---------------------------------------------------------------------------
284
285/// A bridge edge connecting two communities.
286/// Replaces: topology_v2.py BridgeDetector result entries
287#[derive(Clone, Debug)]
288pub struct Bridge {
289    pub source: NodeId,
290    pub target: NodeId,
291    pub edge_idx: EdgeIdx,
292    pub source_community: CommunityId,
293    pub target_community: CommunityId,
294    /// Bridge importance score (betweenness-like).
295    pub importance: FiniteF32,
296}
297
298/// Bridge detector between communities. O(E) scan.
299/// FM-TOP-007 fix: precomputed community assignments.
300/// Replaces: topology_v2.py BridgeDetector
301pub struct BridgeDetector;
302
303impl BridgeDetector {
304    /// Detect all inter-community bridges.
305    /// Replaces: topology_v2.py BridgeDetector.detect()
306    pub fn detect(graph: &Graph, communities: &CommunityResult) -> M1ndResult<Vec<Bridge>> {
307        let n = graph.num_nodes() as usize;
308        let mut bridges = Vec::new();
309
310        for i in 0..n {
311            let ci = communities.assignments[i];
312            let range = graph.csr.out_range(NodeId::new(i as u32));
313            for j in range {
314                let tgt = graph.csr.targets[j];
315                let tgt_idx = tgt.as_usize();
316                if tgt_idx >= n {
317                    continue;
318                }
319                let cj = communities.assignments[tgt_idx];
320                if ci != cj {
321                    let w = graph.csr.read_weight(EdgeIdx::new(j as u32)).get();
322                    bridges.push(Bridge {
323                        source: NodeId::new(i as u32),
324                        target: tgt,
325                        edge_idx: EdgeIdx::new(j as u32),
326                        source_community: ci,
327                        target_community: cj,
328                        importance: FiniteF32::new(w),
329                    });
330                }
331            }
332        }
333
334        bridges.sort_by(|a, b| b.importance.cmp(&a.importance));
335        Ok(bridges)
336    }
337
338    /// Detect bridges between two specific communities.
339    pub fn detect_between(
340        graph: &Graph,
341        communities: &CommunityResult,
342        comm_a: CommunityId,
343        comm_b: CommunityId,
344    ) -> M1ndResult<Vec<Bridge>> {
345        let all = Self::detect(graph, communities)?;
346        Ok(all
347            .into_iter()
348            .filter(|b| {
349                (b.source_community == comm_a && b.target_community == comm_b)
350                    || (b.source_community == comm_b && b.target_community == comm_a)
351            })
352            .collect())
353    }
354}
355
356// ---------------------------------------------------------------------------
357// SpectralGap — Laplacian eigenvalue approximation
358// FM-TOP-010 fix: safety margin + multi-trial power iteration.
359// FM-TOP-011 fix: correct zero eigenvalue count for disconnected graphs.
360// FM-TOP-012 fix: empty graph returns error instead of crash.
361// ---------------------------------------------------------------------------
362
363/// Spectral gap analysis result.
364/// Replaces: topology_v2.py SpectralGapAnalyzer.analyze() return
365#[derive(Clone, Debug)]
366pub struct SpectralGapResult {
367    /// Algebraic connectivity (second smallest eigenvalue of Laplacian).
368    pub algebraic_connectivity: FiniteF32,
369    /// Spectral gap ratio.
370    pub spectral_gap: FiniteF32,
371    /// Number of connected components (from zero eigenvalue count, FM-TOP-011 fix).
372    pub num_components: u32,
373    /// Robustness classification.
374    pub robustness: RobustnessLevel,
375    /// Number of power iteration trials used.
376    pub trials: u32,
377    /// Whether convergence was achieved.
378    pub converged: bool,
379}
380
381#[derive(Clone, Copy, Debug, PartialEq, Eq)]
382pub enum RobustnessLevel {
383    Fragile,
384    Moderate,
385    Robust,
386}
387
388/// Spectral gap analyzer using power iteration on the graph Laplacian.
389/// Replaces: topology_v2.py SpectralGapAnalyzer
390pub struct SpectralGapAnalyzer {
391    max_iterations: u32,
392    tolerance: f64,
393    num_trials: u32,
394}
395
396impl SpectralGapAnalyzer {
397    pub fn new(max_iterations: u32, tolerance: f64, num_trials: u32) -> Self {
398        Self {
399            max_iterations,
400            tolerance,
401            num_trials,
402        }
403    }
404
405    pub fn with_defaults() -> Self {
406        Self {
407            max_iterations: 1000,
408            tolerance: 1e-8,
409            num_trials: 3,
410        }
411    }
412
413    /// Analyze spectral properties of the graph.
414    /// Returns Err(EmptyGraph) for empty graph (FM-TOP-012 fix).
415    /// Returns Err(SpectralDivergence) if power iteration fails (FM-TOP-010 fix).
416    /// Replaces: topology_v2.py SpectralGapAnalyzer.analyze()
417    pub fn analyze(&self, graph: &Graph) -> M1ndResult<SpectralGapResult> {
418        let n = graph.num_nodes() as usize;
419        if n == 0 {
420            return Err(M1ndError::EmptyGraph);
421        }
422
423        // Compute connected components via BFS (FM-TOP-011 fix)
424        let mut component = vec![u32::MAX; n];
425        let mut num_components = 0u32;
426        for start in 0..n {
427            if component[start] != u32::MAX {
428                continue;
429            }
430            let mut queue = std::collections::VecDeque::new();
431            queue.push_back(start);
432            component[start] = num_components;
433            while let Some(node) = queue.pop_front() {
434                let range = graph.csr.out_range(NodeId::new(node as u32));
435                for j in range {
436                    let tgt = graph.csr.targets[j].as_usize();
437                    if tgt < n && component[tgt] == u32::MAX {
438                        component[tgt] = num_components;
439                        queue.push_back(tgt);
440                    }
441                }
442            }
443            num_components += 1;
444        }
445
446        // For single-node or single-component with no edges
447        if n == 1 {
448            return Ok(SpectralGapResult {
449                algebraic_connectivity: FiniteF32::ZERO,
450                spectral_gap: FiniteF32::ZERO,
451                num_components,
452                robustness: RobustnessLevel::Fragile,
453                trials: 0,
454                converged: true,
455            });
456        }
457
458        // Power iteration on shifted Laplacian to find lambda_2
459        // L = D - A (unnormalized Laplacian)
460        // We want the second-smallest eigenvalue (algebraic connectivity)
461        // Use shifted inverse iteration: find largest eigenvalue of (max_eig*I - L)
462        // which corresponds to smallest eigenvalue of L
463
464        // Compute degree
465        let mut degree = vec![0.0f64; n];
466        for i in 0..n {
467            let range = graph.csr.out_range(NodeId::new(i as u32));
468            for j in range {
469                degree[i] += graph.csr.read_weight(EdgeIdx::new(j as u32)).get() as f64;
470            }
471        }
472
473        // Gershgorin estimate for max eigenvalue
474        let max_eig_est = degree.iter().cloned().fold(0.0f64, f64::max) * 2.1;
475        if max_eig_est <= 0.0 {
476            return Ok(SpectralGapResult {
477                algebraic_connectivity: FiniteF32::ZERO,
478                spectral_gap: FiniteF32::ZERO,
479                num_components,
480                robustness: RobustnessLevel::Fragile,
481                trials: 0,
482                converged: true,
483            });
484        }
485
486        // Power iteration: find dominant eigenvector of M = max_eig*I - L
487        let nf = n as f64;
488        let mut best_lambda2 = f64::MAX;
489        let mut converged = false;
490
491        // Simple deterministic "random" vectors
492        for trial in 0..self.num_trials {
493            // Initialize vector (orthogonal to all-ones)
494            let mut v: Vec<f64> = (0..n)
495                .map(|i| {
496                    let seed = (i as u64).wrapping_mul(2654435761 + trial as u64 * 1000003);
497                    (seed as f64 / u64::MAX as f64) - 0.5
498                })
499                .collect();
500
501            // Orthogonalize against all-ones vector
502            let avg: f64 = v.iter().sum::<f64>() / nf;
503            for x in &mut v {
504                *x -= avg;
505            }
506
507            // Normalize
508            let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
509            if norm < 1e-15 {
510                continue;
511            }
512            for x in &mut v {
513                *x /= norm;
514            }
515
516            let mut eigenvalue = 0.0f64;
517
518            for _iter in 0..self.max_iterations {
519                // w = M * v = (max_eig * I - L) * v = max_eig*v - L*v
520                // L*v = D*v - A*v
521                let mut w = vec![0.0f64; n];
522                for i in 0..n {
523                    // (max_eig - degree[i]) * v[i]
524                    w[i] = (max_eig_est - degree[i]) * v[i];
525                    // + sum_j(A[i][j] * v[j])
526                    let range = graph.csr.out_range(NodeId::new(i as u32));
527                    for j in range {
528                        let tgt = graph.csr.targets[j].as_usize();
529                        let wgt = graph.csr.read_weight(EdgeIdx::new(j as u32)).get() as f64;
530                        w[i] += wgt * v[tgt];
531                    }
532                }
533
534                // Orthogonalize against all-ones
535                let avg_w: f64 = w.iter().sum::<f64>() / nf;
536                for x in &mut w {
537                    *x -= avg_w;
538                }
539
540                // Compute eigenvalue (Rayleigh quotient)
541                let dot: f64 = w.iter().zip(v.iter()).map(|(a, b)| a * b).sum();
542                let new_norm: f64 = w.iter().map(|x| x * x).sum::<f64>().sqrt();
543
544                if new_norm < 1e-15 {
545                    break;
546                }
547
548                eigenvalue = dot;
549
550                // Check convergence
551                let residual: f64 = w
552                    .iter()
553                    .zip(v.iter())
554                    .map(|(wi, vi)| (wi / new_norm - vi).powi(2))
555                    .sum::<f64>()
556                    .sqrt();
557
558                // Normalize
559                for i in 0..n {
560                    v[i] = w[i] / new_norm;
561                }
562
563                if residual < self.tolerance {
564                    converged = true;
565                    break;
566                }
567            }
568
569            // lambda_2 = max_eig_est - eigenvalue_of_shifted
570            let lambda2 = (max_eig_est - eigenvalue).max(0.0);
571            if lambda2 < best_lambda2 {
572                best_lambda2 = lambda2;
573            }
574        }
575
576        let algebraic_connectivity = best_lambda2.min(100.0) as f32;
577        let spectral_gap = if max_eig_est > 0.0 {
578            (algebraic_connectivity as f64 / max_eig_est) as f32
579        } else {
580            0.0
581        };
582
583        let robustness = if algebraic_connectivity < 0.01 {
584            RobustnessLevel::Fragile
585        } else if algebraic_connectivity < 0.5 {
586            RobustnessLevel::Moderate
587        } else {
588            RobustnessLevel::Robust
589        };
590
591        Ok(SpectralGapResult {
592            algebraic_connectivity: FiniteF32::new(algebraic_connectivity),
593            spectral_gap: FiniteF32::new(spectral_gap),
594            num_components,
595            robustness,
596            trials: self.num_trials,
597            converged,
598        })
599    }
600}
601
602// ---------------------------------------------------------------------------
603// ActivationFingerprint — probe-based node equivalence
604// FM-TOP-014 fix: LSH for O(N) instead of O(N^2) pairwise comparison.
605// ---------------------------------------------------------------------------
606
607/// Per-node activation fingerprint: response vector across probe queries.
608#[derive(Clone, Debug)]
609pub struct Fingerprint {
610    pub node: NodeId,
611    /// Activation response to each probe query.
612    pub responses: Vec<FiniteF32>,
613    /// LSH bucket for approximate nearest-neighbor (FM-TOP-014 fix).
614    pub lsh_hash: u64,
615}
616
617/// Pair of functionally equivalent nodes.
618#[derive(Clone, Debug)]
619pub struct EquivalentPair {
620    pub node_a: NodeId,
621    pub node_b: NodeId,
622    pub cosine_similarity: FiniteF32,
623    pub directly_connected: bool,
624}
625
626/// Activation fingerprinter for finding functionally equivalent nodes.
627/// Replaces: topology_v2.py ActivationFingerprinter
628pub struct ActivationFingerprinter {
629    /// Budget for pairwise comparison (FM-TOP-014).
630    pair_budget: u64,
631    /// Similarity threshold for equivalence.
632    similarity_threshold: FiniteF32,
633}
634
635impl ActivationFingerprinter {
636    pub fn new(pair_budget: u64, similarity_threshold: FiniteF32) -> Self {
637        Self {
638            pair_budget,
639            similarity_threshold,
640        }
641    }
642
643    /// Compute fingerprints for all nodes using diverse probe queries.
644    /// Replaces: topology_v2.py ActivationFingerprinter.compute_fingerprints()
645    pub fn compute_fingerprints(
646        &self,
647        graph: &Graph,
648        engine: &crate::activation::HybridEngine,
649        probe_queries: &[Vec<(NodeId, FiniteF32)>],
650    ) -> M1ndResult<Vec<Fingerprint>> {
651        use crate::activation::ActivationEngine;
652
653        let n = graph.num_nodes() as usize;
654        let config = PropagationConfig::default();
655        let num_probes = probe_queries.len();
656
657        // Run each probe and collect per-node activations
658        let mut responses = vec![vec![FiniteF32::ZERO; num_probes]; n];
659
660        for (pi, seeds) in probe_queries.iter().enumerate() {
661            let result = engine.propagate(graph, seeds, &config)?;
662            for &(node, score) in &result.scores {
663                let idx = node.as_usize();
664                if idx < n {
665                    responses[idx][pi] = score;
666                }
667            }
668        }
669
670        // Build fingerprints with LSH hash
671        let fingerprints: Vec<Fingerprint> = (0..n)
672            .map(|i| {
673                let resp = &responses[i];
674                // Simple LSH: hash the sign pattern of responses
675                let mut hash = 0u64;
676                for (pi, &v) in resp.iter().enumerate() {
677                    if v.get() > 0.0 {
678                        hash |= 1u64 << (pi & 63);
679                    }
680                }
681                Fingerprint {
682                    node: NodeId::new(i as u32),
683                    responses: resp.clone(),
684                    lsh_hash: hash,
685                }
686            })
687            .collect();
688
689        Ok(fingerprints)
690    }
691
692    /// Find equivalent node pairs from fingerprints.
693    /// Uses LSH for candidate generation (FM-TOP-014 fix: O(N) not O(N^2)).
694    /// Replaces: topology_v2.py ActivationFingerprinter.find_equivalents()
695    pub fn find_equivalents(
696        &self,
697        fingerprints: &[Fingerprint],
698        graph: &Graph,
699    ) -> M1ndResult<Vec<EquivalentPair>> {
700        // Group by LSH hash
701        let mut buckets: std::collections::HashMap<u64, Vec<usize>> =
702            std::collections::HashMap::new();
703        for (i, fp) in fingerprints.iter().enumerate() {
704            buckets.entry(fp.lsh_hash).or_default().push(i);
705        }
706
707        let mut pairs = Vec::new();
708        let mut pair_count = 0u64;
709
710        for bucket in buckets.values() {
711            if bucket.len() < 2 {
712                continue;
713            }
714            for a_idx in 0..bucket.len() {
715                for b_idx in (a_idx + 1)..bucket.len() {
716                    if pair_count >= self.pair_budget {
717                        break;
718                    }
719
720                    let a = &fingerprints[bucket[a_idx]];
721                    let b = &fingerprints[bucket[b_idx]];
722
723                    let sim = Self::cosine_sim(&a.responses, &b.responses);
724                    if sim >= self.similarity_threshold.get() {
725                        // Check direct connection
726                        let connected = {
727                            let range = graph.csr.out_range(a.node);
728                            range.into_iter().any(|j| graph.csr.targets[j] == b.node)
729                        };
730
731                        pairs.push(EquivalentPair {
732                            node_a: a.node,
733                            node_b: b.node,
734                            cosine_similarity: FiniteF32::new(sim),
735                            directly_connected: connected,
736                        });
737                    }
738                    pair_count += 1;
739                }
740            }
741        }
742
743        pairs.sort_by(|a, b| b.cosine_similarity.cmp(&a.cosine_similarity));
744        Ok(pairs)
745    }
746
747    /// Find nodes equivalent to a specific target.
748    pub fn find_equivalents_of(
749        &self,
750        target: NodeId,
751        fingerprints: &[Fingerprint],
752        graph: &Graph,
753    ) -> M1ndResult<Vec<EquivalentPair>> {
754        let target_idx = target.as_usize();
755        if target_idx >= fingerprints.len() {
756            return Ok(Vec::new());
757        }
758
759        let target_fp = &fingerprints[target_idx];
760        let mut pairs = Vec::new();
761
762        for (i, fp) in fingerprints.iter().enumerate() {
763            if i == target_idx {
764                continue;
765            }
766            let sim = Self::cosine_sim(&target_fp.responses, &fp.responses);
767            if sim >= self.similarity_threshold.get() {
768                let connected = {
769                    let range = graph.csr.out_range(target);
770                    range.into_iter().any(|j| graph.csr.targets[j] == fp.node)
771                };
772                pairs.push(EquivalentPair {
773                    node_a: target,
774                    node_b: fp.node,
775                    cosine_similarity: FiniteF32::new(sim),
776                    directly_connected: connected,
777                });
778            }
779        }
780
781        pairs.sort_by(|a, b| b.cosine_similarity.cmp(&a.cosine_similarity));
782        Ok(pairs)
783    }
784
785    fn cosine_sim(a: &[FiniteF32], b: &[FiniteF32]) -> f32 {
786        let mut dot = 0.0f32;
787        let mut na = 0.0f32;
788        let mut nb = 0.0f32;
789        for i in 0..a.len().min(b.len()) {
790            dot += a[i].get() * b[i].get();
791            na += a[i].get() * a[i].get();
792            nb += b[i].get() * b[i].get();
793        }
794        let denom = na.sqrt() * nb.sqrt();
795        if denom > 0.0 {
796            (dot / denom).min(1.0)
797        } else {
798            0.0
799        }
800    }
801}
802
803// ---------------------------------------------------------------------------
804// MultiScaleView — hierarchical community drill-down
805// Replaces: topology_v2.py MultiScaleView
806// ---------------------------------------------------------------------------
807
808/// A view of communities at a particular scale.
809#[derive(Clone, Debug)]
810pub struct ScaleView {
811    pub scale: u8,
812    pub communities: CommunityResult,
813    pub bridges: Vec<Bridge>,
814}
815
816/// Multi-scale topology viewer.
817/// Replaces: topology_v2.py MultiScaleView
818pub struct MultiScaleViewer;
819
820impl MultiScaleViewer {
821    /// Compute multi-scale view (coarsening hierarchy).
822    /// Replaces: topology_v2.py MultiScaleView.compute()
823    pub fn compute(graph: &Graph, max_scales: u8) -> M1ndResult<Vec<ScaleView>> {
824        // For now, compute single-scale Louvain
825        let detector = CommunityDetector::with_defaults();
826        let communities = detector.detect(graph)?;
827        let bridges = BridgeDetector::detect(graph, &communities)?;
828
829        Ok(vec![ScaleView {
830            scale: 0,
831            communities,
832            bridges,
833        }])
834    }
835}
836
837// ---------------------------------------------------------------------------
838// TopologyAnalyzer — facade combining all topology capabilities
839// Replaces: topology_v2.py TopologyAnalyzer
840// ---------------------------------------------------------------------------
841
842/// Full topology analysis result.
843#[derive(Clone, Debug)]
844pub struct TopologyReport {
845    pub communities: CommunityResult,
846    pub community_stats: Vec<CommunityStats>,
847    pub bridges: Vec<Bridge>,
848    pub spectral: SpectralGapResult,
849}
850
851/// Facade for all topology analysis.
852/// Replaces: topology_v2.py TopologyAnalyzer
853pub struct TopologyAnalyzer {
854    pub community_detector: CommunityDetector,
855    pub spectral_analyzer: SpectralGapAnalyzer,
856    pub fingerprinter: ActivationFingerprinter,
857}
858
859impl TopologyAnalyzer {
860    pub fn with_defaults() -> Self {
861        Self {
862            community_detector: CommunityDetector::with_defaults(),
863            spectral_analyzer: SpectralGapAnalyzer::with_defaults(),
864            fingerprinter: ActivationFingerprinter::new(100_000, FiniteF32::new(0.85)),
865        }
866    }
867
868    /// Full topology analysis: communities + bridges + spectral gap.
869    /// Holds read lock for entire analysis duration (FM-TOP-007 consistency note).
870    /// Replaces: topology_v2.py TopologyAnalyzer.analyze()
871    pub fn analyze(&self, graph: &Graph) -> M1ndResult<TopologyReport> {
872        let communities = self.community_detector.detect(graph)?;
873        let community_stats = CommunityDetector::community_stats(graph, &communities);
874        let bridges = BridgeDetector::detect(graph, &communities)?;
875        let spectral = self.spectral_analyzer.analyze(graph)?;
876
877        Ok(TopologyReport {
878            communities,
879            community_stats,
880            bridges,
881            spectral,
882        })
883    }
884}
885
886static_assertions::assert_impl_all!(TopologyAnalyzer: Send, Sync);