Skip to main content

semantic_memory/
projection_derivation.rs

1use crate::{projection_storage, MemoryError, MemoryStore};
2
3impl MemoryStore {
4    /// Invalidate derivation edges matching a trigger mode, bounded by source artifact.
5    ///
6    /// Returns the number of edges invalidated. This enables bounded recomputation:
7    /// only derived artifacts downstream of the specified source are affected.
8    pub async fn invalidate_derivations(
9        &self,
10        source_kind: &str,
11        source_id: &str,
12        trigger_mode: &str,
13        reason: &str,
14    ) -> Result<usize, MemoryError> {
15        let sk = source_kind.to_string();
16        let si = source_id.to_string();
17        let tm = trigger_mode.to_string();
18        let r = reason.to_string();
19        self.with_write_conn(move |conn| {
20            projection_storage::invalidate_derivation_edges(conn, &sk, &si, &tm, &r)
21        })
22        .await
23    }
24}
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29    use crate::{MemoryConfig, MockEmbedder};
30    use forge_memory_bridge::PROJECTION_IMPORT_BATCH_V1_SCHEMA;
31    use tempfile::TempDir;
32
33    fn test_store() -> (MemoryStore, TempDir) {
34        let dir = TempDir::new().unwrap();
35        let config = MemoryConfig {
36            base_dir: dir.path().to_path_buf(),
37            ..Default::default()
38        };
39        let store =
40            MemoryStore::open_with_embedder(config, Box::new(MockEmbedder::new(768))).unwrap();
41        (store, dir)
42    }
43
44    fn derivation_rich_batch() -> String {
45        serde_json::json!({
46            "source_envelope_id": "envelope:env-derivation",
47            "schema_version": PROJECTION_IMPORT_BATCH_V1_SCHEMA,
48            "export_schema_version": "export_envelope_v1",
49            "content_digest": "digest-derivation",
50            "source_authority": "forge",
51            "scope_key": { "namespace": "test-ns" },
52            "source_exported_at": "2026-03-07T00:00:00Z",
53            "transformed_at": "2026-03-07T00:00:01Z",
54            "records": [
55                {
56                    "kind": "claim_version",
57                    "claim_id": "claim:claim-1",
58                    "claim_version_id": "claim-version:claim-1-v1",
59                    "scope_key": { "namespace": "test-ns" },
60                    "claim_state": "active",
61                    "projection_family": "forge_verification",
62                    "subject_entity_id": "entity:ent-1",
63                    "predicate": "has_type",
64                    "object_anchor": "function",
65                    "valid_from": "2026-01-01T00:00:00Z",
66                    "valid_to": null,
67                    "preferred_open": true,
68                    "source_envelope_id": "envelope:env-derivation",
69                    "source_authority": "forge",
70                    "freshness": "current",
71                    "contradiction_status": "none",
72                    "supersedes_claim_version_id": "claim-version:claim-1-v0",
73                    "content": "Entity ent-1 is a function",
74                    "confidence": 0.95
75                },
76                {
77                    "kind": "claim_version",
78                    "claim_id": "claim:claim-2",
79                    "claim_version_id": "claim-version:claim-2-v1",
80                    "claim_state": "active",
81                    "projection_family": "forge_verification",
82                    "subject_entity_id": "entity:ent-2",
83                    "predicate": "depends_on",
84                    "object_anchor": "ent-3",
85                    "scope_key": { "namespace": "test-ns" },
86                    "valid_from": "2026-01-01T00:00:00Z",
87                    "valid_to": null,
88                    "preferred_open": true,
89                    "freshness": "current",
90                    "contradiction_status": "none",
91                    "content": "Entity ent-2 depends on ent-3",
92                    "confidence": 0.9
93                },
94                {
95                    "kind": "relation_version",
96                    "relation_version_id": "relation-version:rel-1-v1",
97                    "scope_key": { "namespace": "test-ns" },
98                    "subject_entity_id": "entity:ent-1",
99                    "predicate": "depends_on",
100                    "object_anchor": "ent-2",
101                    "preferred_open": true,
102                    "claim_id": "claim:claim-1",
103                    "source_episode_id": "episode:episode-1",
104                    "supersedes_relation_version_id": "relation-version:rel-1-v0",
105                    "source_confidence": 0.84,
106                    "projection_family": "forge_verification",
107                    "freshness": "current",
108                    "contradiction_status": "none",
109                },
110                {
111                    "kind": "episode",
112                    "episode_id": "episode:episode-1",
113                    "scope_key": { "namespace": "test-ns" },
114                    "document_id": "doc-1",
115                    "cause_ids": ["claim:claim-1", "claim:claim-2"],
116                    "effect_type": "code_change",
117                    "outcome": "success",
118                    "confidence": 0.88
119                },
120                {
121                    "kind": "entity_alias",
122                    "canonical_entity_id": "entity:ent-1",
123                    "alias_text": "Entity One",
124                    "alias_source": "forge_extraction",
125                    "confidence": 0.9,
126                    "merge_decision": { "automated": { "algorithm": "bridge_default" } },
127                    "scope": { "namespace": "test-ns" },
128                    "review_state": "unreviewed",
129                    "is_human_confirmed": false,
130                    "is_human_confirmed_final": false,
131                    "superseded_by_entity_id": "entity:ent-old",
132                    "split_from_entity_id": "entity:ent-split"
133                },
134                {
135                    "kind": "evidence_ref",
136                    "claim_id": "claim:claim-1",
137                    "claim_version_id": "claim-version:claim-1-v1",
138                    "fetch_handle": "forge://evidence/claim-1/v1",
139                    "source_authority": "forge"
140                },
141                {
142                    "kind": "evidence_ref",
143                    "claim_id": "claim:claim-2",
144                    "fetch_handle": "forge://evidence/claim-2",
145                    "source_authority": "forge"
146                }
147            ]
148        })
149        .to_string()
150    }
151
152    fn edge_exists(
153        edges: &[projection_storage::DerivationEdgeRow],
154        target_kind: &str,
155        target_id: &str,
156        derivation_type: &str,
157        invalidation_mode: &str,
158    ) -> bool {
159        edges.iter().any(|edge| {
160            edge.target_kind == target_kind
161                && edge.target_id == target_id
162                && edge.derivation_type == derivation_type
163                && edge.invalidation_mode == invalidation_mode
164        })
165    }
166
167    #[tokio::test]
168    async fn import_projection_batch_inserts_broad_derivation_edges() {
169        let (store, _dir) = test_store();
170
171        store
172            .import_projection_batch_json_compat(&derivation_rich_batch())
173            .await
174            .unwrap();
175
176        let claim_1_edges = store
177            .with_read_conn(|conn| {
178                projection_storage::query_derivation_edges_by_source(conn, "claim", "claim:claim-1")
179            })
180            .await
181            .unwrap();
182
183        assert!(edge_exists(
184            &claim_1_edges,
185            "claim_version",
186            "claim-version:claim-1-v1",
187            "claim_version_of",
188            "on_source_change",
189        ));
190        let claim_1_claim_version_edges = store
191            .with_read_conn(|conn| {
192                projection_storage::query_derivation_edges_by_source(
193                    conn,
194                    "claim_version",
195                    "claim-version:claim-1-v0",
196                )
197            })
198            .await
199            .unwrap();
200        assert!(edge_exists(
201            &claim_1_claim_version_edges,
202            "claim_version",
203            "claim-version:claim-1-v1",
204            "supersedes",
205            "on_supersession",
206        ));
207        assert!(edge_exists(
208            &claim_1_edges,
209            "relation_version",
210            "relation-version:rel-1-v1",
211            "supports_claim",
212            "on_source_change",
213        ));
214        assert!(edge_exists(
215            &claim_1_edges,
216            "evidence_ref",
217            "forge://evidence/claim-1/v1",
218            "supports",
219            "on_source_change",
220        ));
221        assert!(edge_exists(
222            &claim_1_edges,
223            "episode",
224            "episode:episode-1",
225            "caused_by_claim",
226            "on_source_change",
227        ));
228
229        let claim_2_edges = store
230            .with_read_conn(|conn| {
231                projection_storage::query_derivation_edges_by_source(conn, "claim", "claim:claim-2")
232            })
233            .await
234            .unwrap();
235        assert!(edge_exists(
236            &claim_2_edges,
237            "evidence_ref",
238            "forge://evidence/claim-2",
239            "supports",
240            "on_source_change",
241        ));
242
243        let relation_supersession_edges = store
244            .with_read_conn(|conn| {
245                projection_storage::query_derivation_edges_by_source(
246                    conn,
247                    "relation_version",
248                    "relation-version:rel-1-v0",
249                )
250            })
251            .await
252            .unwrap();
253        assert!(edge_exists(
254            &relation_supersession_edges,
255            "relation_version",
256            "relation-version:rel-1-v1",
257            "supersedes",
258            "on_supersession",
259        ));
260
261        let entity_edges = store
262            .with_read_conn(|conn| {
263                projection_storage::query_derivation_edges_by_source(conn, "entity", "entity:ent-1")
264            })
265            .await
266            .unwrap();
267        assert!(edge_exists(
268            &entity_edges,
269            "entity_alias",
270            "entity:ent-1",
271            "canonical_alias",
272            "on_alias_split",
273        ));
274
275        let split_entity_edges = store
276            .with_read_conn(|conn| {
277                projection_storage::query_derivation_edges_by_source(
278                    conn,
279                    "entity",
280                    "entity:ent-split",
281                )
282            })
283            .await
284            .unwrap();
285        assert!(edge_exists(
286            &split_entity_edges,
287            "entity_alias",
288            "entity:ent-1",
289            "alias_split_from",
290            "on_alias_split",
291        ));
292
293        let superseded_by_entity_edges = store
294            .with_read_conn(|conn| {
295                projection_storage::query_derivation_edges_by_source(
296                    conn,
297                    "entity",
298                    "entity:ent-old",
299                )
300            })
301            .await
302            .unwrap();
303        assert!(edge_exists(
304            &superseded_by_entity_edges,
305            "entity_alias",
306            "entity:ent-1",
307            "alias_superseded_by",
308            "on_supersession",
309        ));
310    }
311
312    #[tokio::test]
313    async fn bounded_invalidation_targets_expected_derived_rows() {
314        let (store, _dir) = test_store();
315        store
316            .import_projection_batch_json_compat(&derivation_rich_batch())
317            .await
318            .unwrap();
319
320        let invalidated_before = store
321            .with_read_conn(|conn| projection_storage::list_invalidated_targets(conn, 100))
322            .await
323            .unwrap();
324        assert!(invalidated_before.is_empty());
325
326        let invalidated_count = store
327            .invalidate_derivations("claim", "claim:claim-1", "on_source_change", "test")
328            .await
329            .unwrap();
330        assert!(invalidated_count > 0);
331
332        let invalidated_after = store
333            .with_read_conn(|conn| projection_storage::list_invalidated_targets(conn, 100))
334            .await
335            .unwrap();
336
337        assert_eq!(invalidated_after.len(), invalidated_count);
338        assert!(invalidated_after
339            .iter()
340            .all(|edge| edge.source_id == "claim:claim-1"));
341        assert!(invalidated_after
342            .iter()
343            .any(|edge| edge.target_kind == "claim_version"
344                && edge.target_id == "claim-version:claim-1-v1"));
345        assert!(invalidated_after
346            .iter()
347            .any(|edge| edge.target_kind == "relation_version"
348                && edge.target_id == "relation-version:rel-1-v1"));
349        assert!(invalidated_after
350            .iter()
351            .any(|edge| edge.target_kind == "evidence_ref"
352                && edge.target_id == "forge://evidence/claim-1/v1"));
353    }
354}