Skip to main content

scc_graph/
lib.rs

1//! Graph layer: Reality Graph loading plus the System IR compilers
2//! (components, flows, invariants) and impact analysis.
3//!
4//! Docs mapping: scc-graph + scc-system-ir + scc-flow.
5
6pub mod archetype;
7pub mod boundaries;
8pub mod clustering;
9pub mod cochange;
10pub mod components;
11pub mod flowgraph;
12pub mod flows;
13pub mod impact;
14pub mod invariants;
15pub mod lifecycle;
16pub mod state;
17pub mod trust;
18pub mod workflow;
19
20pub use trust::{TrustedGraphView, TrustPolicy};
21
22// trace:v1 id=impl.scc-graph.reality-graph work=WORK-SCC-004 satisfies=REQ-SCC-IR
23impl RealityGraph {
24    pub fn empty() -> RealityGraph {
25        RealityGraph {
26            repo_id: String::new(),
27            entities: HashMap::new(),
28            out: HashMap::new(),
29            inn: HashMap::new(),
30            components: Vec::new(),
31            flows: Vec::new(),
32            invariants: Vec::new(),
33        }
34    }
35}
36
37use scc_core::{Entity, Flow, Invariant, Relationship};
38use scc_store::Store;
39use std::collections::HashMap;
40
41#[derive(Debug, thiserror::Error)]
42// trace:exempt reason=internal-detail
43pub enum GraphError {
44    #[error("store: {0}")]
45    Store(#[from] scc_store::StoreError),
46    #[error("cochange: {0}")]
47    Cochange(String),
48    #[error("impact: {0}")]
49    Impact(String),
50}
51
52pub type Result<T> = std::result::Result<T, GraphError>;
53
54/// In-memory view of the reality graph.
55// trace:v1 id=impl.scc-graph.reality-graph-struct work=WORK-SCC-004 satisfies=REQ-SCC-IR
56pub struct RealityGraph {
57    pub repo_id: String,
58    pub entities: HashMap<String, Entity>,
59    /// out edges by subject
60    pub out: HashMap<String, Vec<Relationship>>,
61    /// in edges by object
62    pub inn: HashMap<String, Vec<Relationship>>,
63    pub components: Vec<Entity>,
64    pub flows: Vec<Flow>,
65    pub invariants: Vec<Invariant>,
66}
67
68impl RealityGraph {
69    pub fn load(store: &Store) -> Result<RealityGraph> {
70        let mut entities = HashMap::new();
71        for e in store.all_entities()? {
72            entities.insert(e.id.clone(), e);
73        }
74        let mut out: HashMap<String, Vec<Relationship>> = HashMap::new();
75        let mut inn: HashMap<String, Vec<Relationship>> = HashMap::new();
76        for r in store.all_relationships()? {
77            out.entry(r.subject.clone()).or_default().push(r.clone());
78            inn.entry(r.object.clone()).or_default().push(r);
79        }
80        Ok(RealityGraph {
81            repo_id: store.repo_id.clone(),
82            entities,
83            out,
84            inn,
85            components: store.components()?,
86            flows: store.flows()?,
87            invariants: store.invariants()?,
88        })
89    }
90
91    pub fn entity(&self, id: &str) -> Option<&Entity> {
92        self.entities.get(id)
93    }
94
95    pub fn out_edges(&self, id: &str) -> Vec<&Relationship> {
96        self.out.get(id).map(|v| v.iter().collect()).unwrap_or_default()
97    }
98
99    pub fn in_edges(&self, id: &str) -> Vec<&Relationship> {
100        self.inn.get(id).map(|v| v.iter().collect()).unwrap_or_default()
101    }
102
103    pub fn out_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
104        self.out
105            .get(id)
106            .map(|v| {
107                v.iter()
108                    .filter(|r| r.predicate == predicate)
109                    .collect()
110            })
111            .unwrap_or_default()
112    }
113
114    pub fn in_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
115        self.inn
116            .get(id)
117            .map(|v| {
118                v.iter()
119                    .filter(|r| r.predicate == predicate)
120                    .collect()
121            })
122            .unwrap_or_default()
123    }
124
125    /// Entities of a given kind (sorted by name for determinism).
126    pub fn entities_of_kind(&self, kind: &str) -> Vec<&Entity> {
127        let mut v: Vec<&Entity> = self
128            .entities
129            .values()
130            .filter(|e| e.kind == kind)
131            .collect();
132        v.sort_by(|a, b| a.name.cmp(&b.name));
133        v
134    }
135}
136
137/// Map every symbol id to its component id via the component CONTAINS
138/// edges (shared by the flow graph compiler and flow projections).
139pub fn symbol_component_map(graph: &RealityGraph) -> HashMap<String, String> {
140    let mut symbol_comp: HashMap<String, String> = HashMap::new();
141    for c in &graph.components {
142        for r in graph.out_pred(&c.id, scc_core::predicates::CONTAINS) {
143            for sr in graph.out_pred(&r.object, scc_core::predicates::CONTAINS) {
144                symbol_comp.insert(sr.object.clone(), c.id.clone());
145            }
146        }
147    }
148    symbol_comp
149}
150
151/// Staged derived compilation (P0, docs/SYSTEM_DESIGN.md §7): every stage
152/// writes its output, reloads the reality graph, and only then compiles the
153/// next stage, so drift and later stages can never be computed against a
154/// graph that predates freshly written facts. The derived model epoch is
155/// bumped *before* the first write so cached context packs are invalidated
156/// even if a stage fails mid-pipeline (fail closed — no stale trusted pack
157/// survives a partial recompile).
158// trace:v1 id=impl.scc-graph.compilation-pipeline work=WORK-SCC-004 satisfies=REQ-SCC-IR
159pub struct CompilationPipeline<'a> {
160    store: &'a Store,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum CompilationStage {
165    Components,
166    Flows,
167    Behavior,
168    Invariants,
169    Drift,
170    Boundaries,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct StageCounts {
175    pub components: usize,
176    pub flows: usize,
177    pub invariants: usize,
178    pub drift: usize,
179    pub boundaries: usize,
180}
181
182impl<'a> CompilationPipeline<'a> {
183    pub fn new(store: &'a Store) -> CompilationPipeline<'a> {
184        CompilationPipeline { store }
185    }
186
187    pub fn run(self) -> Result<RecompileReport> {
188        // invalidate epoch-keyed context caches before any derived write
189        self.store
190            .bump_epoch(scc_store::ModelEpochKind::Derived)?;
191
192        // STAGE 0/1: load the base reality graph, compile components.
193        // Co-change pairs are computed first (Wave 5): they feed the
194        // clustering score during compilation and enrich the freshly
195        // written components right after, before any later stage reloads
196        // the graph. Not a git repo yields empty pairs — no signal, no
197        // error.
198        let graph = RealityGraph::load(self.store)?;
199        let intent = self.store.intent_claims()?;
200        // HEAD-keyed cache in store meta: a cache hit skips the git pass
201        // entirely (second `scc index` at the same HEAD is near-instant);
202        // a miss computes with per-commit caps and persists. Any failure
203        // is non-fatal — empty pairs, same as a non-git repo — so the
204        // atlas/index never waits on git history.
205        let pairs = cochange::cached_cochange_pairs(self.store).unwrap_or_default();
206        let comps = components::compile_components(&graph, self.store, &intent, &pairs)?;
207        self.store.replace_components(&comps)?;
208        cochange::enrich_components(self.store, &pairs).map_err(GraphError::Cochange)?;
209
210        // STAGE 2: reload (components now visible), compile flows.
211        let graph = RealityGraph::load(self.store)?;
212        let (seq_flows, data_flows, arch_flow) =
213            flows::compile_flows(&graph, self.store, &intent)?;
214        let mut all = seq_flows;
215        all.extend(data_flows);
216        if let Some(a) = arch_flow {
217            all.push(a);
218        }
219        self.store.replace_flows(&all)?;
220
221        // STAGE 3: canonical causal flow graphs (Wave 3) — the behavioral
222        // truth from which projections derive; then the behavioral views
223        // (lifecycle state machines + operational workflows) which read the
224        // stored sequence flows (reload).
225        let graph = RealityGraph::load(self.store)?;
226        let symbol_comp = symbol_component_map(&graph);
227        let graphs = flowgraph::compile_flow_graphs(&graph, self.store, &intent, &symbol_comp)?;
228        self.store.replace_flow_graphs(&graphs)?;
229        let graph = RealityGraph::load(self.store)?;
230        let mut lifecycles = lifecycle::compile_lifecycles(&graph, self.store)?;
231        let mut workflows = workflow::compile_workflows(&graph, self.store)?;
232        all.append(&mut lifecycles);
233        all.append(&mut workflows);
234        self.store.replace_flows(&all)?;
235
236        // STAGE 4: invariants against the fully compiled model.
237        let graph = RealityGraph::load(self.store)?;
238        let invs = invariants::compile_invariants(&graph, &intent)?;
239        self.store.replace_invariants(&invs)?;
240
241        // STAGE 5: drift against the *stored* components (reloaded), never
242        // the pre-reload in-memory list.
243        let graph = RealityGraph::load(self.store)?;
244        let stored_comps = self.store.components()?;
245        let findings = invariants::drift_findings(&graph, self.store, &intent, &stored_comps)?;
246        self.store.clear_drift_findings()?;
247        for (kind, severity, message) in &findings {
248            self.store
249                .add_drift_finding(kind, severity, message)?;
250        }
251
252        // STAGE 6: trust-boundary crossings (derived facts).
253        let graph = RealityGraph::load(self.store)?;
254        let crossings = boundaries::compile_boundaries(&graph, self.store)?;
255        for (rel, src) in crossings {
256            self.store.insert_relationship(&rel, &src)?;
257        }
258
259        // garbage-collect evidence that lost its last reference during the
260        // rebuild (docs/DATA_STRATEGY.md §6)
261        self.store.sweep_orphan_evidence()?;
262
263        Ok(RecompileReport {
264            components: comps.len(),
265            flows: all.len(),
266            invariants: invs.len(),
267            drift: findings.len(),
268            boundaries: stored_comps.len(),
269        })
270    }
271}
272
273/// Recompile the entire derived layer (components, flows, invariants, drift)
274/// from the reality graph. Idempotent; replaces derived tables in the store.
275/// Equivalent to [`CompilationPipeline::run`] (kept for callers that do not
276/// need stage control).
277pub fn recompile(store: &Store) -> Result<RecompileReport> {
278    CompilationPipeline::new(store).run()
279}
280
281#[derive(Debug, Clone, Default)]
282pub struct RecompileReport {
283    pub components: usize,
284    pub flows: usize,
285    pub invariants: usize,
286    /// Number of drift findings emitted at stage 5 (against the freshly
287    /// compiled model).
288    pub drift: usize,
289    /// Number of trust-boundary crossing edges at stage 6.
290    pub boundaries: usize,
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn load_empty_store() {
299        let dir = tempfile::TempDir::new().unwrap();
300        let root = dir.path().join("repo");
301        std::fs::create_dir_all(&root).unwrap();
302        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
303        let g = RealityGraph::load(&store).unwrap();
304        assert!(g.entities.is_empty());
305        let rep = recompile(&store).unwrap();
306        assert_eq!(rep.components, 1, "empty repos still get a root component");
307    }
308
309    #[test]
310    fn pipeline_bumps_derived_epoch_and_reloads_between_stages() {
311        let dir = tempfile::TempDir::new().unwrap();
312        let root = dir.path().join("repo");
313        std::fs::create_dir_all(&root).unwrap();
314        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
315
316        let epoch_before = store.model_epoch().unwrap();
317        let rep = CompilationPipeline::new(&store).run().unwrap();
318        let epoch_after = store.model_epoch().unwrap();
319
320        // derived compilation invalidates the cache epoch even on an empty
321        // store (fail closed before any derived write)
322        assert_eq!(epoch_after.derived, epoch_before.derived + 1);
323        assert!(rep.components >= 1);
324        assert_eq!(rep.drift, 0, "no drift on an empty model");
325
326        // a second run is idempotent in content but bumps again (each
327        // recompile is a new derived model state)
328        let rep2 = CompilationPipeline::new(&store).run().unwrap();
329        assert_eq!(rep2.components, rep.components);
330        assert_eq!(rep2.flows, rep.flows);
331        assert_eq!(rep2.invariants, rep.invariants);
332    }
333
334    #[test]
335    fn drift_uses_newly_compiled_flows() {
336        // regression (P0 stage ordering): drift findings must be computed
337        // against flows written by the current pipeline run, not a stale
338        // pre-recompile model.
339        let dir = tempfile::TempDir::new().unwrap();
340        let root = dir.path().join("repo");
341        std::fs::create_dir_all(&root).unwrap();
342        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
343
344        // first compile: only the empty-repo architecture flow exists
345        let rep = CompilationPipeline::new(&store).run().unwrap();
346        let base_flows = rep.flows;
347        assert!(base_flows >= 1);
348
349        // add a flow-affecting fact: a route handler
350        let repo = store.repo_id.clone();
351        let route = scc_core::entity_id(&repo, "route", "get-/api/x");
352        store
353            .insert_entity(
354                scc_core::Entity::new(route.clone(), "route", "get-/api/x")
355                    .attr("method", serde_json::json!("GET"))
356                    .attr("path", serde_json::json!("/api/x"))
357                    .attr("handler", serde_json::json!("handle_x")),
358                &["main.py".into()],
359            )
360            .unwrap();
361        let sym = scc_core::symbol_id(&repo, "main.py", "handle_x");
362        store
363            .insert_entity(&scc_core::Entity::new(sym.clone(), "symbol", "handle_x"), &["main.py".into()])
364            .unwrap();
365        store
366            .insert_relationship(
367                &scc_core::Relationship::new(
368                    "rel:route",
369                    sym.clone(),
370                    scc_core::predicates::HANDLES,
371                    route,
372                    scc_core::Provenance::Extracted,
373                ),
374                "main.py",
375            )
376            .unwrap();
377
378        // second compile: the pipeline must see its own newly written
379        // component (root) and flow (get-/api/x) in later stages
380        let rep2 = CompilationPipeline::new(&store).run().unwrap();
381        assert!(rep2.flows > base_flows, "flows: {} vs {base_flows}", rep2.flows);
382        let flows = store.flows().unwrap();
383        assert!(
384            flows.iter().any(|f| f.name.contains("get-/api/x")),
385            "compiled flow must be present: {flows:?}"
386        );
387    }
388
389    // trace:exempt reason=test-helper  # hermetic git fixture factory (test-only)
390    fn git_init(dir: &std::path::Path) {
391        for args in [
392            vec!["init", "-q"],
393            vec!["config", "user.email", "test@example.com"],
394            vec!["config", "user.name", "SCC Test"],
395            // Hermetic: a user's global commit.gpgsign=true must not leak
396            // into test repos (gpg-agent exhaustion under parallel load
397            // made commits flaky).
398            vec!["config", "commit.gpgsign", "false"],
399        ] {
400            let out = std::process::Command::new("git")
401                .args(&args)
402                .current_dir(dir)
403                .output()
404                .unwrap();
405            assert!(out.status.success(), "git {args:?} failed");
406        }
407    }
408
409    fn git_commit_all(dir: &std::path::Path, msg: &str) {
410        let out = std::process::Command::new("git")
411            .args(["add", "-A"])
412            .current_dir(dir)
413            .output()
414            .unwrap();
415        assert!(out.status.success());
416        let out = std::process::Command::new("git")
417            .args(["commit", "-q", "-m", msg])
418            .current_dir(dir)
419            .output()
420            .unwrap();
421        assert!(out.status.success(), "git commit failed");
422    }
423
424    fn git_write(dir: &std::path::Path, name: &str, content: &str) {
425        let p = dir.join(name);
426        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
427        std::fs::write(p, content).unwrap();
428    }
429
430    #[test]
431    fn pipeline_wires_cochange_into_clustering() {
432        // Wave 5: the pipeline computes git co-change pairs before stage 1,
433        // feeds them into the clustering score (+2 per pair fully inside a
434        // candidate), and annotates the freshly written components via
435        // cochange::enrich_components — all in one run.
436        let dir = tempfile::TempDir::new().unwrap();
437        let root = dir.path().join("repo");
438        std::fs::create_dir_all(&root).unwrap();
439        git_init(&root);
440        git_write(&root, "src/a.py", "a = 1\n");
441        git_write(&root, "src/b.py", "b = 2\n");
442        git_commit_all(&root, "c1");
443        git_write(&root, "src/a.py", "a = 2\n");
444        git_write(&root, "src/b.py", "b = 3\n");
445        git_commit_all(&root, "c2");
446
447        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
448        for f in ["src/a.py", "src/b.py"] {
449            let id = scc_core::entity_id(&store.repo_id, scc_core::kinds::FILE, f);
450            store
451                .insert_entity(
452                    &scc_core::Entity::new(id, scc_core::kinds::FILE, f),
453                    &[f.into()],
454                )
455                .unwrap();
456        }
457        store
458            .replace_intent_claims(&[(
459                "component".to_string(),
460                serde_json::json!({"name": "core", "paths": ["src"]}),
461            )])
462            .unwrap();
463
464        let rep = CompilationPipeline::new(&store).run().unwrap();
465        assert!(rep.components >= 2, "root + core: {}", rep.components);
466
467        let comps = store.components().unwrap();
468        let core = comps.iter().find(|c| c.name == "core").unwrap();
469        assert_eq!(
470            core.attributes["boundary_kind"],
471            serde_json::json!("declared")
472        );
473        // one pair (src/a.py <-> src/b.py) fully inside "src": +2.0
474        assert_eq!(
475            core.attributes["clustering_score"],
476            serde_json::json!(2.0),
477            "{:?}",
478            core.attributes
479        );
480        // enrich_components ran on the freshly written components
481        let cc = core.attributes["cochange"].clone();
482        assert_eq!(cc["top"], 2);
483        assert!(
484            cc["pairs"][0]
485                .as_str()
486                .unwrap()
487                .starts_with("src/a.py <-> src/b.py"),
488            "{cc}"
489        );
490        // a second identical run is deterministic
491        let rep2 = CompilationPipeline::new(&store).run().unwrap();
492        assert_eq!(rep2.components, rep.components);
493        let comps2 = store.components().unwrap();
494        let core2 = comps2.iter().find(|c| c.name == "core").unwrap();
495        assert_eq!(
496            core.attributes["clustering_score"],
497            core2.attributes["clustering_score"]
498        );
499    }
500}