1use std::collections::{HashMap, HashSet};
17
18use anyhow::Result;
19use leiden_rs::{GraphDataBuilder, Leiden, LeidenConfig, QualityType};
20use petgraph::visit::{EdgeRef, IntoEdgeReferences, IntoNodeReferences};
21
22use crate::model::*;
23
24const FEATURE_LEIDEN_SEED: u64 = 42;
26const FEATURE_RESOLUTION: f64 = 0.4;
30const STRUCTURE_WEIGHT: f64 = 0.5;
32const SEMANTIC_WEIGHT: f64 = 0.5;
34
35pub trait Embedder: Send + Sync {
42 fn embed(&self, text: &str) -> Result<Vec<f32>>;
44 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
46 fn cosine_similarity(&self, a: &[f32], b: &[f32]) -> f64;
48}
49
50pub fn detect_features(
54 graph: &KnowledgeGraph,
55 embedder: Option<&dyn Embedder>,
56) -> Result<Vec<Feature>> {
57 let funcs: Vec<NodeId> = graph
59 .graph
60 .node_references()
61 .filter(|(_, n)| n.kind == NodeKind::Function)
62 .map(|(id, _)| id)
63 .collect();
64 if funcs.is_empty() {
65 return Ok(Vec::new());
66 }
67 let func_set: HashSet<NodeId> = funcs.iter().copied().collect();
68
69 let mut entity_to_file: HashMap<NodeId, NodeId> = HashMap::new();
71 for edge in graph.graph.edge_references() {
72 let kind = graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
73 if kind == Some(EdgeKind::Contains) {
74 entity_to_file.insert(edge.target(), edge.source());
75 }
76 }
77
78 let mut neighbors: HashMap<NodeId, HashSet<NodeId>> = HashMap::new();
80 let mut cross_edges: Vec<(NodeId, NodeId)> = Vec::new();
81 for edge in graph.graph.edge_references() {
82 let e = graph
85 .graph
86 .edge_weight(edge.id())
87 .expect("边权重必然存在");
88 if e.kind != EdgeKind::Calls {
89 continue;
90 }
91 let (s, t) = (edge.source(), edge.target());
92 if !func_set.contains(&s) || !func_set.contains(&t) {
93 continue;
94 }
95 if entity_to_file.get(&s) == entity_to_file.get(&t) {
96 continue; }
98 neighbors.entry(s).or_default().insert(t);
99 neighbors.entry(t).or_default().insert(s);
100 cross_edges.push((s, t));
101 }
102 if cross_edges.is_empty() {
103 return Ok(Vec::new());
104 }
105
106 let embeddings: Option<HashMap<NodeId, Vec<f32>>> = if let Some(emb) = embedder {
108 let mut involved: Vec<NodeId> = cross_edges
109 .iter()
110 .flat_map(|(s, t)| [*s, *t])
111 .collect();
112 involved.sort();
113 involved.dedup();
114 let texts: Vec<String> = involved
115 .iter()
116 .map(|nid| {
117 let n = graph.graph.node_weight(*nid).expect("实体节点必然存在");
120 format!(
121 "{} {:?} {}",
122 n.name,
123 n.kind,
124 n.signature.as_deref().unwrap_or("")
125 )
126 })
127 .collect();
128 match emb.embed_batch(&texts) {
129 Ok(vecs) => Some(involved.into_iter().zip(vecs).collect()),
130 Err(e) => {
131 tracing::warn!("特征聚类 embedding 失败,降级为纯结构聚类: {e}");
132 None
133 }
134 }
135 } else {
136 None
137 };
138
139 let compact: HashMap<NodeId, usize> = funcs
141 .iter()
142 .enumerate()
143 .map(|(i, &n)| (n, i))
144 .collect();
145 let mut weights: HashMap<(usize, usize), f64> = HashMap::new();
146 for (s, t) in &cross_edges {
147 let structural = 0.5 + 0.5 * jaccard(neighbors.get(s), neighbors.get(t));
148 let semantic = match (&embeddings, embedder) {
149 (Some(em), Some(emb)) => match (em.get(s), em.get(t)) {
150 (Some(a), Some(b)) => emb.cosine_similarity(a, b),
151 _ => 0.0,
152 },
153 _ => 0.0,
154 };
155 let weight = STRUCTURE_WEIGHT * structural + SEMANTIC_WEIGHT * semantic;
156 let (si, ti) = (compact[s], compact[t]);
157 *weights.entry((si, ti)).or_insert(0.0) += weight;
158 }
159
160 let mut builder = GraphDataBuilder::new(funcs.len()).directed();
170 for ((s, t), w) in &weights {
171 builder
172 .add_edge(*s, *t, *w)
173 .expect("边权重均为有限非负数(Embedder 契约:余弦相似度必须有限)");
174 }
175 let data = builder.build().expect("图数据构造失败");
176 let config = LeidenConfig {
177 quality: QualityType::CPM,
178 resolution: FEATURE_RESOLUTION,
179 seed: Some(FEATURE_LEIDEN_SEED),
180 ..Default::default()
181 };
182 let result = Leiden::new(config)
185 .run(&data)
186 .expect("Leiden 特征聚类失败");
187 let membership = result.partition.as_slice();
188
189 let mut groups: HashMap<usize, Vec<NodeId>> = HashMap::new();
191 for (i, &comm) in membership.iter().enumerate() {
192 groups.entry(comm).or_default().push(funcs[i]);
193 }
194 let mut features: Vec<Vec<NodeId>> = groups
195 .into_values()
196 .map(|mut node_ids| {
197 node_ids.sort_by_key(|nid| {
198 graph
199 .graph
200 .node_weight(*nid)
201 .map(|n| n.name.clone())
202 .unwrap_or_default()
203 });
204 node_ids
205 })
206 .collect();
207 features.sort_by_key(|node_ids| {
208 node_ids
209 .first()
210 .map(|nid| {
211 graph
212 .graph
213 .node_weight(*nid)
214 .map(|n| n.name.clone())
215 .unwrap_or_default()
216 })
217 .unwrap_or_default()
218 });
219 Ok(features
220 .into_iter()
221 .enumerate()
222 .map(|(idx, node_ids)| Feature {
223 name: format!("feature_{idx}"),
224 node_ids,
225 description: None,
226 })
227 .collect())
228}
229
230fn jaccard(a: Option<&HashSet<NodeId>>, b: Option<&HashSet<NodeId>>) -> f64 {
232 match (a, b) {
233 (Some(a), Some(b)) => {
234 let union: HashSet<NodeId> = a.union(b).copied().collect();
235 if union.is_empty() {
236 0.0
237 } else {
238 a.intersection(b).count() as f64 / union.len() as f64
239 }
240 }
241 _ => 0.0,
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 fn make_graph() -> KnowledgeGraph {
251 let mut kg = KnowledgeGraph::default();
252 let g = &mut kg.graph;
253 let add_file =
254 |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
255 path: &str|
256 -> (NodeId, NodeId) {
257 let nid = g.add_node(CodeNode {
258 id: NodeId::new(g.node_count()),
259 kind: NodeKind::File,
260 name: path.into(),
261 file_path: Some(path.into()),
262 line_range: None,
263 doc_comment: None,
264 signature: None, visibility: None,
265 module_path: vec!["src".into()],
266 });
267 let eid = g.add_node(CodeNode {
268 id: NodeId::new(g.node_count()),
269 kind: NodeKind::Function,
270 name: format!("fn_{}", path.replace(['/', '.'], "_")),
271 file_path: Some(path.into()),
272 line_range: None,
273 doc_comment: None,
274 signature: None, visibility: None,
275 module_path: Vec::new(),
276 });
277 g.add_edge(
278 nid,
279 eid,
280 CodeEdge {
281 id: EdgeId::new(g.edge_count()),
282 kind: EdgeKind::Contains,
283 source: nid,
284 target: eid,
285 weight: 1.0,
286 location: None,
287 },
288 );
289 (nid, eid)
290 };
291 let (_fa_file, fa) = add_file(g, "src/a.rs");
292 let (_fb_file, fb) = add_file(g, "src/b.rs");
293 let (_fc_file, fc) = add_file(g, "src/c.rs");
294 let (_fd_file, fd) = add_file(g, "src/d.rs");
295 for (s, t) in [(fa, fb), (fc, fd)] {
297 g.add_edge(
298 s,
299 t,
300 CodeEdge {
301 id: EdgeId::new(g.edge_count()),
302 kind: EdgeKind::Calls,
303 source: s,
304 target: t,
305 weight: 0.7,
306 location: None,
307 },
308 );
309 }
310 kg
311 }
312
313 #[test]
314 fn test_detect_features_basic() {
315 let kg = make_graph();
316 let features = detect_features(&kg, None).unwrap();
317 assert!(features.len() >= 2, "应检出至少 2 个特征: {:?}", features);
318 let names: Vec<String> = features
319 .iter()
320 .flat_map(|f| {
321 f.node_ids
322 .iter()
323 .map(|nid| kg.graph.node_weight(*nid).unwrap().name.clone())
324 .collect::<Vec<_>>()
325 })
326 .collect();
327 assert!(names.contains(&"fn_src_a_rs".to_string()));
328 assert!(names.contains(&"fn_src_b_rs".to_string()));
329 }
330
331 #[test]
332 fn test_detect_features_empty_graph() {
333 let kg = KnowledgeGraph::default();
334 let features = detect_features(&kg, None).unwrap();
335 assert!(features.is_empty());
336 }
337
338 #[test]
339 fn test_detect_features_no_cross_file_calls() {
340 let mut kg = KnowledgeGraph::default();
341 let g = &mut kg.graph;
342 let f = g.add_node(CodeNode {
343 id: NodeId::new(0),
344 kind: NodeKind::File,
345 name: "src/a.rs".into(),
346 file_path: Some("src/a.rs".into()),
347 line_range: None,
348 doc_comment: None,
349 signature: None, visibility: None,
350 module_path: vec!["src".into()],
351 });
352 let e1 = g.add_node(CodeNode {
353 id: NodeId::new(1),
354 kind: NodeKind::Function,
355 name: "f1".into(),
356 file_path: Some("src/a.rs".into()),
357 line_range: None,
358 doc_comment: None,
359 signature: None, visibility: None,
360 module_path: Vec::new(),
361 });
362 let e2 = g.add_node(CodeNode {
363 id: NodeId::new(2),
364 kind: NodeKind::Function,
365 name: "f2".into(),
366 file_path: Some("src/a.rs".into()),
367 line_range: None,
368 doc_comment: None,
369 signature: None, visibility: None,
370 module_path: Vec::new(),
371 });
372 for e in [e1, e2] {
373 g.add_edge(
374 f,
375 e,
376 CodeEdge {
377 id: EdgeId::new(g.edge_count()),
378 kind: EdgeKind::Contains,
379 source: f,
380 target: e,
381 weight: 1.0,
382 location: None,
383 },
384 );
385 }
386 g.add_edge(
388 e1,
389 e2,
390 CodeEdge {
391 id: EdgeId::new(g.edge_count()),
392 kind: EdgeKind::Calls,
393 source: e1,
394 target: e2,
395 weight: 0.7,
396 location: None,
397 },
398 );
399 let features = detect_features(&kg, None).unwrap();
400 assert!(features.is_empty(), "同文件调用不构成特征");
401 }
402}