Skip to main content

gid_core/
complexity.rs

1//! Complexity assessment — classify tasks using code graph structure
2//!
3//! Instead of guessing complexity from text (unreliable), we use the
4//! actual code graph to measure structural complexity. This is the
5//! GID way: let the graph tell you.
6
7use crate::code_graph::{CodeGraph, NodeKind, EdgeRelation};
8use std::collections::HashSet;
9
10/// Complexity level of a task/issue
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Complexity {
13    Simple,
14    Medium,
15    Complex,
16}
17
18impl std::fmt::Display for Complexity {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            Complexity::Simple => write!(f, "simple"),
22            Complexity::Medium => write!(f, "medium"),
23            Complexity::Complex => write!(f, "complex"),
24        }
25    }
26}
27
28/// Result of complexity assessment
29#[derive(Debug, Clone)]
30pub struct ComplexityReport {
31    pub complexity: Complexity,
32    pub relevant_nodes: usize,
33    pub relevant_files: usize,
34    pub class_count: usize,
35    pub inheritance_edges: usize,
36    pub import_edges: usize,
37    pub test_count: usize,
38    pub summary: String,
39}
40
41/// Assess complexity from the code graph structure.
42///
43/// Uses relevant node count, edge density, and inheritance depth
44/// to determine whether the task is simple, medium, or complex.
45/// Zero LLM calls — pure structural analysis.
46pub fn assess_complexity_from_graph(
47    code_graph: &CodeGraph,
48    keywords: &[&str],
49    test_count: usize,
50) -> ComplexityReport {
51    let relevant = code_graph.find_relevant_nodes(keywords);
52    let relevant_count = relevant.len();
53
54    // Count relevant files (unique file paths)
55    let relevant_files: HashSet<&str> = relevant.iter()
56        .map(|n| n.file_path.as_str())
57        .collect();
58    let file_count = relevant_files.len();
59
60    // Count classes in relevant nodes
61    let class_count = relevant.iter()
62        .filter(|n| n.kind == NodeKind::Class)
63        .count();
64
65    // Count inheritance edges involving relevant nodes
66    let relevant_ids: HashSet<&str> = relevant.iter()
67        .map(|n| n.id.as_str())
68        .collect();
69    let inheritance_edges = code_graph.edges.iter()
70        .filter(|e| {
71            e.relation == EdgeRelation::Inherits
72                && (relevant_ids.contains(e.from.as_str()) || relevant_ids.contains(e.to.as_str()))
73        })
74        .count();
75
76    // Count import edges between relevant files
77    let import_edges = code_graph.edges.iter()
78        .filter(|e| {
79            e.relation == EdgeRelation::Imports
80                && (relevant_ids.contains(e.from.as_str()) || relevant_ids.contains(e.to.as_str()))
81        })
82        .count();
83
84    tracing::debug!(
85        "Graph complexity metrics: relevant_nodes={}, files={}, classes={}, inheritance={}, imports={}, tests={}",
86        relevant_count, file_count, class_count, inheritance_edges, import_edges, test_count
87    );
88
89    // Decision logic
90    let complexity = if relevant_count <= 2 && file_count <= 1 && class_count == 0 && test_count <= 1 {
91        Complexity::Simple
92    } else if relevant_count >= 6 || file_count >= 3 || inheritance_edges >= 2 || import_edges >= 4 || test_count > 3 {
93        Complexity::Complex
94    } else {
95        Complexity::Medium
96    };
97
98    let summary = format!(
99        "Complexity: {:?} (nodes={}, files={}, classes={}, inherit={}, imports={}, tests={})",
100        complexity, relevant_count, file_count, class_count, inheritance_edges, import_edges, test_count
101    );
102
103    tracing::info!("{}", summary);
104
105    ComplexityReport {
106        complexity,
107        relevant_nodes: relevant_count,
108        relevant_files: file_count,
109        class_count,
110        inheritance_edges,
111        import_edges,
112        test_count,
113        summary,
114    }
115}
116
117/// Assess complexity from a problem statement
118pub fn assess_complexity(
119    code_graph: &CodeGraph,
120    problem_statement: &str,
121    test_count: usize,
122) -> ComplexityReport {
123    let keywords = CodeGraph::extract_keywords(problem_statement);
124    let keyword_refs: Vec<&str> = keywords.iter().map(|s| *s).collect();
125    assess_complexity_from_graph(code_graph, &keyword_refs, test_count)
126}
127
128/// Quick check if a change is high-risk based on caller count
129pub fn is_high_risk_change(code_graph: &CodeGraph, node_ids: &[&str]) -> bool {
130    let total_callers: usize = node_ids.iter()
131        .map(|id| code_graph.get_callers(id).len())
132        .sum();
133    
134    // High risk if total callers > 10 or any single node has > 5 callers
135    let max_callers = node_ids.iter()
136        .map(|id| code_graph.get_callers(id).len())
137        .max()
138        .unwrap_or(0);
139    
140    total_callers > 10 || max_callers > 5
141}
142
143/// Get risk level for a set of changed files
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum RiskLevel {
146    Low,      // < 5 total callers
147    Medium,   // 5-20 total callers
148    High,     // 20-50 total callers
149    Critical, // > 50 total callers
150}
151
152impl std::fmt::Display for RiskLevel {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            RiskLevel::Low => write!(f, "low"),
156            RiskLevel::Medium => write!(f, "medium"),
157            RiskLevel::High => write!(f, "high"),
158            RiskLevel::Critical => write!(f, "critical"),
159        }
160    }
161}
162
163pub fn assess_risk_level(code_graph: &CodeGraph, node_ids: &[&str]) -> RiskLevel {
164    let total_callers: usize = node_ids.iter()
165        .map(|id| code_graph.get_callers(id).len())
166        .sum();
167    
168    match total_callers {
169        0..=5 => RiskLevel::Low,
170        6..=20 => RiskLevel::Medium,
171        21..=50 => RiskLevel::High,
172        _ => RiskLevel::Critical,
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::code_graph::{CodeGraph, CodeNode, CodeEdge};
180
181    #[test]
182    fn test_empty_graph_defaults_simple() {
183        let graph = CodeGraph::default();
184        let report = assess_complexity_from_graph(&graph, &["test"], 0);
185        assert_eq!(report.complexity, Complexity::Simple);
186    }
187
188    #[test]
189    fn test_complex_with_many_files() {
190        let mut graph = CodeGraph::default();
191        
192        // Add nodes from multiple files
193        for i in 0..5 {
194            let file_path = format!("file{}.py", i);
195            graph.nodes.push(CodeNode {
196                id: format!("class:{}:TestClass{}", file_path, i),
197                kind: NodeKind::Class,
198                name: format!("TestClass{}", i),
199                file_path,
200                line: Some(1),
201                decorators: vec![],
202                signature: None,
203                docstring: None,
204                line_count: 10,
205                is_test: false,
206                visibility: None,
207                lang: None,
208                body_hash: None,
209                end_line: None,
210                complexity: None,
211            });
212        }
213        
214        graph.build_indexes();
215        
216        let report = assess_complexity_from_graph(&graph, &["TestClass"], 0);
217        assert_eq!(report.complexity, Complexity::Complex);
218        assert!(report.relevant_files >= 3);
219    }
220
221    #[test]
222    fn test_risk_level() {
223        let mut graph = CodeGraph::default();
224        
225        // Create a function with many callers
226        graph.nodes.push(CodeNode {
227            id: "func:core.py:hot_func".into(),
228            kind: NodeKind::Function,
229            name: "hot_func".into(),
230            file_path: "core.py".into(),
231            line: Some(10),
232            decorators: vec![],
233            signature: None,
234            docstring: None,
235            line_count: 20,
236            is_test: false,
237            visibility: None,
238            lang: None,
239            body_hash: None,
240            end_line: None,
241            complexity: None,
242        });
243        
244        // Add many callers
245        for i in 0..30 {
246            let caller_id = format!("func:caller{}.py:caller_{}", i, i);
247            graph.nodes.push(CodeNode {
248                id: caller_id.clone(),
249                kind: NodeKind::Function,
250                name: format!("caller_{}", i),
251                file_path: format!("caller{}.py", i),
252                line: Some(1),
253                decorators: vec![],
254                signature: None,
255                docstring: None,
256                line_count: 5,
257                is_test: false,
258                visibility: None,
259                lang: None,
260                body_hash: None,
261                end_line: None,
262                complexity: None,
263            });
264            graph.edges.push(CodeEdge::new(&caller_id, "func:core.py:hot_func", EdgeRelation::Calls));
265        }
266        
267        graph.build_indexes();
268        
269        let risk = assess_risk_level(&graph, &["func:core.py:hot_func"]);
270        assert_eq!(risk, RiskLevel::High);
271    }
272}