1use crate::error::{M1ndError, M1ndResult};
7use crate::graph::Graph;
8use crate::types::*;
9use serde::Serialize;
10use std::collections::{HashMap, HashSet, VecDeque};
11
12pub const DEFAULT_MAX_LAYERS: u8 = 8;
16pub const DEFAULT_MIN_NODES_PER_LAYER: u32 = 2;
18
19const UTILITY_LAYER_FRACTION: f32 = 0.5;
22
23#[derive(Clone, Debug, Serialize)]
27pub struct ArchLayer {
28 pub level: u8,
30 pub name: String,
32 pub description: String,
34 pub nodes: Vec<NodeId>,
36 pub node_confidence: Vec<f32>,
38 pub avg_pagerank: f32,
40 pub avg_out_degree: f32,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
46pub enum ViolationSeverity {
47 Low,
49 Medium,
51 High,
53 Critical,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
59pub enum ViolationType {
60 UpwardDependency,
62 SkipLayerDependency,
64 CircularDependency,
66}
67
68#[derive(Clone, Debug, Serialize)]
70pub struct LayerViolation {
71 pub source: NodeId,
73 pub source_layer: u8,
75 pub target: NodeId,
77 pub target_layer: u8,
79 pub edge_relation: String,
81 pub edge_weight: f32,
83 pub severity: ViolationSeverity,
85 pub violation_type: ViolationType,
87 pub explanation: String,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
93pub enum UtilityClassification {
94 CrossCutting,
96 Bridge,
98 Orphan,
100}
101
102#[derive(Clone, Debug, Serialize)]
104pub struct UtilityNode {
105 pub node: NodeId,
107 pub used_by_layers: Vec<u8>,
109 pub classification: UtilityClassification,
111}
112
113#[derive(Clone, Debug, Serialize)]
115pub struct LayerDetectionResult {
116 pub layers: Vec<ArchLayer>,
118 pub violations: Vec<LayerViolation>,
120 pub utility_nodes: Vec<UtilityNode>,
122 pub has_cycles: bool,
124 pub layer_separation_score: f32,
126 pub total_nodes_classified: u32,
128}
129
130#[derive(Clone, Debug, Serialize)]
132pub struct LayerHealth {
133 pub cohesion: f32,
135 pub coupling_up: f32,
137 pub coupling_down: f32,
139 pub violation_density: f32,
141}
142
143#[derive(Clone, Debug)]
145pub struct LayerCache {
146 pub graph_generation: u64,
148 pub cache_generation: u64,
150 pub scope: Option<String>,
152 pub result: LayerDetectionResult,
154}
155
156pub struct LayerDetector {
163 pub max_layers: u8,
165 pub min_nodes_per_layer: u32,
167}
168
169impl LayerDetector {
170 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 pub fn with_defaults() -> Self {
184 Self::new(DEFAULT_MAX_LAYERS, DEFAULT_MIN_NODES_PER_LAYER)
185 }
186
187 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 let mut candidates = layer_collect_candidates(graph, scope, node_type_filter);
216
217 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 let sccs = tarjan_scc(graph, &candidates);
237 let has_cycles = !sccs.is_empty();
238
239 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 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 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 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 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 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 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 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 for &node in &dag_nodes {
354 depth.entry(node).or_insert(0);
355 }
356
357 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 let max_depth = node_depth.values().copied().max().unwrap_or(0);
367
368 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 while depth_groups.len() > self.max_layers as usize && depth_groups.len() > 1 {
377 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 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 } else {
403 i += 1;
404 }
405 }
406
407 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 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 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 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 }
474 }
475 }
476
477 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 for group in depth_groups.iter_mut() {
504 group.retain(|n| !utility_set.contains(n));
505 }
506
507 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 for (i, layer) in layers.iter_mut().enumerate() {
540 layer.level = i as u8;
541 }
542
543 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 let mut violations: Vec<LayerViolation> = Vec::new();
553
554 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 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; }
593 }
594 }
595 }
596 }
597
598 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 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 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 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 } 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 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 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 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 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
797fn 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 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 #[derive(Clone)]
826 enum TarjanFrame {
827 Enter(usize),
828 Resume(usize, usize), }
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 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 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 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 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
917fn 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 if !node_type_filter.is_empty() && !node_type_filter.contains(&graph.nodes.node_type[i]) {
933 continue;
934 }
935
936 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
959fn 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
971fn 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
986fn 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 consistent_edges += 1;
1013 }
1014 }
1015 }
1016
1017 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 consistent_edges += 1;
1029 }
1030 }
1031 }
1032
1033 if total_edges == 0 {
1034 0.5 } else {
1036 consistent_edges as f32 / total_edges as f32
1037 }
1038}
1039
1040fn 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 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
1075fn 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
1107fn layer_name_heuristic(
1109 graph: &Graph,
1110 nodes: &[NodeId],
1111 level: usize,
1112 total_levels: usize,
1113) -> (String, String) {
1114 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 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 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
1231fn 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, }
1254}
1255
1256fn 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 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 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(); b.add_edge(y, z, "imports", 0.5).unwrap();
1291 b.finalize().unwrap()
1292 }
1293
1294 #[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 #[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 assert!(result.layers.len() >= 1);
1311 assert!(result.total_nodes_classified >= 1);
1312 }
1313
1314 #[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 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 #[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 #[test]
1365 fn violations_upward_detected() {
1366 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 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 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 #[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 #[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 let result_excl = detector.detect(&graph, None, &[], true, "auto").unwrap();
1449 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 assert!(result_excl.total_nodes_classified <= result_all.total_nodes_classified);
1469 }
1470
1471 #[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 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 #[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 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}