Skip to main content

remem/memory/
graph_contract.rs

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}