Skip to main content

code_repo_wiki/analysis/
feature.rs

1//! 实体级特征聚类(演进计划 T1.2b)
2//!
3//! 特征(Feature)= 跨文件协作实现同一功能的一组方法,对应论文中
4//! RepoSummary 的 "functional feature" 与 RepoDoc 的功能内聚单元。
5//!
6//! 实现要点:
7//! - 聚类单位 = Function 实体,边 = **跨文件** Calls 边(同文件内调用是
8//!   内部实现细节,不构成特征协作);
9//! - 边权重 = 结构相似度与语义相似度各半融合(DIP:本模块只定义
10//!   [`Embedder`] 窄接口,具体实现由 generate::embed::EmbeddingEngine 承担,
11//!   lib.rs 注入);无 embedder 或 embedding 失败时降级为纯结构聚类;
12//! - 结构相似度 = 0.5(存在跨文件调用即视为协作)+ 0.5 × 公共调用邻居
13//!   Jaccard(调用网络越相似,协作越紧密);
14//! - 聚类算法与模块检测一致:Leiden CPM + 固定种子(确定性输出)。
15
16use 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
24/// 特征聚类 Leiden 固定种子(与 community.rs 一致,保证可复现)
25const FEATURE_LEIDEN_SEED: u64 = 42;
26/// CPM 分辨率:特征粒度应比模块细(方法组),取 0.4——实现事实:
27/// 单边协作结构权重 0.5,CPM 合并判据是 边权−γ>0(即 0.5−0.4=0.1),
28/// 因此单边协作**不**合并(需 ≥0.4 才满足);多边协作权重更高,可合并。
29const FEATURE_RESOLUTION: f64 = 0.4;
30/// 结构相似度占比(存在跨文件调用即给 0.5 基线分)
31const STRUCTURE_WEIGHT: f64 = 0.5;
32/// 语义相似度占比(embedding 融合)
33const SEMANTIC_WEIGHT: f64 = 0.5;
34
35/// 向量嵌入窄接口(analysis 层不依赖具体实现,实现方见 generate::embed)。
36///
37/// 刻意使用同步方法而非 async fn in trait:当前编译器不支持 async fn in
38/// trait 的 dyn 分发(llm.rs 已记载该限制),而特征聚类需要
39/// `Option<&dyn Embedder>` 做运行时注入;同步方法由实现方内部驱动
40/// tokio Runtime(EmbeddingEngine 持有 Handle)。
41pub trait Embedder: Send + Sync {
42    /// 单文本嵌入
43    fn embed(&self, text: &str) -> Result<Vec<f32>>;
44    /// 批量嵌入
45    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
46    /// 余弦相似度(返回 0.0~1.0,负值截断为 0)
47    fn cosine_similarity(&self, a: &[f32], b: &[f32]) -> f64;
48}
49
50/// 实体级特征聚类
51///
52/// `embedder` 为 None 或嵌入失败时降级为纯结构聚类(结构权重即总权重)。
53pub fn detect_features(
54    graph: &KnowledgeGraph,
55    embedder: Option<&dyn Embedder>,
56) -> Result<Vec<Feature>> {
57    // 1. 收集 Function 实体
58    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    // 2. 实体 → 所属文件(经 Contains 边反查,判定跨文件调用)
70    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    // 3. 跨文件调用边 + 调用邻居集
79    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        // 结构安全证据(R1 审计):边权重由 build_graph 创建边时同步写入,
83        // 非 Option 类型;此处 expect 仅暴露未来构造代码的回归。
84        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; // 同文件内调用
97        }
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    // 4. 可选 embedding(只嵌入参与跨文件协作的实体;失败降级纯结构)
107    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                // 结构安全证据(R1 审计):involved 来自 cross_edges 的端点
118                //(从 graph 边引用收集,端点必然有节点权重)。
119                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    // 5. 融合边权重
140    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    // 6. Leiden CPM 聚类
161    // 结构安全证据(R1 审计):structural 是 0.5~1.0 的 Jaccard 变换
162    //(jaccard 本身 ∈[0,1]);semantic 来自 embedder.cosine_similarity——
163    // **契约:Embedder 实现必须返回有限值**(唯一实现 EmbeddingEngine
164    // 对零范数向量返回 0.0 并 clamp,恒满足)。若未来新增 Embedder
165    // 实现返回 NaN/Inf,此处 add_edge 会失败并触发 panic——这是有意
166    // 的 fail-loud 契约校验(票 07 裁决:信任边界违约必须暴露,而非
167    // 静默降级掩盖 bug)。常量权重 STRUCTURE_WEIGHT=0.5/SEMANTIC_WEIGHT=0.5
168    // 有限非负,无其他注入路径。
169    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    // 结构安全证据(R1 审计):leiden-rs 0.8.1 对用户输入从不 panic
183    //(空图/单节点/无边图安全返回),run() 仅内部不变量可失败。
184    let result = Leiden::new(config)
185        .run(&data)
186        .expect("Leiden 特征聚类失败");
187    let membership = result.partition.as_slice();
188
189    // 7. 组装 Feature(确定性:按社区内最小实体名排序)
190    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
230/// 两个邻居集的 Jaccard 相似度(|A∩B| / |A∪B|,空并集为 0)
231fn 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    /// 构造图:a.rs 的 fa 调用 b.rs 的 fb(跨文件),c.rs 的 fc 调用 d.rs 的 fd
250    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        // fa→fb、fc→fd 两对跨文件调用
296        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        // 同文件内调用
387        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}