1use crate::{projection_storage, MemoryError, MemoryStore};
2
3impl MemoryStore {
4 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}