Skip to main content

weavatrix_clone/
accuracy.rs

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