Skip to main content

graphify_core/
model.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::confidence::Confidence;
6
7// ---------------------------------------------------------------------------
8// NodeType
9// ---------------------------------------------------------------------------
10
11/// The kind of entity a graph node represents.
12///
13/// Serialized as lowercase strings (e.g. `"class"`, `"function"`).
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum NodeType {
17    Class,
18    Function,
19    Module,
20    Concept,
21    Paper,
22    Image,
23    File,
24    Method,
25    Interface,
26    Enum,
27    Struct,
28    Trait,
29    Constant,
30    Variable,
31    Package,
32    Namespace,
33}
34
35// ---------------------------------------------------------------------------
36// GraphNode
37// ---------------------------------------------------------------------------
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct GraphNode {
41    pub id: String,
42    pub label: String,
43    pub source_file: String,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub source_location: Option<String>,
46    pub node_type: NodeType,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub community: Option<usize>,
49    #[serde(flatten)]
50    pub extra: HashMap<String, serde_json::Value>,
51}
52
53// ---------------------------------------------------------------------------
54// GraphEdge
55// ---------------------------------------------------------------------------
56
57fn default_confidence_score() -> f64 {
58    1.0
59}
60
61fn default_weight() -> f64 {
62    1.0
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct GraphEdge {
67    pub source: String,
68    pub target: String,
69    pub relation: String,
70    pub confidence: Confidence,
71    #[serde(default = "default_confidence_score")]
72    pub confidence_score: f64,
73    pub source_file: String,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub source_location: Option<String>,
76    #[serde(default = "default_weight")]
77    pub weight: f64,
78    #[serde(flatten)]
79    pub extra: HashMap<String, serde_json::Value>,
80}
81
82// ---------------------------------------------------------------------------
83// Hyperedge
84// ---------------------------------------------------------------------------
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Hyperedge {
88    pub nodes: Vec<String>,
89    pub relation: String,
90    pub label: String,
91}
92
93// ---------------------------------------------------------------------------
94// ExtractionResult
95// ---------------------------------------------------------------------------
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct ExtractionResult {
99    pub nodes: Vec<GraphNode>,
100    pub edges: Vec<GraphEdge>,
101    #[serde(default)]
102    pub hyperedges: Vec<Hyperedge>,
103}
104
105// ---------------------------------------------------------------------------
106// CommunityInfo
107// ---------------------------------------------------------------------------
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CommunityInfo {
111    pub id: usize,
112    pub nodes: Vec<String>,
113    pub cohesion: f64,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub label: Option<String>,
116}
117
118// ---------------------------------------------------------------------------
119// AnalysisResult & helpers
120// ---------------------------------------------------------------------------
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct GodNode {
124    pub id: String,
125    pub label: String,
126    pub degree: usize,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub community: Option<usize>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Surprise {
133    pub source: String,
134    pub target: String,
135    pub source_community: usize,
136    pub target_community: usize,
137    pub relation: String,
138}
139
140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141pub struct AnalysisResult {
142    pub god_nodes: Vec<GodNode>,
143    pub surprises: Vec<Surprise>,
144    pub questions: Vec<String>,
145}
146
147/// A node that bridges multiple communities.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct BridgeNode {
150    pub id: String,
151    pub label: String,
152    pub total_edges: usize,
153    pub cross_community_edges: usize,
154    /// Ratio of cross-community edges to total edges (0.0–1.0).
155    pub bridge_ratio: f64,
156    pub communities_touched: Vec<usize>,
157}
158
159/// PageRank importance score for a node.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct PageRankNode {
162    pub id: String,
163    pub label: String,
164    pub score: f64,
165    pub degree: usize,
166}
167
168/// A dependency cycle detected in the graph.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct DependencyCycle {
171    pub nodes: Vec<String>,
172    pub edges: Vec<(String, String)>,
173    /// Shorter cycles are more severe (1.0 / len).
174    pub severity: f64,
175}
176
177/// A node with temporal risk metrics from git history.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct TemporalNode {
180    pub id: String,
181    pub label: String,
182    pub last_modified: String,
183    pub change_count: usize,
184    pub age_days: u64,
185    pub churn_rate: f64,
186    pub risk_score: f64,
187}
188
189/// A pair of structurally similar nodes found via graph embedding.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct SimilarPair {
192    pub node_a: String,
193    pub node_b: String,
194    pub similarity: f64,
195    pub label_a: String,
196    pub label_b: String,
197}
198
199// ---------------------------------------------------------------------------
200// Tests
201// ---------------------------------------------------------------------------
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn sample_node() -> GraphNode {
208        GraphNode {
209            id: "my_class".into(),
210            label: "MyClass".into(),
211            source_file: "src/main.rs".into(),
212            source_location: Some("L42".into()),
213            node_type: NodeType::Class,
214            community: None,
215            extra: HashMap::new(),
216        }
217    }
218
219    fn sample_edge() -> GraphEdge {
220        GraphEdge {
221            source: "a".into(),
222            target: "b".into(),
223            relation: "calls".into(),
224            confidence: Confidence::Extracted,
225            confidence_score: 1.0,
226            source_file: "src/main.rs".into(),
227            source_location: None,
228            weight: 1.0,
229            extra: HashMap::new(),
230        }
231    }
232
233    #[test]
234    fn node_type_serializes_lowercase() {
235        assert_eq!(
236            serde_json::to_string(&NodeType::Class).unwrap(),
237            r#""class""#
238        );
239        assert_eq!(
240            serde_json::to_string(&NodeType::Function).unwrap(),
241            r#""function""#
242        );
243        assert_eq!(
244            serde_json::to_string(&NodeType::Namespace).unwrap(),
245            r#""namespace""#
246        );
247    }
248
249    #[test]
250    fn node_roundtrip() {
251        let node = sample_node();
252        let json = serde_json::to_string(&node).unwrap();
253        let back: GraphNode = serde_json::from_str(&json).unwrap();
254        assert_eq!(back.id, "my_class");
255        assert_eq!(back.node_type, NodeType::Class);
256    }
257
258    #[test]
259    fn node_skip_none_fields() {
260        let mut node = sample_node();
261        node.source_location = None;
262        node.community = None;
263        let json = serde_json::to_string(&node).unwrap();
264        assert!(!json.contains("source_location"));
265        assert!(!json.contains("community"));
266    }
267
268    #[test]
269    fn edge_defaults() {
270        let json = r#"{
271            "source": "a",
272            "target": "b",
273            "relation": "calls",
274            "confidence": "EXTRACTED",
275            "source_file": "x.rs"
276        }"#;
277        let edge: GraphEdge = serde_json::from_str(json).unwrap();
278        assert!((edge.confidence_score - 1.0).abs() < f64::EPSILON);
279        assert!((edge.weight - 1.0).abs() < f64::EPSILON);
280    }
281
282    #[test]
283    fn edge_roundtrip() {
284        let edge = sample_edge();
285        let json = serde_json::to_string(&edge).unwrap();
286        let back: GraphEdge = serde_json::from_str(&json).unwrap();
287        assert_eq!(back.relation, "calls");
288        assert_eq!(back.confidence, Confidence::Extracted);
289    }
290
291    #[test]
292    fn extraction_result_default() {
293        let r = ExtractionResult::default();
294        assert!(r.nodes.is_empty());
295        assert!(r.edges.is_empty());
296        assert!(r.hyperedges.is_empty());
297    }
298
299    #[test]
300    fn extra_fields_flatten() {
301        let mut node = sample_node();
302        node.extra
303            .insert("custom".into(), serde_json::Value::Bool(true));
304        let json = serde_json::to_string(&node).unwrap();
305        assert!(json.contains(r#""custom":true"#));
306    }
307
308    #[test]
309    fn community_info_roundtrip() {
310        let ci = CommunityInfo {
311            id: 0,
312            nodes: vec!["a".into(), "b".into()],
313            cohesion: 0.85,
314            label: Some("cluster-0".into()),
315        };
316        let json = serde_json::to_string(&ci).unwrap();
317        let back: CommunityInfo = serde_json::from_str(&json).unwrap();
318        assert_eq!(back.id, 0);
319        assert_eq!(back.nodes.len(), 2);
320    }
321
322    #[test]
323    fn analysis_result_default() {
324        let ar = AnalysisResult::default();
325        assert!(ar.god_nodes.is_empty());
326        assert!(ar.surprises.is_empty());
327        assert!(ar.questions.is_empty());
328    }
329}