Skip to main content

perspt_coding/diag/
cluster.rs

1//! Root-cause clustering and profile-normalized magnitudes
2//! (PSP-10 system 26).
3//!
4//! Cascaded errors are grouped by root cause before scoring: two hundred
5//! cascade errors from one missing import are one cluster, not two hundred
6//! units of energy. The magnitude formula is part of the immutable sensor
7//! profile [`CLUSTER_PROFILE_V1`]; measurements under different profiles
8//! are never pooled or compared.
9
10use std::collections::BTreeMap;
11
12use perspt_sdk::ResidualClass;
13
14use super::types::StructuredDiagnostic;
15
16/// The clustering + normalization profile identity. Changing the grouping
17/// key or the magnitude formula requires a new version.
18pub const CLUSTER_PROFILE_V1: &str = "perspt-cluster-v1:log-damped";
19
20/// One root-cause cluster of diagnostics.
21#[derive(Debug, Clone, PartialEq)]
22pub struct DiagnosticCluster {
23    pub class: ResidualClass,
24    /// The grouping code (tool code, or the class name when the tool has
25    /// none).
26    pub code: String,
27    /// The primary (first) diagnostic — the root cause the cascade folds
28    /// into.
29    pub root: StructuredDiagnostic,
30    pub members: Vec<StructuredDiagnostic>,
31}
32
33impl DiagnosticCluster {
34    /// Log-damped magnitude: `1 + ln(member_count)`. One diagnostic scores
35    /// 1.0; two hundred cascades score ≈ 6.3 instead of 200.
36    pub fn magnitude(&self) -> f64 {
37        1.0 + (self.members.len() as f64).ln()
38    }
39}
40
41/// Group diagnostics by `(class, code)`. Secondary (non-primary)
42/// diagnostics with no code of their own fold into the preceding primary's
43/// cluster — that is the cascade rule.
44pub fn cluster(diagnostics: Vec<StructuredDiagnostic>) -> Vec<DiagnosticCluster> {
45    let mut order: Vec<(ResidualClass, String)> = Vec::new();
46    let mut buckets: BTreeMap<(String, String), Vec<StructuredDiagnostic>> = BTreeMap::new();
47    let mut last_primary_key: Option<(ResidualClass, String)> = None;
48    for diagnostic in diagnostics {
49        let key = if !diagnostic.primary && diagnostic.code.is_none() {
50            last_primary_key
51                .clone()
52                .unwrap_or((diagnostic.class, format!("{:?}", diagnostic.class)))
53        } else {
54            let code = diagnostic
55                .code
56                .clone()
57                .unwrap_or_else(|| format!("{:?}", diagnostic.class));
58            let key = (diagnostic.class, code);
59            if diagnostic.primary {
60                last_primary_key = Some(key.clone());
61            }
62            key
63        };
64        let map_key = (format!("{:?}", key.0), key.1.clone());
65        if !buckets.contains_key(&map_key) {
66            order.push(key.clone());
67        }
68        buckets.entry(map_key).or_default().push(diagnostic);
69    }
70    order
71        .into_iter()
72        .map(|(class, code)| {
73            let members = buckets
74                .remove(&(format!("{class:?}"), code.clone()))
75                .unwrap_or_default();
76            DiagnosticCluster {
77                class,
78                code,
79                root: members.first().cloned().expect("nonempty cluster"),
80                members,
81            }
82        })
83        .collect()
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    fn diag(class: ResidualClass, code: Option<&str>, primary: bool) -> StructuredDiagnostic {
91        StructuredDiagnostic {
92            class,
93            code: code.map(str::to_string),
94            message: "m".into(),
95            path: Some("src/lib.rs".into()),
96            line: Some(1),
97            column: Some(1),
98            primary,
99            suggestion: None,
100        }
101    }
102
103    #[test]
104    fn cascades_fold_into_their_root_cluster() {
105        let mut diagnostics = vec![diag(ResidualClass::ImportGraph, Some("E0432"), true)];
106        for _ in 0..199 {
107            diagnostics.push(diag(ResidualClass::ImportGraph, None, false));
108        }
109        let clusters = cluster(diagnostics);
110        assert_eq!(clusters.len(), 1);
111        assert_eq!(clusters[0].members.len(), 200);
112        let magnitude = clusters[0].magnitude();
113        assert!(magnitude < 8.0, "log damping: {magnitude}");
114        assert!(magnitude > 1.0);
115    }
116
117    #[test]
118    fn distinct_codes_stay_distinct_and_singletons_score_one() {
119        let clusters = cluster(vec![
120            diag(ResidualClass::Type, Some("E0308"), true),
121            diag(ResidualClass::ImportGraph, Some("E0432"), true),
122        ]);
123        assert_eq!(clusters.len(), 2);
124        assert!((clusters[0].magnitude() - 1.0).abs() < 1e-9);
125    }
126}