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    // Both sides are read through the facade: the in-memory copy gets the
73    // gate of the project the index belongs to, like the index side (G04).
74    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
155/// Render a [`ParityReport`] as a compact, deterministic text block.
156pub 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        // The index side is gated by the process-wide protection state;
249        // pin Community mode (see property_graph::sync facade parity).
250        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        // The index side is gated by the process-wide protection state;
267        // pin Community mode (see property_graph::sync facade parity).
268        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
269        // GI's import-only dependencies() ignores reexport/sibling; PG exposes
270        // them as structural edges. That is *more*, never less — still lossless.
271        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        // The index side is gated by the process-wide protection state;
296        // pin Community mode (see property_graph::sync facade parity).
297        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
298        // Trait-impl symbol names contain `::` (e.g. `std::fmt::Display for T`).
299        // The symbol key is `file::name`, so the PG lookup must split on the
300        // FIRST `::`, not the last — `rsplitn` put the `::`-tail of the name on
301        // the file side and these symbols silently failed to match (#682.3).
302        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}