File size: 10,460 Bytes
e5034c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
//! Deduplication logic for code search results across multiple queries.
//!
//! When performing batch semantic searches, the same code node may appear in
//! multiple queries with different scores. This module provides functionality
//! to deduplicate results, keeping each node only in the query where it has
//! the best score.

use std::cmp::Ordering;
use std::collections::HashMap;

use forge_domain::{Node, NodeId};

/// Tracks the best score for a node across multiple queries.
///
/// Implements `Ord` to enable comparison based on score quality.
/// Priority: relevance (higher is better) → distance (lower is better) →
/// similarity (higher is better) → query index (lower is better, tie-breaker).
#[derive(Debug, Clone, PartialEq)]
struct Score {
    query_idx: usize,
    relevance: Option<f32>,
    distance: Option<f32>,
}

impl Score {
    /// Creates a new `BestScore` from a query index and search result.
    fn new(query_idx: usize, result: &Node) -> Self {
        Self {
            query_idx,
            relevance: result.relevance,
            distance: result.distance,
        }
    }
}

impl Eq for Score {}

impl PartialOrd for Score {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Score {
    fn cmp(&self, other: &Self) -> Ordering {
        /// Helper to compare two `Option<f32>` values (higher is better).
        ///
        /// # Returns
        /// - `Some(Ordering)` if comparison is decisive
        /// - `None` to continue to next comparison
        fn compare(a: Option<f32>, b: Option<f32>) -> Option<Ordering> {
            match (a, b) {
                (Some(x), Some(y)) => match x.partial_cmp(&y)? {
                    Ordering::Equal => None, // Continue to next comparison
                    ord => Some(ord),
                },
                (Some(_), None) => Some(Ordering::Greater), // Having a value is better than None
                (None, Some(_)) => Some(Ordering::Less),    // None is worse than having a value
                (None, None) => None,                       // Continue to next comparison
            }
        }

        // Compare in priority order: relevance → distance → similarity → query index
        compare(self.relevance, other.relevance) // Higher relevance is better
            .or_else(|| compare(other.distance, self.distance)) // Lower distance is better (flipped)
            .unwrap_or_else(|| self.query_idx.cmp(&other.query_idx).reverse()) // Lower query index wins (first query wins)
    }
}

/// Deduplicates code search results across multiple queries.
///
/// Each node appears only once across all query results, kept in the query
/// where it has the highest score according to the `BestScore` ordering.
///
/// # Arguments
/// * `results` - Vector of search results per query (will be modified in place)
///
/// # Errors
/// Returns an error if node IDs cannot be extracted from results.
pub fn deduplicate_results(results: &mut [Vec<Node>]) {
    // Track best score for each node_id across all queries
    let mut best_scores: HashMap<NodeId, Score> = HashMap::new();

    // First pass: find which query has the best score for each node
    for (query_idx, query_results) in results.iter().enumerate() {
        for result in query_results {
            let current_score = Score::new(query_idx, result);
            match best_scores.entry(result.node_id.clone()) {
                std::collections::hash_map::Entry::Occupied(mut entry) => {
                    if current_score > *entry.get() {
                        entry.insert(current_score);
                    }
                }
                std::collections::hash_map::Entry::Vacant(entry) => {
                    entry.insert(current_score);
                }
            }
        }
    }

    // Second pass: remove duplicates, keeping only in the query with best score
    for (query_idx, query_results) in results.iter_mut().enumerate() {
        query_results.retain(|result| {
            best_scores
                .get(&result.node_id)
                .is_none_or(|best| best.query_idx == query_idx)
        });
    }
}

#[cfg(test)]
mod tests {
    use forge_domain::{Node, NodeData};
    use pretty_assertions::assert_eq;

    use super::*;

    /// Test fixture for creating a minimal `CodeSearchResult`.
    fn result(node_id: &str) -> Node {
        Node {
            node_id: node_id.into(),
            node: NodeData::FileChunk(forge_domain::FileChunk {
                file_path: "test.rs".into(),
                content: "test".into(),
                start_line: 1,
                end_line: 1,
            }),
            relevance: None,
            distance: None,
        }
    }

    #[test]
    fn test_best_score_ordering_by_relevance() {
        let score1 = Score::new(0, &result("node_a").relevance(0.9));
        let score2 = Score::new(1, &result("node_a").relevance(0.8));

        assert!(score1 > score2);
    }

    #[test]
    fn test_best_score_ordering_by_distance_when_relevance_equal() {
        let score1 = Score::new(0, &result("node_a").relevance(0.9).distance(0.1));
        let score2 = Score::new(1, &result("node_a").relevance(0.9).distance(0.2));

        assert!(score1 > score2);
    }

