Skip to main content

scc_store/
history.rs

1//! Versioned reality graph (mission §XIX): durable per-index revisions with
2//! source-hash/extractor provenance, introduction/removal history, and
3//! transactional updates — all in SQLite, no extra infrastructure.
4//!
5//! Model: every successful index records one [`GraphRevision`] plus the full
6//! member id + row sets (`revision_members`). History is a chain of complete
7//! snapshots (simple and correct; storage cost is one row-set per index —
8//! fine for normal repos, noted for very large ones). Diffs replay sets,
9//! never the live tables, so a historical view is stable under later edits.
10//!
11//! Complementary to [`ModelEpoch`](crate::ModelEpoch): a revision is a
12//! persistent history position; the epoch is the identity of the active
13//! compiled view. See docs/DATA_STRATEGY.md L5.
14
15use crate::{Entity, Relationship, Store, StoreError};
16use rusqlite::params;
17use serde::{Deserialize, Serialize};
18
19/// One durable graph revision: what source, what extractor, what changed.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21// trace:v1 id=impl.crates-scc-store-src-history.graph-revision work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
22pub struct GraphRevision {
23    /// Monotonic history position (1-based).
24    pub rev: i64,
25    /// Previous revision, 0 for genesis.
26    pub base_rev: i64,
27    /// When this revision was recorded (RFC3339).
28    pub created_at: String,
29    /// Content hash of the indexed file inventory (path+hash list).
30    pub source_hash: String,
31    /// Extractor/schema versions that produced this revision.
32    pub extractor_version: String,
33    /// Hash of the semantic configuration (language backends, resolver
34    /// switches): a config change without file changes still advances.
35    pub semantic_config_hash: String,
36    /// Hash of the actual graph rows (entity + relationship JSON): an
37    /// extractor/confidence/provenance change without file changes still
38    /// advances, and it is the content-identity half of dedup.
39    pub graph_content_hash: String,
40    /// Live counts at record time.
41    pub entity_count: u64,
42    /// Live counts at record time.
43    pub rel_count: u64,
44    /// Live counts at record time.
45    pub file_count: u64,
46}
47
48/// Introduction/removal delta between two revisions, grouped by id.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50// trace:v1 id=impl.crates-scc-store-src-history.semantic-delta work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
51pub struct SemanticDelta {
52    /// Entity ids present in `to` but not `from`.
53    pub added_entities: Vec<String>,
54    /// Entity ids present in `from` but not `to`.
55    pub removed_entities: Vec<String>,
56    /// Entity ids in both whose recorded row changed (attributes,
57    /// confidence, provenance, evidence — anything in the row JSON).
58    pub modified_entities: Vec<String>,
59    /// Relationship ids present in `to` but not `from`.
60    pub added_relationships: Vec<String>,
61    /// Relationship ids present in `from` but not `to`.
62    pub removed_relationships: Vec<String>,
63    /// Relationship ids in both whose recorded row changed.
64    pub modified_relationships: Vec<String>,
65    /// Modified-entity counts by entity kind (contract/state/flow-visible
66    /// changes surface here through their member entities; contracts,
67    /// state claims, and flows derive from these rows, so no parallel
68    /// versioned store is needed).
69    pub modified_kinds: std::collections::BTreeMap<String, usize>,
70}
71
72// trace:exempt reason=internal-detail
73impl SemanticDelta {
74    /// True when the two revisions hold identical member sets.
75    // trace:exempt reason=internal-detail
76    pub fn is_empty(&self) -> bool {
77        self.added_entities.is_empty()
78            && self.removed_entities.is_empty()
79            && self.modified_entities.is_empty()
80            && self.added_relationships.is_empty()
81            && self.removed_relationships.is_empty()
82            && self.modified_relationships.is_empty()
83    }
84}
85
86// trace:exempt reason=internal-detail
87// trace:exempt reason=internal-detail
88impl Store {
89    /// Record the current graph as a new revision, transactionally:
90    /// revision row + full member row sets commit atomically, so a crash
91    /// never leaves a revision with half its members.
92    // trace:v1 id=impl.crates-scc-store-src-history.record-revision work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
93    pub fn record_revision(
94        &self,
95        source_hash: &str,
96        extractor_version: &str,
97        file_count: u64,
98        semantic_config_hash: &str,
99        graph_content_hash: &str,
100    ) -> Result<GraphRevision, StoreError> {
101        let entities = self.all_entities()?;
102        let rels = self.all_relationships()?;
103        let base: i64 = self
104            .conn
105            .query_row(
106                "SELECT COALESCE(MAX(rev), 0) FROM graph_revisions",
107                [],
108                |r| r.get(0),
109            )
110            .unwrap_or(0);
111        let tx = self.conn.unchecked_transaction()?;
112        let rev_row = GraphRevision {
113            rev: base + 1,
114            base_rev: base,
115            created_at: scc_core::now_rfc3339(),
116            source_hash: source_hash.to_string(),
117            extractor_version: extractor_version.to_string(),
118            semantic_config_hash: semantic_config_hash.to_string(),
119            graph_content_hash: graph_content_hash.to_string(),
120            entity_count: entities.len() as u64,
121            rel_count: rels.len() as u64,
122            file_count,
123        };
124        {
125            let mut ins_rev = tx.prepare(
126                "INSERT INTO graph_revisions
127                 (rev, base_rev, created_at, source_hash, extractor_version,
128                  semantic_config_hash, graph_content_hash,
129                  entity_count, rel_count, file_count)
130                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
131            )?;
132            ins_rev.execute(params![
133                rev_row.rev,
134                rev_row.base_rev,
135                rev_row.created_at,
136                rev_row.source_hash,
137                rev_row.extractor_version,
138                rev_row.semantic_config_hash,
139                rev_row.graph_content_hash,
140                rev_row.entity_count as i64,
141                rev_row.rel_count as i64,
142                rev_row.file_count as i64,
143            ])?;
144            let mut ins_mem = tx.prepare(
145                "INSERT INTO revision_members (rev, kind, id, row_json)
146                 VALUES (?1, ?2, ?3, ?4)",
147            )?;
148            for e in &entities {
149                ins_mem.execute(params![
150                    rev_row.rev,
151                    "entity",
152                    e.id,
153                    serde_json::to_string(e).unwrap_or_default(),
154                ])?;
155            }
156            for r in &rels {
157                ins_mem.execute(params![
158                    rev_row.rev,
159                    "relationship",
160                    r.id,
161                    serde_json::to_string(r).unwrap_or_default(),
162                ])?;
163            }
164        }
165        tx.commit()?;
166        Ok(rev_row)
167    }
168
169    /// All recorded revisions, oldest first.
170    // trace:v1 id=impl.crates-scc-store-src-history.revisions work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
171    pub fn revisions(&self) -> Result<Vec<GraphRevision>, StoreError> {
172        let mut stmt = self.conn.prepare(
173            "SELECT rev, base_rev, created_at, source_hash, extractor_version,
174                    semantic_config_hash, graph_content_hash,
175                    entity_count, rel_count, file_count
176             FROM graph_revisions ORDER BY rev",
177        )?;
178        let rows = stmt.query_map([], |r| {
179            Ok(GraphRevision {
180                rev: r.get(0)?,
181                base_rev: r.get(1)?,
182                created_at: r.get(2)?,
183                source_hash: r.get(3)?,
184                extractor_version: r.get(4)?,
185                semantic_config_hash: r.get(5)?,
186                graph_content_hash: r.get(6)?,
187                entity_count: r.get::<_, i64>(7)? as u64,
188                rel_count: r.get::<_, i64>(8)? as u64,
189                file_count: r.get::<_, i64>(9)? as u64,
190            })
191        })?;
192        rows.collect::<Result<Vec<_>, _>>().map_err(StoreError::from)
193    }
194
195    /// Member id sets at a revision: the representative historical view.
196    /// Rows are the recorded JSON; ids absent from the live graph decode
197    /// as tombstones (present historically, gone now).
198    // trace:v1 id=impl.crates-scc-store-src-history.revision-members work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
199    pub fn revision_members(
200        &self,
201        rev: i64,
202    ) -> Result<(Vec<Entity>, Vec<Relationship>), StoreError> {
203        let mut stmt = self.conn.prepare(
204            "SELECT kind, row_json FROM revision_members WHERE rev = ?1",
205        )?;
206        let mut entities = Vec::new();
207        let mut rels = Vec::new();
208        let rows = stmt.query_map(params![rev], |r| {
209            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
210        })?;
211        for row in rows {
212            let (kind, js) = row?;
213            if kind == "entity" {
214                if let Ok(e) = serde_json::from_str::<Entity>(&js) {
215                    entities.push(e);
216                }
217            } else if let Ok(r) = serde_json::from_str::<Relationship>(&js) {
218                rels.push(r);
219            }
220        }
221        Ok((entities, rels))
222    }
223
224    /// Raw recorded rows at a revision: id -> row JSON, the content
225    /// half of the V2 diff. Rows decode losslessly (see
226    /// [`Store::revision_members`]); comparing JSON here avoids
227    /// re-serialization drift.
228    // trace:exempt reason=internal-detail
229    pub fn revision_rows(
230        &self,
231        rev: i64,
232    ) -> Result<
233        (
234            std::collections::BTreeMap<String, String>,
235            std::collections::BTreeMap<String, String>,
236        ),
237        StoreError,
238    > {
239        let mut stmt = self
240            .conn
241            .prepare("SELECT kind, id, row_json FROM revision_members WHERE rev = ?1")?;
242        let mut entities = std::collections::BTreeMap::new();
243        let mut rels = std::collections::BTreeMap::new();
244        let rows = stmt.query_map(params![rev], |r| {
245            Ok((
246                r.get::<_, String>(0)?,
247                r.get::<_, String>(1)?,
248                r.get::<_, String>(2)?,
249            ))
250        })?;
251        for row in rows {
252            let (kind, id, js) = row?;
253            if kind == "entity" {
254                entities.insert(id, js);
255            } else {
256                rels.insert(id, js);
257            }
258        }
259        Ok((entities, rels))
260    }
261
262    /// Introduction/removal/modification history between two revisions.
263    /// Same id in both revisions with different row JSON (attributes,
264    /// confidence, provenance, evidence) reports as modified — the V1
265    /// set-membership blind spot.
266    // trace:v1 id=impl.crates-scc-store-src-history.semantic-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
267    pub fn semantic_diff(&self, from: i64, to: i64) -> Result<SemanticDelta, StoreError> {
268        let (fe, fr) = self.revision_rows(from)?;
269        let (te, tr) = self.revision_rows(to)?;
270        let ids = |v: Vec<String>| {
271            let mut v = v;
272            v.sort();
273            v
274        };
275        let fset: std::collections::BTreeSet<String> = fe.keys().cloned().collect();
276        let tset: std::collections::BTreeSet<String> = te.keys().cloned().collect();
277        let frset: std::collections::BTreeSet<String> = fr.keys().cloned().collect();
278        let trset: std::collections::BTreeSet<String> = tr.keys().cloned().collect();
279        let mut modified_entities: Vec<String> = tset
280            .intersection(&fset)
281            .filter(|id| te.get(*id) != fe.get(*id))
282            .cloned()
283            .collect();
284        modified_entities.sort();
285        let mut modified_relationships: Vec<String> = trset
286            .intersection(&frset)
287            .filter(|id| tr.get(*id) != fr.get(*id))
288            .cloned()
289            .collect();
290        modified_relationships.sort();
291        // Modified counts by entity kind: decode the TO row (kinds are
292        // stable row fields, never inferred here).
293        let mut modified_kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
294        for id in &modified_entities {
295            if let Some(js) = te.get(id) {
296                if let Ok(v) = serde_json::from_str::<serde_json::Value>(js) {
297                    if let Some(k) = v.get("kind").and_then(|k| k.as_str()) {
298                        *modified_kinds.entry(k.to_string()).or_default() += 1;
299                    }
300                }
301            }
302        }
303        Ok(SemanticDelta {
304            added_entities: ids(tset.difference(&fset).cloned().collect()),
305            removed_entities: ids(fset.difference(&tset).cloned().collect()),
306            modified_entities,
307            added_relationships: ids(trset.difference(&frset).cloned().collect()),
308            removed_relationships: ids(frset.difference(&trset).cloned().collect()),
309            modified_relationships,
310            modified_kinds,
311        })
312    }
313}
314
315// trace:exempt reason=internal-detail
316impl Store {
317    /// Content hash of the live graph rows (raw entity + relationship
318    /// columns, fed incrementally in deterministic query order): the
319    /// content-identity half of revision dedup. An extractor, confidence,
320    /// or provenance change advances the revision even when no source file
321    /// moved. Raw columns, not re-serialized structs: the old code parsed
322    /// every row's JSON and re-serialized it (parse + reserialize of 26k
323    /// rows per record) only to hash the bytes. Same contract — opaque
324    /// equality vs the head — at read cost only. NOTE: the digest value
325    /// changes once vs pre-0.2.6 binaries (one extra revision on first
326    /// record after upgrade, then dedup resumes).
327    // trace:exempt reason=internal-detail
328    pub fn graph_content_hash(&self) -> Result<String, StoreError> {
329        const OFFSET: u64 = 0xcbf29ce484222325;
330        const PRIME: u64 = 0x100000001b3;
331        let mut h = OFFSET;
332        let mut feed = |b: &[u8]| {
333            for byte in b {
334                h ^= u64::from(*byte);
335                h = h.wrapping_mul(PRIME);
336            }
337            h ^= 0xff;
338            h = h.wrapping_mul(PRIME);
339        };
340        let mut stmt = self.conn.prepare(
341            "SELECT id, kind, name, attributes, evidence FROM entities ORDER BY kind, name",
342        )?;
343        let rows = stmt.query_map([], |r| {
344            Ok((
345                r.get::<_, String>(0)?,
346                r.get::<_, String>(1)?,
347                r.get::<_, String>(2)?,
348                r.get::<_, String>(3)?,
349                r.get::<_, String>(4)?,
350            ))
351        })?;
352        for r in rows {
353            let (id, kind, name, attributes, evidence) = r?;
354            feed(id.as_bytes());
355            feed(kind.as_bytes());
356            feed(name.as_bytes());
357            feed(attributes.as_bytes());
358            feed(evidence.as_bytes());
359        }
360        let mut stmt = self.conn.prepare(
361            "SELECT id, subject, predicate, object, provenance, confidence, evidence, verified_at FROM relationships ORDER BY id",
362        )?;
363        let rows = stmt.query_map([], |r| {
364            Ok((
365                r.get::<_, String>(0)?,
366                r.get::<_, String>(1)?,
367                r.get::<_, String>(2)?,
368                r.get::<_, String>(3)?,
369                r.get::<_, String>(4)?,
370                r.get::<_, f64>(5)?,
371                r.get::<_, String>(6)?,
372                r.get::<_, String>(7)?,
373            ))
374        })?;
375        for r in rows {
376            let (id, subject, predicate, object, provenance, confidence, evidence, verified_at) =
377                r?;
378            feed(id.as_bytes());
379            feed(subject.as_bytes());
380            feed(predicate.as_bytes());
381            feed(object.as_bytes());
382            feed(provenance.as_bytes());
383            feed(&confidence.to_le_bytes());
384            feed(evidence.as_bytes());
385            feed(verified_at.as_bytes());
386        }
387        Ok(format!("{h:016x}"))
388    }
389
390    /// Record the current graph, skipping fully-identical runs: when
391    /// source inventory, extractor, semantic config, AND graph content
392    /// all match the head revision, that revision is returned instead of
393    /// appending a duplicate. Any axis changing advances the history.
394    // trace:v1 id=impl.crates-scc-store-src-history.record-current-revision work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
395    pub fn record_current_revision(&self) -> Result<GraphRevision, StoreError> {
396        self.record_current_revision_with_config("")
397    }
398
399    /// [`record_current_revision`] with the caller's semantic-config hash
400    /// (language backends, resolver switches — the indexer hashes its
401    /// semantic-relevant config). Empty means "no config axis".
402    // trace:v1 id=impl.crates-scc-store-src-history.record-current-revision-config work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
403    pub fn record_current_revision_with_config(
404        &self,
405        semantic_config_hash: &str,
406    ) -> Result<GraphRevision, StoreError> {
407        let files = self.all_files()?;
408        let mut buf = String::new();
409        for (path, hash, _, _, _) in files.iter() {
410            buf.push_str(path);
411            buf.push('\0');
412            buf.push_str(hash);
413            buf.push('\n');
414        }
415        let source_hash = scc_core::fnv1a64_hex(buf.as_bytes());
416        let content_hash = self.graph_content_hash()?;
417        let extractor = format!(
418            "store:{};core:{}",
419            crate::SCHEMA_VERSION,
420            scc_core::SCHEMA_VERSION
421        );
422        let head: Option<GraphRevision> = self.revisions()?.into_iter().last();
423        if let Some(h) = head {
424            if h.source_hash == source_hash
425                && h.extractor_version == extractor
426                && h.semantic_config_hash == semantic_config_hash
427                && h.graph_content_hash == content_hash
428            {
429                return Ok(h);
430            }
431        }
432        self.record_revision(
433            &source_hash,
434            &extractor,
435            files.len() as u64,
436            semantic_config_hash,
437            &content_hash,
438        )
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::tests::tmp_store;
446
447    #[test]
448    // trace:v1 id=test.scc.store.history-records-and-diffs verifies=REQ-SI-503JSBGP exercises=impl.crates-scc-store-src-history.record-current-revision
449    fn revisions_record_intro_removal_and_diff() {
450        let (s, _d) = tmp_store();
451        let r1 = s.record_current_revision().unwrap();
452        assert_eq!(r1.rev, 1);
453        assert_eq!(r1.base_rev, 0);
454        assert!(!r1.source_hash.is_empty());
455        assert!(!r1.extractor_version.is_empty());
456        // content-identical rerun: no duplicate revision
457        let r1b = s.record_current_revision().unwrap();
458        assert_eq!(r1b.rev, 1);
459        // introduce an entity (with its file row, as indexing always does)
460        s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
461        s.insert_entity(
462            &Entity::new("repo://t/symbol/a.py/f", "symbol", "f"),
463            &["a.py".into()],
464        )
465        .unwrap();
466        let r2 = s.record_current_revision().unwrap();
467        assert_eq!((r2.rev, r2.base_rev), (2, 1));
468        let d = s.semantic_diff(1, 2).unwrap();
469        assert_eq!(d.added_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
470        assert!(d.removed_entities.is_empty());
471        assert!(!d.is_empty());
472        // historical view still sees rev-1 membership (empty of f)
473        let (e1, _) = s.revision_members(1).unwrap();
474        assert!(e1.is_empty());
475        let (e2, _) = s.revision_members(2).unwrap();
476        assert_eq!(e2.len(), 1);
477        // remove it again: removal history
478        s.delete_entity("repo://t/symbol/a.py/f").unwrap();
479        s.delete_file("a.py").unwrap();
480        let r3 = s.record_current_revision().unwrap();
481        assert_eq!(r3.rev, 3);
482        let d2 = s.semantic_diff(2, 3).unwrap();
483        assert_eq!(d2.removed_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
484        assert!(s.semantic_diff(3, 3).unwrap().is_empty());
485        // revision log is durable and ordered
486        let revs = s.revisions().unwrap();
487        assert_eq!(revs.len(), 3);
488        assert_eq!(revs[0].base_rev, 0);
489        // V2: same files, changed row content (confidence bump) advances
490        // the history and diffs as modified, not added/removed.
491        s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
492        s.insert_entity(
493            &Entity::new("repo://t/symbol/a.py/g", "symbol", "g"),
494            &["a.py".into()],
495        )
496        .unwrap();
497        let r4 = s.record_current_revision().unwrap();
498        assert_eq!(r4.rev, 4);
499        let mut g = Entity::new("repo://t/symbol/a.py/g", "symbol", "g");
500        g.attr("confidence", serde_json::json!(0.77));
501        s.insert_entity(&g, &["a.py".into()]).unwrap();
502        let r5 = s.record_current_revision().unwrap();
503        assert_eq!(r5.rev, 5, "content change without file change must advance");
504        assert_ne!(r5.graph_content_hash, r4.graph_content_hash);
505        let d4 = s.semantic_diff(4, 5).unwrap();
506        assert!(d4.added_entities.is_empty() && d4.removed_entities.is_empty());
507        assert_eq!(d4.modified_entities, vec!["repo://t/symbol/a.py/g".to_string()]);
508        assert_eq!(d4.modified_kinds.get("symbol"), Some(&1));
509        assert!(s.semantic_diff(5, 5).unwrap().is_empty());
510        // V2: config axis — same content, changed config hash advances.
511        let r6 = s.record_current_revision_with_config("cfg2").unwrap();
512        assert_eq!(r6.rev, 6);
513        assert_eq!(r6.semantic_config_hash, "cfg2");
514        let r6b = s.record_current_revision_with_config("cfg2").unwrap();
515        assert_eq!(r6b.rev, 6, "fully-identical rerun dedups");
516    }
517}