Skip to main content

m1nd_core/
layer.rs

1// === m1nd-core/src/layer.rs ===
2//
3// Architectural layer detection from graph topology.
4// Tarjan SCC -> BFS depth -> layer grouping -> violation detection.
5
6use crate::error::{M1ndError, M1ndResult};
7use crate::graph::Graph;
8use crate::types::*;
9use serde::Serialize;
10use std::collections::{HashMap, HashSet, VecDeque};
11
12// ── Constants ──
13
14/// Maximum number of architectural layers the detector will produce (default).
15pub const DEFAULT_MAX_LAYERS: u8 = 8;
16/// Minimum number of nodes a layer must contain before it is merged into an adjacent layer (default).
17pub const DEFAULT_MIN_NODES_PER_LAYER: u32 = 2;
18
19/// Threshold for classifying a node as utility/cross-cutting:
20/// used by >= ceil(total_layers * UTILITY_LAYER_FRACTION) layers.
21const UTILITY_LAYER_FRACTION: f32 = 0.5;
22
23// ── Core Types ──
24
25/// A single detected architectural layer in the dependency hierarchy.
26#[derive(Clone, Debug, Serialize)]
27pub struct ArchLayer {
28    /// Zero-based level index (0 = highest/entry point, N = lowest/foundation).
29    pub level: u8,
30    /// Human-readable layer name derived from the naming strategy.
31    pub name: String,
32    /// Short description of the layer's role.
33    pub description: String,
34    /// Node IDs assigned to this layer.
35    pub nodes: Vec<NodeId>,
36    /// Per-node confidence scores in `[0.0, 1.0]` (parallel to `nodes`).
37    pub node_confidence: Vec<f32>,
38    /// Mean PageRank across all nodes in this layer.
39    pub avg_pagerank: f32,
40    /// Mean out-degree across all nodes in this layer.
41    pub avg_out_degree: f32,
42}
43
44/// Severity of a detected layering violation.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
46pub enum ViolationSeverity {
47    /// Minor — low-weight edge crossing one layer boundary.
48    Low,
49    /// Moderate — skip-layer or medium-weight upward dependency.
50    Medium,
51    /// Significant — high-weight or multi-layer violation.
52    High,
53    /// Critical — circular dependency (SCC with >1 node).
54    Critical,
55}
56
57/// Category of a detected layering violation.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
59pub enum ViolationType {
60    /// A node in a deeper layer depends on a node in a shallower layer by exactly one level.
61    UpwardDependency,
62    /// A node in a deeper layer depends on a node two or more levels shallower.
63    SkipLayerDependency,
64    /// Two or more nodes form a mutual dependency cycle (Tarjan SCC).
65    CircularDependency,
66}
67
68/// A single layering violation detected in the dependency graph.
69#[derive(Clone, Debug, Serialize)]
70pub struct LayerViolation {
71    /// ID of the node originating the problematic edge.
72    pub source: NodeId,
73    /// Layer level of the source node.
74    pub source_layer: u8,
75    /// ID of the node that is the target of the problematic edge.
76    pub target: NodeId,
77    /// Layer level of the target node.
78    pub target_layer: u8,
79    /// Relation label on the edge.
80    pub edge_relation: String,
81    /// Weight of the edge.
82    pub edge_weight: f32,
83    /// Assessed severity.
84    pub severity: ViolationSeverity,
85    /// Category of violation.
86    pub violation_type: ViolationType,
87    /// Human-readable explanation of the violation.
88    pub explanation: String,
89}
90
91/// Classification of a utility/cross-cutting node.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
93pub enum UtilityClassification {
94    /// Used by ≥50 % of all layers — excluded from layer assignment.
95    CrossCutting,
96    /// Used by two or more non-adjacent layers — kept in layers but flagged.
97    Bridge,
98    /// No incoming or outgoing edges within the candidate set.
99    Orphan,
100}
101
102/// A node identified as cross-cutting, a bridge between non-adjacent layers, or an orphan.
103#[derive(Clone, Debug, Serialize)]
104pub struct UtilityNode {
105    /// The node ID.
106    pub node: NodeId,
107    /// Layer levels that reference this node via incoming edges.
108    pub used_by_layers: Vec<u8>,
109    /// How this node was classified.
110    pub classification: UtilityClassification,
111}
112
113/// Full output from a layer detection run.
114#[derive(Clone, Debug, Serialize)]
115pub struct LayerDetectionResult {
116    /// Detected layers, sorted by level ascending.
117    pub layers: Vec<ArchLayer>,
118    /// All layering violations found.
119    pub violations: Vec<LayerViolation>,
120    /// Nodes classified as cross-cutting, bridge, or orphan.
121    pub utility_nodes: Vec<UtilityNode>,
122    /// `true` if Tarjan SCC found any dependency cycle.
123    pub has_cycles: bool,
124    /// Score in `[0.0, 1.0]` — 1.0 means perfectly separated layers, 0.0 means full cycle chaos.
125    pub layer_separation_score: f32,
126    /// Number of nodes assigned to a layer (utility nodes excluded).
127    pub total_nodes_classified: u32,
128}
129
130/// Health metrics for a single architectural layer.
131#[derive(Clone, Debug, Serialize)]
132pub struct LayerHealth {
133    /// Ratio of intra-layer edges to the theoretical maximum — measures how internally cohesive the layer is.
134    pub cohesion: f32,
135    /// Fraction of external edges that go to a shallower (higher-level) layer — upward coupling is a smell.
136    pub coupling_up: f32,
137    /// Fraction of external edges that go to a deeper (lower-level) layer — normal downward dependency.
138    pub coupling_down: f32,
139    /// Number of violations involving this layer divided by the layer's node count.
140    pub violation_density: f32,
141}
142
143/// Cache for layer detection results to avoid re-running the full algorithm on unchanged graphs.
144#[derive(Clone, Debug)]
145pub struct LayerCache {
146    /// Graph generation counter at the time the result was computed.
147    pub graph_generation: u64,
148    /// Monotonically increasing cache generation counter for invalidation.
149    pub cache_generation: u64,
150    /// Scope string used when the result was computed (`None` = whole graph).
151    pub scope: Option<String>,
152    /// Cached detection output.
153    pub result: LayerDetectionResult,
154}
155
156// ── Engine ──
157
158/// Architectural layer detector.
159///
160/// Runs a multi-phase pipeline: Tarjan SCC → BFS depth assignment →
161/// layer grouping + merging → utility-node detection → violation detection.
162pub struct LayerDetector {
163    /// Maximum number of layers to produce; adjacent layers are merged until this cap is satisfied.
164    pub max_layers: u8,
165    /// Minimum node count per layer; tiny layers are absorbed into neighbours.
166    pub min_nodes_per_layer: u32,
167}
168
169impl LayerDetector {
170    /// Create a detector with the given parameters.
171    ///
172    /// # Parameters
173    /// - `max_layers`: upper bound on the number of output layers.
174    /// - `min_nodes_per_layer`: layers smaller than this are merged into an adjacent layer.
175    pub fn new(max_layers: u8, min_nodes_per_layer: u32) -> Self {
176        Self {
177            max_layers,
178            min_nodes_per_layer,
179        }
180    }
181
182    /// Create a detector using [`DEFAULT_MAX_LAYERS`] and [`DEFAULT_MIN_NODES_PER_LAYER`].
183    pub fn with_defaults() -> Self {
184        Self::new(DEFAULT_MAX_LAYERS, DEFAULT_MIN_NODES_PER_LAYER)
185    }
186
187    /// Detect architectural layers from graph topology.
188    ///
189    /// Algorithm: Tarjan SCC → BFS depth assignment → layer grouping + merging →
190    /// utility-node detection → violation detection.
191    ///
192    /// # Parameters
193    /// - `graph`: the connectivity graph to analyse.
194    /// - `scope`: optional path prefix to restrict which nodes are considered.
195    /// - `node_type_filter`: if non-empty, only nodes of these types are included.
196    /// - `exclude_tests`: if `true`, nodes whose labels contain "test" are dropped.
197    /// - `naming_strategy`: one of `"heuristic"`, `"path_prefix"`, or `"pagerank"`.
198    ///
199    /// # Errors
200    /// Returns `M1ndError::EmptyGraph` if no candidate nodes match the filters.
201    pub fn detect(
202        &self,
203        graph: &Graph,
204        scope: Option<&str>,
205        node_type_filter: &[NodeType],
206        exclude_tests: bool,
207        naming_strategy: &str,
208    ) -> M1ndResult<LayerDetectionResult> {
209        let n = graph.num_nodes() as usize;
210        if n == 0 {
211            return Err(M1ndError::EmptyGraph);
212        }
213
214        // Phase 0: Collect candidate nodes (scope + type filter)
215        let mut candidates = layer_collect_candidates(graph, scope, node_type_filter);
216
217        // Exclude test files if requested
218        if exclude_tests {
219            candidates.retain(|&nid| {
220                let idx = nid.as_usize();
221                if idx >= graph.nodes.count as usize {
222                    return false;
223                }
224                let label = graph.strings.resolve(graph.nodes.label[idx]).to_lowercase();
225                !label.contains("test")
226            });
227        }
228
229        if candidates.is_empty() {
230            return Err(M1ndError::EmptyGraph);
231        }
232
233        let candidate_set: HashSet<NodeId> = candidates.iter().copied().collect();
234
235        // Phase 1: Tarjan SCC detection
236        let sccs = tarjan_scc(graph, &candidates);
237        let has_cycles = !sccs.is_empty();
238
239        // Build SCC membership: node -> scc_representative (lowest NodeId in SCC)
240        let mut scc_map: HashMap<NodeId, NodeId> = HashMap::new();
241        for scc in &sccs {
242            let representative = *scc.iter().min().unwrap();
243            for &node in scc {
244                scc_map.insert(node, representative);
245            }
246        }
247
248        // Build DAG nodes: either the node itself or its SCC representative
249        let mut dag_nodes: Vec<NodeId> = Vec::new();
250        let mut dag_set: HashSet<NodeId> = HashSet::new();
251        for &node in &candidates {
252            let effective = scc_map.get(&node).copied().unwrap_or(node);
253            if dag_set.insert(effective) {
254                dag_nodes.push(effective);
255            }
256        }
257
258        // Phase 2: BFS depth assignment on the DAG
259        // Compute in-degree for DAG nodes considering only edges within candidate set
260        let mut in_degree: HashMap<NodeId, u32> = HashMap::new();
261        for &node in &dag_nodes {
262            in_degree.insert(node, 0);
263        }
264
265        for &node in &candidates {
266            let effective_src = scc_map.get(&node).copied().unwrap_or(node);
267            let range = graph.csr.out_range(node);
268            for j in range {
269                let target = graph.csr.targets[j];
270                if !candidate_set.contains(&target) {
271                    continue;
272                }
273                let effective_tgt = scc_map.get(&target).copied().unwrap_or(target);
274                if effective_src != effective_tgt {
275                    *in_degree.entry(effective_tgt).or_insert(0) += 1;
276                }
277            }
278        }
279
280        // Find roots (in-degree == 0)
281        let mut queue: VecDeque<NodeId> = VecDeque::new();
282        let mut depth: HashMap<NodeId, u32> = HashMap::new();
283        for &node in &dag_nodes {
284            if *in_degree.get(&node).unwrap_or(&0) == 0 {
285                queue.push_back(node);
286                depth.insert(node, 0);
287            }
288        }
289
290        // If no roots found (all nodes in cycles), pick nodes with min in-degree
291        if queue.is_empty() {
292            let min_in = dag_nodes
293                .iter()
294                .map(|n| *in_degree.get(n).unwrap_or(&0))
295                .min()
296                .unwrap_or(0);
297            for &node in &dag_nodes {
298                if *in_degree.get(&node).unwrap_or(&0) == min_in {
299                    queue.push_back(node);
300                    depth.insert(node, 0);
301                }
302            }
303        }
304
305        // Build forward adjacency for DAG
306        let mut dag_adj: HashMap<NodeId, HashSet<NodeId>> = HashMap::new();
307        for &node in &candidates {
308            let effective_src = scc_map.get(&node).copied().unwrap_or(node);
309            let range = graph.csr.out_range(node);
310            for j in range {
311                let target = graph.csr.targets[j];
312                if !candidate_set.contains(&target) {
313                    continue;
314                }
315                let effective_tgt = scc_map.get(&target).copied().unwrap_or(target);
316                if effective_src != effective_tgt {
317                    dag_adj
318                        .entry(effective_src)
319                        .or_default()
320                        .insert(effective_tgt);
321                }
322            }
323        }
324
325        // BFS: assign depth = max(depth of parents) + 1 for longest-path layering
326        // We use iterative relaxation (Kahn-like) for this
327        let mut visited: HashSet<NodeId> = HashSet::new();
328        while let Some(node) = queue.pop_front() {
329            if !visited.insert(node) {
330                continue;
331            }
332            let current_depth = *depth.get(&node).unwrap_or(&0);
333            if let Some(neighbors) = dag_adj.get(&node) {
334                for &next in neighbors {
335                    let new_depth = current_depth + 1;
336                    let entry = depth.entry(next).or_insert(0);
337                    if new_depth > *entry {
338                        *entry = new_depth;
339                    }
340                    // Decrement in-degree; if zero, enqueue
341                    let deg = in_degree.entry(next).or_insert(0);
342                    if *deg > 0 {
343                        *deg -= 1;
344                    }
345                    if *deg == 0 {
346                        queue.push_back(next);
347                    }
348                }
349            }
350        }
351
352        // Handle unreachable nodes (disconnected components)
353        for &node in &dag_nodes {
354            depth.entry(node).or_insert(0);
355        }
356
357        // Map DAG depths back to original nodes
358        let mut node_depth: HashMap<NodeId, u32> = HashMap::new();
359        for &node in &candidates {
360            let effective = scc_map.get(&node).copied().unwrap_or(node);
361            let d = *depth.get(&effective).unwrap_or(&0);
362            node_depth.insert(node, d);
363        }
364
365        // Phase 3: Group into layers
366        let max_depth = node_depth.values().copied().max().unwrap_or(0);
367
368        // Build initial layers by depth
369        let mut depth_groups: Vec<Vec<NodeId>> = vec![Vec::new(); (max_depth + 1) as usize];
370        for &node in &candidates {
371            let d = *node_depth.get(&node).unwrap_or(&0);
372            depth_groups[d as usize].push(node);
373        }
374
375        // Merge layers if too many (> max_layers)
376        while depth_groups.len() > self.max_layers as usize && depth_groups.len() > 1 {
377            // Find two adjacent layers with most similar avg PageRank to merge
378            let mut best_idx = 0;
379            let mut best_diff = f32::MAX;
380            for i in 0..depth_groups.len() - 1 {
381                let pr_a = layer_avg_pagerank(graph, &depth_groups[i]);
382                let pr_b = layer_avg_pagerank(graph, &depth_groups[i + 1]);
383                let diff = (pr_a - pr_b).abs();
384                if diff < best_diff {
385                    best_diff = diff;
386                    best_idx = i;
387                }
388            }
389            let merged = depth_groups.remove(best_idx + 1);
390            depth_groups[best_idx].extend(merged);
391        }
392
393        // Prune tiny layers (below min_nodes_per_layer) by merging into nearest
394        let mut i = 0;
395        while i < depth_groups.len() {
396            if (depth_groups[i].len() as u32) < self.min_nodes_per_layer && depth_groups.len() > 1 {
397                let removed = depth_groups.remove(i);
398                let merge_into = if i > 0 { i - 1 } else { 0 };
399                let merge_into = merge_into.min(depth_groups.len() - 1);
400                depth_groups[merge_into].extend(removed);
401                // Don't increment i, re-check the current position
402            } else {
403                i += 1;
404            }
405        }
406
407        // Update node_depth after merging
408        let mut node_layer: HashMap<NodeId, u8> = HashMap::new();
409        for (level, group) in depth_groups.iter().enumerate() {
410            for &node in group {
411                node_layer.insert(node, level as u8);
412            }
413        }
414
415        let total_layers = depth_groups.len();
416
417        // Phase 4: Utility node detection
418        let utility_threshold = (total_layers as f32 * UTILITY_LAYER_FRACTION).ceil() as usize;
419        let mut utility_nodes: Vec<UtilityNode> = Vec::new();
420        let mut utility_set: HashSet<NodeId> = HashSet::new();
421
422        for &node in &candidates {
423            // Count how many distinct layers reference this node (incoming edges)
424            let range = graph.csr.in_range(node);
425            let mut referencing_layers: HashSet<u8> = HashSet::new();
426            for j in range {
427                let source = graph.csr.rev_sources[j];
428                if candidate_set.contains(&source) && !scc_map.contains_key(&source)
429                    || scc_map.get(&source).copied() != scc_map.get(&node).copied()
430                {
431                    if let Some(&layer) = node_layer.get(&source) {
432                        if layer != *node_layer.get(&node).unwrap_or(&255) {
433                            referencing_layers.insert(layer);
434                        }
435                    }
436                }
437            }
438
439            let used_by: Vec<u8> = {
440                let mut v: Vec<u8> = referencing_layers.into_iter().collect();
441                v.sort();
442                v
443            };
444
445            if used_by.len() >= utility_threshold && utility_threshold > 0 {
446                utility_nodes.push(UtilityNode {
447                    node,
448                    used_by_layers: used_by,
449                    classification: UtilityClassification::CrossCutting,
450                });
451                utility_set.insert(node);
452            } else if used_by.len() >= 2 {
453                // Check for Bridge: used by 2 non-adjacent layers
454                let mut is_bridge = false;
455                for i in 0..used_by.len() {
456                    for j in i + 1..used_by.len() {
457                        if (used_by[j] as i16 - used_by[i] as i16).unsigned_abs() > 1 {
458                            is_bridge = true;
459                            break;
460                        }
461                    }
462                    if is_bridge {
463                        break;
464                    }
465                }
466                if is_bridge {
467                    utility_nodes.push(UtilityNode {
468                        node,
469                        used_by_layers: used_by,
470                        classification: UtilityClassification::Bridge,
471                    });
472                    // Bridges are NOT removed from layers, only CrossCutting nodes are
473                }
474            }
475        }
476
477        // Also detect orphan nodes (no incoming references at all)
478        for &node in &candidates {
479            if utility_set.contains(&node) {
480                continue;
481            }
482            let in_range = graph.csr.in_range(node);
483            let has_incoming = in_range.clone().any(|j| {
484                let source = graph.csr.rev_sources[j];
485                candidate_set.contains(&source) && source != node
486            });
487            let out_range = graph.csr.out_range(node);
488            let has_outgoing = out_range.clone().any(|j| {
489                let target = graph.csr.targets[j];
490                candidate_set.contains(&target) && target != node
491            });
492            if !has_incoming && !has_outgoing {
493                utility_nodes.push(UtilityNode {
494                    node,
495                    used_by_layers: Vec::new(),
496                    classification: UtilityClassification::Orphan,
497                });
498                utility_set.insert(node);
499            }
500        }
501
502        // Remove utility (CrossCutting + Orphan) nodes from layers
503        for group in depth_groups.iter_mut() {
504            group.retain(|n| !utility_set.contains(n));
505        }
506
507        // Phase 5: Build ArchLayer structs with naming
508        let mut layers: Vec<ArchLayer> = Vec::new();
509        for (level, group) in depth_groups.iter().enumerate() {
510            if group.is_empty() {
511                continue;
512            }
513
514            let avg_pr = layer_avg_pagerank(graph, group);
515            let avg_out = layer_avg_out_degree(graph, group);
516            let confidences: Vec<f32> = group
517                .iter()
518                .map(|&node| layer_node_confidence(graph, node, &node_layer, &candidate_set))
519                .collect();
520
521            let (name, description) = match naming_strategy {
522                "path_prefix" => layer_name_path_prefix(graph, group, level),
523                "pagerank" => layer_name_by_pagerank(graph, group, level),
524                _ => layer_name_heuristic(graph, group, level, depth_groups.len()),
525            };
526
527            layers.push(ArchLayer {
528                level: level as u8,
529                name,
530                description,
531                nodes: group.clone(),
532                node_confidence: confidences,
533                avg_pagerank: avg_pr,
534                avg_out_degree: avg_out,
535            });
536        }
537
538        // Re-number levels to be contiguous 0..N
539        for (i, layer) in layers.iter_mut().enumerate() {
540            layer.level = i as u8;
541        }
542
543        // Rebuild node_layer after re-numbering
544        let mut node_layer_final: HashMap<NodeId, u8> = HashMap::new();
545        for layer in &layers {
546            for &node in &layer.nodes {
547                node_layer_final.insert(node, layer.level);
548            }
549        }
550
551        // Phase 6: Violation detection
552        let mut violations: Vec<LayerViolation> = Vec::new();
553
554        // Add circular dependency violations for SCCs
555        for scc in &sccs {
556            for &node in scc {
557                let source_layer = node_layer_final.get(&node).copied().unwrap_or(0);
558                for &other in scc {
559                    if node == other {
560                        continue;
561                    }
562                    // Check if there's an actual edge
563                    let range = graph.csr.out_range(node);
564                    for j in range {
565                        let target = graph.csr.targets[j];
566                        if target == other {
567                            let rel = graph.strings.resolve(graph.csr.relations[j]).to_string();
568                            let w = graph.csr.read_weight(EdgeIdx::new(j as u32)).get();
569                            let src_label = graph
570                                .strings
571                                .resolve(graph.nodes.label[node.as_usize()])
572                                .to_string();
573                            let tgt_label = graph
574                                .strings
575                                .resolve(graph.nodes.label[other.as_usize()])
576                                .to_string();
577                            violations.push(LayerViolation {
578                                source: node,
579                                source_layer,
580                                target: other,
581                                target_layer: source_layer,
582                                edge_relation: rel,
583                                edge_weight: w,
584                                severity: ViolationSeverity::Critical,
585                                violation_type: ViolationType::CircularDependency,
586                                explanation: format!(
587                                    "Circular dependency: {} and {} form a dependency cycle",
588                                    src_label, tgt_label
589                                ),
590                            });
591                            break; // One violation per edge direction is enough
592                        }
593                    }
594                }
595            }
596        }
597
598        // Detect upward dependency violations
599        let mut total_cross_layer_edges: u32 = 0;
600        let mut violation_edges: f32 = 0.0;
601
602        for &node in &candidates {
603            if utility_set.contains(&node) {
604                continue;
605            }
606            let src_layer = match node_layer_final.get(&node) {
607                Some(&l) => l,
608                None => continue,
609            };
610
611            let range = graph.csr.out_range(node);
612            for j in range {
613                let target = graph.csr.targets[j];
614                if utility_set.contains(&target) {
615                    continue;
616                }
617                if !candidate_set.contains(&target) {
618                    continue;
619                }
620
621                let tgt_layer = match node_layer_final.get(&target) {
622                    Some(&l) => l,
623                    None => continue,
624                };
625
626                if src_layer != tgt_layer {
627                    total_cross_layer_edges += 1;
628                }
629
630                // Upward violation: source in deeper layer, target in shallower layer
631                if src_layer > tgt_layer {
632                    let rel = graph.strings.resolve(graph.csr.relations[j]).to_string();
633                    let w = graph.csr.read_weight(EdgeIdx::new(j as u32)).get();
634                    let gap = src_layer - tgt_layer;
635
636                    let (vtype, severity) = if gap > 1 {
637                        (
638                            ViolationType::SkipLayerDependency,
639                            layer_violation_severity(&rel, gap, w),
640                        )
641                    } else {
642                        (
643                            ViolationType::UpwardDependency,
644                            layer_violation_severity(&rel, gap, w),
645                        )
646                    };
647
648                    // Weighted penalty: skip-layer violations penalized more
649                    violation_edges += gap as f32;
650
651                    let src_name = layer_name_for_level(src_layer, &layers);
652                    let tgt_name = layer_name_for_level(tgt_layer, &layers);
653                    let src_label = graph
654                        .strings
655                        .resolve(graph.nodes.label[node.as_usize()])
656                        .to_string();
657                    let tgt_label = graph
658                        .strings
659                        .resolve(graph.nodes.label[target.as_usize()])
660                        .to_string();
661
662                    violations.push(LayerViolation {
663                        source: node,
664                        source_layer: src_layer,
665                        target,
666                        target_layer: tgt_layer,
667                        edge_relation: rel,
668                        edge_weight: w,
669                        severity,
670                        violation_type: vtype,
671                        explanation: format!(
672                            "{} (layer {}: {}) depends on {} (layer {}: {})",
673                            src_label, src_layer, src_name, tgt_label, tgt_layer, tgt_name,
674                        ),
675                    });
676                }
677            }
678        }
679
680        // Compute layer separation score
681        // Penalize cycles proportionally
682        let cycle_penalty: f32 =
683            sccs.iter().map(|scc| scc.len() as f32).sum::<f32>() / candidates.len().max(1) as f32;
684
685        let layer_separation_score = if total_cross_layer_edges == 0 && !has_cycles {
686            1.0 // Perfect: no cross-layer edges, no violations possible
687        } else if total_cross_layer_edges == 0 {
688            (1.0 - cycle_penalty).max(0.0)
689        } else {
690            let raw = 1.0 - (violation_edges / total_cross_layer_edges as f32);
691            (raw - cycle_penalty).clamp(0.0, 1.0)
692        };
693
694        let total_nodes_classified = layers.iter().map(|l| l.nodes.len() as u32).sum();
695
696        Ok(LayerDetectionResult {
697            layers,
698            violations,
699            utility_nodes,
700            has_cycles,
701            layer_separation_score,
702            total_nodes_classified,
703        })
704    }
705
706    /// Compute cohesion, coupling, and violation-density metrics for one layer.
707    ///
708    /// # Parameters
709    /// - `graph`: the connectivity graph (must be the same graph used for `detect`).
710    /// - `result`: the `LayerDetectionResult` to pull layer membership from.
711    /// - `level`: zero-based layer level to analyse.
712    ///
713    /// # Errors
714    /// Returns `M1ndError::LayerNotFound { level }` if no layer with that level exists in `result`.
715    pub fn layer_health(
716        &self,
717        graph: &Graph,
718        result: &LayerDetectionResult,
719        level: u8,
720    ) -> M1ndResult<LayerHealth> {
721        let layer = result
722            .layers
723            .iter()
724            .find(|l| l.level == level)
725            .ok_or(M1ndError::LayerNotFound { level })?;
726
727        let node_set: HashSet<NodeId> = layer.nodes.iter().copied().collect();
728        let utility_set: HashSet<NodeId> = result.utility_nodes.iter().map(|u| u.node).collect();
729        let all_layer_nodes: HashMap<NodeId, u8> = result
730            .layers
731            .iter()
732            .flat_map(|l| l.nodes.iter().map(move |&n| (n, l.level)))
733            .collect();
734
735        let n = layer.nodes.len();
736        if n == 0 {
737            return Ok(LayerHealth {
738                cohesion: 0.0,
739                coupling_up: 0.0,
740                coupling_down: 0.0,
741                violation_density: 0.0,
742            });
743        }
744
745        let mut intra_edges: u32 = 0;
746        let mut edges_up: u32 = 0;
747        let mut edges_down: u32 = 0;
748        let mut total_outgoing: u32 = 0;
749
750        for &node in &layer.nodes {
751            let range = graph.csr.out_range(node);
752            for j in range {
753                let target = graph.csr.targets[j];
754                if utility_set.contains(&target) {
755                    continue;
756                }
757
758                if node_set.contains(&target) {
759                    intra_edges += 1;
760                } else if let Some(&tgt_level) = all_layer_nodes.get(&target) {
761                    total_outgoing += 1;
762                    if tgt_level < level {
763                        edges_up += 1;
764                    } else if tgt_level > level {
765                        edges_down += 1;
766                    }
767                }
768            }
769        }
770
771        // Cohesion: intra-layer edges / max possible intra-layer edges
772        let max_intra = if n > 1 { (n * (n - 1)) as f32 } else { 1.0 };
773        let cohesion = (intra_edges as f32 / max_intra).min(1.0);
774
775        // Coupling ratios
776        let total_external = (edges_up + edges_down).max(1);
777        let coupling_up = edges_up as f32 / total_external as f32;
778        let coupling_down = edges_down as f32 / total_external as f32;
779
780        // Violation density: violations per node
781        let violations_in_layer = result
782            .violations
783            .iter()
784            .filter(|v| v.source_layer == level || v.target_layer == level)
785            .count();
786        let violation_density = violations_in_layer as f32 / n as f32;
787
788        Ok(LayerHealth {
789            cohesion,
790            coupling_up,
791            coupling_down,
792            violation_density,
793        })
794    }
795}
796
797// ── Tarjan's SCC ──
798
799/// Run Tarjan's SCC algorithm. Returns Vec of SCCs (each SCC = Vec<NodeId>).
800/// Only SCCs with size > 1 are returned (single nodes are trivial SCCs).
801fn tarjan_scc(graph: &Graph, nodes: &[NodeId]) -> Vec<Vec<NodeId>> {
802    let node_set: HashSet<NodeId> = nodes.iter().copied().collect();
803    let n = nodes.len();
804    if n == 0 {
805        return Vec::new();
806    }
807
808    // Map NodeId to local index and back
809    let mut node_to_idx: HashMap<NodeId, usize> = HashMap::with_capacity(n);
810    let mut idx_to_node: Vec<NodeId> = Vec::with_capacity(n);
811    for (i, &node) in nodes.iter().enumerate() {
812        node_to_idx.insert(node, i);
813        idx_to_node.push(node);
814    }
815
816    let mut index_counter: usize = 0;
817    let mut stack: Vec<usize> = Vec::new();
818    let mut on_stack: Vec<bool> = vec![false; n];
819    let mut indices: Vec<Option<usize>> = vec![None; n];
820    let mut lowlinks: Vec<usize> = vec![0; n];
821    let mut result: Vec<Vec<NodeId>> = Vec::new();
822
823    // Iterative Tarjan's (avoids stack overflow on large graphs)
824    // We use an explicit call stack to simulate recursion.
825    #[derive(Clone)]
826    enum TarjanFrame {
827        Enter(usize),
828        Resume(usize, usize), // (node_local_idx, neighbor_iterator_position)
829    }
830
831    for start in 0..n {
832        if indices[start].is_some() {
833            continue;
834        }
835
836        let mut call_stack: Vec<TarjanFrame> = vec![TarjanFrame::Enter(start)];
837
838        while let Some(frame) = call_stack.pop() {
839            match frame {
840                TarjanFrame::Enter(v) => {
841                    indices[v] = Some(index_counter);
842                    lowlinks[v] = index_counter;
843                    index_counter += 1;
844                    stack.push(v);
845                    on_stack[v] = true;
846
847                    // Push resume frame then process neighbors
848                    call_stack.push(TarjanFrame::Resume(v, 0));
849                }
850                TarjanFrame::Resume(v, pos) => {
851                    let node = idx_to_node[v];
852                    let range = graph.csr.out_range(node);
853                    let neighbors: Vec<usize> = range
854                        .filter_map(|j| {
855                            let target = graph.csr.targets[j];
856                            if node_set.contains(&target) {
857                                node_to_idx.get(&target).copied()
858                            } else {
859                                None
860                            }
861                        })
862                        .collect();
863
864                    let mut next_pos = pos;
865                    let mut found_unvisited = false;
866
867                    while next_pos < neighbors.len() {
868                        let w = neighbors[next_pos];
869                        next_pos += 1;
870
871                        if indices[w].is_none() {
872                            // Push resume for after w returns, then enter w
873                            call_stack.push(TarjanFrame::Resume(v, next_pos));
874                            call_stack.push(TarjanFrame::Enter(w));
875                            found_unvisited = true;
876                            break;
877                        } else if on_stack[w] {
878                            lowlinks[v] = lowlinks[v].min(indices[w].unwrap());
879                        }
880                    }
881
882                    if found_unvisited {
883                        continue;
884                    }
885
886                    // Update parent's lowlink if we were called from a resume
887                    // (this happens naturally when we complete processing)
888
889                    // Check if v is a root of an SCC
890                    if lowlinks[v] == indices[v].unwrap() {
891                        let mut scc: Vec<NodeId> = Vec::new();
892                        loop {
893                            let w = stack.pop().unwrap();
894                            on_stack[w] = false;
895                            scc.push(idx_to_node[w]);
896                            if w == v {
897                                break;
898                            }
899                        }
900                        if scc.len() > 1 {
901                            result.push(scc);
902                        }
903                    }
904
905                    // Propagate lowlink to parent
906                    if let Some(TarjanFrame::Resume(parent, _)) = call_stack.last() {
907                        lowlinks[*parent] = lowlinks[*parent].min(lowlinks[v]);
908                    }
909                }
910            }
911        }
912    }
913
914    result
915}
916
917// ── Helper Functions ──
918
919/// Collect candidate nodes based on scope and type filters.
920fn layer_collect_candidates(
921    graph: &Graph,
922    scope: Option<&str>,
923    node_type_filter: &[NodeType],
924) -> Vec<NodeId> {
925    let n = graph.num_nodes() as usize;
926    let mut candidates = Vec::new();
927
928    for i in 0..n {
929        let nid = NodeId::new(i as u32);
930
931        // Type filter
932        if !node_type_filter.is_empty() && !node_type_filter.contains(&graph.nodes.node_type[i]) {
933            continue;
934        }
935
936        // Scope filter: check external_id prefix
937        if let Some(scope_prefix) = scope {
938            let mut matched = false;
939            for (&interned, &id) in &graph.id_to_node {
940                if id == nid {
941                    let ext_id = graph.strings.resolve(interned);
942                    if ext_id.starts_with(scope_prefix) {
943                        matched = true;
944                    }
945                    break;
946                }
947            }
948            if !matched {
949                continue;
950            }
951        }
952
953        candidates.push(nid);
954    }
955
956    candidates
957}
958
959/// Average PageRank for a set of nodes.
960fn layer_avg_pagerank(graph: &Graph, nodes: &[NodeId]) -> f32 {
961    if nodes.is_empty() {
962        return 0.0;
963    }
964    let sum: f32 = nodes
965        .iter()
966        .map(|&n| graph.nodes.pagerank[n.as_usize()].get())
967        .sum();
968    sum / nodes.len() as f32
969}
970
971/// Average out-degree for a set of nodes.
972fn layer_avg_out_degree(graph: &Graph, nodes: &[NodeId]) -> f32 {
973    if nodes.is_empty() {
974        return 0.0;
975    }
976    let sum: f32 = nodes
977        .iter()
978        .map(|&n| {
979            let range = graph.csr.out_range(n);
980            range.len() as f32
981        })
982        .sum();
983    sum / nodes.len() as f32
984}
985
986/// Confidence of a node's layer assignment.
987/// Based on how consistent its edges are with the layering.
988fn layer_node_confidence(
989    graph: &Graph,
990    node: NodeId,
991    node_layer: &HashMap<NodeId, u8>,
992    candidate_set: &HashSet<NodeId>,
993) -> f32 {
994    let my_layer = match node_layer.get(&node) {
995        Some(&l) => l,
996        None => return 0.5,
997    };
998
999    let range = graph.csr.out_range(node);
1000    let mut total_edges = 0u32;
1001    let mut consistent_edges = 0u32;
1002
1003    for j in range {
1004        let target = graph.csr.targets[j];
1005        if !candidate_set.contains(&target) {
1006            continue;
1007        }
1008        if let Some(&tgt_layer) = node_layer.get(&target) {
1009            total_edges += 1;
1010            if tgt_layer >= my_layer {
1011                // Edge going down or lateral = consistent
1012                consistent_edges += 1;
1013            }
1014        }
1015    }
1016
1017    // Also check incoming edges
1018    let in_range = graph.csr.in_range(node);
1019    for j in in_range {
1020        let source = graph.csr.rev_sources[j];
1021        if !candidate_set.contains(&source) {
1022            continue;
1023        }
1024        if let Some(&src_layer) = node_layer.get(&source) {
1025            total_edges += 1;
1026            if src_layer <= my_layer {
1027                // Incoming from shallower or same = consistent
1028                consistent_edges += 1;
1029            }
1030        }
1031    }
1032
1033    if total_edges == 0 {
1034        0.5 // No edges = moderate confidence
1035    } else {
1036        consistent_edges as f32 / total_edges as f32
1037    }
1038}
1039
1040/// Name layer by most common path prefix component.
1041fn layer_name_path_prefix(graph: &Graph, nodes: &[NodeId], level: usize) -> (String, String) {
1042    let mut prefix_counts: HashMap<String, u32> = HashMap::new();
1043
1044    for &node in nodes {
1045        let label = graph
1046            .strings
1047            .resolve(graph.nodes.label[node.as_usize()])
1048            .to_lowercase();
1049        // Extract first path component or identifier prefix
1050        let prefix = if let Some(slash_pos) = label.find('/') {
1051            &label[..slash_pos]
1052        } else if let Some(underscore_pos) = label.find('_') {
1053            &label[..underscore_pos]
1054        } else {
1055            &label
1056        };
1057        if !prefix.is_empty() {
1058            *prefix_counts.entry(prefix.to_string()).or_insert(0) += 1;
1059        }
1060    }
1061
1062    if let Some((prefix, _)) = prefix_counts.iter().max_by_key(|&(_, count)| count) {
1063        (
1064            prefix.clone(),
1065            format!("Layer {} grouped by path prefix '{}'", level, prefix),
1066        )
1067    } else {
1068        (
1069            format!("layer_{}", level),
1070            format!("Layer at depth {}", level),
1071        )
1072    }
1073}
1074
1075/// Name layer by highest PageRank node.
1076fn layer_name_by_pagerank(graph: &Graph, nodes: &[NodeId], level: usize) -> (String, String) {
1077    if nodes.is_empty() {
1078        return (
1079            format!("layer_{}", level),
1080            format!("Layer at depth {}", level),
1081        );
1082    }
1083
1084    let top_node = nodes
1085        .iter()
1086        .max_by(|&&a, &&b| {
1087            graph.nodes.pagerank[a.as_usize()]
1088                .get()
1089                .partial_cmp(&graph.nodes.pagerank[b.as_usize()].get())
1090                .unwrap_or(std::cmp::Ordering::Equal)
1091        })
1092        .unwrap();
1093
1094    let label = graph
1095        .strings
1096        .resolve(graph.nodes.label[top_node.as_usize()])
1097        .to_string();
1098    (
1099        label.clone(),
1100        format!(
1101            "Layer {} named after highest-PageRank node: {}",
1102            level, label
1103        ),
1104    )
1105}
1106
1107/// Determine layer name and description from filename patterns.
1108fn layer_name_heuristic(
1109    graph: &Graph,
1110    nodes: &[NodeId],
1111    level: usize,
1112    total_levels: usize,
1113) -> (String, String) {
1114    // Count filename pattern matches
1115    let mut route_count = 0u32;
1116    let mut handler_count = 0u32;
1117    let mut service_count = 0u32;
1118    let mut store_count = 0u32;
1119    let mut model_count = 0u32;
1120    let mut config_count = 0u32;
1121    let mut main_count = 0u32;
1122    let mut test_count = 0u32;
1123
1124    for &node in nodes {
1125        let label = graph
1126            .strings
1127            .resolve(graph.nodes.label[node.as_usize()])
1128            .to_lowercase();
1129
1130        if label.contains("route") || label.contains("router") || label.contains("endpoint") {
1131            route_count += 1;
1132        }
1133        if label.contains("handler") || label.contains("middleware") || label.contains("dispatch") {
1134            handler_count += 1;
1135        }
1136        if label.contains("manager")
1137            || label.contains("orchestr")
1138            || label.contains("engine")
1139            || label.contains("service")
1140            || label.contains("daemon")
1141            || label.contains("processor")
1142        {
1143            service_count += 1;
1144        }
1145        if label.contains("store")
1146            || label.contains("pool")
1147            || label.contains("client")
1148            || label.contains("db")
1149            || label.contains("cache")
1150            || label.contains("repository")
1151        {
1152            store_count += 1;
1153        }
1154        if label.contains("model")
1155            || label.contains("types")
1156            || label.contains("schema")
1157            || label.contains("struct")
1158            || label.contains("enum")
1159        {
1160            model_count += 1;
1161        }
1162        if label.contains("config") || label.contains("settings") || label.contains("constants") {
1163            config_count += 1;
1164        }
1165        if label.contains("main") || label.contains("app") || label.contains("cli") {
1166            main_count += 1;
1167        }
1168        if label.contains("test") || label.contains("spec") {
1169            test_count += 1;
1170        }
1171    }
1172
1173    // Pick dominant pattern
1174    let counts = [
1175        (
1176            main_count + route_count,
1177            "entry_points",
1178            "Surface layer: HTTP routes, CLI entry points, main modules",
1179        ),
1180        (
1181            handler_count,
1182            "handlers",
1183            "Request processing: route handlers, middleware, dispatchers",
1184        ),
1185        (
1186            service_count,
1187            "services",
1188            "Business logic: orchestrators, managers, processors",
1189        ),
1190        (
1191            store_count,
1192            "data_access",
1193            "Persistence and I/O: stores, pools, clients",
1194        ),
1195        (
1196            model_count + config_count,
1197            "foundation",
1198            "Core definitions: models, types, configuration",
1199        ),
1200        (
1201            test_count,
1202            "tests",
1203            "Test infrastructure: unit tests, integration tests",
1204        ),
1205    ];
1206
1207    let dominant = counts.iter().max_by_key(|(c, _, _)| *c).unwrap();
1208
1209    if dominant.0 > 0 {
1210        return (dominant.1.to_string(), dominant.2.to_string());
1211    }
1212
1213    // Fallback: use positional naming
1214    if level == 0 {
1215        (
1216            "entry_points".to_string(),
1217            "Surface layer: entry points and top-level modules".to_string(),
1218        )
1219    } else if level == total_levels - 1 {
1220        (
1221            "foundation".to_string(),
1222            "Deepest layer: foundational modules and definitions".to_string(),
1223        )
1224    } else {
1225        let name = format!("layer_{}", level);
1226        let desc = format!("Intermediate layer at depth {}", level);
1227        (name, desc)
1228    }
1229}
1230
1231/// Compute violation severity from edge relation, layer gap, and weight.
1232fn layer_violation_severity(relation: &str, gap: u8, weight: f32) -> ViolationSeverity {
1233    let base_severity = match relation {
1234        "imports" => 3,
1235        "calls" => 2,
1236        "inherits" => 2,
1237        "registers" => 1,
1238        "references" => 1,
1239        "configures" => 1,
1240        _ => 2,
1241    };
1242
1243    let gap_factor = if gap > 1 { 1 } else { 0 };
1244    let weight_factor = if weight > 0.5 { 1 } else { 0 };
1245
1246    let total = base_severity + gap_factor + weight_factor;
1247
1248    match total {
1249        0..=1 => ViolationSeverity::Low,
1250        2 => ViolationSeverity::Medium,
1251        3 => ViolationSeverity::High,
1252        _ => ViolationSeverity::High, // Reserve Critical for circular deps
1253    }
1254}
1255
1256/// Get layer name for a given level from the layers vec.
1257fn layer_name_for_level(level: u8, layers: &[ArchLayer]) -> String {
1258    layers
1259        .iter()
1260        .find(|l| l.level == level)
1261        .map(|l| l.name.clone())
1262        .unwrap_or_else(|| format!("layer_{}", level))
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268    use crate::builder::GraphBuilder;
1269    use crate::types::NodeType;
1270
1271    // ── Helper: build a simple DAG  A → B → C ──
1272    fn dag_graph() -> Graph {
1273        let mut b = GraphBuilder::new();
1274        let a = b.add_node("file::a", "a", NodeType::File, &[]).unwrap();
1275        let c = b.add_node("file::c", "c", NodeType::File, &[]).unwrap();
1276        let bb = b.add_node("file::b", "b", NodeType::File, &[]).unwrap();
1277        b.add_edge(a, bb, "imports", 0.8).unwrap();
1278        b.add_edge(bb, c, "imports", 0.8).unwrap();
1279        b.finalize().unwrap()
1280    }
1281
1282    // ── Helper: build a graph with a cycle  X ↔ Y and a DAG tail Z ──
1283    fn cyclic_graph() -> Graph {
1284        let mut b = GraphBuilder::new();
1285        let x = b.add_node("file::x", "x", NodeType::File, &[]).unwrap();
1286        let y = b.add_node("file::y", "y", NodeType::File, &[]).unwrap();
1287        let z = b.add_node("file::z", "z", NodeType::File, &[]).unwrap();
1288        b.add_edge(x, y, "imports", 0.9).unwrap();
1289        b.add_edge(y, x, "imports", 0.9).unwrap(); // cycle
1290        b.add_edge(y, z, "imports", 0.5).unwrap();
1291        b.finalize().unwrap()
1292    }
1293
1294    // 1. test_tarjan_empty (already exists — kept for completeness)
1295    #[test]
1296    fn test_tarjan_empty() {
1297        let graph = Graph::new();
1298        let result = tarjan_scc(&graph, &[]);
1299        assert!(result.is_empty());
1300    }
1301
1302    // 2. detect_dag: DAG graph has no cycles, produces multiple layers
1303    #[test]
1304    fn detect_dag_produces_layers_no_cycles() {
1305        let graph = dag_graph();
1306        let detector = LayerDetector::new(8, 1);
1307        let result = detector.detect(&graph, None, &[], false, "auto").unwrap();
1308        assert!(!result.has_cycles, "DAG should have no cycles");
1309        // A→B→C should produce at least 2 layers (depth 0 and 1 or 2)
1310        assert!(result.layers.len() >= 1);
1311        assert!(result.total_nodes_classified >= 1);
1312    }
1313
1314    // 3. detect_cycle_scc: cyclic graph has_cycles = true
1315    #[test]
1316    fn detect_cycle_scc_sets_has_cycles() {
1317        let graph = cyclic_graph();
1318        let detector = LayerDetector::new(8, 1);
1319        let result = detector.detect(&graph, None, &[], false, "auto").unwrap();
1320        assert!(
1321            result.has_cycles,
1322            "Cyclic graph should set has_cycles = true"
1323        );
1324        // Circular dependency violations should be present
1325        let circular = result
1326            .violations
1327            .iter()
1328            .filter(|v| v.violation_type == ViolationType::CircularDependency)
1329            .count();
1330        assert!(circular > 0, "Expected CircularDependency violations");
1331    }
1332
1333    // 4. utility_nodes: orphan node (no edges) is classified as Orphan
1334    #[test]
1335    fn orphan_node_is_utility() {
1336        let mut b = GraphBuilder::new();
1337        let _a = b
1338            .add_node("file::lone", "lone", NodeType::File, &[])
1339            .unwrap();
1340        let _bb = b
1341            .add_node("file::connected_a", "connected_a", NodeType::File, &[])
1342            .unwrap();
1343        let _c = b
1344            .add_node("file::connected_b", "connected_b", NodeType::File, &[])
1345            .unwrap();
1346        b.add_edge(_bb, _c, "imports", 0.5).unwrap();
1347        let graph = b.finalize().unwrap();
1348
1349        let detector = LayerDetector::new(8, 1);
1350        let result = detector.detect(&graph, None, &[], false, "auto").unwrap();
1351
1352        let orphans: Vec<_> = result
1353            .utility_nodes
1354            .iter()
1355            .filter(|u| u.classification == UtilityClassification::Orphan)
1356            .collect();
1357        assert!(
1358            !orphans.is_empty(),
1359            "Expected at least one Orphan utility node"
1360        );
1361    }
1362
1363    // 5. violations_upward: an edge from deeper layer back to shallower yields UpwardDependency
1364    #[test]
1365    fn violations_upward_detected() {
1366        // Build: entry → service → entry (upward edge from service back to entry)
1367        let mut b = GraphBuilder::new();
1368        let entry = b
1369            .add_node("file::main_entry", "main_entry", NodeType::File, &[])
1370            .unwrap();
1371        let svc = b
1372            .add_node("file::service_layer", "service_layer", NodeType::File, &[])
1373            .unwrap();
1374        let deepest = b
1375            .add_node("file::deep_store", "deep_store", NodeType::File, &[])
1376            .unwrap();
1377        b.add_edge(entry, svc, "imports", 0.8).unwrap();
1378        b.add_edge(svc, deepest, "imports", 0.8).unwrap();
1379        // Upward violation: deep → entry
1380        b.add_edge(deepest, entry, "imports", 0.6).unwrap();
1381        let graph = b.finalize().unwrap();
1382
1383        let detector = LayerDetector::new(8, 1);
1384        let result = detector.detect(&graph, None, &[], false, "auto").unwrap();
1385
1386        // Either has_cycles (if Tarjan sees it as cycle) or upward violation
1387        let has_violation = result.has_cycles
1388            || result.violations.iter().any(|v| {
1389                v.violation_type == ViolationType::UpwardDependency
1390                    || v.violation_type == ViolationType::CircularDependency
1391            });
1392        assert!(
1393            has_violation,
1394            "Expected violation or cycle for cross-layer back-edge"
1395        );
1396    }
1397
1398    // 6. naming_heuristic: node labelled "main_routes" → layer named "entry_points"
1399    #[test]
1400    fn naming_heuristic_routes_become_entry_points() {
1401        let mut b = GraphBuilder::new();
1402        let r = b
1403            .add_node("file::main_routes", "main_routes", NodeType::File, &[])
1404            .unwrap();
1405        let s = b
1406            .add_node(
1407                "file::service_manager",
1408                "service_manager",
1409                NodeType::File,
1410                &[],
1411            )
1412            .unwrap();
1413        b.add_edge(r, s, "imports", 0.8).unwrap();
1414        let graph = b.finalize().unwrap();
1415
1416        let detector = LayerDetector::new(8, 1);
1417        let result = detector.detect(&graph, None, &[], false, "auto").unwrap();
1418
1419        let names: Vec<&str> = result.layers.iter().map(|l| l.name.as_str()).collect();
1420        let has_entry = names
1421            .iter()
1422            .any(|&n| n == "entry_points" || n == "services" || n.starts_with("layer_"));
1423        assert!(
1424            has_entry,
1425            "Naming should produce recognizable layer names, got: {:?}",
1426            names
1427        );
1428    }
1429
1430    // 7. exclude_tests: nodes with "test" in label are filtered out when exclude_tests=true
1431    #[test]
1432    fn exclude_tests_removes_test_nodes() {
1433        let mut b = GraphBuilder::new();
1434        let prod = b
1435            .add_node("file::main_app", "main_app", NodeType::File, &[])
1436            .unwrap();
1437        let _test = b
1438            .add_node("file::test_routes", "test_routes", NodeType::File, &[])
1439            .unwrap();
1440        let svc = b
1441            .add_node("file::service_core", "service_core", NodeType::File, &[])
1442            .unwrap();
1443        b.add_edge(prod, svc, "imports", 0.8).unwrap();
1444        let graph = b.finalize().unwrap();
1445
1446        let detector = LayerDetector::new(8, 1);
1447        // With exclude_tests=true
1448        let result_excl = detector.detect(&graph, None, &[], true, "auto").unwrap();
1449        // With exclude_tests=false
1450        let result_all = detector.detect(&graph, None, &[], false, "auto").unwrap();
1451
1452        let test_in_excl = result_excl
1453            .layers
1454            .iter()
1455            .flat_map(|l| l.nodes.iter())
1456            .any(|&nid| {
1457                graph
1458                    .strings
1459                    .resolve(graph.nodes.label[nid.as_usize()])
1460                    .contains("test")
1461            });
1462        assert!(
1463            !test_in_excl,
1464            "Test nodes should be excluded when exclude_tests=true"
1465        );
1466
1467        // excluded result should classify fewer nodes than all
1468        assert!(result_excl.total_nodes_classified <= result_all.total_nodes_classified);
1469    }
1470
1471    // 8. health_metrics: layer_health returns Ok for a valid level
1472    #[test]
1473    fn health_metrics_for_valid_layer() {
1474        let graph = dag_graph();
1475        let detector = LayerDetector::new(8, 1);
1476        let result = detector.detect(&graph, None, &[], false, "auto").unwrap();
1477
1478        assert!(!result.layers.is_empty(), "Need at least one layer");
1479        let level = result.layers[0].level;
1480        let health = detector.layer_health(&graph, &result, level).unwrap();
1481
1482        // cohesion and coupling values are in [0, 1] or small multiples
1483        assert!(
1484            health.cohesion >= 0.0 && health.cohesion <= 1.0,
1485            "cohesion out of range: {}",
1486            health.cohesion
1487        );
1488        assert!(
1489            health.coupling_up >= 0.0 && health.coupling_up <= 1.0,
1490            "coupling_up out of range: {}",
1491            health.coupling_up
1492        );
1493        assert!(
1494            health.coupling_down >= 0.0 && health.coupling_down <= 1.0,
1495            "coupling_down out of range: {}",
1496            health.coupling_down
1497        );
1498        assert!(
1499            health.violation_density >= 0.0,
1500            "violation_density should be non-negative: {}",
1501            health.violation_density
1502        );
1503    }
1504
1505    // Existing violation severity tests (kept)
1506    #[test]
1507    fn test_violation_severity_imports_high() {
1508        let sev = layer_violation_severity("imports", 1, 0.8);
1509        assert_eq!(sev, ViolationSeverity::High);
1510    }
1511
1512    #[test]
1513    fn test_violation_severity_references_low() {
1514        let sev = layer_violation_severity("references", 1, 0.2);
1515        assert_eq!(sev, ViolationSeverity::Low);
1516    }
1517
1518    #[test]
1519    fn test_violation_severity_skip_layer() {
1520        // imports + gap > 1 + high weight = High
1521        let sev = layer_violation_severity("imports", 3, 0.9);
1522        assert_eq!(sev, ViolationSeverity::High);
1523    }
1524
1525    #[test]
1526    fn test_detector_empty_graph() {
1527        let graph = Graph::new();
1528        let detector = LayerDetector::with_defaults();
1529        let result = detector.detect(&graph, None, &[], false, "auto");
1530        assert!(matches!(result, Err(M1ndError::EmptyGraph)));
1531    }
1532
1533    #[test]
1534    fn test_layer_health_not_found() {
1535        let result = LayerDetectionResult {
1536            layers: Vec::new(),
1537            violations: Vec::new(),
1538            utility_nodes: Vec::new(),
1539            has_cycles: false,
1540            layer_separation_score: 1.0,
1541            total_nodes_classified: 0,
1542        };
1543        let graph = Graph::new();
1544        let detector = LayerDetector::with_defaults();
1545        let health = detector.layer_health(&graph, &result, 99);
1546        assert!(matches!(
1547            health,
1548            Err(M1ndError::LayerNotFound { level: 99 })
1549        ));
1550    }
1551}