Skip to main content

zeph_memory/
episodic_graph.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! EM-Graph: episodic event extraction and causal linking (issue #3713).
5//!
6//! The Episodic Memory Graph stores conversation events (actions, decisions, discoveries,
7//! errors) as nodes connected by causal relationships. This is distinct from the
8//! entity-centric MAGMA graph stored in `graph_edges` — EM-Graph captures *what happened*
9//! and *why*, while MAGMA captures *what is related to what*.
10//!
11//! # Storage
12//!
13//! Events are stored in `episodic_events` and causal links in `causal_links`
14//! (both created by migration 086). Messages are never deleted (spec 001-6), so
15//! FK references from events to messages are always valid even after optical forgetting
16//! compresses the message content.
17//!
18//! # Extraction
19//!
20//! [`extract_events`] calls an LLM to identify events in a conversation turn.
21//! [`link_events`] detects causal relationships between the new events and
22//! recent events in the same session.
23//!
24//! # Retrieval
25//!
26//! [`recall_episodic_causal`] finds events relevant to a query and walks the
27//! causal graph to build a chain of causally-related events.
28
29use std::sync::Arc;
30use std::time::Duration;
31
32use tracing::Instrument as _;
33use zeph_llm::any::AnyProvider;
34use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
35
36pub use zeph_config::memory::EmGraphConfig;
37
38use zeph_common::SessionId;
39use zeph_common::llm_response::extract_json_array_slice;
40use zeph_db::ActiveDialect;
41
42use crate::error::MemoryError;
43use crate::store::SqliteStore;
44use crate::types::MessageId;
45
46// SQLite default SQLITE_MAX_VARIABLE_NUMBER = 999. Each causal hop uses |frontier| + |visited|
47// bind parameters. Cap visited to stay within the limit with any config value of max_chain_depth.
48const MAX_CAUSAL_VISITED: usize = 400;
49
50// ── Domain types ──────────────────────────────────────────────────────────────
51
52/// An episodic event extracted from a conversation turn.
53#[derive(Debug, Clone)]
54pub struct EpisodicEvent {
55    /// `SQLite` row ID (`0` when not yet persisted).
56    pub id: i64,
57    /// Session identifier this event belongs to.
58    pub session_id: SessionId,
59    /// The message that triggered this event.
60    pub message_id: MessageId,
61    /// Short event category (e.g. `"decision"`, `"discovery"`, `"error"`, `"tool_use"`).
62    pub event_type: String,
63    /// One-sentence description of the event.
64    pub summary: String,
65    /// Optional embedding blob (populated when vector search is enabled).
66    pub embedding: Option<Vec<u8>>,
67    /// Unix timestamp of creation.
68    pub created_at: i64,
69}
70
71/// A directed causal link between two episodic events.
72#[derive(Debug, Clone)]
73pub struct CausalLink {
74    /// `SQLite` row ID (`0` when not yet persisted).
75    pub id: i64,
76    /// Source (cause) event ID.
77    pub cause_event_id: i64,
78    /// Target (effect) event ID.
79    pub effect_event_id: i64,
80    /// Causal strength in [0.0, 1.0].
81    pub strength: f32,
82    /// Unix timestamp of creation.
83    pub created_at: i64,
84}
85
86// ── Event extraction ──────────────────────────────────────────────────────────
87
88/// Extract episodic events from a conversation turn via LLM.
89///
90/// Returns a list of events identified in `content`. The caller is responsible for
91/// persisting the events via [`store_events`].
92///
93/// Falls back to an empty list on LLM failure (fail-open: missing events are a
94/// quality degradation, not a correctness error).
95///
96/// # Errors
97///
98/// Returns an error only if the LLM call produces an error that cannot be recovered.
99/// Network timeouts and parse failures return an empty list instead.
100pub async fn extract_events(
101    provider: &Arc<AnyProvider>,
102    content: &str,
103    session_id: &str,
104    message_id: MessageId,
105    config: &EmGraphConfig,
106) -> Vec<EpisodicEvent> {
107    let span = tracing::debug_span!("memory.em_graph.extract_events", message_id = message_id.0);
108
109    async move {
110        if !config.enabled {
111            return vec![];
112        }
113
114        let snippet = content.chars().take(2000).collect::<String>();
115
116        let prompt = format!(
117            "Identify episodic events in the following conversation turn. \
118            An event is a concrete action, decision, discovery, or error. \
119            Return a JSON array of objects with fields: \
120            {{\"event_type\": \"<type>\", \"summary\": \"<one sentence>\"}}. \
121            Types: decision, discovery, error, tool_use, question, answer, other. \
122            Return [] if no notable events. Output JSON only.\n\nTurn:\n{snippet}"
123        );
124
125        let messages = vec![
126            Message {
127                role: Role::System,
128                content: "You are an episodic memory extractor. Extract concrete events from \
129                          conversation turns as structured JSON. Output only valid JSON, no preamble."
130                    .to_owned(),
131                parts: vec![],
132                metadata: MessageMetadata::default(),
133            },
134            Message {
135                role: Role::User,
136                content: prompt,
137                parts: vec![],
138                metadata: MessageMetadata::default(),
139            },
140        ];
141
142        let raw = match tokio::time::timeout(Duration::from_secs(10), provider.chat(&messages)).await {
143            Ok(Ok(r)) => r,
144            Ok(Err(e)) => {
145                tracing::warn!(error = %e, "em_graph: event extraction LLM call failed");
146                return vec![];
147            }
148            Err(_) => {
149                tracing::warn!("em_graph: event extraction timed out");
150                return vec![];
151            }
152        };
153
154        parse_events_response(&raw, session_id, message_id)
155    }
156    .instrument(span)
157    .await
158}
159
160fn parse_events_response(raw: &str, session_id: &str, message_id: MessageId) -> Vec<EpisodicEvent> {
161    let json_str = extract_json_array_slice(raw).unwrap_or("[]");
162
163    let values: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap_or_default();
164
165    values
166        .into_iter()
167        .filter_map(|v| {
168            let event_type = v.get("event_type")?.as_str()?.to_owned();
169            let summary = v.get("summary")?.as_str()?.to_owned();
170            if summary.is_empty() {
171                return None;
172            }
173            Some(EpisodicEvent {
174                id: 0,
175                session_id: SessionId::new(session_id),
176                message_id,
177                event_type,
178                summary,
179                embedding: None,
180                created_at: 0,
181            })
182        })
183        .collect()
184}
185
186// ── Causal link detection ──────────────────────────────────────────────────────
187
188/// Detect causal links between `new_events` and `recent_events` via LLM.
189///
190/// Returns a list of causal links. The caller is responsible for persisting them
191/// via [`store_links`] after persisting the events via [`store_events`].
192///
193/// **Ordering requirement**: `store_events` MUST be called before `link_events` so
194/// that `new_events[i].id` reflects the real database row ID. The LLM prompt embeds
195/// event IDs; IDs of 0 (pre-persistence default) will produce links that cannot be
196/// matched after `store_events` assigns the real IDs.
197///
198/// Returns an empty list on LLM failure (fail-open).
199pub async fn link_events(
200    provider: &Arc<AnyProvider>,
201    new_events: &[EpisodicEvent],
202    recent_events: &[EpisodicEvent],
203    config: &EmGraphConfig,
204) -> Vec<CausalLink> {
205    let span = tracing::debug_span!(
206        "memory.em_graph.link_events",
207        new_count = new_events.len(),
208        recent_count = recent_events.len()
209    );
210
211    async move {
212        if !config.enabled || new_events.is_empty() || recent_events.is_empty() {
213            return vec![];
214        }
215
216        // Summaries are stored LLM output; cap length to limit prompt injection surface.
217        let new_desc: Vec<String> = new_events
218            .iter()
219            .enumerate()
220            .map(|(i, e)| {
221                let s: String = e.summary.chars().take(200).collect();
222                format!("NEW[{i}] (id={}): {s}", e.id)
223            })
224            .collect();
225
226        let recent_desc: Vec<String> = recent_events
227            .iter()
228            .enumerate()
229            .map(|(i, e)| {
230                let s: String = e.summary.chars().take(200).collect();
231                format!("RECENT[{i}] (id={}): {s}", e.id)
232            })
233            .collect();
234
235        let prompt = format!(
236            "Given these recent events and new events, identify causal relationships \
237            (cause → effect). Return a JSON array of objects: \
238            {{\"cause_id\": <event_id>, \"effect_id\": <event_id>, \"strength\": 0.0-1.0}}. \
239            Only include strong causal links (strength >= 0.5). Output [] if none.\n\n\
240            Recent events:\n{}\n\nNew events:\n{}",
241            recent_desc.join("\n"),
242            new_desc.join("\n"),
243        );
244
245        let messages = vec![
246            Message {
247                role: Role::System,
248                content: "You are a causal reasoning engine. Identify cause-and-effect \
249                          relationships between events. Output only valid JSON."
250                    .to_owned(),
251                parts: vec![],
252                metadata: MessageMetadata::default(),
253            },
254            Message {
255                role: Role::User,
256                content: prompt,
257                parts: vec![],
258                metadata: MessageMetadata::default(),
259            },
260        ];
261
262        let raw =
263            match tokio::time::timeout(Duration::from_secs(10), provider.chat(&messages)).await {
264                Ok(Ok(r)) => r,
265                Ok(Err(e)) => {
266                    tracing::warn!(error = %e, "em_graph: causal link LLM call failed");
267                    return vec![];
268                }
269                Err(_) => {
270                    tracing::warn!("em_graph: causal link detection timed out");
271                    return vec![];
272                }
273            };
274
275        parse_links_response(&raw)
276    }
277    .instrument(span)
278    .await
279}
280
281fn parse_links_response(raw: &str) -> Vec<CausalLink> {
282    let json_str = extract_json_array_slice(raw).unwrap_or("[]");
283
284    let values: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap_or_default();
285
286    values
287        .into_iter()
288        .filter_map(|v| {
289            let cause_id = v.get("cause_id")?.as_i64()?;
290            let effect_id = v.get("effect_id")?.as_i64()?;
291            #[allow(clippy::cast_possible_truncation)]
292            let strength = v
293                .get("strength")
294                .and_then(serde_json::Value::as_f64)
295                .map_or(0.5, |s| s.clamp(0.0, 1.0) as f32);
296            if strength < 0.5 {
297                return None;
298            }
299            Some(CausalLink {
300                id: 0,
301                cause_event_id: cause_id,
302                effect_event_id: effect_id,
303                strength,
304                created_at: 0,
305            })
306        })
307        .collect()
308}
309
310// ── Persistence ───────────────────────────────────────────────────────────────
311
312/// Persist extracted events to the `episodic_events` table.
313///
314/// All inserts are batched inside a single transaction. On success, `events[i].id`
315/// is updated to the assigned row ID.
316///
317/// # Errors
318///
319/// Returns an error if any insert or the transaction commit fails.
320pub async fn store_events(
321    store: &SqliteStore,
322    events: &mut [EpisodicEvent],
323) -> Result<(), MemoryError> {
324    if events.is_empty() {
325        return Ok(());
326    }
327    let mut tx = store.pool().begin().await?;
328    for event in events.iter_mut() {
329        let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
330        let raw = format!(
331            "INSERT INTO episodic_events (session_id, message_id, event_type, summary, created_at) \
332             VALUES (?, ?, ?, ?, {epoch_now}) \
333             RETURNING id"
334        );
335        let sql = zeph_db::rewrite_placeholders(&raw);
336        let id = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(sql))
337            .bind(event.session_id.as_str())
338            .bind(event.message_id.0)
339            .bind(&event.event_type)
340            .bind(&event.summary)
341            .fetch_one(&mut *tx)
342            .await?;
343        event.id = id;
344    }
345    tx.commit().await?;
346    Ok(())
347}
348
349/// Persist causal links to the `causal_links` table.
350///
351/// All inserts are batched inside a single transaction. Duplicate
352/// `(cause_event_id, effect_event_id)` pairs are silently ignored via
353/// `ON CONFLICT (cause_event_id, effect_event_id) DO NOTHING`
354/// (requires the `UNIQUE` constraint added in migration 086).
355///
356/// # Errors
357///
358/// Returns an error if any insert or the transaction commit fails.
359pub async fn store_links(store: &SqliteStore, links: &[CausalLink]) -> Result<(), MemoryError> {
360    if links.is_empty() {
361        return Ok(());
362    }
363    let mut tx = store.pool().begin().await?;
364    for link in links {
365        let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
366        let raw = format!(
367            "INSERT INTO causal_links \
368             (cause_event_id, effect_event_id, strength, created_at) \
369             VALUES (?, ?, ?, {epoch_now}) \
370             ON CONFLICT (cause_event_id, effect_event_id) DO NOTHING"
371        );
372        let sql = zeph_db::rewrite_placeholders(&raw);
373        sqlx::query(sqlx::AssertSqlSafe(sql))
374            .bind(link.cause_event_id)
375            .bind(link.effect_event_id)
376            .bind(link.strength)
377            .execute(&mut *tx)
378            .await?;
379    }
380    tx.commit().await?;
381    Ok(())
382}
383
384// ── Recall ────────────────────────────────────────────────────────────────────
385
386/// Retrieve recent events for a session (context for causal linking).
387///
388/// Returns the most recent `limit` events ordered by creation time descending.
389///
390/// # Errors
391///
392/// Returns an error if the database query fails.
393pub async fn fetch_recent_events(
394    store: &SqliteStore,
395    session_id: &str,
396    limit: usize,
397) -> Result<Vec<EpisodicEvent>, MemoryError> {
398    let rows = sqlx::query_as::<_, (i64, String, i64, String, String, i64)>(zeph_db::sql!(
399        "SELECT id, session_id, message_id, event_type, summary, created_at
400         FROM episodic_events
401         WHERE session_id = ?
402         ORDER BY created_at DESC
403         LIMIT ?"
404    ))
405    .bind(session_id)
406    .bind(i64::try_from(limit).unwrap_or(i64::MAX))
407    .fetch_all(store.pool())
408    .await?;
409
410    Ok(rows
411        .into_iter()
412        .map(
413            |(id, session_id, message_id, event_type, summary, created_at)| EpisodicEvent {
414                id,
415                session_id: SessionId::new(session_id),
416                message_id: MessageId(message_id),
417                event_type,
418                summary,
419                embedding: None,
420                created_at,
421            },
422        )
423        .collect())
424}
425
426/// Retrieve a causal chain of events starting from a seed event.
427///
428/// Walks `causal_links` forward (cause → effect) up to `max_depth` hops,
429/// collecting the causally-connected event chain ordered by event ID.
430///
431/// # Errors
432///
433/// Returns an error if any database query fails.
434pub async fn recall_episodic_causal(
435    store: &SqliteStore,
436    seed_event_id: i64,
437    session_id: &str,
438    max_depth: u32,
439    config: &EmGraphConfig,
440) -> Result<Vec<EpisodicEvent>, MemoryError> {
441    let span = tracing::debug_span!("memory.em_graph.causal_recall", seed_event_id, max_depth);
442
443    if !config.enabled {
444        return Ok(vec![]);
445    }
446
447    // Clone the pool (cheap Arc clone) so the async block can be 'static / Send.
448    let pool = store.pool().clone();
449    let session_id = session_id.to_owned();
450
451    async move {
452        let mut visited: Vec<i64> = vec![seed_event_id];
453        let mut frontier: Vec<i64> = vec![seed_event_id];
454
455        for depth in 0..max_depth {
456            if frontier.is_empty() || visited.len() >= MAX_CAUSAL_VISITED {
457                break;
458            }
459
460            let frontier_ph = frontier.iter().map(|_| "?").collect::<Vec<_>>().join(",");
461            let visited_ph = visited.iter().map(|_| "?").collect::<Vec<_>>().join(",");
462
463            let query = zeph_db::rewrite_placeholders(&format!(
464                "SELECT DISTINCT effect_event_id FROM causal_links
465                 WHERE cause_event_id IN ({frontier_ph})
466                   AND effect_event_id NOT IN ({visited_ph})"
467            ));
468
469            let mut q = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(query));
470            for &id in &frontier {
471                q = q.bind(id);
472            }
473            for &id in &visited {
474                q = q.bind(id);
475            }
476
477            let next: Vec<i64> = q.fetch_all(&pool).await?;
478
479            tracing::debug!(depth, next_count = next.len(), "em_graph: causal hop");
480            visited.extend_from_slice(&next);
481            frontier = next;
482        }
483
484        if visited.is_empty() {
485            return Ok(vec![]);
486        }
487
488        // Fetch all collected events ordered by creation time.
489        let placeholders = visited.iter().map(|_| "?").collect::<Vec<_>>().join(",");
490
491        let query = zeph_db::rewrite_placeholders(&format!(
492            "SELECT id, session_id, message_id, event_type, summary, created_at
493             FROM episodic_events
494             WHERE id IN ({placeholders}) AND session_id = ?
495             ORDER BY created_at ASC"
496        ));
497
498        let mut q = sqlx::query_as::<_, (i64, String, i64, String, String, i64)>(
499            sqlx::AssertSqlSafe(query),
500        );
501        for &id in &visited {
502            q = q.bind(id);
503        }
504        q = q.bind(session_id.as_str());
505
506        let rows = q.fetch_all(&pool).await?;
507
508        Ok(rows
509            .into_iter()
510            .map(
511                |(id, session_id, message_id, event_type, summary, created_at)| EpisodicEvent {
512                    id,
513                    session_id: SessionId::new(session_id),
514                    message_id: MessageId(message_id),
515                    event_type,
516                    summary,
517                    embedding: None,
518                    created_at,
519                },
520            )
521            .collect())
522    }
523    .instrument(span)
524    .await
525}
526
527// ── Tests ─────────────────────────────────────────────────────────────────────
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use zeph_config::providers::ProviderName;
533
534    #[test]
535    fn parse_events_response_valid_json() {
536        let raw = r#"[{"event_type":"decision","summary":"User chose approach A"},{"event_type":"discovery","summary":"Found a bug in module X"}]"#;
537        let events = parse_events_response(raw, "sess-1", MessageId(42));
538        assert_eq!(events.len(), 2);
539        assert_eq!(events[0].event_type, "decision");
540        assert_eq!(events[1].summary, "Found a bug in module X");
541        assert_eq!(events[0].message_id, MessageId(42));
542        assert_eq!(events[0].session_id, "sess-1");
543    }
544
545    #[test]
546    fn parse_events_response_empty_array() {
547        let events = parse_events_response("[]", "sess-1", MessageId(1));
548        assert!(events.is_empty());
549    }
550
551    #[test]
552    fn parse_events_response_malformed_json() {
553        let events = parse_events_response("not json", "sess-1", MessageId(1));
554        assert!(events.is_empty());
555    }
556
557    #[test]
558    fn parse_events_response_skips_empty_summary() {
559        let raw = r#"[{"event_type":"decision","summary":""}]"#;
560        let events = parse_events_response(raw, "sess-1", MessageId(1));
561        assert!(events.is_empty(), "empty summary must be skipped");
562    }
563
564    #[test]
565    fn parse_links_response_valid_json() {
566        let raw = r#"[{"cause_id":1,"effect_id":2,"strength":0.8}]"#;
567        let links = parse_links_response(raw);
568        assert_eq!(links.len(), 1);
569        assert_eq!(links[0].cause_event_id, 1);
570        assert_eq!(links[0].effect_event_id, 2);
571        assert!((links[0].strength - 0.8).abs() < 0.01);
572    }
573
574    #[test]
575    fn parse_links_response_filters_weak_links() {
576        let raw = r#"[{"cause_id":1,"effect_id":2,"strength":0.3}]"#;
577        let links = parse_links_response(raw);
578        assert!(
579            links.is_empty(),
580            "weak links (strength < 0.5) must be filtered"
581        );
582    }
583
584    #[test]
585    fn parse_links_response_empty() {
586        let links = parse_links_response("[]");
587        assert!(links.is_empty());
588    }
589
590    #[test]
591    fn parse_links_response_malformed_json() {
592        let links = parse_links_response("not json");
593        assert!(links.is_empty());
594    }
595
596    #[test]
597    fn em_graph_config_defaults() {
598        let cfg = EmGraphConfig::default();
599        assert!(!cfg.enabled);
600        assert_eq!(cfg.max_chain_depth, 3);
601    }
602
603    #[tokio::test]
604    async fn store_and_fetch_events_in_memory_db() {
605        use crate::store::SqliteStore;
606
607        let store = SqliteStore::new(":memory:")
608            .await
609            .expect("SqliteStore::new");
610        let cid = store.create_conversation().await.expect("conversation");
611        let mid = store
612            .save_message(cid, "user", "hello world")
613            .await
614            .expect("save_message");
615
616        let mut events = vec![EpisodicEvent {
617            id: 0,
618            session_id: SessionId::new("test-session"),
619            message_id: mid,
620            event_type: "decision".to_owned(),
621            summary: "User decided to use approach A".to_owned(),
622            embedding: None,
623            created_at: 0,
624        }];
625
626        store_events(&store, &mut events)
627            .await
628            .expect("store_events");
629        assert!(events[0].id > 0, "id must be assigned after insert");
630
631        let fetched = fetch_recent_events(&store, "test-session", 10)
632            .await
633            .expect("fetch_recent_events");
634        assert_eq!(fetched.len(), 1);
635        assert_eq!(fetched[0].summary, "User decided to use approach A");
636    }
637
638    #[tokio::test]
639    async fn store_and_recall_causal_chain() {
640        use crate::store::SqliteStore;
641
642        let store = SqliteStore::new(":memory:")
643            .await
644            .expect("SqliteStore::new");
645        let cid = store.create_conversation().await.expect("conversation");
646        let mid = store
647            .save_message(cid, "user", "test")
648            .await
649            .expect("save_message");
650
651        let mut events = vec![
652            EpisodicEvent {
653                id: 0,
654                session_id: SessionId::new("sess"),
655                message_id: mid,
656                event_type: "discovery".to_owned(),
657                summary: "Found a bug".to_owned(),
658                embedding: None,
659                created_at: 0,
660            },
661            EpisodicEvent {
662                id: 0,
663                session_id: SessionId::new("sess"),
664                message_id: mid,
665                event_type: "decision".to_owned(),
666                summary: "Decided to fix it".to_owned(),
667                embedding: None,
668                created_at: 0,
669            },
670        ];
671        store_events(&store, &mut events)
672            .await
673            .expect("store_events");
674
675        let link = CausalLink {
676            id: 0,
677            cause_event_id: events[0].id,
678            effect_event_id: events[1].id,
679            strength: 0.9,
680            created_at: 0,
681        };
682        store_links(&store, &[link]).await.expect("store_links");
683
684        let config = EmGraphConfig {
685            enabled: true,
686            extract_provider: ProviderName::default(),
687            max_chain_depth: 3,
688        };
689        let chain = recall_episodic_causal(&store, events[0].id, "sess", 3, &config)
690            .await
691            .expect("recall_episodic_causal");
692
693        assert_eq!(
694            chain.len(),
695            2,
696            "chain must include seed and causally-linked event"
697        );
698    }
699
700    #[test]
701    fn parse_links_response_strength_at_boundary_included() {
702        // strength == 0.5 is exactly at the threshold — must be included (filter is `< 0.5`)
703        let raw = r#"[{"cause_id":1,"effect_id":2,"strength":0.5}]"#;
704        let links = parse_links_response(raw);
705        assert_eq!(
706            links.len(),
707            1,
708            "strength=0.5 must be included (threshold is strict < 0.5)"
709        );
710        assert!((links[0].strength - 0.5).abs() < 0.001);
711    }
712
713    #[tokio::test]
714    async fn recall_episodic_causal_disabled_returns_empty() {
715        use crate::store::SqliteStore;
716
717        let store = SqliteStore::new(":memory:")
718            .await
719            .expect("SqliteStore::new");
720        let config = EmGraphConfig {
721            enabled: false,
722            extract_provider: ProviderName::default(),
723            max_chain_depth: 3,
724        };
725        let result = recall_episodic_causal(&store, 1, "sess", 3, &config).await;
726        assert!(result.is_ok());
727        assert!(
728            result.unwrap().is_empty(),
729            "disabled config must return empty"
730        );
731    }
732
733    #[tokio::test]
734    async fn store_links_is_idempotent_with_unique_constraint() {
735        use crate::store::SqliteStore;
736
737        let store = SqliteStore::new(":memory:")
738            .await
739            .expect("SqliteStore::new");
740        let cid = store.create_conversation().await.expect("conversation");
741        let mid = store
742            .save_message(cid, "user", "test")
743            .await
744            .expect("save_message");
745
746        let mut events = vec![
747            EpisodicEvent {
748                id: 0,
749                session_id: SessionId::new("sess"),
750                message_id: mid,
751                event_type: "decision".to_owned(),
752                summary: "A".to_owned(),
753                embedding: None,
754                created_at: 0,
755            },
756            EpisodicEvent {
757                id: 0,
758                session_id: SessionId::new("sess"),
759                message_id: mid,
760                event_type: "discovery".to_owned(),
761                summary: "B".to_owned(),
762                embedding: None,
763                created_at: 0,
764            },
765        ];
766        store_events(&store, &mut events)
767            .await
768            .expect("store_events");
769
770        let link = CausalLink {
771            id: 0,
772            cause_event_id: events[0].id,
773            effect_event_id: events[1].id,
774            strength: 0.8,
775            created_at: 0,
776        };
777        // Insert twice — second must be ignored, not duplicated.
778        store_links(&store, std::slice::from_ref(&link))
779            .await
780            .expect("first store_links");
781        store_links(&store, &[link])
782            .await
783            .expect("second store_links (idempotent)");
784
785        let count: i64 = sqlx::query_scalar(
786            "SELECT COUNT(*) FROM causal_links WHERE cause_event_id = ? AND effect_event_id = ?",
787        )
788        .bind(events[0].id)
789        .bind(events[1].id)
790        .fetch_one(store.pool())
791        .await
792        .expect("count query");
793
794        assert_eq!(
795            count, 1,
796            "duplicate causal links must be deduplicated by UNIQUE constraint"
797        );
798    }
799}