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