1use std::collections::HashSet;
14
15use crate::core::graph_index::ProjectIndex;
16use crate::core::graph_provider::GraphProvider;
17use crate::core::property_graph::{CodeGraph, populate_from_project_index};
18
19const MAX_DIVERGENCES: usize = 20;
22
23#[derive(Debug, Default, Clone)]
25pub struct ParityReport {
26 pub files: usize,
27 pub symbol_count_gi: usize,
28 pub symbol_count_pg: usize,
29 pub edge_count_gi: usize,
30 pub edge_count_pg: usize,
31 pub file_inventory_equal: bool,
32 pub files_checked: usize,
33 pub dependencies_lossless: usize,
35 pub dependents_lossless: usize,
37 pub dependencies_extra: usize,
40 pub symbols_checked: usize,
41 pub symbols_matched: usize,
42 pub edge_pairs_lossless: bool,
44 pub divergences: Vec<String>,
45}
46
47impl ParityReport {
48 pub fn is_lossless(&self) -> bool {
52 self.symbol_count_pg == self.symbol_count_gi
53 && self.edge_count_pg == self.edge_count_gi
54 && self.file_inventory_equal
55 && self.dependencies_lossless == self.files_checked
56 && self.dependents_lossless == self.files_checked
57 && self.symbols_matched == self.symbols_checked
58 && self.edge_pairs_lossless
59 }
60
61 fn note(&mut self, msg: String) {
62 if self.divergences.len() < MAX_DIVERGENCES {
63 self.divergences.push(msg);
64 }
65 }
66}
67
68pub fn compare(index: &ProjectIndex) -> anyhow::Result<ParityReport> {
72 let pg = CodeGraph::open_in_memory()?.gated_for(&index.project_root);
75 populate_from_project_index(&pg, index)?;
76 let pgp = GraphProvider::PropertyGraph(pg);
77 let gip = GraphProvider::GraphIndex(index.clone());
78
79 let mut r = ParityReport {
80 files: index.files.len(),
81 symbol_count_gi: gip.symbol_count(),
82 symbol_count_pg: pgp.symbol_count(),
83 edge_count_gi: gip.edge_count().unwrap_or(0),
84 edge_count_pg: pgp.edge_count().unwrap_or(0),
85 ..Default::default()
86 };
87
88 r.file_inventory_equal = pgp.file_paths() == gip.file_paths();
89 if !r.file_inventory_equal {
90 r.note("file inventory differs".to_string());
91 }
92 if r.symbol_count_pg != r.symbol_count_gi {
93 r.note(format!(
94 "symbol count: gi={} pg={}",
95 r.symbol_count_gi, r.symbol_count_pg
96 ));
97 }
98 if r.edge_count_pg != r.edge_count_gi {
99 r.note(format!(
100 "edge count: gi={} pg={}",
101 r.edge_count_gi, r.edge_count_pg
102 ));
103 }
104
105 for path in gip.file_paths() {
106 r.files_checked += 1;
107
108 let gi_dep: HashSet<String> = gip.dependencies(&path).into_iter().collect();
109 let pg_dep: HashSet<String> = pgp.dependencies(&path).into_iter().collect();
110 if gi_dep.is_subset(&pg_dep) {
111 r.dependencies_lossless += 1;
112 } else {
113 let missing: Vec<_> = gi_dep.difference(&pg_dep).cloned().collect();
114 r.note(format!("deps lost for {path}: {missing:?}"));
115 }
116 r.dependencies_extra += pg_dep.difference(&gi_dep).count();
117
118 let gi_rdep: HashSet<String> = gip.dependents(&path).into_iter().collect();
119 let pg_rdep: HashSet<String> = pgp.dependents(&path).into_iter().collect();
120 if gi_rdep.is_subset(&pg_rdep) {
121 r.dependents_lossless += 1;
122 } else {
123 let missing: Vec<_> = gi_rdep.difference(&pg_rdep).cloned().collect();
124 r.note(format!("dependents lost for {path}: {missing:?}"));
125 }
126 }
127
128 for (key, sym) in &index.symbols {
129 r.symbols_checked += 1;
130 match pgp.get_symbol(key) {
131 Some(pg_sym)
132 if pg_sym.name == sym.name
133 && pg_sym.file == sym.file
134 && pg_sym.start_line == sym.start_line
135 && pg_sym.end_line == sym.end_line =>
136 {
137 r.symbols_matched += 1;
138 }
139 _ => r.note(format!("symbol mismatch: {key}")),
140 }
141 }
142
143 let pg_pairs: HashSet<(String, String)> =
144 pgp.edges().into_iter().map(|e| (e.from, e.to)).collect();
145 let gi_pairs: HashSet<(String, String)> =
146 gip.edges().into_iter().map(|e| (e.from, e.to)).collect();
147 r.edge_pairs_lossless = gi_pairs.is_subset(&pg_pairs);
148 if !r.edge_pairs_lossless {
149 r.note("structural edge (from,to) set is not a superset".to_string());
150 }
151
152 Ok(r)
153}
154
155pub fn format_report(r: &ParityReport) -> String {
157 let verdict = if r.is_lossless() {
158 "LOSSLESS — PropertyGraph reproduces graph_index (safe to flip)"
159 } else {
160 "DIVERGENT — see divergences below (NOT safe to flip)"
161 };
162 let mut out = format!(
163 "Shadow parity (PropertyGraph vs graph_index)\n\
164 Verdict: {verdict}\n\
165 Files: {files}\n\
166 Symbols: gi={sgi} pg={spg} ({sm}/{sc} matched)\n\
167 Edges: gi={egi} pg={epg} (superset={eps})\n\
168 Dependencies: {dl}/{fc} lossless (+{dx} enrichment edges)\n\
169 Dependents: {rl}/{fc} lossless",
170 verdict = verdict,
171 files = r.files,
172 sgi = r.symbol_count_gi,
173 spg = r.symbol_count_pg,
174 sm = r.symbols_matched,
175 sc = r.symbols_checked,
176 egi = r.edge_count_gi,
177 epg = r.edge_count_pg,
178 eps = r.edge_pairs_lossless,
179 dl = r.dependencies_lossless,
180 rl = r.dependents_lossless,
181 fc = r.files_checked,
182 dx = r.dependencies_extra,
183 );
184 if !r.divergences.is_empty() {
185 out.push_str("\nDivergences:");
186 for d in &r.divergences {
187 out.push_str(&format!("\n - {d}"));
188 }
189 }
190 out
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::core::graph_index::{FileEntry, IndexEdge, SymbolEntry};
197
198 fn fe(path: &str) -> FileEntry {
199 FileEntry {
200 path: path.to_string(),
201 hash: "h".to_string(),
202 language: "rs".to_string(),
203 line_count: 1,
204 token_count: 1,
205 exports: vec![],
206 summary: String::new(),
207 }
208 }
209
210 fn sym(file: &str, name: &str, a: usize, b: usize) -> (String, SymbolEntry) {
211 (
212 format!("{file}::{name}"),
213 SymbolEntry {
214 file: file.to_string(),
215 name: name.to_string(),
216 kind: "function".to_string(),
217 start_line: a,
218 end_line: b,
219 is_exported: true,
220 },
221 )
222 }
223
224 fn edge(from: &str, to: &str, kind: &str) -> IndexEdge {
225 IndexEdge {
226 from: from.to_string(),
227 to: to.to_string(),
228 kind: kind.to_string(),
229 weight: 1.0,
230 }
231 }
232
233 fn index_with(edges: Vec<IndexEdge>) -> (ProjectIndex, tempfile::TempDir) {
234 let mut idx = ProjectIndex::new("/t");
235 for f in ["a.rs", "b.rs", "c.rs"] {
236 idx.files.insert(f.to_string(), fe(f));
237 }
238 let (k1, s1) = sym("a.rs", "run", 1, 4);
239 let (k2, s2) = sym("b.rs", "Helper", 2, 8);
240 idx.symbols.insert(k1, s1);
241 idx.symbols.insert(k2, s2);
242 idx.edges = edges;
243 idx.on_disk(&[])
244 }
245
246 #[test]
247 fn import_only_index_is_lossless() {
248 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
251 let (idx, _files) = index_with(vec![
252 edge("a.rs", "b.rs", "import"),
253 edge("a.rs", "c.rs", "import"),
254 ]);
255 let r = compare(&idx).unwrap();
256 assert!(
257 r.is_lossless(),
258 "import-only mirror must be lossless: {r:?}"
259 );
260 assert_eq!(r.symbol_count_pg, 2);
261 assert_eq!(r.dependencies_extra, 0, "no enrichment for pure imports");
262 }
263
264 #[test]
265 fn reexport_and_sibling_are_lossless_with_enrichment() {
266 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
269 let (idx, _files) = index_with(vec![
272 edge("a.rs", "b.rs", "import"),
273 edge("a.rs", "c.rs", "reexport"),
274 edge("b.rs", "c.rs", "sibling"),
275 ]);
276 let r = compare(&idx).unwrap();
277 assert!(r.is_lossless(), "superset must still be lossless: {r:?}");
278 assert!(
279 r.dependencies_extra >= 1,
280 "PG exposes the extra structural edges"
281 );
282 assert!(r.edge_pairs_lossless);
283 }
284
285 #[test]
286 fn empty_index_is_trivially_lossless() {
287 let idx = ProjectIndex::new("/t");
288 let r = compare(&idx).unwrap();
289 assert!(r.is_lossless());
290 assert_eq!(r.files, 0);
291 }
292
293 #[test]
294 fn trait_impl_symbol_name_with_colons_roundtrips() {
295 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
298 let mut idx = ProjectIndex::new("/t");
303 idx.files.insert("a.rs".to_string(), fe("a.rs"));
304 let (k, s) = sym("a.rs", "std::fmt::Display for ProfileSource", 10, 20);
305 idx.symbols.insert(k, s);
306 let (idx, _files) = idx.on_disk(&[]);
307 let r = compare(&idx).unwrap();
308 assert_eq!(
309 r.symbols_matched, r.symbols_checked,
310 "trait-impl symbol name with `::` must round-trip: {r:?}"
311 );
312 assert!(r.is_lossless(), "{r:?}");
313 }
314}