Skip to main content

_diffctx/edges/
mod.rs

1pub mod base;
2pub mod config_edges;
3pub mod document;
4pub mod history;
5pub mod semantic;
6pub mod similarity;
7pub mod structural;
8
9use std::cmp::Reverse;
10use std::path::{Path, PathBuf};
11
12use rayon::prelude::*;
13use rustc_hash::{FxHashMap, FxHashSet};
14use tracing::debug;
15
16use crate::graph::{
17    CappedEdges, CompactEdge, EdgeCapStats, EdgeCategory, RankedCandidate, SourceTopK,
18    SuppressionFactors, cap_out_edges_per_source, dedup_compact_edges, intern_fragment_nodes,
19    push_bounded_top_k, read_max_out_edges_per_node,
20};
21use crate::types::FragmentId;
22
23pub type EdgeDict = FxHashMap<(FragmentId, FragmentId), f64>;
24pub type EdgeCategories = FxHashMap<(FragmentId, FragmentId), EdgeCategory>;
25
26use crate::types::Fragment;
27
28use self::base::EdgeBuilder;
29
30const EXPENSIVE_CATEGORIES: &[&str] = &["similarity", "history"];
31
32struct BuilderCategory {
33    name: &'static str,
34    builders: fn() -> Vec<Box<dyn EdgeBuilder>>,
35}
36
37fn builder_categories() -> Vec<BuilderCategory> {
38    vec![
39        BuilderCategory {
40            name: "semantic",
41            builders: || semantic::get_semantic_builders(),
42        },
43        BuilderCategory {
44            name: "structural",
45            builders: || structural::get_structural_builders(),
46        },
47        BuilderCategory {
48            name: "config",
49            builders: || config_edges::get_config_builders(),
50        },
51        BuilderCategory {
52            name: "document",
53            builders: || document::get_document_builders(),
54        },
55        BuilderCategory {
56            name: "similarity",
57            builders: || similarity::get_similarity_builders(),
58        },
59        BuilderCategory {
60            name: "history",
61            builders: || history::get_history_builders(),
62        },
63    ]
64}
65
66pub fn get_all_builders() -> Vec<Box<dyn EdgeBuilder>> {
67    let mut all = Vec::new();
68    for cat in builder_categories() {
69        all.extend((cat.builders)());
70    }
71    all
72}
73
74fn pack_pair(src: u32, dst: u32) -> u64 {
75    ((src as u64) << 32) | dst as u64
76}
77
78struct LoggedEmission {
79    src: u32,
80    dst: u32,
81    weight: f64,
82}
83
84/// Two-pass edge construction that never retains the raw edge universe
85/// as keyed dictionaries and runs every builder exactly once.
86///
87/// Pass 1 runs every builder and records its emissions into a compact
88/// per-builder log of (src, dst, weight) triples (16 bytes/edge, builder
89/// tag implicit in the outer index); a first-seen scan in builder
90/// registration order reproduces `dedup_compact_edges` semantics exactly
91/// (each pair counted once, category from the first builder that
92/// produced it) and yields per-node in-degree, per-source out-degree,
93/// the semantic distinct-file fan counts, and a sorted `(src, dst) ->
94/// category` lookup used only internally by pass 2 (below) — it is
95/// *not* returned to the caller; `assemble_graph` derives the exported
96/// category table from the post-cap edges instead, which is what keeps
97/// it aligned with the CSR (see `graph::assemble_graph`).
98///
99/// Pass 2 replays the log instead of rerunning the builders, damps each
100/// emission on the fly with the pass-1 hub-suppression factors — always
101/// under the pair's canonical first-builder category — and keeps at most
102/// K candidates per source per builder in a bounded min-heap, freeing
103/// each builder's log shard as it is consumed. Any edge evicted from a
104/// per-builder heap is outranked by K surviving same-source edges, so
105/// the final merge + dedup + cap over the survivors is bit-identical to
106/// capping the full materialized universe.
107pub fn collect_capped_edges(
108    fragments: &[Fragment],
109    repo_root: Option<&Path>,
110    skip_expensive: bool,
111) -> CappedEdges {
112    let mut all_builders: Vec<(&str, Box<dyn EdgeBuilder>)> = Vec::new();
113    for cat in builder_categories() {
114        if skip_expensive && EXPENSIVE_CATEGORIES.contains(&cat.name) {
115            debug!("skipping {} edge builders (skip_expensive=true)", cat.name);
116            continue;
117        }
118        for builder in (cat.builders)() {
119            all_builders.push((cat.name, builder));
120        }
121    }
122
123    let (node_to_idx, idx_to_node) = intern_fragment_nodes(fragments);
124    let category_weights = *crate::config::category_weights::CATEGORY_WEIGHTS;
125    let builder_meta: Vec<(EdgeCategory, f64)> = all_builders
126        .iter()
127        .map(|(cat_name, builder)| {
128            let category = EdgeCategory::from_str(builder.category_label().unwrap_or(cat_name));
129            (category, category_weights.multiplier(category))
130        })
131        .collect();
132    let fallback_flags: Vec<bool> = all_builders
133        .iter()
134        .map(|(_, builder)| builder.is_fallback())
135        .collect();
136
137    let per_builder_log: Vec<Vec<LoggedEmission>> = all_builders
138        .par_iter()
139        .map(|(name, builder)| {
140            crate::deadline::check_compute_deadline("edge construction");
141            let t = std::time::Instant::now();
142            let edges = builder.build(fragments, repo_root);
143            if std::env::var_os("DIFFCTX_TRACE_BUILDERS").is_some() {
144                eprintln!(
145                    "builder {name}: {:.1}s, {} edges",
146                    t.elapsed().as_secs_f64(),
147                    edges.len()
148                );
149            }
150            let mut log = Vec::with_capacity(edges.len());
151            for ((src, dst), weight) in edges {
152                let (Some(&s), Some(&d)) = (node_to_idx.get(&src), node_to_idx.get(&dst)) else {
153                    continue;
154                };
155                log.push(LoggedEmission {
156                    src: s,
157                    dst: d,
158                    weight,
159                });
160            }
161            log
162        })
163        .collect();
164    drop(all_builders);
165
166    // Fallback builders (tags) only count where the dedicated builders came
167    // back empty: an emission survives only if at least one endpoint file has
168    // no dedicated semantic edge. Dual coverage was measured as pure noise —
169    // a tags edge duplicating a real import/call edge adds mass, not reach —
170    // while a parser-degraded file genuinely has nothing else (#131).
171    let mut per_builder_log = per_builder_log;
172    if fallback_flags.iter().any(|&f| f) {
173        let mut dedicated_files: FxHashSet<&str> = FxHashSet::default();
174        for (builder_idx, log) in per_builder_log.iter().enumerate() {
175            if fallback_flags[builder_idx] || builder_meta[builder_idx].0 != EdgeCategory::Semantic
176            {
177                continue;
178            }
179            for e in log {
180                dedicated_files.insert(idx_to_node[e.src as usize].path.as_ref());
181                dedicated_files.insert(idx_to_node[e.dst as usize].path.as_ref());
182            }
183        }
184        for (builder_idx, log) in per_builder_log.iter_mut().enumerate() {
185            if !fallback_flags[builder_idx] {
186                continue;
187            }
188            log.retain(|e| {
189                !dedicated_files.contains(idx_to_node[e.src as usize].path.as_ref())
190                    || !dedicated_files.contains(idx_to_node[e.dst as usize].path.as_ref())
191            });
192        }
193    }
194
195    let n_nodes = idx_to_node.len();
196    let mut in_degree = vec![0u32; n_nodes];
197    let mut out_degree = vec![0u32; n_nodes];
198    let mut category_entries: Vec<(u32, u32, EdgeCategory)> = Vec::new();
199    let mut sem_out_files: FxHashMap<u32, FxHashSet<&str>> = FxHashMap::default();
200    let mut raw_by_category: FxHashMap<EdgeCategory, u64> = FxHashMap::default();
201    let mut deduped_by_category: FxHashMap<EdgeCategory, u64> = FxHashMap::default();
202    let mut seen: FxHashSet<u64> = FxHashSet::default();
203    for (builder_idx, log) in per_builder_log.iter().enumerate() {
204        let (category, _) = builder_meta[builder_idx];
205        *raw_by_category.entry(category).or_default() += log.len() as u64;
206        for e in log {
207            if !seen.insert(pack_pair(e.src, e.dst)) {
208                continue;
209            }
210            *deduped_by_category.entry(category).or_default() += 1;
211            in_degree[e.dst as usize] += 1;
212            out_degree[e.src as usize] += 1;
213            category_entries.push((e.src, e.dst, category));
214            if category == EdgeCategory::Semantic {
215                sem_out_files
216                    .entry(e.src)
217                    .or_default()
218                    .insert(idx_to_node[e.dst as usize].path.as_ref());
219            }
220        }
221    }
222    drop(seen);
223    let mut emissions_by_category: Vec<(EdgeCategory, u64, u64)> = raw_by_category
224        .iter()
225        .map(|(&category, &raw)| {
226            let deduped = deduped_by_category.get(&category).copied().unwrap_or(0);
227            (category, raw, deduped)
228        })
229        .collect();
230    emissions_by_category.sort_unstable_by_key(|e| e.0.as_str());
231    category_entries.sort_unstable_by_key(|e| (e.0, e.1));
232
233    let mut sem_file_deg = vec![0u32; n_nodes];
234    for (&src, files) in &sem_out_files {
235        sem_file_deg[src as usize] = files.len() as u32;
236    }
237    drop(sem_out_files);
238
239    let deduped_edge_count = category_entries.len();
240    let factors = SuppressionFactors::from_counters(in_degree, sem_file_deg);
241    let max_per_node = read_max_out_edges_per_node();
242
243    let capped_per_builder: Vec<Vec<CompactEdge>> = per_builder_log
244        .into_par_iter()
245        .enumerate()
246        .map(|(builder_idx, log)| {
247            let (builder_category, multiplier) = builder_meta[builder_idx];
248            let mut per_source: FxHashMap<u32, SourceTopK> = FxHashMap::default();
249            for e in log {
250                let category = category_entries
251                    .binary_search_by_key(&(e.src, e.dst), |c| (c.0, c.1))
252                    .map(|k| category_entries[k].2)
253                    .unwrap_or(builder_category);
254                let damped = factors.damp(e.weight * multiplier, category, e.src, e.dst);
255                push_bounded_top_k(
256                    per_source.entry(e.src).or_default(),
257                    RankedCandidate {
258                        weight: damped,
259                        dst: e.dst,
260                        category,
261                    },
262                    max_per_node,
263                );
264            }
265            let mut survivors =
266                Vec::with_capacity(per_source.values().map(|h| h.len()).sum::<usize>());
267            for (src, heap) in per_source {
268                for Reverse(c) in heap {
269                    survivors.push(CompactEdge {
270                        src,
271                        dst: c.dst,
272                        weight: c.weight,
273                        category: c.category,
274                    });
275                }
276            }
277            survivors
278        })
279        .collect();
280
281    let total: usize = capped_per_builder.iter().map(|v| v.len()).sum();
282    let mut edges: Vec<CompactEdge> = Vec::with_capacity(total);
283    for v in capped_per_builder {
284        edges.extend(v);
285    }
286    dedup_compact_edges(&mut edges);
287    cap_out_edges_per_source(&mut edges, max_per_node);
288
289    let nodes_capped = out_degree
290        .iter()
291        .filter(|&&d| d as usize > max_per_node)
292        .count();
293    let cap_stats = EdgeCapStats {
294        edges_before_cap: deduped_edge_count,
295        edges_after_cap: edges.len(),
296        edges_dropped_by_cap: deduped_edge_count - edges.len(),
297        nodes_capped,
298        max_out_edges_per_node: max_per_node,
299        emissions_by_category,
300    };
301
302    CappedEdges {
303        node_to_idx,
304        idx_to_node,
305        edges,
306        cap_stats,
307    }
308}
309
310pub fn discover_all_related_files(
311    changed_files: &[PathBuf],
312    all_candidates: &[PathBuf],
313    repo_root: Option<&Path>,
314    file_cache: Option<&FxHashMap<PathBuf, String>>,
315) -> Vec<PathBuf> {
316    let mut discovered: FxHashMap<PathBuf, ()> = FxHashMap::default();
317    for builder in get_all_builders() {
318        for f in
319            builder.discover_related_files(changed_files, all_candidates, repo_root, file_cache)
320        {
321            discovered.entry(f).or_insert(());
322        }
323    }
324    let mut result: Vec<PathBuf> = discovered.into_keys().collect();
325    result.sort();
326    result
327}
328
329#[cfg(test)]
330mod fallback_gate_tests {
331    use super::*;
332    use rustc_hash::FxHashSet as Set;
333    use std::sync::Arc;
334
335    fn frag(path: &str, content: &str, idents: &[&str]) -> Fragment {
336        Fragment {
337            id: crate::types::FragmentId::new(Arc::from(path), 1, 10),
338            kind: crate::types::FragmentKind::Function,
339            content: Arc::from(content),
340            identifiers: idents.iter().map(|s| s.to_string()).collect::<Set<_>>(),
341            token_count: 10,
342            symbol_name: None,
343        }
344    }
345
346    #[test]
347    fn tags_edges_survive_only_where_dedicated_builders_came_back_empty() {
348        // a.py <-> b.py carry a dedicated import edge; a.py and c.py share an
349        // identifier but nothing imports between them. Two .xyz files have no
350        // dedicated builder at all and share the same identifier.
351        let fragments = vec![
352            frag(
353                "proj/a.c",
354                "#include \"bdep.h\"\nint zzcommonzz;\n",
355                &["zzcommonzz"],
356            ),
357            frag("proj/bdep.h", "int bdecl(void);\n", &["bdecl"]),
358            frag(
359                "proj/c.c",
360                "#include \"ddep.h\"\nint zzcommonzz;\n",
361                &["zzcommonzz"],
362            ),
363            frag("proj/ddep.h", "int ddecl(void);\n", &["ddecl"]),
364            frag("proj/u1.xyz", "zzcommonzz here\n", &["zzcommonzz"]),
365            frag("proj/u2.xyz", "zzcommonzz there\n", &["zzcommonzz"]),
366        ];
367        let capped = collect_capped_edges(&fragments, None, false);
368        let node_path = |idx: u32| capped.idx_to_node[idx as usize].path.clone();
369        // Category matters: a.py and c.py legitimately share a structural
370        // sibling edge; the class under test is the SEMANTIC tags link.
371        let has = |a: &str, b: &str| {
372            capped.edges.iter().any(|e| {
373                if e.category != EdgeCategory::Semantic {
374                    return false;
375                }
376                let s = node_path(e.src);
377                let d = node_path(e.dst);
378                (s.ends_with(a) && d.ends_with(b)) || (s.ends_with(b) && d.ends_with(a))
379            })
380        };
381        assert!(
382            has("u1.xyz", "u2.xyz"),
383            "fallback must still connect files no dedicated builder covers"
384        );
385        assert!(
386            !has("a.c", "c.c"),
387            "a tags-only link between two dedicated-covered files is the measured noise class (#131)"
388        );
389    }
390}