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    component_signals: Vec<components::ComponentSignal>,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum CompilationStage {
166    Components,
167    Flows,
168    Behavior,
169    Invariants,
170    Drift,
171    Boundaries,
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct StageCounts {
176    pub components: usize,
177    pub flows: usize,
178    pub invariants: usize,
179    pub drift: usize,
180    pub boundaries: usize,
181}
182
183// trace:exempt reason=internal-detail
184impl<'a> CompilationPipeline<'a> {
185    // trace:exempt reason=internal-detail
186    pub fn new(store: &'a Store) -> CompilationPipeline<'a> {
187        CompilationPipeline { store, component_signals: Vec::new() }
188    }
189
190    /// Plugin component signals (§31 ComponentSignalProvider): merged by
191    /// name into the builtin candidate set before clustering. Same-name
192    /// signal dirs append (never rename or re-rank); new names enter at
193    /// plugin rank; failures upstream degrade to empty (never fail here).
194    // trace:exempt reason=internal-detail
195    pub fn component_signals(mut self, signals: Vec<components::ComponentSignal>) -> Self {
196        self.component_signals = signals;
197        self
198    }
199
200    // trace:exempt reason=internal-detail
201    pub fn run(self) -> Result<RecompileReport> {
202        // invalidate epoch-keyed context caches before any derived write
203        self.store
204            .bump_epoch(scc_store::ModelEpochKind::Derived)?;
205
206        // STAGE 0/1: load the base reality graph, compile components.
207        // Co-change pairs are computed first (Wave 5): they feed the
208        // clustering score during compilation and enrich the freshly
209        // written components right after, before any later stage reloads
210        // the graph. Not a git repo yields empty pairs — no signal, no
211        // error.
212        let graph = RealityGraph::load(self.store)?;
213        let intent = self.store.intent_claims()?;
214        // HEAD-keyed cache in store meta: a cache hit skips the git pass
215        // entirely (second `scc index` at the same HEAD is near-instant);
216        // a miss computes with per-commit caps and persists. Any failure
217        // is non-fatal — empty pairs, same as a non-git repo — so the
218        // atlas/index never waits on git history.
219        let pairs = cochange::cached_cochange_pairs(self.store).unwrap_or_default();
220        let comps = components::compile_components_with_signals(&graph, self.store, &intent, &pairs, &self.component_signals)?;
221        self.store.replace_components(&comps)?;
222        cochange::enrich_components(self.store, &pairs).map_err(GraphError::Cochange)?;
223
224        // STAGE 2: reload (components now visible), compile flows.
225        let graph = RealityGraph::load(self.store)?;
226        let (seq_flows, data_flows, arch_flow) =
227            flows::compile_flows(&graph, self.store, &intent)?;
228        let mut all = seq_flows;
229        all.extend(data_flows);
230        if let Some(a) = arch_flow {
231            all.push(a);
232        }
233        self.store.replace_flows(&all)?;
234
235        // STAGE 3: canonical causal flow graphs (Wave 3) — the behavioral
236        // truth from which projections derive; then the behavioral views
237        // (lifecycle state machines + operational workflows) which read the
238        // stored sequence flows (reload).
239        let graph = RealityGraph::load(self.store)?;
240        let symbol_comp = symbol_component_map(&graph);
241        let graphs = flowgraph::compile_flow_graphs(&graph, self.store, &intent, &symbol_comp)?;
242        self.store.replace_flow_graphs(&graphs)?;
243        let graph = RealityGraph::load(self.store)?;
244        let mut lifecycles = lifecycle::compile_lifecycles(&graph, self.store)?;
245        let mut workflows = workflow::compile_workflows(&graph, self.store)?;
246        all.append(&mut lifecycles);
247        all.append(&mut workflows);
248        self.store.replace_flows(&all)?;
249
250        // STAGE 4: invariants against the fully compiled model.
251        let graph = RealityGraph::load(self.store)?;
252        let invs = invariants::compile_invariants(&graph, &intent)?;
253        self.store.replace_invariants(&invs)?;
254
255        // STAGE 5: drift against the *stored* components (reloaded), never
256        // the pre-reload in-memory list.
257        let graph = RealityGraph::load(self.store)?;
258        let stored_comps = self.store.components()?;
259        let findings = invariants::drift_findings(&graph, self.store, &intent, &stored_comps)?;
260        self.store.clear_drift_findings()?;
261        for (kind, severity, message) in &findings {
262            self.store
263                .add_drift_finding(kind, severity, message)?;
264        }
265
266        // STAGE 6: trust-boundary crossings (derived facts).
267        let graph = RealityGraph::load(self.store)?;
268        let crossings = boundaries::compile_boundaries(&graph, self.store)?;
269        for (rel, src) in crossings {
270            self.store.insert_relationship(&rel, &src)?;
271        }
272
273        // garbage-collect evidence that lost its last reference during the
274        // rebuild (docs/DATA_STRATEGY.md §6)
275        self.store.sweep_orphan_evidence()?;
276
277        Ok(RecompileReport {
278            components: comps.len(),
279            flows: all.len(),
280            invariants: invs.len(),
281            drift: findings.len(),
282            boundaries: stored_comps.len(),
283        })
284    }
285}
286
287/// Recompile the entire derived layer (components, flows, invariants, drift)
288/// from the reality graph. Idempotent; replaces derived tables in the store.
289/// Equivalent to [`CompilationPipeline::run`] (kept for callers that do not
290/// need stage control).
291pub fn recompile(store: &Store) -> Result<RecompileReport> {
292    CompilationPipeline::new(store).run()
293}
294
295#[derive(Debug, Clone, Default)]
296pub struct RecompileReport {
297    pub components: usize,
298    pub flows: usize,
299    pub invariants: usize,
300    /// Number of drift findings emitted at stage 5 (against the freshly
301    /// compiled model).
302    pub drift: usize,
303    /// Number of trust-boundary crossing edges at stage 6.
304    pub boundaries: usize,
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn load_empty_store() {
313        let dir = tempfile::TempDir::new().unwrap();
314        let root = dir.path().join("repo");
315        std::fs::create_dir_all(&root).unwrap();
316        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
317        let g = RealityGraph::load(&store).unwrap();
318        assert!(g.entities.is_empty());
319        let rep = recompile(&store).unwrap();
320        assert_eq!(rep.components, 1, "empty repos still get a root component");
321    }
322
323    #[test]
324    fn pipeline_bumps_derived_epoch_and_reloads_between_stages() {
325        let dir = tempfile::TempDir::new().unwrap();
326        let root = dir.path().join("repo");
327        std::fs::create_dir_all(&root).unwrap();
328        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
329
330        let epoch_before = store.model_epoch().unwrap();
331        let rep = CompilationPipeline::new(&store).run().unwrap();
332        let epoch_after = store.model_epoch().unwrap();
333
334        // derived compilation invalidates the cache epoch even on an empty
335        // store (fail closed before any derived write)
336        assert_eq!(epoch_after.derived, epoch_before.derived + 1);
337        assert!(rep.components >= 1);
338        assert_eq!(rep.drift, 0, "no drift on an empty model");
339
340        // a second run is idempotent in content but bumps again (each
341        // recompile is a new derived model state)
342        let rep2 = CompilationPipeline::new(&store).run().unwrap();
343        assert_eq!(rep2.components, rep.components);
344        assert_eq!(rep2.flows, rep.flows);
345        assert_eq!(rep2.invariants, rep.invariants);
346    }
347
348    #[test]
349    fn drift_uses_newly_compiled_flows() {
350        // regression (P0 stage ordering): drift findings must be computed
351        // against flows written by the current pipeline run, not a stale
352        // pre-recompile model.
353        let dir = tempfile::TempDir::new().unwrap();
354        let root = dir.path().join("repo");
355        std::fs::create_dir_all(&root).unwrap();
356        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
357
358        // first compile: only the empty-repo architecture flow exists
359        let rep = CompilationPipeline::new(&store).run().unwrap();
360        let base_flows = rep.flows;
361        assert!(base_flows >= 1);
362
363        // add a flow-affecting fact: a route handler
364        let repo = store.repo_id.clone();
365        let route = scc_core::entity_id(&repo, "route", "get-/api/x");
366        store
367            .insert_entity(
368                scc_core::Entity::new(route.clone(), "route", "get-/api/x")
369                    .attr("method", serde_json::json!("GET"))
370                    .attr("path", serde_json::json!("/api/x"))
371                    .attr("handler", serde_json::json!("handle_x")),
372                &["main.py".into()],
373            )
374            .unwrap();
375        let sym = scc_core::symbol_id(&repo, "main.py", "handle_x");
376        store
377            .insert_entity(&scc_core::Entity::new(sym.clone(), "symbol", "handle_x"), &["main.py".into()])
378            .unwrap();
379        store
380            .insert_relationship(
381                &scc_core::Relationship::new(
382                    "rel:route",
383                    sym.clone(),
384                    scc_core::predicates::HANDLES,
385                    route,
386                    scc_core::Provenance::Extracted,
387                ),
388                "main.py",
389            )
390            .unwrap();
391
392        // second compile: the pipeline must see its own newly written
393        // component (root) and flow (get-/api/x) in later stages
394        let rep2 = CompilationPipeline::new(&store).run().unwrap();
395        assert!(rep2.flows > base_flows, "flows: {} vs {base_flows}", rep2.flows);
396        let flows = store.flows().unwrap();
397        assert!(
398            flows.iter().any(|f| f.name.contains("get-/api/x")),
399            "compiled flow must be present: {flows:?}"
400        );
401    }
402
403    // trace:exempt reason=test-helper  # hermetic git fixture factory (test-only)
404    fn git_init(dir: &std::path::Path) {
405        for args in [
406            vec!["init", "-q"],
407            vec!["config", "user.email", "test@example.com"],
408            vec!["config", "user.name", "SCC Test"],
409            // Hermetic: a user's global commit.gpgsign=true must not leak
410            // into test repos (gpg-agent exhaustion under parallel load
411            // made commits flaky).
412            vec!["config", "commit.gpgsign", "false"],
413        ] {
414            let out = std::process::Command::new("git")
415                .args(&args)
416                .current_dir(dir)
417                .output()
418                .unwrap();
419            assert!(out.status.success(), "git {args:?} failed");
420        }
421    }
422
423    fn git_commit_all(dir: &std::path::Path, msg: &str) {
424        let out = std::process::Command::new("git")
425            .args(["add", "-A"])
426            .current_dir(dir)
427            .output()
428            .unwrap();
429        assert!(out.status.success());
430        let out = std::process::Command::new("git")
431            .args(["commit", "-q", "-m", msg])
432            .current_dir(dir)
433            .output()
434            .unwrap();
435        assert!(out.status.success(), "git commit failed");
436    }
437
438    fn git_write(dir: &std::path::Path, name: &str, content: &str) {
439        let p = dir.join(name);
440        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
441        std::fs::write(p, content).unwrap();
442    }
443
444    #[test]
445    fn pipeline_wires_cochange_into_clustering() {
446        // Wave 5: the pipeline computes git co-change pairs before stage 1,
447        // feeds them into the clustering score (+2 per pair fully inside a
448        // candidate), and annotates the freshly written components via
449        // cochange::enrich_components — all in one run.
450        let dir = tempfile::TempDir::new().unwrap();
451        let root = dir.path().join("repo");
452        std::fs::create_dir_all(&root).unwrap();
453        git_init(&root);
454        git_write(&root, "src/a.py", "a = 1\n");
455        git_write(&root, "src/b.py", "b = 2\n");
456        git_commit_all(&root, "c1");
457        git_write(&root, "src/a.py", "a = 2\n");
458        git_write(&root, "src/b.py", "b = 3\n");
459        git_commit_all(&root, "c2");
460
461        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
462        for f in ["src/a.py", "src/b.py"] {
463            let id = scc_core::entity_id(&store.repo_id, scc_core::kinds::FILE, f);
464            store
465                .insert_entity(
466                    &scc_core::Entity::new(id, scc_core::kinds::FILE, f),
467                    &[f.into()],
468                )
469                .unwrap();
470        }
471        store
472            .replace_intent_claims(&[(
473                "component".to_string(),
474                serde_json::json!({"name": "core", "paths": ["src"]}),
475            )])
476            .unwrap();
477
478        let rep = CompilationPipeline::new(&store).run().unwrap();
479        assert!(rep.components >= 2, "root + core: {}", rep.components);
480
481        let comps = store.components().unwrap();
482        let core = comps.iter().find(|c| c.name == "core").unwrap();
483        assert_eq!(
484            core.attributes["boundary_kind"],
485            serde_json::json!("declared")
486        );
487        // one pair (src/a.py <-> src/b.py) fully inside "src": +2.0
488        assert_eq!(
489            core.attributes["clustering_score"],
490            serde_json::json!(2.0),
491            "{:?}",
492            core.attributes
493        );
494        // enrich_components ran on the freshly written components
495        let cc = core.attributes["cochange"].clone();
496        assert_eq!(cc["top"], 2);
497        assert!(
498            cc["pairs"][0]
499                .as_str()
500                .unwrap()
501                .starts_with("src/a.py <-> src/b.py"),
502            "{cc}"
503        );
504        // a second identical run is deterministic
505        let rep2 = CompilationPipeline::new(&store).run().unwrap();
506        assert_eq!(rep2.components, rep.components);
507        let comps2 = store.components().unwrap();
508        let core2 = comps2.iter().find(|c| c.name == "core").unwrap();
509        assert_eq!(
510            core.attributes["clustering_score"],
511            core2.attributes["clustering_score"]
512        );
513    }
514}