Skip to main content

drft/
metrics.rs

1use crate::analyses::{
2    bridges::BridgesResult, change_propagation::ChangePropagationResult,
3    connected_components::ConnectedComponentsResult, degree::DegreeResult,
4    graph_stats::GraphStatsResult, pagerank::PageRankResult, scc::SccResult,
5    transitive_reduction::TransitiveReductionResult,
6};
7use crate::graph::Graph;
8
9/// A scalar metric extracted from analysis results.
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct Metric {
12    pub name: String,
13    pub value: f64,
14    pub kind: MetricKind,
15    pub dimension: String,
16}
17
18#[derive(Debug, Clone, serde::Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum MetricKind {
21    Ratio,
22    Count,
23    Score,
24}
25
26/// All known metric names, sorted alphabetically.
27pub fn all_metric_names() -> &'static [&'static str] {
28    &[
29        "average_path_length",
30        "bridge_count",
31        "component_count",
32        "cut_node_count",
33        "cycle_count",
34        "cyclomatic_complexity",
35        "density",
36        "diameter",
37        "directly_changed_count",
38        "island_ratio",
39        "max_pagerank",
40        "orphan_ratio",
41        "redundant_edge_ratio",
42        "stale_ratio",
43        "transitively_stale_count",
44    ]
45}
46
47/// Pre-computed analysis results needed for metric extraction.
48pub struct AnalysisInputs<'a> {
49    pub degree: &'a DegreeResult,
50    pub scc: &'a SccResult,
51    pub connected_components: &'a ConnectedComponentsResult,
52    pub graph_stats: &'a GraphStatsResult,
53    pub bridges: &'a BridgesResult,
54    pub transitive_reduction: &'a TransitiveReductionResult,
55    pub change_propagation: &'a ChangePropagationResult,
56    pub pagerank: &'a PageRankResult,
57}
58
59/// Compute all scalar health metrics from pre-computed analysis results.
60pub fn compute_metrics(inputs: &AnalysisInputs, graph: &Graph) -> Vec<Metric> {
61    let mut metrics: Vec<Metric> = Vec::new();
62
63    // Connectivity
64    let total_nodes = graph
65        .nodes
66        .values()
67        .filter(|n| graph.is_included_node(&n.path))
68        .count() as f64;
69    if total_nodes > 0.0 {
70        let orphans = inputs
71            .degree
72            .nodes
73            .iter()
74            .filter(|n| n.in_degree == 0)
75            .count() as f64;
76        metrics.push(Metric {
77            name: "orphan_ratio".into(),
78            value: orphans / total_nodes,
79            kind: MetricKind::Ratio,
80            dimension: "connectivity".into(),
81        });
82
83        let islands = inputs
84            .connected_components
85            .components
86            .iter()
87            .filter(|c| c.members.len() == 1)
88            .count() as f64;
89        metrics.push(Metric {
90            name: "island_ratio".into(),
91            value: islands / total_nodes,
92            kind: MetricKind::Ratio,
93            dimension: "connectivity".into(),
94        });
95    }
96
97    // Complexity
98    metrics.push(Metric {
99        name: "component_count".into(),
100        value: inputs.connected_components.component_count as f64,
101        kind: MetricKind::Count,
102        dimension: "complexity".into(),
103    });
104    metrics.push(Metric {
105        name: "density".into(),
106        value: inputs.graph_stats.density,
107        kind: MetricKind::Ratio,
108        dimension: "complexity".into(),
109    });
110    let internal_edge_count = graph
111        .edges
112        .iter()
113        .filter(|e| graph.is_internal_edge(e))
114        .count();
115    metrics.push(Metric {
116        name: "cyclomatic_complexity".into(),
117        value: (internal_edge_count as f64 - total_nodes
118            + inputs.connected_components.components.len() as f64),
119        kind: MetricKind::Count,
120        dimension: "complexity".into(),
121    });
122    if let Some(d) = inputs.graph_stats.diameter {
123        metrics.push(Metric {
124            name: "diameter".into(),
125            value: d as f64,
126            kind: MetricKind::Count,
127            dimension: "complexity".into(),
128        });
129    }
130    if let Some(avg) = inputs.graph_stats.average_path_length {
131        metrics.push(Metric {
132            name: "average_path_length".into(),
133            value: avg,
134            kind: MetricKind::Score,
135            dimension: "complexity".into(),
136        });
137    }
138
139    // Conciseness
140    let total_edges = internal_edge_count as f64;
141    if total_edges > 0.0 {
142        metrics.push(Metric {
143            name: "redundant_edge_ratio".into(),
144            value: inputs.transitive_reduction.redundant_edges.len() as f64 / total_edges,
145            kind: MetricKind::Ratio,
146            dimension: "conciseness".into(),
147        });
148    }
149
150    // Resilience
151    metrics.push(Metric {
152        name: "bridge_count".into(),
153        value: inputs.bridges.bridges.len() as f64,
154        kind: MetricKind::Count,
155        dimension: "resilience".into(),
156    });
157    metrics.push(Metric {
158        name: "cut_node_count".into(),
159        value: inputs.bridges.cut_vertices.len() as f64,
160        kind: MetricKind::Count,
161        dimension: "resilience".into(),
162    });
163
164    // Freshness
165    if inputs.change_propagation.has_lockfile {
166        metrics.push(Metric {
167            name: "directly_changed_count".into(),
168            value: inputs.change_propagation.directly_changed.len() as f64,
169            kind: MetricKind::Count,
170            dimension: "freshness".into(),
171        });
172        metrics.push(Metric {
173            name: "transitively_stale_count".into(),
174            value: inputs.change_propagation.transitively_stale.len() as f64,
175            kind: MetricKind::Count,
176            dimension: "freshness".into(),
177        });
178        if total_nodes > 0.0 {
179            let stale = (inputs.change_propagation.directly_changed.len()
180                + inputs.change_propagation.transitively_stale.len())
181                as f64;
182            metrics.push(Metric {
183                name: "stale_ratio".into(),
184                value: stale / total_nodes,
185                kind: MetricKind::Ratio,
186                dimension: "freshness".into(),
187            });
188        }
189    }
190
191    // PageRank concentration
192    if !inputs.pagerank.nodes.is_empty() {
193        let max = inputs
194            .pagerank
195            .nodes
196            .iter()
197            .map(|n| n.score)
198            .fold(f64::NEG_INFINITY, f64::max);
199        metrics.push(Metric {
200            name: "max_pagerank".into(),
201            value: max,
202            kind: MetricKind::Score,
203            dimension: "complexity".into(),
204        });
205    }
206
207    // SCC
208    metrics.push(Metric {
209        name: "cycle_count".into(),
210        value: inputs.scc.nontrivial_count as f64,
211        kind: MetricKind::Count,
212        dimension: "complexity".into(),
213    });
214
215    metrics
216}