1use anyhow::{bail, Context, Result};
2use rusqlite::{params, Connection};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum GraphNodeKind {
6 Memory,
7 Entity,
8 Fact,
9 Episode,
10 State,
11 Topic,
12 File,
13}
14
15impl GraphNodeKind {
16 pub const fn as_str(self) -> &'static str {
17 match self {
18 Self::Memory => "memory",
19 Self::Entity => "entity",
20 Self::Fact => "fact",
21 Self::Episode => "episode",
22 Self::State => "state",
23 Self::Topic => "topic",
24 Self::File => "file",
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct GraphNodeRef {
31 pub kind: GraphNodeKind,
32 pub id: i64,
33}
34
35impl GraphNodeRef {
36 pub fn new(kind: GraphNodeKind, id: i64) -> Result<Self> {
37 if id <= 0 {
38 bail!("graph node id must be positive");
39 }
40 Ok(Self { kind, id })
41 }
42
43 pub fn memory(id: i64) -> Result<Self> {
44 Self::new(GraphNodeKind::Memory, id)
45 }
46
47 pub fn entity(id: i64) -> Result<Self> {
48 Self::new(GraphNodeKind::Entity, id)
49 }
50
51 pub fn fact(id: i64) -> Result<Self> {
52 Self::new(GraphNodeKind::Fact, id)
53 }
54
55 pub fn episode(id: i64) -> Result<Self> {
56 Self::new(GraphNodeKind::Episode, id)
57 }
58
59 pub fn state(id: i64) -> Result<Self> {
60 Self::new(GraphNodeKind::State, id)
61 }
62
63 pub fn topic(id: i64) -> Result<Self> {
64 Self::new(GraphNodeKind::Topic, id)
65 }
66
67 pub fn file(id: i64) -> Result<Self> {
68 Self::new(GraphNodeKind::File, id)
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum GraphEdgeTrust {
74 Trusted,
75 DiagnosticHint,
76}
77
78impl GraphEdgeTrust {
79 pub const fn as_str(self) -> &'static str {
80 match self {
81 Self::Trusted => "trusted",
82 Self::DiagnosticHint => "diagnostic_hint",
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum GraphEdgeType {
89 Supersedes,
90 Duplicates,
91 Conflicts,
92 DerivedFrom,
93 MergedInto,
94 SplitFrom,
95 ExtractedFrom,
96 Mentions,
97 TouchesFile,
98 HasState,
99 HasTopic,
100 SimilarTo,
101 CandidateHint,
102 CoOccursWith,
103}
104
105impl GraphEdgeType {
106 pub const fn as_str(self) -> &'static str {
107 match self {
108 Self::Supersedes => "supersedes",
109 Self::Duplicates => "duplicates",
110 Self::Conflicts => "conflicts",
111 Self::DerivedFrom => "derived_from",
112 Self::MergedInto => "merged_into",
113 Self::SplitFrom => "split_from",
114 Self::ExtractedFrom => "extracted_from",
115 Self::Mentions => "mentions",
116 Self::TouchesFile => "touches_file",
117 Self::HasState => "has_state",
118 Self::HasTopic => "has_topic",
119 Self::SimilarTo => "similar_to",
120 Self::CandidateHint => "candidate_hint",
121 Self::CoOccursWith => "co_occurs_with",
122 }
123 }
124
125 pub const fn trust(self) -> GraphEdgeTrust {
126 match self {
127 Self::SimilarTo | Self::CandidateHint | Self::CoOccursWith => {
128 GraphEdgeTrust::DiagnosticHint
129 }
130 Self::Supersedes
131 | Self::Duplicates
132 | Self::Conflicts
133 | Self::DerivedFrom
134 | Self::MergedInto
135 | Self::SplitFrom
136 | Self::ExtractedFrom
137 | Self::Mentions
138 | Self::TouchesFile
139 | Self::HasState
140 | Self::HasTopic => GraphEdgeTrust::Trusted,
141 }
142 }
143
144 pub const fn allows_endpoints(self, from: GraphNodeKind, to: GraphNodeKind) -> bool {
145 match self {
146 Self::Supersedes
147 | Self::Duplicates
148 | Self::Conflicts
149 | Self::DerivedFrom
150 | Self::MergedInto
151 | Self::SplitFrom
152 | Self::SimilarTo
153 | Self::CandidateHint
154 | Self::CoOccursWith => same_graph_node_kind(from, to),
155 Self::ExtractedFrom => {
156 matches!(
157 from,
158 GraphNodeKind::Entity
159 | GraphNodeKind::Fact
160 | GraphNodeKind::State
161 | GraphNodeKind::Topic
162 ) && matches!(to, GraphNodeKind::Episode)
163 }
164 Self::Mentions => {
165 matches!(from, GraphNodeKind::Memory | GraphNodeKind::Episode)
166 && matches!(to, GraphNodeKind::Entity)
167 }
168 Self::TouchesFile => {
169 matches!(from, GraphNodeKind::Memory | GraphNodeKind::Episode)
170 && matches!(to, GraphNodeKind::File)
171 }
172 Self::HasState => {
173 matches!(from, GraphNodeKind::Memory) && matches!(to, GraphNodeKind::State)
174 }
175 Self::HasTopic => {
176 matches!(from, GraphNodeKind::Memory | GraphNodeKind::Episode)
177 && matches!(to, GraphNodeKind::Topic)
178 }
179 }
180 }
181}
182
183const fn same_graph_node_kind(left: GraphNodeKind, right: GraphNodeKind) -> bool {
184 matches!(
185 (left, right),
186 (GraphNodeKind::Memory, GraphNodeKind::Memory)
187 | (GraphNodeKind::Entity, GraphNodeKind::Entity)
188 | (GraphNodeKind::Fact, GraphNodeKind::Fact)
189 | (GraphNodeKind::Episode, GraphNodeKind::Episode)
190 | (GraphNodeKind::State, GraphNodeKind::State)
191 | (GraphNodeKind::Topic, GraphNodeKind::Topic)
192 | (GraphNodeKind::File, GraphNodeKind::File)
193 )
194}
195
196#[derive(Debug, Clone, Copy, Default, PartialEq)]
197pub struct GraphEdgeProvenance<'a> {
198 pub source_event_ids: &'a [i64],
199 pub source_candidate_id: Option<i64>,
200 pub source_operation_id: Option<i64>,
201 pub confidence: Option<f64>,
202 pub reason: Option<&'a str>,
203}
204
205#[derive(Debug, Clone, PartialEq)]
206pub struct GraphEdgeInput<'a> {
207 pub edge_type: GraphEdgeType,
208 pub from_node: GraphNodeRef,
209 pub to_node: GraphNodeRef,
210 pub provenance: GraphEdgeProvenance<'a>,
211 pub valid_from_epoch: Option<i64>,
212 pub valid_to_epoch: Option<i64>,
213}
214
215pub fn insert_graph_edge(conn: &Connection, input: &GraphEdgeInput<'_>) -> Result<i64> {
216 validate_graph_edge(conn, input)?;
217 let now = chrono::Utc::now().timestamp();
218 let source_event_ids = serde_json::to_string(input.provenance.source_event_ids)
219 .context("serialize graph edge source event ids")?;
220 conn.execute(
221 "INSERT INTO graph_edges
222 (edge_type, edge_trust, from_node_kind, from_node_id, to_node_kind, to_node_id,
223 source_event_ids, source_candidate_id, source_operation_id, confidence, reason,
224 valid_from_epoch, valid_to_epoch, created_at_epoch)
225 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
226 params![
227 input.edge_type.as_str(),
228 input.edge_type.trust().as_str(),
229 input.from_node.kind.as_str(),
230 input.from_node.id,
231 input.to_node.kind.as_str(),
232 input.to_node.id,
233 source_event_ids,
234 input.provenance.source_candidate_id,
235 input.provenance.source_operation_id,
236 input.provenance.confidence,
237 input.provenance.reason,
238 input.valid_from_epoch,
239 input.valid_to_epoch,
240 now
241 ],
242 )
243 .context("insert graph edge")?;
244 Ok(conn.last_insert_rowid())
245}
246
247fn validate_graph_edge(conn: &Connection, input: &GraphEdgeInput<'_>) -> Result<()> {
248 if input.from_node == input.to_node {
249 bail!("graph edge cannot link a node to itself");
250 }
251 if !input
252 .edge_type
253 .allows_endpoints(input.from_node.kind, input.to_node.kind)
254 {
255 bail!(
256 "graph edge type {} does not allow {} to {} endpoints",
257 input.edge_type.as_str(),
258 input.from_node.kind.as_str(),
259 input.to_node.kind.as_str()
260 );
261 }
262 if let (Some(valid_from), Some(valid_to)) = (input.valid_from_epoch, input.valid_to_epoch) {
263 if valid_to < valid_from {
264 bail!("graph edge valid_to_epoch cannot be before valid_from_epoch");
265 }
266 }
267 if let Some(confidence) = input.provenance.confidence {
268 if !(0.0..=1.0).contains(&confidence) {
269 bail!("graph edge confidence out of range");
270 }
271 }
272 if input.edge_type.trust() == GraphEdgeTrust::Trusted {
273 validate_trusted_provenance(conn, input.provenance)?;
274 }
275 Ok(())
276}
277
278fn validate_trusted_provenance(
279 conn: &Connection,
280 provenance: GraphEdgeProvenance<'_>,
281) -> Result<()> {
282 if provenance.source_event_ids.is_empty() {
283 bail!("trusted graph edge requires source event ids");
284 }
285 if provenance.source_event_ids.iter().any(|id| *id <= 0) {
286 bail!("trusted graph edge source event ids must be positive");
287 }
288 for event_id in provenance.source_event_ids {
289 let exists: bool = conn
290 .query_row(
291 "SELECT EXISTS(SELECT 1 FROM captured_events WHERE id = ?1)",
292 [event_id],
293 |row| row.get(0),
294 )
295 .with_context(|| format!("validate graph edge source event id {event_id}"))?;
296 if !exists {
297 bail!("trusted graph edge source event id {event_id} does not exist");
298 }
299 }
300 crate::memory::graph_provenance::validate_trusted_source_candidate(
301 conn,
302 provenance.source_candidate_id,
303 provenance.source_operation_id,
304 )?;
305 if provenance.confidence.is_none() {
306 bail!("trusted graph edge requires confidence");
307 }
308 let reason = provenance.reason.unwrap_or_default().trim();
309 if reason.is_empty() {
310 bail!("trusted graph edge requires reason");
311 }
312 Ok(())
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::memory::facts::{insert_temporal_fact, FactPredicate, TemporalFactInput};
319
320 struct GraphFixture {
321 conn: Connection,
322 memory_id: i64,
323 second_memory_id: i64,
324 entity_id: i64,
325 second_entity_id: i64,
326 fact_id: i64,
327 episode_id: i64,
328 state_id: i64,
329 topic_id: i64,
330 candidate_id: i64,
331 operation_id: i64,
332 }
333
334 fn fixture() -> Result<GraphFixture> {
335 let mut conn = Connection::open_in_memory()?;
336 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
337 crate::migrate::run_migrations(&conn)?;
338
339 let now = 1_700_000_000_i64;
340 let host_id: i64 =
341 conn.query_row("SELECT id FROM hosts WHERE name = 'codex-cli'", [], |row| {
342 row.get(0)
343 })?;
344 conn.execute(
345 "INSERT INTO workspaces(root_path, git_remote, git_branch, created_at_epoch, updated_at_epoch)
346 VALUES ('/tmp/remem-graph', 'origin', 'main', ?1, ?1)",
347 [now],
348 )?;
349 let workspace_id = conn.last_insert_rowid();
350 conn.execute(
351 "INSERT INTO projects(workspace_id, project_path, project_key, created_at_epoch, updated_at_epoch)
352 VALUES (?1, '/tmp/remem-graph', 'tmp-remem-graph', ?2, ?2)",
353 params![workspace_id, now],
354 )?;
355 let project_id = conn.last_insert_rowid();
356 conn.execute(
357 "INSERT INTO sessions(host_id, workspace_id, project_id, session_id, started_at_epoch,
358 last_seen_at_epoch, status)
359 VALUES (?1, ?2, ?3, 'session-a', ?4, ?4, 'active')",
360 params![host_id, workspace_id, project_id, now],
361 )?;
362 let session_row_id = conn.last_insert_rowid();
363 conn.execute(
364 "INSERT INTO captured_events(host_id, workspace_id, project_id, session_row_id,
365 session_id, event_id, event_type, content_hash,
366 retention_class, created_at_epoch, inserted_at_epoch)
367 VALUES (?1, ?2, ?3, ?4, 'session-a', 'event-a', 'message',
368 'hash-a', 'default', ?5, ?5)",
369 params![host_id, workspace_id, project_id, session_row_id, now],
370 )?;
371 let episode_id = conn.last_insert_rowid();
372
373 let memory_id = crate::memory::insert_memory(
374 &conn,
375 Some("session-a"),
376 "/tmp/remem-graph",
377 Some("graph-contract"),
378 "Graph contract",
379 "Typed graph refs are available.",
380 "decision",
381 None,
382 )?;
383 let second_memory_id = crate::memory::insert_memory(
384 &conn,
385 Some("session-a"),
386 "/tmp/remem-graph",
387 Some("graph-contract-2"),
388 "Graph contract follow-up",
389 "Typed graph refs keep endpoints constrained.",
390 "decision",
391 None,
392 )?;
393 conn.execute(
394 "INSERT INTO entities(canonical_name, entity_type, mention_count, created_at_epoch)
395 VALUES ('Graph API', 'concept', 1, ?1)",
396 [now],
397 )?;
398 let entity_id = conn.last_insert_rowid();
399 conn.execute(
400 "INSERT INTO entities(canonical_name, entity_type, mention_count, created_at_epoch)
401 VALUES ('Graph API Review', 'concept', 1, ?1)",
402 [now],
403 )?;
404 let second_entity_id = conn.last_insert_rowid();
405 conn.execute(
406 "INSERT INTO memory_state_keys(owner_scope, owner_key, memory_type, state_key,
407 state_label, current_memory_id,
408 created_at_epoch, updated_at_epoch)
409 VALUES ('project', '/tmp/remem-graph', 'decision', 'graph-contract',
410 'graph contract', ?1, ?2, ?2)",
411 params![memory_id, now],
412 )?;
413 let state_id = conn.last_insert_rowid();
414 conn.execute(
415 "INSERT INTO topic_segments(host_id, project_id, session_row_id, project, topic_key,
416 title, summary, status, segment_index,
417 covered_from_event_id, covered_to_event_id,
418 evidence_event_ids, confidence,
419 created_at_epoch, updated_at_epoch)
420 VALUES (?1, ?2, ?3, '/tmp/remem-graph', 'graph-contract',
421 'Graph contract', 'Typed graph refs.', 'resolved', 0,
422 ?4, ?4, ?5, 0.9, ?6, ?6)",
423 params![
424 host_id,
425 project_id,
426 session_row_id,
427 episode_id,
428 format!("[{episode_id}]"),
429 now
430 ],
431 )?;
432 let topic_id = conn.last_insert_rowid();
433 conn.execute(
434 "INSERT INTO memory_candidates(project_id, scope, memory_type, topic_key, text,
435 evidence_event_ids, confidence, risk_class,
436 review_status, created_at_epoch, updated_at_epoch)
437 VALUES (?1, 'project', 'decision', 'graph-contract', 'Typed graph refs.',
438 ?2, 0.9, 'low', 'accepted', ?3, ?3)",
439 params![project_id, format!("[{episode_id}]"), now],
440 )?;
441 let candidate_id = conn.last_insert_rowid();
442 conn.execute(
443 "INSERT INTO memory_operation_log(operation, planner_version, actor, source,
444 owner_scope, owner_key, memory_type, state_key,
445 source_candidate_id, result_memory_id,
446 superseded_ids, conflicting_ids, confidence,
447 reason, created_at_epoch)
448 VALUES ('add', 'graph-contract-test', 'test', 'memory_candidate',
449 'project', '/tmp/remem-graph', 'decision', 'graph-contract',
450 ?1, ?2, '[]', '[]', 0.9, 'test provenance', ?3)",
451 params![candidate_id, memory_id, now],
452 )?;
453 let operation_id = conn.last_insert_rowid();
454 let fact_id = insert_temporal_fact(
455 &mut conn,
456 &TemporalFactInput {
457 project: "/tmp/remem-graph",
458 subject: "graph-contract",
459 predicate: FactPredicate::VerifiedBy,
460 object: "cargo test memory::graph",
461 valid_from_epoch: Some(now),
462 valid_to_epoch: None,
463 learned_at_epoch: Some(now),
464 source_memory_id: Some(memory_id),
465 source_observation_id: None,
466 source_event_ids: &[episode_id],
467 confidence: 0.9,
468 supersedes_fact_id: None,
469 },
470 )?;
471
472 Ok(GraphFixture {
473 conn,
474 memory_id,
475 second_memory_id,
476 entity_id,
477 second_entity_id,
478 fact_id,
479 episode_id,
480 state_id,
481 topic_id,
482 candidate_id,
483 operation_id,
484 })
485 }
486
487 fn trusted_provenance(fixture: &GraphFixture) -> GraphEdgeProvenance<'_> {
488 GraphEdgeProvenance {
489 source_event_ids: std::slice::from_ref(&fixture.episode_id),
490 source_candidate_id: Some(fixture.candidate_id),
491 source_operation_id: Some(fixture.operation_id),
492 confidence: Some(0.9),
493 reason: Some("test provenance"),
494 }
495 }
496
497 fn insert_raw_trusted_mention(
498 fixture: &GraphFixture,
499 source_event_ids: &str,
500 ) -> rusqlite::Result<usize> {
501 fixture.conn.execute(
502 "INSERT INTO graph_edges
503 (edge_type, edge_trust, from_node_kind, from_node_id, to_node_kind, to_node_id,
504 source_event_ids, source_candidate_id, source_operation_id, confidence, reason,
505 created_at_epoch)
506 VALUES ('mentions', 'trusted', 'memory', ?1, 'entity', ?2,
507 ?3, ?4, ?5, 0.9, 'raw trusted provenance', 1)",
508 params![
509 fixture.memory_id,
510 fixture.entity_id,
511 source_event_ids,
512 fixture.candidate_id,
513 fixture.operation_id
514 ],
515 )
516 }
517
518 #[test]
519 fn graph_node_refs_use_stable_db_values() -> Result<()> {
520 assert_eq!(GraphNodeRef::memory(1)?.kind.as_str(), "memory");
521 assert_eq!(GraphNodeRef::entity(1)?.kind.as_str(), "entity");
522 assert_eq!(GraphNodeRef::fact(1)?.kind.as_str(), "fact");
523 assert_eq!(GraphNodeRef::episode(1)?.kind.as_str(), "episode");
524 assert_eq!(GraphNodeRef::state(1)?.kind.as_str(), "state");
525 assert_eq!(GraphNodeRef::topic(1)?.kind.as_str(), "topic");
526 assert!(GraphNodeRef::memory(0).is_err());
527 Ok(())
528 }
529
530 #[test]
531 fn trusted_edge_requires_complete_provenance() -> Result<()> {
532 let fixture = fixture()?;
533 let err = insert_graph_edge(
534 &fixture.conn,
535 &GraphEdgeInput {
536 edge_type: GraphEdgeType::Mentions,
537 from_node: GraphNodeRef::memory(fixture.memory_id)?,
538 to_node: GraphNodeRef::entity(fixture.entity_id)?,
539 provenance: GraphEdgeProvenance::default(),
540 valid_from_epoch: None,
541 valid_to_epoch: None,
542 },
543 )
544 .expect_err("trusted graph edge without provenance must fail");
545 assert!(err.to_string().contains("source event ids"));
546 Ok(())
547 }
548
549 #[test]
550 fn inserts_typed_graph_edges_for_supported_node_refs() -> Result<()> {
551 let fixture = fixture()?;
552 let edges = [
553 (
554 GraphEdgeType::Mentions,
555 GraphNodeRef::memory(fixture.memory_id)?,
556 GraphNodeRef::entity(fixture.entity_id)?,
557 ),
558 (
559 GraphEdgeType::ExtractedFrom,
560 GraphNodeRef::fact(fixture.fact_id)?,
561 GraphNodeRef::episode(fixture.episode_id)?,
562 ),
563 (
564 GraphEdgeType::HasState,
565 GraphNodeRef::memory(fixture.memory_id)?,
566 GraphNodeRef::state(fixture.state_id)?,
567 ),
568 (
569 GraphEdgeType::HasTopic,
570 GraphNodeRef::memory(fixture.memory_id)?,
571 GraphNodeRef::topic(fixture.topic_id)?,
572 ),
573 ];
574 for (edge_type, from_node, to_node) in edges {
575 let id = insert_graph_edge(
576 &fixture.conn,
577 &GraphEdgeInput {
578 edge_type,
579 from_node,
580 to_node,
581 provenance: trusted_provenance(&fixture),
582 valid_from_epoch: Some(1_700_000_000),
583 valid_to_epoch: None,
584 },
585 )?;
586 assert!(id > 0);
587 }
588
589 let count: i64 = fixture
590 .conn
591 .query_row("SELECT COUNT(*) FROM graph_edges", [], |row| row.get(0))?;
592 assert_eq!(count, 4);
593 Ok(())
594 }
595
596 #[test]
597 fn diagnostic_hint_can_be_written_without_trusted_provenance() -> Result<()> {
598 let fixture = fixture()?;
599 let id = insert_graph_edge(
600 &fixture.conn,
601 &GraphEdgeInput {
602 edge_type: GraphEdgeType::SimilarTo,
603 from_node: GraphNodeRef::memory(fixture.memory_id)?,
604 to_node: GraphNodeRef::memory(fixture.second_memory_id)?,
605 provenance: GraphEdgeProvenance::default(),
606 valid_from_epoch: None,
607 valid_to_epoch: None,
608 },
609 )?;
610 let trust: String = fixture.conn.query_row(
611 "SELECT edge_trust FROM graph_edges WHERE id = ?1",
612 [id],
613 |row| row.get(0),
614 )?;
615 assert_eq!(trust, "diagnostic_hint");
616 Ok(())
617 }
618
619 #[test]
620 fn missing_target_node_fails_closed() -> Result<()> {
621 let fixture = fixture()?;
622 let err = insert_graph_edge(
623 &fixture.conn,
624 &GraphEdgeInput {
625 edge_type: GraphEdgeType::SimilarTo,
626 from_node: GraphNodeRef::memory(fixture.memory_id)?,
627 to_node: GraphNodeRef::memory(99_999)?,
628 provenance: GraphEdgeProvenance::default(),
629 valid_from_epoch: None,
630 valid_to_epoch: None,
631 },
632 )
633 .expect_err("missing graph target must fail");
634 assert!(err.to_string().contains("insert graph edge"));
635 let chain = format!("{err:#}");
636 assert!(chain.contains("graph_edges to memory node missing"));
637 Ok(())
638 }
639
640 #[test]
641 fn trusted_edge_requires_existing_source_events() -> Result<()> {
642 let fixture = fixture()?;
643 let missing_event_id = 99_999_i64;
644 let source_event_ids = [missing_event_id];
645 let err = insert_graph_edge(
646 &fixture.conn,
647 &GraphEdgeInput {
648 edge_type: GraphEdgeType::Mentions,
649 from_node: GraphNodeRef::memory(fixture.memory_id)?,
650 to_node: GraphNodeRef::entity(fixture.entity_id)?,
651 provenance: GraphEdgeProvenance {
652 source_event_ids: &source_event_ids,
653 source_candidate_id: Some(fixture.candidate_id),
654 source_operation_id: Some(fixture.operation_id),
655 confidence: Some(0.9),
656 reason: Some("missing event must fail"),
657 },
658 valid_from_epoch: None,
659 valid_to_epoch: None,
660 },
661 )
662 .expect_err("trusted graph edge with missing evidence must fail");
663 assert!(err
664 .to_string()
665 .contains("source event id 99999 does not exist"));
666 Ok(())
667 }
668
669 #[test]
670 fn invalid_edge_type_fails_closed_at_schema_boundary() -> Result<()> {
671 let fixture = fixture()?;
672 let err = fixture
673 .conn
674 .execute(
675 "INSERT INTO graph_edges
676 (edge_type, edge_trust, from_node_kind, from_node_id, to_node_kind, to_node_id,
677 source_event_ids, source_candidate_id, source_operation_id, confidence, reason,
678 created_at_epoch)
679 VALUES ('made_up', 'trusted', 'memory', ?1, 'entity', ?2,
680 ?3, ?4, ?5, 0.9, 'invalid edge type', 1)",
681 params![
682 fixture.memory_id,
683 fixture.entity_id,
684 format!("[{}]", fixture.episode_id),
685 fixture.candidate_id,
686 fixture.operation_id
687 ],
688 )
689 .expect_err("invalid graph edge type must fail");
690 assert!(err.to_string().contains("CHECK constraint failed"));
691 Ok(())
692 }
693
694 #[test]
695 fn invalid_source_event_ids_fail_closed_at_schema_boundary() -> Result<()> {
696 let fixture = fixture()?;
697 for source_event_ids in ["[ ]", "not-json", "{\"id\":1}", "[\"event-a\"]", "[99999]"] {
698 assert!(
699 insert_raw_trusted_mention(&fixture, source_event_ids).is_err(),
700 "invalid source_event_ids {source_event_ids:?} must fail"
701 );
702 }
703 Ok(())
704 }
705
706 #[test]
707 fn edge_type_rejects_invalid_endpoint_kinds() -> Result<()> {
708 let fixture = fixture()?;
709 let err = insert_graph_edge(
710 &fixture.conn,
711 &GraphEdgeInput {
712 edge_type: GraphEdgeType::HasState,
713 from_node: GraphNodeRef::entity(fixture.entity_id)?,
714 to_node: GraphNodeRef::memory(fixture.memory_id)?,
715 provenance: trusted_provenance(&fixture),
716 valid_from_epoch: None,
717 valid_to_epoch: None,
718 },
719 )
720 .expect_err("has_state with entity to memory endpoints must fail");
721 assert!(err
722 .to_string()
723 .contains("graph edge type has_state does not allow entity to memory endpoints"));
724
725 let err = fixture
726 .conn
727 .execute(
728 "INSERT INTO graph_edges
729 (edge_type, edge_trust, from_node_kind, from_node_id, to_node_kind, to_node_id,
730 source_event_ids, source_candidate_id, source_operation_id, confidence, reason,
731 created_at_epoch)
732 VALUES ('has_state', 'trusted', 'entity', ?1, 'memory', ?2,
733 ?3, ?4, ?5, 0.9, 'invalid endpoints', 1)",
734 params![
735 fixture.entity_id,
736 fixture.memory_id,
737 format!("[{}]", fixture.episode_id),
738 fixture.candidate_id,
739 fixture.operation_id
740 ],
741 )
742 .expect_err("raw SQL invalid graph endpoints must fail");
743 assert!(err.to_string().contains("CHECK constraint failed"));
744 Ok(())
745 }
746
747 #[test]
748 fn parent_node_deletes_remove_graph_edges() -> Result<()> {
749 let fixture = fixture()?;
750 let entity_edge_id = insert_graph_edge(
751 &fixture.conn,
752 &GraphEdgeInput {
753 edge_type: GraphEdgeType::Mentions,
754 from_node: GraphNodeRef::memory(fixture.memory_id)?,
755 to_node: GraphNodeRef::entity(fixture.second_entity_id)?,
756 provenance: trusted_provenance(&fixture),
757 valid_from_epoch: None,
758 valid_to_epoch: None,
759 },
760 )?;
761 let state_edge_id = insert_graph_edge(
762 &fixture.conn,
763 &GraphEdgeInput {
764 edge_type: GraphEdgeType::HasState,
765 from_node: GraphNodeRef::memory(fixture.memory_id)?,
766 to_node: GraphNodeRef::state(fixture.state_id)?,
767 provenance: trusted_provenance(&fixture),
768 valid_from_epoch: None,
769 valid_to_epoch: None,
770 },
771 )?;
772 fixture.conn.execute(
773 "DELETE FROM entities WHERE id = ?1",
774 [fixture.second_entity_id],
775 )?;
776 let entity_edge_count: i64 = fixture.conn.query_row(
777 "SELECT COUNT(*) FROM graph_edges WHERE id = ?1",
778 [entity_edge_id],
779 |row| row.get(0),
780 )?;
781 assert_eq!(entity_edge_count, 0);
782
783 fixture.conn.execute(
784 "DELETE FROM memory_state_keys WHERE id = ?1",
785 [fixture.state_id],
786 )?;
787 let state_edge_count: i64 = fixture.conn.query_row(
788 "SELECT COUNT(*) FROM graph_edges WHERE id = ?1",
789 [state_edge_id],
790 |row| row.get(0),
791 )?;
792 assert_eq!(state_edge_count, 0);
793 Ok(())
794 }
795}