Skip to main content

_diffctx/edges/similarity/
lexical.rs

1use std::path::Path;
2
3use rayon::prelude::*;
4use rustc_hash::{FxHashMap, FxHashSet};
5
6use crate::config::limits::LEXICAL;
7use crate::config::tokenization::TOKENIZATION;
8use crate::config::weights::{DEFAULT_LANG_WEIGHTS, LANG_WEIGHTS, LangWeights};
9use crate::languages::EXTENSION_TO_LANGUAGE;
10use crate::stopwords::{filter_idents, profile_from_path};
11use crate::types::{Fragment, FragmentId, extract_identifier_list};
12
13use super::super::EdgeDict;
14use super::super::base::EdgeBuilder;
15
16static LANG_ALIAS: &[(&str, &str)] = &[
17    ("bash", "shell"),
18    ("zsh", "shell"),
19    ("fish", "shell"),
20    ("powershell", "shell"),
21];
22
23fn get_lang_weights(path: &Path) -> &LangWeights {
24    let ext = path
25        .extension()
26        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
27        .unwrap_or_default();
28    let lang = EXTENSION_TO_LANGUAGE.get(ext.as_str()).copied();
29    if let Some(lang) = lang {
30        let aliased = LANG_ALIAS
31            .iter()
32            .find(|(k, _)| *k == lang)
33            .map(|(_, v)| *v)
34            .unwrap_or(lang);
35        LANG_WEIGHTS.get(aliased).unwrap_or(&DEFAULT_LANG_WEIGHTS)
36    } else {
37        &DEFAULT_LANG_WEIGHTS
38    }
39}
40
41fn clamp_lexical_weight(raw_sim: f64, src_path: Option<&Path>, dst_path: Option<&Path>) -> f64 {
42    let (lex_max, lex_min) = match (src_path, dst_path) {
43        (Some(sp), Some(dp)) => {
44            let sw = get_lang_weights(sp);
45            let dw = get_lang_weights(dp);
46            (
47                sw.lexical_max.max(dw.lexical_max),
48                sw.lexical_min.max(dw.lexical_min),
49            )
50        }
51        _ => (LEXICAL.weight_max, LEXICAL.weight_min),
52    };
53
54    if raw_sim < LEXICAL.min_similarity {
55        return 0.0;
56    }
57    let denom = 1.0 - LEXICAL.min_similarity;
58    if denom <= 0.0 {
59        return lex_max;
60    }
61    let normalized = (raw_sim - LEXICAL.min_similarity) / denom;
62    lex_min + normalized * (lex_max - lex_min)
63}
64
65pub struct LexicalEdgeBuilder;
66
67/// Maps each unique term to a compact u32 id. Stores each term string exactly once.
68struct TermInterner {
69    by_str: FxHashMap<String, u32>,
70}
71
72impl TermInterner {
73    fn new() -> Self {
74        Self {
75            by_str: FxHashMap::default(),
76        }
77    }
78
79    fn intern(&mut self, term: String) -> u32 {
80        let next_id = self.by_str.len() as u32;
81        *self.by_str.entry(term).or_insert(next_id)
82    }
83
84    fn len(&self) -> usize {
85        self.by_str.len()
86    }
87}
88
89impl LexicalEdgeBuilder {
90    /// Tokenize and filter identifiers for one fragment. Returns the raw filtered identifier list.
91    fn tokens(frag: &Fragment) -> Vec<String> {
92        let profile = profile_from_path(frag.path());
93        let idents =
94            extract_identifier_list(&frag.content, TOKENIZATION.query_min_identifier_length);
95        filter_idents(&idents, 3, profile)
96    }
97}
98
99impl EdgeBuilder for LexicalEdgeBuilder {
100    fn build(&self, fragments: &[Fragment], _repo_root: Option<&Path>) -> EdgeDict {
101        if fragments.is_empty() {
102            return FxHashMap::default();
103        }
104
105        let n_docs = fragments.len();
106        let max_df = (n_docs as f64 * LEXICAL.max_df_ratio).max(1.0) as usize;
107
108        // Pass 1: tokenize each fragment in parallel; flatten to per-fragment Vec<String>.
109        let per_frag_tokens: Vec<Vec<String>> =
110            fragments.par_iter().map(|f| Self::tokens(f)).collect();
111
112        // Pass 2: build the term interner serially, computing document frequency in one go.
113        let mut interner = TermInterner::new();
114        let mut doc_freq: Vec<u32> = Vec::new();
115        let per_frag_term_ids: Vec<Vec<u32>> = per_frag_tokens
116            .into_iter()
117            .map(|tokens| {
118                let mut seen_in_doc: FxHashSet<u32> = FxHashSet::default();
119                let mut ids: Vec<u32> = Vec::with_capacity(tokens.len());
120                for tok in tokens {
121                    let id = interner.intern(tok);
122                    if doc_freq.len() <= id as usize {
123                        doc_freq.resize(id as usize + 1, 0);
124                    }
125                    if seen_in_doc.insert(id) {
126                        doc_freq[id as usize] += 1;
127                    }
128                    ids.push(id);
129                }
130                ids
131            })
132            .collect();
133
134        let n_terms = interner.len();
135        // Interner string-table is no longer needed once doc_freq has been built.
136        drop(interner);
137
138        let n_docs_f = n_docs as f64;
139        let mut idf: Vec<f32> = Vec::with_capacity(n_terms);
140        for &df in &doc_freq {
141            let v = ((n_docs_f + 1.0) / (df as f64 + 1.0)).ln() + 1.0;
142            idf.push(v as f32);
143        }
144
145        // Pass 3: build TF-IDF vectors as sparse Vec<(TermId, f32)>, normalized.
146        let tf_idf: Vec<Vec<(u32, f32)>> = per_frag_term_ids
147            .par_iter()
148            .map(|term_ids| {
149                let mut tf: FxHashMap<u32, u32> = FxHashMap::default();
150                for &id in term_ids {
151                    *tf.entry(id).or_insert(0) += 1;
152                }
153                let mut vec: Vec<(u32, f32)> = Vec::with_capacity(tf.len());
154                for (&term_id, &count) in &tf {
155                    let df = doc_freq[term_id as usize] as usize;
156                    if df == 0 || df > max_df {
157                        continue;
158                    }
159                    let term_idf = idf[term_id as usize];
160                    if (term_idf as f64) < LEXICAL.min_idf {
161                        continue;
162                    }
163                    vec.push((term_id, count as f32 * term_idf));
164                }
165                let norm: f32 = vec.iter().map(|(_, w)| w * w).sum::<f32>().sqrt();
166                if norm > 0.0 {
167                    for (_, w) in &mut vec {
168                        *w /= norm;
169                    }
170                }
171                vec.sort_unstable_by_key(|&(id, _)| id);
172                vec
173            })
174            .collect();
175
176        drop(per_frag_term_ids);
177        drop(doc_freq);
178        drop(idf);
179
180        // Pass 4: invert into postings — for each term, list of (frag_idx, weight).
181        // Consume tf_idf as we go so it never coexists with the inverted index.
182        let mut postings: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n_terms];
183        for (frag_idx, vec) in tf_idf.into_iter().enumerate() {
184            for (term_id, weight) in vec {
185                postings[term_id as usize].push((frag_idx as u32, weight));
186            }
187        }
188
189        // Pass 5: accumulate one contribution per co-occurring pair per term,
190        // then reduce. This used to be a `FxHashMap<(u32, u32), f32>`, whose
191        // *output* is bounded by `top_k_neighbors` but whose size is not: it
192        // holds every distinct pair that co-occurs in any posting list under
193        // `max_postings`, i.e. up to `terms * C(max_postings, 2)` entries. On a
194        // large repo that reached 199M pairs and tens of GB of resident memory
195        // (#116) — an unbounded accumulator behind a bounded result.
196        //
197        // A flat vector is 12 bytes per contribution against hashbrown's ~16
198        // plus a transient copy of the whole table on every doubling rehash, so
199        // peak drops several-fold and loses the rehash spikes entirely.
200        let mut contributions: Vec<(u32, u32, f32)> = Vec::new();
201        for posting_list in postings.iter_mut() {
202            if posting_list.len() > LEXICAL.max_postings || posting_list.len() < 2 {
203                posting_list.clear();
204                posting_list.shrink_to_fit();
205                continue;
206            }
207            for i in 0..posting_list.len() {
208                let (frag_i, weight_i) = posting_list[i];
209                for j in (i + 1)..posting_list.len() {
210                    let (frag_j, weight_j) = posting_list[j];
211                    let (lo, hi) = if frag_i < frag_j {
212                        (frag_i, frag_j)
213                    } else {
214                        (frag_j, frag_i)
215                    };
216                    contributions.push((lo, hi, weight_i * weight_j));
217                }
218            }
219            posting_list.clear();
220            posting_list.shrink_to_fit();
221        }
222        drop(postings);
223
224        // STABLE sort, deliberately: it preserves each pair's original
225        // contribution order, so the f32 sums below are bit-identical to what
226        // the hashmap produced. `sort_unstable` would reorder within a run and
227        // f32 addition is not associative, which could flip a pair across
228        // `min_similarity`.
229        contributions.sort_by_key(|&(a, b, _)| (a, b));
230        let mut dot_products: Vec<((u32, u32), f32)> = Vec::new();
231        let mut idx = 0usize;
232        while idx < contributions.len() {
233            let (a, b, first) = contributions[idx];
234            let mut sum = first;
235            let mut next = idx + 1;
236            while next < contributions.len() {
237                let (na, nb, w) = contributions[next];
238                if na != a || nb != b {
239                    break;
240                }
241                sum += w;
242                next += 1;
243            }
244            dot_products.push(((a, b), sum));
245            idx = next;
246        }
247        drop(contributions);
248
249        // Pass 6: turn pairwise similarities into per-node top-k candidate edges.
250        let frag_paths: Vec<&str> = fragments.iter().map(|f| f.path()).collect();
251        let mut neighbors_by_node: FxHashMap<u32, Vec<(f32, u32)>> = FxHashMap::default();
252
253        let min_sim = LEXICAL.min_similarity as f32;
254        let backward_factor = LEXICAL.backward_factor as f32;
255        for ((src_idx, dst_idx), sim) in &dot_products {
256            if *sim < min_sim {
257                continue;
258            }
259            let src_path = Path::new(frag_paths[*src_idx as usize]);
260            let dst_path = Path::new(frag_paths[*dst_idx as usize]);
261            let fwd = clamp_lexical_weight(*sim as f64, Some(src_path), Some(dst_path)) as f32;
262            let bwd = clamp_lexical_weight(*sim as f64, Some(dst_path), Some(src_path)) as f32
263                * backward_factor;
264            neighbors_by_node
265                .entry(*src_idx)
266                .or_default()
267                .push((fwd, *dst_idx));
268            neighbors_by_node
269                .entry(*dst_idx)
270                .or_default()
271                .push((bwd, *src_idx));
272        }
273
274        let frag_ids: Vec<&FragmentId> = fragments.iter().map(|f| &f.id).collect();
275        let mut edges: EdgeDict = FxHashMap::default();
276        for (node_idx, mut candidates) in neighbors_by_node {
277            // Ties broken by neighbour index: the candidate order now derives
278            // from a sorted pair list rather than hashmap iteration, so without
279            // this the top-k cut would depend on accumulation order. Lexical
280            // weights collide often, which makes that a real difference rather
281            // than a theoretical one.
282            candidates.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
283            candidates.truncate(LEXICAL.top_k_neighbors);
284            for (weight, neighbor_idx) in candidates {
285                let key = (
286                    frag_ids[node_idx as usize].clone(),
287                    frag_ids[neighbor_idx as usize].clone(),
288                );
289                let existing = edges.get(&key).copied().unwrap_or(0.0);
290                let weight_f64 = weight as f64;
291                if weight_f64 > existing {
292                    edges.insert(key, weight_f64);
293                }
294            }
295        }
296
297        edges
298    }
299
300    fn is_expensive(&self) -> bool {
301        true
302    }
303}