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": "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-1",
58                    "claim_version_id": "claim-1-v1",
59                    "scope_key": { "namespace": "test-ns" },
60                    "claim_state": "active",
61                    "projection_family": "forge_verification",
62                    "subject_entity_id": "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": "env-derivation",
69                    "source_authority": "forge",
70                    "freshness": "current",
71                    "contradiction_status": "none",
72                    "supersedes_claim_version_id": "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-2",
79                    "claim_version_id": "claim-2-v1",
80                    "claim_state": "active",
81                    "projection_family": "forge_verification",
82                    "subject_entity_id": "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": "rel-1-v1",
97                    "scope_key": { "namespace": "test-ns" },
98                    "subject_entity_id": "ent-1",
99                    "predicate": "depends_on",
100                    "object_anchor": "ent-2",
101                    "preferred_open": true,
102                    "claim_id": "claim-1",
103                    "source_episode_id": "episode-1",
104                    "supersedes_relation_version_id": "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-1",
113                    "scope_key": { "namespace": "test-ns" },
114                    "document_id": "doc-1",
115                    "cause_ids": ["claim-1", "claim-2"],
116                    "effect_type": "code_change",
117                    "outcome": "success",
118                    "confidence": 0.88
119                },
120                {
121                    "kind": "entity_alias",
122                    "canonical_entity_id": "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": "ent-old",
132                    "split_from_entity_id": "ent-split"
133                },
134                {
135                    "kind": "evidence_ref",
136                    "claim_id": "claim-1",
137                    "claim_version_id": "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-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-1")
179            })
180            .await
181            .unwrap();
182
183        assert!(edge_exists(
184            &claim_1_edges,
185            "claim_version",
186            "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-1-v0",
196                )
197            })
198            .await
199            .unwrap();
200        assert!(edge_exists(
201            &claim_1_claim_version_edges,
202            "claim_version",
203            "claim-1-v1",
204            "supersedes",
205            "on_supersession",
206        ));
207        assert!(edge_exists(
208            &claim_1_edges,
209            "relation_version",
210            "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-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-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                    "rel-1-v0",
249                )
250            })
251            .await
252            .unwrap();
253        assert!(edge_exists(
254            &relation_supersession_edges,
255            "relation_version",
256            "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", "ent-1")
264            })
265            .await
266            .unwrap();
267        assert!(edge_exists(
268            &entity_edges,
269            "entity_alias",
270            "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(conn, "entity", "ent-split")
278            })
279            .await
280            .unwrap();
281        assert!(edge_exists(
282            &split_entity_edges,
283            "entity_alias",
284            "ent-1",
285            "alias_split_from",
286            "on_alias_split",
287        ));
288
289        let superseded_by_entity_edges = store
290            .with_read_conn(|conn| {
291                projection_storage::query_derivation_edges_by_source(conn, "entity", "ent-old")
292            })
293            .await
294            .unwrap();
295        assert!(edge_exists(
296            &superseded_by_entity_edges,
297            "entity_alias",
298            "ent-1",
299            "alias_superseded_by",
300            "on_supersession",
301        ));
302    }
303
304    #[tokio::test]
305    async fn bounded_invalidation_targets_expected_derived_rows() {
306        let (store, _dir) = test_store();
307        store
308            .import_projection_batch_json_compat(&derivation_rich_batch())
309            .await
310            .unwrap();
311
312        let invalidated_before = store
313            .with_read_conn(|conn| projection_storage::list_invalidated_targets(conn, 100))
314            .await
315            .unwrap();
316        assert!(invalidated_before.is_empty());
317
318        let invalidated_count = store
319            .invalidate_derivations("claim", "claim-1", "on_source_change", "test")
320            .await
321            .unwrap();
322        assert!(invalidated_count > 0);
323
324        let invalidated_after = store
325            .with_read_conn(|conn| projection_storage::list_invalidated_targets(conn, 100))
326            .await
327            .unwrap();
328
329        assert_eq!(invalidated_after.len(), invalidated_count);
330        assert!(invalidated_after
331            .iter()
332            .all(|edge| edge.source_id == "claim-1"));
333        assert!(invalidated_after
334            .iter()
335            .any(|edge| edge.target_kind == "claim_version" && edge.target_id == "claim-1-v1"));
336        assert!(invalidated_after
337            .iter()
338            .any(|edge| edge.target_kind == "relation_version" && edge.target_id == "rel-1-v1"));
339        assert!(invalidated_after
340            .iter()
341            .any(|edge| edge.target_kind == "evidence_ref"
342                && edge.target_id == "forge://evidence/claim-1/v1"));
343    }
344}