    #[test]
    fn test_best_score_ordering_by_similarity_when_relevance_distance_equal() {
        let score1 = Score::new(0, &result("node_a").relevance(0.9).distance(0.1));
        let score2 = Score::new(1, &result("node_a").relevance(0.9).distance(0.1));

        assert!(score1 > score2);
    }

    #[test]
    fn test_best_score_ordering_by_query_idx_when_all_equal() {
        let score1 = Score::new(0, &result("node_a").relevance(0.9).distance(0.1));
        let score2 = Score::new(1, &result("node_a").relevance(0.9).distance(0.1));

        assert!(score1 > score2); // Lower query index wins
    }

    #[test]
    fn test_best_score_some_value_better_than_none() {
        let score1 = Score::new(0, &result("node_a").relevance(0.5));
        let score2 = Score::new(1, &result("node_a"));

        assert!(score1 > score2);
    }

    #[test]
    fn test_deduplicate_results_keeps_highest_relevance() {
        let mut actual = vec![
            vec![
                result("node_a").relevance(0.8).distance(0.2),
                result("node_b").relevance(0.7).distance(0.3),
            ],
            vec![
                result("node_a").relevance(0.9).distance(0.1),
                result("node_c").relevance(0.6).distance(0.4),
            ],
        ];

        deduplicate_results(&mut actual);

        let expected = vec![
            vec![result("node_b").relevance(0.7).distance(0.3)],
            vec![
                result("node_a").relevance(0.9).distance(0.1),
                result("node_c").relevance(0.6).distance(0.4),
            ],
        ];

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_deduplicate_multiple_duplicates() {
        let mut actual = vec![
            vec![
                result("node_a").relevance(0.8).distance(0.2),
                result("node_b").relevance(0.7).distance(0.3),
                result("node_c").relevance(0.6).distance(0.4),
            ],
            vec![
                result("node_a").relevance(0.9).distance(0.1),
                result("node_b").relevance(0.5).distance(0.5),
                result("node_d").relevance(0.95).distance(0.05),
            ],
        ];

        deduplicate_results(&mut actual);

        let expected = vec![
            vec![
                result("node_b").relevance(0.7).distance(0.3),
                result("node_c").relevance(0.6).distance(0.4),
            ],
            vec![
                result("node_a").relevance(0.9).distance(0.1),
                result("node_d").relevance(0.95).distance(0.05),
            ],
        ];

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_deduplicate_equal_relevance_uses_distance_tiebreaker() {
        let mut actual = vec![
            vec![
                result("node_a").relevance(0.9).distance(0.2),
                result("node_b").relevance(0.8).distance(0.2),
            ],
            vec![
                result("node_a").relevance(0.9).distance(0.1),
                result("node_c").relevance(0.7).distance(0.3),
            ],
        ];

        deduplicate_results(&mut actual);

        let expected = vec![
            vec![result("node_b").relevance(0.8).distance(0.2)],
            vec![
                result("node_a").relevance(0.9).distance(0.1),
                result("node_c").relevance(0.7).distance(0.3),
            ],
        ];

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_deduplicate_across_three_queries() {
        let mut actual = vec![
            vec![
                result("node_a").relevance(0.85).distance(0.15),
                result("node_b").relevance(0.75).distance(0.25),
                result("node_e").relevance(0.65).distance(0.35),
            ],
            vec![
                result("node_a").relevance(0.90).distance(0.10),
                result("node_c").relevance(0.80).distance(0.20),
                result("node_d").relevance(0.70).distance(0.30),
            ],
            vec![
                result("node_a").relevance(0.88).distance(0.12),
                result("node_b").relevance(0.78).distance(0.22),
                result("node_d").relevance(0.72).distance(0.28),
            ],
        ];

        deduplicate_results(&mut actual);

        let expected = vec![
            vec![result("node_e").relevance(0.65).distance(0.35)],
            vec![
                result("node_a").relevance(0.90).distance(0.10),
                result("node_c").relevance(0.80).distance(0.20),
            ],
            vec![
                result("node_b").relevance(0.78).distance(0.22),
                result("node_d").relevance(0.72).distance(0.28),
            ],
        ];

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_deduplicate_all_scores_equal_first_query_wins() {
        let mut actual = vec![
            vec![result("node_a").relevance(0.8).distance(0.2)],
            vec![result("node_a").relevance(0.8).distance(0.2)],
        ];

        deduplicate_results(&mut actual);

        let expected = vec![vec![result("node_a").relevance(0.8).distance(0.2)], vec![]];

        assert_eq!(actual, expected);
    }
}