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.included_nodes().count() as f64;
65    if total_nodes > 0.0 {
66        let orphans = inputs
67            .degree
68            .nodes
69            .iter()
70            .filter(|n| n.in_degree == 0)
71            .count() as f64;
72        metrics.push(Metric {
73            name: "orphan_ratio".into(),
74            value: orphans / total_nodes,
75            kind: MetricKind::Ratio,
76            dimension: "connectivity".into(),
77        });
78
79        let islands = inputs
80            .connected_components
81            .components
82            .iter()
83            .filter(|c| c.members.len() == 1)
84            .count() as f64;
85        metrics.push(Metric {
86            name: "island_ratio".into(),
87            value: islands / total_nodes,
88            kind: MetricKind::Ratio,
89            dimension: "connectivity".into(),
90        });
91    }
92
93    // Complexity
94    metrics.push(Metric {
95        name: "component_count".into(),
96        value: inputs.connected_components.component_count as f64,
97        kind: MetricKind::Count,
98        dimension: "complexity".into(),
99    });
100    metrics.push(Metric {
101        name: "density".into(),
102        value: inputs.graph_stats.density,
103        kind: MetricKind::Ratio,
104        dimension: "complexity".into(),
105    });
106    let internal_edge_count = graph
107        .edges
108        .iter()
109        .filter(|e| graph.is_internal_edge(e))
110        .count();
111    metrics.push(Metric {
112        name: "cyclomatic_complexity".into(),
113        value: (internal_edge_count as f64 - total_nodes
114            + inputs.connected_components.components.len() as f64),
115        kind: MetricKind::Count,
116        dimension: "complexity".into(),
117    });
118    if let Some(d) = inputs.graph_stats.diameter {
119        metrics.push(Metric {
120            name: "diameter".into(),
121            value: d as f64,
122            kind: MetricKind::Count,
123            dimension: "complexity".into(),
124        });
125    }
126    if let Some(avg) = inputs.graph_stats.average_path_length {
127        metrics.push(Metric {
128            name: "average_path_length".into(),
129            value: avg,
130            kind: MetricKind::Score,
131            dimension: "complexity".into(),
132        });
133    }
134
135    // Conciseness
136    let total_edges = internal_edge_count as f64;
137    if total_edges > 0.0 {
138        metrics.push(Metric {
139            name: "redundant_edge_ratio".into(),
140            value: inputs.transitive_reduction.redundant_edges.len() as f64 / total_edges,
141            kind: MetricKind::Ratio,
142            dimension: "conciseness".into(),
143        });
144    }
145
146    // Resilience
147    metrics.push(Metric {
148        name: "bridge_count".into(),
149        value: inputs.bridges.bridges.len() as f64,
150        kind: MetricKind::Count,
151        dimension: "resilience".into(),
152    });
153    metrics.push(Metric {
154        name: "cut_node_count".into(),
155        value: inputs.bridges.cut_vertices.len() as f64,
156        kind: MetricKind::Count,
157        dimension: "resilience".into(),
158    });
159
160    // Freshness
161    if inputs.change_propagation.has_lockfile {
162        metrics.push(Metric {
163            name: "directly_changed_count".into(),
164            value: inputs.change_propagation.directly_changed.len() as f64,
165            kind: MetricKind::Count,
166            dimension: "freshness".into(),
167        });
168        metrics.push(Metric {
169            name: "transitively_stale_count".into(),
170            value: inputs.change_propagation.transitively_stale.len() as f64,
171            kind: MetricKind::Count,
172            dimension: "freshness".into(),
173        });
174        if total_nodes > 0.0 {
175            let stale = (inputs.change_propagation.directly_changed.len()
176                + inputs.change_propagation.transitively_stale.len())
177                as f64;
178            metrics.push(Metric {
179                name: "stale_ratio".into(),
180                value: stale / total_nodes,
181                kind: MetricKind::Ratio,
182                dimension: "freshness".into(),
183            });
184        }
185    }
186
187    // PageRank concentration
188    if !inputs.pagerank.nodes.is_empty() {
189        let max = inputs
190            .pagerank
191            .nodes
192            .iter()
193            .map(|n| n.score)
194            .fold(f64::NEG_INFINITY, f64::max);
195        metrics.push(Metric {
196            name: "max_pagerank".into(),
197            value: max,
198            kind: MetricKind::Score,
199            dimension: "complexity".into(),
200        });
201    }
202
203    // SCC
204    metrics.push(Metric {
205        name: "cycle_count".into(),
206        value: inputs.scc.nontrivial_count as f64,
207        kind: MetricKind::Count,
208        dimension: "complexity".into(),
209    });
210
211    metrics
212}