Skip to main content

lean_ctx/core/
graph_parity.rs

1//! Shadow-mode parity harness (#682.3): prove the PropertyGraph reproduces
2//! everything `graph_index` exposes through the [`GraphProvider`] facade —
3//! symbols, edges and structural dependencies — *before* any backend flip
4//! (#682.4) relies on PG.
5//!
6//! The mirror ([`populate_from_project_index`]) sources PG from the very
7//! `ProjectIndex` that `graph_index` produces, so equivalence is expected by
8//! construction. This harness is the executable proof and the regression gate
9//! that it stays so: it asserts PG loses nothing graph_index exposed (exact
10//! counts + inventory + symbol lookups, and a structural-edge / dependency
11//! superset — PG may legitimately expose *more*, never less).
12
13use 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
19/// Cap on stored human-readable divergences so a pathological index cannot
20/// balloon the report; counts remain exact regardless.
21const MAX_DIVERGENCES: usize = 20;
22
23/// Quantified comparison of PropertyGraph vs graph_index facade output.
24#[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    /// Files where `gi.dependencies ⊆ pg.dependencies` (no loss).
34    pub dependencies_lossless: usize,
35    /// Files where `gi.dependents ⊆ pg.dependents` (no loss).
36    pub dependents_lossless: usize,
37    /// Extra dependency edges PG exposes beyond graph_index (enrichment, e.g.
38    /// re-export / sibling / co-change edges the import-only GI facade omits).
39    pub dependencies_extra: usize,
40    pub symbols_checked: usize,
41    pub symbols_matched: usize,
42    /// `gi (from,to)` structural edge pairs are all present in PG.
43    pub edge_pairs_lossless: bool,
44    pub divergences: Vec<String>,
45}
46
47impl ParityReport {
48    /// True when PG loses nothing graph_index exposed: exact counts, identical
49    /// file inventory, no dependency/dependent loss, every sampled symbol
50    /// matched, and the structural-edge set is a superset.
51    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
68/// Build an in-memory PropertyGraph from `index` and compare it, through the
69/// shared [`GraphProvider`] facade, against the same index served as
70/// graph_index. Pure in-memory — no disk, no rescan.
71pub 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
153/// Render a [`ParityReport`] as a compact, deterministic text block.
154pub 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        // GI's import-only dependencies() ignores reexport/sibling; PG exposes
262        // them as structural edges. That is *more*, never less — still lossless.
263        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        // Trait-impl symbol names contain `::` (e.g. `std::fmt::Display for T`).
288        // The symbol key is `file::name`, so the PG lookup must split on the
289        // FIRST `::`, not the last — `rsplitn` put the `::`-tail of the name on
290        // the file side and these symbols silently failed to match (#682.3).
291        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}