Skip to main content

scc_store/
snapshot.rs

1//! Durable context snapshots (mission §XXII): checkpoints of RENDERED agent
2//! knowledge — what was actually shown, not all candidates considered.
3//!
4//! Complements [`ContextLedger`](crate) novelty semantics (which the
5//! snapshot never weakens): the ledger answers what is new *now*; a
6//! snapshot pins what was visible *then* so later model changes can be
7//! diffed (still-valid vs invalidated facts, §XXIII).
8
9use crate::{Store, StoreError};
10use rusqlite::params;
11use serde::{Deserialize, Serialize};
12
13/// Row fingerprint: deterministic content hash of one entity or
14/// relationship row (attributes, confidence, provenance, evidence).
15// trace:exempt reason=internal-detail
16fn fingerprint_row<T: serde::Serialize>(row: &T) -> String {
17    scc_core::fnv1a64_hex(serde_json::to_string(row).unwrap_or_default().as_bytes())
18}
19
20/// A durable checkpoint of one rendered artifact.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22// trace:v1 id=impl.crates-scc-store-src-snapshot.context-snapshot work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
23pub struct ContextSnapshot {
24    /// Deterministic id: `snap-{revision}-{fnv(task+epoch)}`.
25    pub id: String,
26    /// Creation time (RFC3339).
27    pub created_at: String,
28    /// Task the artifact was rendered for.
29    pub task: String,
30    /// Active compiled-view identity at render time (not a source revision).
31    pub epoch: String,
32    /// Persistent history position at render time.
33    pub revision: i64,
34    /// Rendered artifact text (visible knowledge only).
35    pub artifact: String,
36    /// Content hash of the artifact text: detects re-render drift even
37    /// when every entity id survives.
38    pub artifact_hash: String,
39    /// Canonical entity ids visible in the artifact.
40    pub entity_ids: Vec<String>,
41    /// Fingerprints of the visible entities at save time (id -> row hash):
42    /// modification detection, not just presence.
43    pub entity_fp: std::collections::BTreeMap<String, String>,
44    /// Fingerprints of live relationships touching the visible set.
45    pub rel_fp: std::collections::BTreeMap<String, String>,
46    /// Fingerprints of contract-like entities (CONTRACT, ROUTE, TOPIC,
47    /// CONFIGURATION kinds) at save time.
48    pub contract_fp: std::collections::BTreeMap<String, String>,
49    /// Fingerprints of state entities (DATA_STORE, DATA_ENTITY kinds).
50    pub state_fp: std::collections::BTreeMap<String, String>,
51    /// Flow graph names at save time (sorted set-compare).
52    pub flow_names: Vec<String>,
53    /// Budget the artifact was rendered under.
54    pub budget: usize,
55    /// Warnings surfaced with the artifact.
56    pub warnings: Vec<String>,
57}
58
59/// Snapshot vs current-model comparison (§XXIII).
60#[derive(Debug, Clone, Serialize, Deserialize)]
61// trace:v1 id=impl.crates-scc-store-src-snapshot.snapshot-diff work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
62pub struct SnapshotDiff {
63    /// Snapshot facts present AND unmodified in the current graph.
64    pub still_valid: Vec<String>,
65    /// Snapshot facts gone from the current graph.
66    pub invalidated: Vec<String>,
67    /// Snapshot facts present but content-changed (attributes,
68    /// confidence, provenance). "Still exists" is NOT "still valid".
69    pub modified_entities: Vec<String>,
70    /// Visible-touching relationships added/removed/changed (ids).
71    pub changed_relationships: Vec<String>,
72    /// Contract-like entities added/removed/modified (ids).
73    pub changed_contracts: Vec<String>,
74    /// State entities added/removed/modified (ids).
75    pub changed_state: Vec<String>,
76    /// Flow graph names added/removed.
77    pub changed_flows: Vec<String>,
78    /// The rendered artifact text itself would differ on re-render.
79    pub artifact_changed: bool,
80    /// Revision the snapshot was taken at.
81    pub snapshot_revision: i64,
82    /// Current head revision (0 when history was never recorded).
83    pub current_revision: i64,
84}
85
86/// Parameters for [`Store::save_snapshot`] (kept as one struct so the
87/// seven-field render call stays under the complexity budget).
88// trace:exempt reason=internal-detail
89pub struct SnapshotSave<'a> {
90    /// Task the artifact was rendered for.
91    pub task: &'a str,
92    /// Active compiled-view identity at render time.
93    pub epoch: &'a str,
94    /// Persistent history position at render time.
95    pub revision: i64,
96    /// Rendered artifact text (visible knowledge only).
97    pub artifact: &'a str,
98    /// Canonical entity ids visible in the artifact.
99    pub entity_ids: &'a [String],
100    /// Budget the artifact was rendered under.
101    pub budget: usize,
102    /// Warnings surfaced with the artifact.
103    pub warnings: &'a [String],
104}
105
106// trace:exempt reason=internal-detail
107impl Store {
108    /// Persist a rendered artifact as a durable checkpoint. Idempotent:
109    /// same (task, epoch) resolves to the same id and replaces it.
110    // trace:v1 id=impl.crates-scc-store-src-snapshot.save-snapshot work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
111    pub fn save_snapshot(&self, save: SnapshotSave<'_>) -> Result<ContextSnapshot, StoreError> {
112        let hex =
113            scc_core::fnv1a64_hex(format!("{}\0{}", save.task, save.epoch).as_bytes());
114        // Fingerprints resolve against the LIVE model at save time: the
115        // snapshot pins content, not just ids. Relationships fingerprinted
116        // are those touching the visible set (visible knowledge only).
117        let visible: std::collections::BTreeSet<&str> =
118            save.entity_ids.iter().map(|s| s.as_str()).collect();
119        let mut entity_fp = std::collections::BTreeMap::new();
120        let mut contract_fp = std::collections::BTreeMap::new();
121        let mut state_fp = std::collections::BTreeMap::new();
122        for e in self.all_entities()? {
123            let fp = fingerprint_row(&e);
124            if visible.contains(e.id.as_str()) {
125                entity_fp.insert(e.id.clone(), fp.clone());
126            }
127            match e.kind.as_str() {
128                k if k == scc_core::kinds::CONTRACT
129                    || k == scc_core::kinds::ROUTE
130                    || k == scc_core::kinds::TOPIC
131                    || k == scc_core::kinds::CONFIGURATION =>
132                {
133                    contract_fp.insert(e.id.clone(), fp);
134                }
135                k if k == scc_core::kinds::DATA_STORE || k == scc_core::kinds::DATA_ENTITY => {
136                    state_fp.insert(e.id.clone(), fp);
137                }
138                _ => {}
139            }
140        }
141        let mut rel_fp = std::collections::BTreeMap::new();
142        for r in self.all_relationships()? {
143            if visible.contains(r.subject.as_str()) || visible.contains(r.object.as_str()) {
144                rel_fp.insert(r.id.clone(), fingerprint_row(&r));
145            }
146        }
147        let mut flow_names: Vec<String> = self
148            .flow_graphs()
149            .unwrap_or_default()
150            .into_iter()
151            .map(|g| g.name)
152            .collect();
153        flow_names.sort();
154        let snap = ContextSnapshot {
155            id: format!("snap-{}-{hex}", save.revision),
156            created_at: scc_core::now_rfc3339(),
157            task: save.task.to_string(),
158            epoch: save.epoch.to_string(),
159            revision: save.revision,
160            artifact: save.artifact.to_string(),
161            artifact_hash: scc_core::fnv1a64_hex(save.artifact.as_bytes()),
162            entity_ids: save.entity_ids.to_vec(),
163            entity_fp,
164            rel_fp,
165            contract_fp,
166            state_fp,
167            flow_names,
168            budget: save.budget,
169            warnings: save.warnings.to_vec(),
170        };
171        self.conn.execute(
172            "INSERT INTO context_snapshots
173             (id, created_at, task, epoch, revision, artifact, artifact_hash,
174              entity_ids, entity_fp, rel_fp, contract_fp, state_fp, flow_names,
175              budget, warnings)
176             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
177             ON CONFLICT(id) DO UPDATE SET
178               created_at = excluded.created_at, artifact = excluded.artifact,
179               artifact_hash = excluded.artifact_hash,
180               entity_ids = excluded.entity_ids, entity_fp = excluded.entity_fp,
181               rel_fp = excluded.rel_fp, contract_fp = excluded.contract_fp,
182               state_fp = excluded.state_fp, flow_names = excluded.flow_names,
183               budget = excluded.budget, warnings = excluded.warnings",
184            params![
185                snap.id,
186                snap.created_at,
187                snap.task,
188                snap.epoch,
189                snap.revision,
190                snap.artifact,
191                snap.artifact_hash,
192                serde_json::to_string(&snap.entity_ids).unwrap_or_default(),
193                serde_json::to_string(&snap.entity_fp).unwrap_or_default(),
194                serde_json::to_string(&snap.rel_fp).unwrap_or_default(),
195                serde_json::to_string(&snap.contract_fp).unwrap_or_default(),
196                serde_json::to_string(&snap.state_fp).unwrap_or_default(),
197                serde_json::to_string(&snap.flow_names).unwrap_or_default(),
198                snap.budget as i64,
199                serde_json::to_string(&snap.warnings).unwrap_or_default(),
200            ],
201        )?;
202        Ok(snap)
203    }
204
205    /// Load a snapshot by id. Survives later model changes: the row is
206    /// never mutated by indexing, only replaced by an explicit re-save.
207    // trace:v1 id=impl.crates-scc-store-src-snapshot.load-snapshot work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
208    pub fn load_snapshot(&self, id: &str) -> Result<Option<ContextSnapshot>, StoreError> {
209        let mut stmt = self.conn.prepare(
210            "SELECT id, created_at, task, epoch, revision, artifact, artifact_hash,
211                    entity_ids, entity_fp, rel_fp, contract_fp, state_fp, flow_names,
212                    budget, warnings
213             FROM context_snapshots WHERE id = ?1",
214        )?;
215        let js = |r: &rusqlite::Row, i: usize| -> String { r.get(i).unwrap_or_default() };
216        let mut rows = stmt.query_map(params![id], |r| {
217            Ok(ContextSnapshot {
218                id: r.get(0)?,
219                created_at: r.get(1)?,
220                task: r.get(2)?,
221                epoch: r.get(3)?,
222                revision: r.get(4)?,
223                artifact: r.get(5)?,
224                artifact_hash: r.get(6)?,
225                entity_ids: serde_json::from_str(&js(r, 7)).unwrap_or_default(),
226                entity_fp: serde_json::from_str(&js(r, 8)).unwrap_or_default(),
227                rel_fp: serde_json::from_str(&js(r, 9)).unwrap_or_default(),
228                contract_fp: serde_json::from_str(&js(r, 10)).unwrap_or_default(),
229                state_fp: serde_json::from_str(&js(r, 11)).unwrap_or_default(),
230                flow_names: serde_json::from_str(&js(r, 12)).unwrap_or_default(),
231                budget: r.get::<_, i64>(13)? as usize,
232                warnings: serde_json::from_str(&js(r, 14)).unwrap_or_default(),
233            })
234        })?;
235        match rows.next() {
236            Some(r) => Ok(Some(r?)),
237            None => Ok(None),
238        }
239    }
240
241    /// Diff a snapshot against the current graph: presence AND content.
242    /// A surviving id with a changed row is `modified`, not `still_valid`.
243    /// Relationships touching the visible set, contract/state entities, flow
244    /// names, and the artifact hash are compared too. Newly-relevant
245    /// knowledge needs a re-render (resume), which this diff does not invent.
246    // trace:v1 id=impl.crates-scc-store-src-snapshot.diff-snapshot work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
247    pub fn diff_snapshot(&self, id: &str) -> Result<Option<SnapshotDiff>, StoreError> {
248        let Some(snap) = self.load_snapshot(id)? else {
249            return Ok(None);
250        };
251        let mut live_fp = std::collections::BTreeMap::new();
252        for e in self.all_entities()? {
253            live_fp.insert(e.id.clone(), fingerprint_row(&e));
254        }
255        let mut still_valid = Vec::new();
256        let mut invalidated = Vec::new();
257        let mut modified_entities = Vec::new();
258        for eid in &snap.entity_ids {
259            match live_fp.get(eid) {
260                None => invalidated.push(eid.clone()),
261                Some(fp) => {
262                    if snap.entity_fp.get(eid).map(|s| s == fp).unwrap_or(true) {
263                        still_valid.push(eid.clone());
264                    } else {
265                        modified_entities.push(eid.clone());
266                    }
267                }
268            }
269        }
270        // Relationships touching the visible set: added/removed/changed.
271        let visible: std::collections::BTreeSet<&str> =
272            snap.entity_ids.iter().map(|s| s.as_str()).collect();
273        let mut live_rel_fp = std::collections::BTreeMap::new();
274        for r in self.all_relationships()? {
275            if visible.contains(r.subject.as_str()) || visible.contains(r.object.as_str()) {
276                live_rel_fp.insert(r.id.clone(), fingerprint_row(&r));
277            }
278        }
279        let mut changed_relationships: Vec<String> = live_rel_fp
280            .iter()
281            .filter(|(k, v)| snap.rel_fp.get(*k) != Some(*v))
282            .map(|(k, _)| k.clone())
283            .chain(
284                snap.rel_fp
285                    .keys()
286                    .filter(|k| !live_rel_fp.contains_key(*k))
287                    .cloned(),
288            )
289            .collect();
290        changed_relationships.sort();
291        // Contract-like and state entities: fingerprint maps, same treatment.
292        let fp_diff = |old: &std::collections::BTreeMap<String, String>| -> Vec<String> {
293            let mut live = std::collections::BTreeMap::new();
294            for e in self.all_entities().unwrap_or_default() {
295                live.insert(e.id.clone(), fingerprint_row(&e));
296            }
297            let mut out: Vec<String> = live
298                .iter()
299                .filter(|(k, v)| old.get(*k) != Some(*v))
300                .map(|(k, _)| k.clone())
301                .chain(old.keys().filter(|k| !live.contains_key(*k)).cloned())
302                .collect();
303            out.sort();
304            out
305        };
306        // Restrict the generic entity sweep to the saved category maps:
307        // an id counts as a contract/state change only if it was (or is) one.
308        let mut live_kind: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
309        for e in self.all_entities().unwrap_or_default() {
310            live_kind.insert(e.id.clone(), e.kind.clone());
311        }
312        let changed_contracts: Vec<String> = fp_diff(&snap.contract_fp)
313            .into_iter()
314            .filter(|id| {
315                snap.contract_fp.contains_key(id)
316                    || live_kind.get(id).map(|k| {
317                        k == scc_core::kinds::CONTRACT
318                            || k == scc_core::kinds::ROUTE
319                            || k == scc_core::kinds::TOPIC
320                            || k == scc_core::kinds::CONFIGURATION
321                    }).unwrap_or(false)
322            })
323            .collect();
324        let changed_state: Vec<String> = fp_diff(&snap.state_fp)
325            .into_iter()
326            .filter(|id| {
327                snap.state_fp.contains_key(id)
328                    || live_kind.get(id).map(|k| {
329                        k == scc_core::kinds::DATA_STORE || k == scc_core::kinds::DATA_ENTITY
330                    }).unwrap_or(false)
331            })
332            .collect();
333        let mut live_flows: Vec<String> = self
334            .flow_graphs()
335            .unwrap_or_default()
336            .into_iter()
337            .map(|g| g.name)
338            .collect();
339        live_flows.sort();
340        let live_flow_set: std::collections::BTreeSet<&str> =
341            live_flows.iter().map(|s| s.as_str()).collect();
342        let saved_flow_set: std::collections::BTreeSet<&str> =
343            snap.flow_names.iter().map(|s| s.as_str()).collect();
344        let mut changed_flows: Vec<String> = live_flow_set
345            .symmetric_difference(&saved_flow_set)
346            .map(|s| s.to_string())
347            .collect();
348        changed_flows.sort();
349        let current_revision: i64 = self
350            .conn
351            .query_row("SELECT COALESCE(MAX(rev), 0) FROM graph_revisions", [], |r| {
352                r.get(0)
353            })
354            .unwrap_or(0);
355        // Artifact drift: the artifact is a pure function of visible
356        // entity rows + touching rels, so any change on those axes means a
357        // re-render would differ. (The stored artifact_hash pins the exact
358        // text for external comparison; this verdict is the live signal.)
359        let artifact_changed = !snap.artifact.is_empty()
360            && (!invalidated.is_empty()
361                || !modified_entities.is_empty()
362                || !changed_relationships.is_empty());
363        Ok(Some(SnapshotDiff {
364            still_valid,
365            invalidated,
366            modified_entities,
367            changed_relationships,
368            changed_contracts,
369            changed_state,
370            changed_flows,
371            artifact_changed,
372            snapshot_revision: snap.revision,
373            current_revision,
374        }))
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::{tests::tmp_store, Entity};
382
383    #[test]
384    // trace:v1 id=test.scc.store.snapshot-save-diff verifies=REQ-SI-503JSBGP exercises=impl.crates-scc-store-src-snapshot.save-snapshot
385    fn snapshots_persist_diff_and_survive() {
386        let (s, _d) = tmp_store();
387        s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
388        for id in ["repo://t/symbol/a.py/f", "repo://t/symbol/a.py/g"] {
389            s.insert_entity(&Entity::new(id, "symbol", "f"), &["a.py".into()])
390                .unwrap();
391        }
392        let r1 = s.record_current_revision().unwrap();
393        let epoch = "epoch:test";
394        let snap = s
395            .save_snapshot(SnapshotSave {
396                task: "do the thing",
397                epoch,
398                revision: r1.rev,
399                artifact: "pack showing f and g",
400                entity_ids: &[
401                    "repo://t/symbol/a.py/f".into(),
402                    "repo://t/symbol/a.py/g".into(),
403                ],
404                budget: 100,
405                warnings: &[],
406            })
407            .unwrap();
408        assert_eq!(snap.revision, r1.rev);
409        assert_eq!(snap.epoch, epoch);
410        // idempotent re-save of the same render
411        let snap2 = s
412            .save_snapshot(SnapshotSave {
413                task: "do the thing",
414                epoch,
415                revision: r1.rev,
416                artifact: "pack showing f and g",
417                entity_ids: &[
418                    "repo://t/symbol/a.py/f".into(),
419                    "repo://t/symbol/a.py/g".into(),
420                ],
421                budget: 100,
422                warnings: &[],
423            })
424            .unwrap();
425        assert_eq!(snap.id, snap2.id);
426        // resume: load returns the stored render
427        let loaded = s.load_snapshot(&snap.id).unwrap().unwrap();
428        assert_eq!(loaded.artifact, "pack showing f and g");
429        // everything valid while the model is unchanged
430        let d0 = s.diff_snapshot(&snap.id).unwrap().unwrap();
431        assert!(d0.invalidated.is_empty());
432        assert_eq!(d0.still_valid.len(), 2);
433        // model change invalidates exactly the removed fact
434        s.delete_entity("repo://t/symbol/a.py/g").unwrap();
435        s.upsert_file("a.py", "h2", "python", "source", 10).unwrap();
436        let _r2 = s.record_current_revision().unwrap();
437        let d1 = s.diff_snapshot(&snap.id).unwrap().unwrap();
438        assert_eq!(d1.still_valid, vec!["repo://t/symbol/a.py/f".to_string()]);
439        assert_eq!(d1.invalidated, vec!["repo://t/symbol/a.py/g".to_string()]);
440        assert_eq!(d1.snapshot_revision, r1.rev);
441        assert_eq!(d1.current_revision, r1.rev + 1);
442        // same id, changed row: modified, NOT still valid (the V1 blind spot)
443        let mut f2 = Entity::new("repo://t/symbol/a.py/f", "symbol", "f");
444        f2.attr("confidence", serde_json::json!(0.99));
445        s.insert_entity(&f2, &["a.py".into()]).unwrap();
446        let d2 = s.diff_snapshot(&snap.id).unwrap().unwrap();
447        assert!(d2.still_valid.is_empty(), "{d2:?}");
448        assert_eq!(d2.modified_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
449        assert!(d2.artifact_changed, "visible rows changed: re-render would differ");
450        // unknown id: honest None, not an error
451        assert!(s.diff_snapshot("snap-0-deadbeef").unwrap().is_none());
452    }
453}