Skip to main content

weavatrix_clone/
accuracy.rs

1use crate::error::{CloneError, Result};
2use crate::model::{CloneKind, CloneLocation, ClonePair, CloneReport, Similarity};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct OracleLocation {
7    pub path: String,
8    pub start_line: u32,
9    pub end_line: u32,
10}
11
12impl OracleLocation {
13    #[must_use]
14    pub fn new(path: impl Into<String>, start_line: u32, end_line: u32) -> Self {
15        Self {
16            path: path.into().replace('\\', "/"),
17            start_line,
18            end_line,
19        }
20    }
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct OraclePair {
25    pub id: String,
26    pub left: OracleLocation,
27    pub right: OracleLocation,
28    pub expected: bool,
29    pub kind: Option<CloneKind>,
30}
31
32impl OraclePair {
33    #[must_use]
34    pub fn positive(
35        id: impl Into<String>,
36        kind: CloneKind,
37        left: OracleLocation,
38        right: OracleLocation,
39    ) -> Self {
40        Self {
41            id: id.into(),
42            left,
43            right,
44            expected: true,
45            kind: Some(kind),
46        }
47    }
48
49    #[must_use]
50    pub fn negative(id: impl Into<String>, left: OracleLocation, right: OracleLocation) -> Self {
51        Self {
52            id: id.into(),
53            left,
54            right,
55            expected: false,
56            kind: None,
57        }
58    }
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub struct AccuracyCounts {
63    pub true_positives: usize,
64    pub false_positives: usize,
65    pub true_negatives: usize,
66    pub false_negatives: usize,
67}
68
69impl AccuracyCounts {
70    #[must_use]
71    pub fn precision(self) -> Similarity {
72        ratio(
73            self.true_positives,
74            self.true_positives.saturating_add(self.false_positives),
75        )
76    }
77
78    #[must_use]
79    pub fn recall(self) -> Similarity {
80        ratio(
81            self.true_positives,
82            self.true_positives.saturating_add(self.false_negatives),
83        )
84    }
85
86    #[must_use]
87    pub fn f1(self) -> Similarity {
88        let precision = usize::from(self.precision().permille());
89        let recall = usize::from(self.recall().permille());
90        if precision + recall == 0 {
91            return Similarity::from_permille(0);
92        }
93        Similarity::from_permille(
94            u16::try_from(2 * precision * recall / (precision + recall)).unwrap_or(1_000),
95        )
96    }
97
98    fn record(&mut self, expected: bool, detected: bool) {
99        match (expected, detected) {
100            (true, true) => self.true_positives += 1,
101            (false, true) => self.false_positives += 1,
102            (false, false) => self.true_negatives += 1,
103            (true, false) => self.false_negatives += 1,
104        }
105    }
106}
107
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
109pub struct AccuracyReport {
110    pub overall: AccuracyCounts,
111    pub type1: AccuracyCounts,
112    pub type2: AccuracyCounts,
113    pub type3: AccuracyCounts,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct AccuracyGate {
118    pub coverage: Similarity,
119    pub min_precision: Similarity,
120    pub min_recall: Similarity,
121}
122
123impl Default for AccuracyGate {
124    fn default() -> Self {
125        Self {
126            coverage: Similarity::from_permille(700),
127            min_precision: Similarity::PERFECT,
128            min_recall: Similarity::PERFECT,
129        }
130    }
131}
132
133impl AccuracyGate {
134    #[must_use]
135    pub fn evaluate(self, report: &CloneReport, oracle: &[OraclePair]) -> AccuracyReport {
136        let mut accuracy = AccuracyReport::default();
137        let mut by_paths = HashMap::<(&str, &str), Vec<&ClonePair>>::new();
138        for candidate in &report.pairs {
139            by_paths
140                .entry(path_key(&candidate.left.path, &candidate.right.path))
141                .or_default()
142                .push(candidate);
143        }
144        for expected in oracle {
145            let detected = by_paths
146                .get(&path_key(&expected.left.path, &expected.right.path))
147                .into_iter()
148                .flatten()
149                .any(|candidate| pair_matches(candidate, expected, self.coverage));
150            accuracy.overall.record(expected.expected, detected);
151            let kind_counts = match expected.kind {
152                Some(CloneKind::Type1) => Some(&mut accuracy.type1),
153                Some(CloneKind::Type2) => Some(&mut accuracy.type2),
154                Some(CloneKind::Type3) => Some(&mut accuracy.type3),
155                None => None,
156            };
157            if let Some(counts) = kind_counts {
158                counts.record(expected.expected, detected);
159            }
160        }
161        accuracy
162    }
163
164    /// Checks precision and recall over explicitly labeled oracle relations.
165    ///
166    /// # Errors
167    ///
168    /// Returns an accuracy error when either configured threshold is missed.
169    pub fn check(self, report: &CloneReport, oracle: &[OraclePair]) -> Result<AccuracyReport> {
170        let accuracy = self.evaluate(report, oracle);
171        require(
172            "precision",
173            accuracy.overall.precision(),
174            self.min_precision,
175        )?;
176        require("recall", accuracy.overall.recall(), self.min_recall)?;
177        Ok(accuracy)
178    }
179}
180
181fn path_key<'a>(left: &'a str, right: &'a str) -> (&'a str, &'a str) {
182    if left <= right {
183        (left, right)
184    } else {
185        (right, left)
186    }
187}
188
189fn pair_matches(candidate: &ClonePair, expected: &OraclePair, threshold: Similarity) -> bool {
190    (covers(&candidate.left, &expected.left, threshold)
191        && covers(&candidate.right, &expected.right, threshold))
192        || (covers(&candidate.left, &expected.right, threshold)
193            && covers(&candidate.right, &expected.left, threshold))
194}
195
196fn covers(candidate: &CloneLocation, expected: &OracleLocation, threshold: Similarity) -> bool {
197    if candidate.path.replace('\\', "/") != expected.path || expected.start_line > expected.end_line
198    {
199        return false;
200    }
201    let start = candidate.span.start_line.max(expected.start_line);
202    let end = candidate.span.end_line.min(expected.end_line);
203    let intersection = end
204        .saturating_sub(start)
205        .saturating_add(u32::from(end >= start));
206    let expected_lines = expected
207        .end_line
208        .saturating_sub(expected.start_line)
209        .saturating_add(1);
210    u64::from(intersection) * 1_000 >= u64::from(expected_lines) * u64::from(threshold.permille())
211}
212
213fn ratio(numerator: usize, denominator: usize) -> Similarity {
214    if denominator == 0 {
215        Similarity::PERFECT
216    } else {
217        Similarity::from_ratio(numerator, denominator)
218    }
219}
220
221fn require(metric: &'static str, actual: Similarity, required: Similarity) -> Result<()> {
222    if actual < required {
223        return Err(CloneError::AccuracyGate {
224            metric,
225            actual: actual.permille(),
226            required: required.permille(),
227        });
228    }
229    Ok(())
230}