Skip to main content

zeph_memory/graph/store/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! SQLite-backed knowledge graph store.
5//!
6//! [`GraphStore`] manages the `graph_entities`, `graph_edges`, `graph_entity_aliases`,
7//! `graph_communities`, and `graph_episodes` tables. It is the persistence layer for
8//! the MAGMA / GAAMA graph subsystem.
9
10use std::collections::HashMap;
11#[allow(unused_imports)]
12use zeph_db::sql;
13
14use futures::Stream;
15use zeph_db::fts::sanitize_fts_query;
16use zeph_db::{ActiveDialect, DbPool, numbered_placeholder, placeholder_list};
17
18use crate::error::MemoryError;
19use crate::graph::conflict::{ApexMetrics, SUPERSEDE_DEPTH_CAP};
20use crate::types::{EntityId, MessageId};
21
22use super::types::{
23    Community, Edge, EdgeType, Entity, EntityAlias, EntityType, GraphProvenance, provenance_parts,
24};
25
26/// SQLite-backed persistence layer for the knowledge graph.
27///
28/// All graph mutations go through this type: entity upserts, edge creation,
29/// alias recording, community assignment, and episode management.
30///
31/// Obtain an instance via [`GraphStore::new`] after running the `zeph-db` migrations.
32pub struct GraphStore {
33    pool: DbPool,
34    /// Benna-Fusi fast-variable learning rate. Range: `(0.0, 1.0]`. Default: `0.5`.
35    benna_fast_rate: f32,
36    /// Benna-Fusi slow-variable learning rate. Range: `(0.0, 1.0]`. Default: `0.05`.
37    benna_slow_rate: f32,
38    /// When `false`, edges with imported (non-conversation) origins are excluded from
39    /// `query_batch_edges` and `edges_for_entity` results (spec-067 FR-003, INV-3). Default: `true`.
40    recall_include_imported: bool,
41}
42
43/// Safe chunk size for `SQLite` ID batches, staying under `SQLite`'s
44/// `SQLITE_MAX_VARIABLE_NUMBER` limit (999 by default): `999 / 2 = 499`, rounded down to 490
45/// for headroom. Sized for call sites that bind each id twice per row (e.g. an `IN (...)`
46/// clause matched against both `source_entity_id` and `target_entity_id`); call sites that
47/// only bind each id once reuse the same constant for a uniform, still-safe batch size.
48const SQLITE_BATCH_LIMIT_2X: usize = 490;
49
50impl GraphStore {
51    /// Create a new `GraphStore` wrapping `pool`.
52    ///
53    /// The pool must come from a database with the `zeph-db` migrations applied.
54    #[must_use]
55    pub fn new(pool: DbPool) -> Self {
56        Self {
57            pool,
58            benna_fast_rate: 0.5,
59            benna_slow_rate: 0.05,
60            recall_include_imported: true,
61        }
62    }
63
64    /// Set Benna-Fusi learning rates for the multi-timescale synaptic update (#3709).
65    ///
66    /// Call this after [`Self::new`] when the rates come from `[memory.graph.spreading_activation]`.
67    #[must_use]
68    pub fn with_benna_rates(mut self, fast_rate: f32, slow_rate: f32) -> Self {
69        self.benna_fast_rate = fast_rate;
70        self.benna_slow_rate = slow_rate;
71        self
72    }
73
74    /// Control whether imported (non-conversation) edges appear in recall (spec-067 FR-003).
75    ///
76    /// When `false`, edges with any non-conversation origin (e.g. `'ingest'`, `'subagent'`)
77    /// are excluded from [`Self::query_batch_edges`] and [`Self::edges_for_entity`].
78    /// Default: `true` (all edges included).
79    #[must_use]
80    pub fn with_recall_include_imported(mut self, include: bool) -> Self {
81        self.recall_include_imported = include;
82        self
83    }
84
85    /// Access the underlying database pool.
86    #[must_use]
87    pub fn pool(&self) -> &DbPool {
88        &self.pool
89    }
90
91    // ── Entities ─────────────────────────────────────────────────────────────
92
93    /// Insert or update an entity by `(canonical_name, entity_type)`.
94    ///
95    /// - `surface_name`: the original display form (e.g. `"Rust"`) — stored in the `name` column
96    ///   so user-facing output preserves casing. Updated on every upsert to the latest seen form.
97    /// - `canonical_name`: the stable normalized key (e.g. `"rust"`) — used for deduplication.
98    /// - `summary`: pass `None` to preserve the existing summary; pass `Some("")` to blank it.
99    /// - `provenance`: pass `None` for conversation-origin rows (the default); pass
100    ///   `Some(prov)` from the ingest path to stamp `origin` and `import_batch_id`.
101    ///   The `DO UPDATE` clause intentionally does NOT overwrite `origin` on conflict —
102    ///   an entity first seen in conversation that reappears in an import stays `conversation`.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if the database query fails.
107    #[tracing::instrument(name = "memory.graph.store.upsert_entity", skip_all)]
108    pub async fn upsert_entity(
109        &self,
110        surface_name: &str,
111        canonical_name: &str,
112        entity_type: EntityType,
113        summary: Option<&str>,
114        provenance: Option<&GraphProvenance>,
115    ) -> Result<EntityId, MemoryError> {
116        let type_str = entity_type.as_str();
117        let (origin, batch_id, _source_uri) = provenance_parts(provenance);
118        let id: i64 = zeph_db::query_scalar(sql!(
119            "INSERT INTO graph_entities (name, canonical_name, entity_type, summary, origin, import_batch_id)
120             VALUES (?, ?, ?, ?, ?, ?)
121             ON CONFLICT(canonical_name, entity_type) DO UPDATE SET
122               name = excluded.name,
123               summary = COALESCE(excluded.summary, graph_entities.summary),
124               last_seen_at = CURRENT_TIMESTAMP
125             RETURNING id"
126        ))
127        .bind(surface_name)
128        .bind(canonical_name)
129        .bind(type_str)
130        .bind(summary)
131        .bind(origin)
132        .bind(batch_id)
133        .fetch_one(&self.pool)
134        .await?;
135        Ok(EntityId(id))
136    }
137
138    /// Find an entity by exact canonical name and type.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the database query fails.
143    #[tracing::instrument(name = "memory.graph.store.find_entity", skip_all)]
144    pub async fn find_entity(
145        &self,
146        canonical_name: &str,
147        entity_type: EntityType,
148    ) -> Result<Option<Entity>, MemoryError> {
149        let type_str = entity_type.as_str();
150        let raw = format!(
151            "SELECT {} FROM graph_entities WHERE canonical_name = ? AND entity_type = ?",
152            entity_select_cols()
153        );
154        let query_sql = zeph_db::rewrite_placeholders(&raw);
155        let row: Option<EntityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
156            .bind(canonical_name)
157            .bind(type_str)
158            .fetch_optional(&self.pool)
159            .await?;
160        row.map(entity_from_row).transpose()
161    }
162
163    /// Find an entity by its numeric ID.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the database query fails.
168    #[tracing::instrument(name = "memory.graph.store.find_entity_by_id", skip_all)]
169    pub async fn find_entity_by_id(&self, entity_id: i64) -> Result<Option<Entity>, MemoryError> {
170        let raw = format!(
171            "SELECT {} FROM graph_entities WHERE id = ?",
172            entity_select_cols()
173        );
174        let query_sql = zeph_db::rewrite_placeholders(&raw);
175        let row: Option<EntityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
176            .bind(entity_id)
177            .fetch_optional(&self.pool)
178            .await?;
179        row.map(entity_from_row).transpose()
180    }
181
182    /// Resolve entity IDs to their canonical names in a single batched query.
183    ///
184    /// Ids with no matching row are silently omitted from the result map. Requests are
185    /// chunked at 490 ids to stay under the `SQLite` bind-parameter limit, mirroring the
186    /// chunking used by [`Self::entity_ids_in`] and [`Self::qdrant_point_ids_for_entities`].
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the database query fails.
191    #[tracing::instrument(name = "memory.graph.store.resolve_entity_names", skip_all, fields(count = ids.len()))]
192    pub async fn resolve_entity_names(
193        &self,
194        ids: &[i64],
195    ) -> Result<HashMap<i64, String>, MemoryError> {
196        let mut result: HashMap<i64, String> = HashMap::with_capacity(ids.len());
197        if ids.is_empty() {
198            return Ok(result);
199        }
200        for chunk in ids.chunks(SQLITE_BATCH_LIMIT_2X) {
201            let placeholders = placeholder_list(1, chunk.len());
202            let sql = format!(
203                "SELECT id, canonical_name FROM graph_entities WHERE id IN ({placeholders})"
204            );
205            let mut q = zeph_db::query_as::<_, (i64, String)>(sqlx::AssertSqlSafe(sql));
206            for id in chunk {
207                q = q.bind(*id);
208            }
209            let rows: Vec<(i64, String)> = q.fetch_all(&self.pool).await?;
210            for (id, name) in rows {
211                result.insert(id, name);
212            }
213        }
214        Ok(result)
215    }
216
217    /// Update the `qdrant_point_id` for an entity.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the database query fails.
222    #[tracing::instrument(name = "memory.graph.store.set_entity_qdrant_point_id", skip_all)]
223    pub async fn set_entity_qdrant_point_id(
224        &self,
225        entity_id: i64,
226        point_id: &str,
227    ) -> Result<(), MemoryError> {
228        zeph_db::query(sql!(
229            "UPDATE graph_entities SET qdrant_point_id = ? WHERE id = ?"
230        ))
231        .bind(point_id)
232        .bind(entity_id)
233        .execute(&self.pool)
234        .await?;
235        Ok(())
236    }
237
238    /// Find entities matching `query` in name, summary, or aliases, up to `limit` results, ranked by relevance.
239    ///
240    /// Uses FTS5 MATCH with prefix wildcards (`token*`) and bm25 ranking. Name matches are
241    /// weighted 10x higher than summary matches. Also searches `graph_entity_aliases` for
242    /// alias matches via a UNION query.
243    ///
244    /// # Behavioral note
245    ///
246    /// This replaces the previous `LIKE '%query%'` implementation. FTS5 prefix matching differs
247    /// from substring matching: searching "SQL" will match "`SQLite`" (prefix) but NOT "`GraphQL`"
248    /// (substring). Entity names are indexed as single tokens by the unicode61 tokenizer, so
249    /// mid-word substrings are not matched. This is a known trade-off for index performance.
250    ///
251    /// Single-character queries (e.g., "a") are allowed and produce a broad prefix match ("a*").
252    /// The `limit` parameter caps the result set. No minimum query length is enforced; if this
253    /// causes noise in practice, add a minimum length guard at the call site.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the database query fails.
258    #[tracing::instrument(name = "memory.graph.store.find_entities_fuzzy", skip_all)]
259    pub async fn find_entities_fuzzy(
260        &self,
261        query: &str,
262        limit: usize,
263    ) -> Result<Vec<Entity>, MemoryError> {
264        // FTS5 boolean operator keywords (case-sensitive uppercase). Filtering these
265        // prevents syntax errors when user input contains them as literal search terms
266        // (e.g., "graph OR unrelated" must not produce "graph* OR* unrelated*").
267        const FTS5_OPERATORS: &[&str] = &["AND", "OR", "NOT", "NEAR"];
268        let query = &query[..query.floor_char_boundary(512)];
269        // Sanitize input: split on non-alphanumeric characters, filter empty tokens,
270        // append '*' to each token for FTS5 prefix matching ("graph" -> "graph*").
271        let sanitized = sanitize_fts_query(query);
272        if sanitized.is_empty() {
273            return Ok(vec![]);
274        }
275        let fts_query: String = sanitized
276            .split_whitespace()
277            .filter(|t| !FTS5_OPERATORS.contains(t))
278            .map(|t| format!("{t}*"))
279            .collect::<Vec<_>>()
280            .join(" ");
281        if fts_query.is_empty() {
282            return Ok(vec![]);
283        }
284
285        let limit = i64::try_from(limit)?;
286        // bm25(graph_entities_fts, 10.0, 1.0): name column weighted 10x over summary.
287        // bm25() returns negative values; ORDER BY ASC puts best matches first.
288        let search_sql = format!(
289            "SELECT DISTINCT e.id, e.name, e.canonical_name, e.entity_type, e.summary, \
290                    e.first_seen_at, e.last_seen_at, e.qdrant_point_id \
291             FROM graph_entities_fts fts \
292             JOIN graph_entities e ON e.id = fts.rowid \
293             WHERE graph_entities_fts MATCH ? \
294             UNION \
295             SELECT e.id, e.name, e.canonical_name, e.entity_type, e.summary, \
296                    e.first_seen_at, e.last_seen_at, e.qdrant_point_id \
297             FROM graph_entity_aliases a \
298             JOIN graph_entities e ON e.id = a.entity_id \
299             WHERE a.alias_name LIKE ? ESCAPE '\\' {} \
300             LIMIT ?",
301            <ActiveDialect as zeph_db::dialect::Dialect>::COLLATE_NOCASE,
302        );
303        let rows: Vec<EntityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(search_sql))
304            .bind(&fts_query)
305            .bind(format!(
306                "%{}%",
307                query
308                    .trim()
309                    .replace('\\', "\\\\")
310                    .replace('%', "\\%")
311                    .replace('_', "\\_")
312            ))
313            .bind(limit)
314            .fetch_all(&self.pool)
315            .await?;
316        rows.into_iter()
317            .map(entity_from_row)
318            .collect::<Result<Vec<_>, _>>()
319    }
320
321    /// Flush the `SQLite` WAL to the main database file.
322    ///
323    /// Runs `PRAGMA wal_checkpoint(PASSIVE)`. Safe to call at any time; does not block active
324    /// readers or writers. Call after bulk entity inserts to ensure FTS5 shadow table writes are
325    /// visible to connections opened in future sessions.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the PRAGMA execution fails.
330    #[cfg(feature = "sqlite")]
331    #[tracing::instrument(name = "memory.graph.store.checkpoint_wal", skip_all)]
332    pub async fn checkpoint_wal(&self) -> Result<(), MemoryError> {
333        zeph_db::query("PRAGMA wal_checkpoint(PASSIVE)")
334            .execute(&self.pool)
335            .await?;
336        Ok(())
337    }
338
339    /// No-op on `PostgreSQL` (WAL management is handled by the server).
340    ///
341    /// # Errors
342    ///
343    /// Never returns an error.
344    #[cfg(feature = "postgres")]
345    #[allow(clippy::unused_async)]
346    #[tracing::instrument(name = "memory.graph.store.checkpoint_wal", skip_all)]
347    pub async fn checkpoint_wal(&self) -> Result<(), MemoryError> {
348        Ok(())
349    }
350
351    /// Stream all entities from the database incrementally (true cursor, no full-table load).
352    pub fn all_entities_stream(&self) -> impl Stream<Item = Result<Entity, MemoryError>> + '_ {
353        use futures::StreamExt as _;
354        let raw = format!(
355            "SELECT {} FROM graph_entities ORDER BY id ASC",
356            entity_select_cols()
357        );
358        zeph_db::query_as::<_, EntityRow>(sqlx::AssertSqlSafe(raw))
359            .fetch(&self.pool)
360            .map(|r: Result<EntityRow, zeph_db::SqlxError>| {
361                r.map_err(MemoryError::from).and_then(entity_from_row)
362            })
363    }
364
365    // ── Alias methods ─────────────────────────────────────────────────────────
366
367    /// Insert an alias for an entity (idempotent: duplicate alias is silently ignored via UNIQUE constraint).
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if the database query fails.
372    #[tracing::instrument(name = "memory.graph.store.add_alias", skip_all)]
373    pub async fn add_alias(&self, entity_id: i64, alias_name: &str) -> Result<(), MemoryError> {
374        let insert_alias_sql = zeph_db::rewrite_placeholders(&format!(
375            "{} INTO graph_entity_aliases (entity_id, alias_name) VALUES (?, ?){}",
376            <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
377            <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
378        ));
379        zeph_db::query(sqlx::AssertSqlSafe(insert_alias_sql))
380            .bind(entity_id)
381            .bind(alias_name)
382            .execute(&self.pool)
383            .await?;
384        Ok(())
385    }
386
387    /// Find an entity by alias name and entity type (case-insensitive).
388    ///
389    /// Filters by `entity_type` to avoid cross-type alias collisions (S2 fix).
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if the database query fails.
394    #[tracing::instrument(name = "memory.graph.store.find_entity_by_alias", skip_all)]
395    pub async fn find_entity_by_alias(
396        &self,
397        alias_name: &str,
398        entity_type: EntityType,
399    ) -> Result<Option<Entity>, MemoryError> {
400        let type_str = entity_type.as_str();
401        let first_seen_sel =
402            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("e.first_seen_at");
403        let last_seen_sel =
404            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("e.last_seen_at");
405        let alias_typed_sql = zeph_db::rewrite_placeholders(&format!(
406            "SELECT e.id, e.name, e.canonical_name, e.entity_type, e.summary, \
407                    {first_seen_sel} AS first_seen_at, {last_seen_sel} AS last_seen_at, \
408                    e.qdrant_point_id \
409             FROM graph_entity_aliases a \
410             JOIN graph_entities e ON e.id = a.entity_id \
411             WHERE a.alias_name = ? {} \
412               AND e.entity_type = ? \
413             ORDER BY e.id ASC \
414             LIMIT 1",
415            <ActiveDialect as zeph_db::dialect::Dialect>::COLLATE_NOCASE,
416        ));
417        let row: Option<EntityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(alias_typed_sql))
418            .bind(alias_name)
419            .bind(type_str)
420            .fetch_optional(&self.pool)
421            .await?;
422        row.map(entity_from_row).transpose()
423    }
424
425    /// Get all aliases for an entity.
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if the database query fails.
430    #[tracing::instrument(name = "memory.graph.store.aliases_for_entity", skip_all)]
431    pub async fn aliases_for_entity(
432        &self,
433        entity_id: i64,
434    ) -> Result<Vec<EntityAlias>, MemoryError> {
435        let sql = zeph_db::rewrite_placeholders(&format!(
436            "SELECT {} FROM graph_entity_aliases WHERE entity_id = ? ORDER BY id ASC",
437            alias_select_cols()
438        ));
439        let rows: Vec<AliasRow> = zeph_db::query_as(sqlx::AssertSqlSafe(sql))
440            .bind(entity_id)
441            .fetch_all(&self.pool)
442            .await?;
443        Ok(rows.into_iter().map(alias_from_row).collect())
444    }
445
446    /// Collect all entities into a Vec.
447    ///
448    /// # Errors
449    ///
450    /// Returns an error if the database query fails or `entity_type` parsing fails.
451    #[tracing::instrument(name = "memory.graph.store.all_entities", skip_all)]
452    pub async fn all_entities(&self) -> Result<Vec<Entity>, MemoryError> {
453        use futures::TryStreamExt as _;
454        self.all_entities_stream().try_collect().await
455    }
456
457    /// Count the total number of entities.
458    ///
459    /// # Errors
460    ///
461    /// Returns an error if the database query fails.
462    #[tracing::instrument(name = "memory.graph.store.entity_count", skip_all)]
463    pub async fn entity_count(&self) -> Result<i64, MemoryError> {
464        let count: i64 = zeph_db::query_scalar(sql!("SELECT COUNT(*) FROM graph_entities"))
465            .fetch_one(&self.pool)
466            .await?;
467        Ok(count)
468    }
469
470    // ── Edges ─────────────────────────────────────────────────────────────────
471
472    /// Insert a new edge between two entities, or update the existing active edge.
473    ///
474    /// An active edge is identified by `(source_entity_id, target_entity_id, relation, edge_type)`
475    /// with `valid_to IS NULL`. If such an edge already exists, its `confidence` is updated to the
476    /// maximum of the stored and incoming values, and the existing id is returned. This prevents
477    /// duplicate edges from repeated extraction of the same context messages.
478    ///
479    /// The dedup key includes `edge_type` (critic mitigation): the same `(source, target, relation)`
480    /// triple can legitimately exist with different edge types (e.g., `depends_on` can be both
481    /// Semantic and Causal). Without `edge_type` in the key, the second insertion would silently
482    /// update the first and lose the type classification.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if the database query fails.
487    #[allow(clippy::too_many_arguments)]
488    #[tracing::instrument(name = "memory.graph.store.insert_edge", skip_all)]
489    pub async fn insert_edge(
490        &self,
491        source_entity_id: i64,
492        target_entity_id: i64,
493        relation: &str,
494        fact: &str,
495        confidence: f32,
496        episode_id: Option<MessageId>,
497        provenance: Option<&GraphProvenance>,
498    ) -> Result<i64, MemoryError> {
499        self.insert_edge_typed(
500            source_entity_id,
501            target_entity_id,
502            relation,
503            fact,
504            confidence,
505            episode_id,
506            EdgeType::Semantic,
507            None,
508            provenance,
509        )
510        .await
511    }
512
513    /// Insert a typed edge between two entities, or update the existing active edge of the same type.
514    ///
515    /// Identical semantics to [`Self::insert_edge`] but with an explicit `edge_type` parameter.
516    /// The dedup key is `(source_entity_id, target_entity_id, relation, edge_type, valid_to IS NULL)`.
517    ///
518    /// `turn_index` (#5784) is stored only on the newly inserted row; the dedup UPDATE branch
519    /// (re-encountering an identical active edge) does not overwrite it, matching
520    /// [`Self::insert_or_supersede_with_turn_index_and_metrics`]'s reassertion semantics.
521    ///
522    /// The `provenance` parameter stamps `origin`, `import_batch_id`, and `source_uri` on the new
523    /// row. Pass `None` for conversation-origin edges (the default). The dedup UPDATE branch does
524    /// NOT overwrite provenance — an existing edge keeps its original origin on re-encounter.
525    ///
526    /// # Errors
527    ///
528    /// Returns an error if the database query fails.
529    #[allow(clippy::too_many_arguments)]
530    // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
531    #[tracing::instrument(name = "memory.graph.store.insert_edge_typed", skip_all)]
532    pub async fn insert_edge_typed(
533        &self,
534        source_entity_id: i64,
535        target_entity_id: i64,
536        relation: &str,
537        fact: &str,
538        confidence: f32,
539        episode_id: Option<MessageId>,
540        edge_type: EdgeType,
541        turn_index: Option<u32>,
542        provenance: Option<&GraphProvenance>,
543    ) -> Result<i64, MemoryError> {
544        if source_entity_id == target_entity_id {
545            return Err(MemoryError::InvalidInput(format!(
546                "self-loop edge rejected: source and target are the same entity (id={source_entity_id})"
547            )));
548        }
549        let confidence = confidence.clamp(0.0, 1.0);
550        let edge_type_str = edge_type.as_str();
551        let (origin, batch_id, source_uri) = provenance_parts(provenance);
552
553        // Wrap SELECT + INSERT/UPDATE in a single transaction to eliminate the race window
554        // between existence check and write. The unique partial index uq_graph_edges_active
555        // covers (source, target, relation, edge_type) WHERE valid_to IS NULL; SQLite does not
556        // support ON CONFLICT DO UPDATE against partial indexes, so we keep two statements.
557        let mut tx = zeph_db::begin(&self.pool).await?;
558
559        let existing: Option<(i64, f64, f64, f64)> = zeph_db::query_as(sql!(
560            "SELECT id, confidence, CAST(confidence_fast AS DOUBLE PRECISION),
561                    CAST(confidence_slow AS DOUBLE PRECISION) FROM graph_edges
562             WHERE source_entity_id = ?
563               AND target_entity_id = ?
564               AND relation = ?
565               AND edge_type = ?
566               AND valid_to IS NULL
567             LIMIT 1"
568        ))
569        .bind(source_entity_id)
570        .bind(target_entity_id)
571        .bind(relation)
572        .bind(edge_type_str)
573        .fetch_optional(&mut *tx)
574        .await?;
575
576        if let Some((existing_id, stored_conf, stored_fast, stored_slow)) = existing {
577            let updated_conf = f64::from(confidence).max(stored_conf);
578            // Benna-Fusi multi-timescale update (#3709):
579            // fast tracks incoming confidence at high plasticity; slow integrates fast at high retention.
580            // Clamp DB-read values to [0, 1] as defense-in-depth against corrupted rows.
581            #[allow(clippy::cast_possible_truncation)]
582            let stored_fast_f32 = (stored_fast as f32).clamp(0.0, 1.0);
583            #[allow(clippy::cast_possible_truncation)]
584            let stored_slow_f32 = (stored_slow as f32).clamp(0.0, 1.0);
585            let new_fast = stored_fast_f32 + self.benna_fast_rate * (confidence - stored_fast_f32);
586            let new_slow = stored_slow_f32 + self.benna_slow_rate * (new_fast - stored_slow_f32);
587            // DO NOT update origin/import_batch_id/source_uri — the existing row keeps its origin.
588            zeph_db::query(sql!(
589                "UPDATE graph_edges SET confidence = ?, confidence_fast = ?, confidence_slow = ? WHERE id = ?"
590            ))
591            .bind(updated_conf)
592            .bind(f64::from(new_fast))
593            .bind(f64::from(new_slow))
594            .bind(existing_id)
595            .execute(&mut *tx)
596            .await?;
597            tx.commit().await?;
598            return Ok(existing_id);
599        }
600
601        let episode_raw: Option<i64> = episode_id.map(|m| m.0);
602        let turn_index_raw: Option<i64> = turn_index.map(i64::from);
603        let id: i64 = zeph_db::query_scalar(sql!(
604            "INSERT INTO graph_edges
605             (source_entity_id, target_entity_id, relation, fact, confidence,
606              confidence_fast, confidence_slow, episode_id, edge_type, turn_index,
607              origin, import_batch_id, source_uri)
608             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
609             RETURNING id"
610        ))
611        .bind(source_entity_id)
612        .bind(target_entity_id)
613        .bind(relation)
614        .bind(fact)
615        .bind(f64::from(confidence))
616        .bind(f64::from(confidence))
617        .bind(f64::from(confidence))
618        .bind(episode_raw)
619        .bind(edge_type_str)
620        .bind(turn_index_raw)
621        .bind(origin)
622        .bind(batch_id)
623        .bind(source_uri)
624        .fetch_one(&mut *tx)
625        .await?;
626        tx.commit().await?;
627        Ok(id)
628    }
629
630    /// Mark an edge as invalid (set `valid_to` and `expired_at` to now).
631    ///
632    /// # Errors
633    ///
634    /// Returns an error if the database update fails.
635    #[tracing::instrument(name = "memory.graph.store.invalidate_edge", skip_all)]
636    pub async fn invalidate_edge(&self, edge_id: i64) -> Result<(), MemoryError> {
637        zeph_db::query(sql!(
638            "UPDATE graph_edges SET valid_to = CURRENT_TIMESTAMP, expired_at = CURRENT_TIMESTAMP
639             WHERE id = ?"
640        ))
641        .bind(edge_id)
642        .execute(&self.pool)
643        .await?;
644        Ok(())
645    }
646
647    /// Invalidate an edge and record the supersession pointer for Kumiho belief revision audit trail.
648    ///
649    /// Sets `valid_to`, `expired_at`, and `superseded_by` on the old edge to link it to its replacement.
650    ///
651    /// # Errors
652    ///
653    /// Returns an error if the database update fails.
654    #[tracing::instrument(
655        name = "memory.graph.store.invalidate_edge_with_supersession",
656        skip_all
657    )]
658    pub async fn invalidate_edge_with_supersession(
659        &self,
660        old_edge_id: i64,
661        new_edge_id: i64,
662    ) -> Result<(), MemoryError> {
663        zeph_db::query(sql!(
664            "UPDATE graph_edges
665             SET valid_to = CURRENT_TIMESTAMP,
666                 expired_at = CURRENT_TIMESTAMP,
667                 superseded_by = ?
668             WHERE id = ?"
669        ))
670        .bind(new_edge_id)
671        .bind(old_edge_id)
672        .execute(&self.pool)
673        .await?;
674        Ok(())
675    }
676
677    /// Get all active edges for a batch of entity IDs, with optional MAGMA edge type filtering.
678    ///
679    /// Fetches all currently-active edges (`valid_to IS NULL`) where either endpoint
680    /// is in `entity_ids`. Traversal is always current-time only (no `at_timestamp` support
681    /// in v1 — see `bfs_at_timestamp` for historical traversal).
682    ///
683    /// # `SQLite` bind limit safety
684    ///
685    /// `SQLite` limits the number of bind parameters to `SQLITE_MAX_VARIABLE_NUMBER` (999 by
686    /// default). Each entity ID requires two bind slots (source OR target), so batches are
687    /// chunked at [`SQLITE_BATCH_LIMIT_2X`] to stay safely under the limit regardless of
688    /// compile-time `SQLite` configuration.
689    ///
690    /// # Errors
691    ///
692    /// Returns an error if the database query fails.
693    #[tracing::instrument(name = "memory.graph.store.edges_for_entities", skip_all, fields(count = entity_ids.len()))]
694    pub async fn edges_for_entities(
695        &self,
696        entity_ids: &[i64],
697        edge_types: &[super::types::EdgeType],
698    ) -> Result<Vec<Edge>, MemoryError> {
699        let mut all_edges: Vec<Edge> = Vec::new();
700
701        for chunk in entity_ids.chunks(SQLITE_BATCH_LIMIT_2X) {
702            let edges = self.query_batch_edges(chunk, edge_types).await?;
703            all_edges.extend(edges);
704        }
705
706        Ok(all_edges)
707    }
708
709    /// Query active edges for a single chunk of entity IDs (internal helper).
710    ///
711    /// Caller is responsible for ensuring `entity_ids.len() <= SQLITE_BATCH_LIMIT_2X`.
712    ///
713    /// # Errors
714    ///
715    /// Returns an error if any database query fails.
716    #[tracing::instrument(name = "memory.graph.store.query_batch_edges", skip_all)]
717    async fn query_batch_edges(
718        &self,
719        entity_ids: &[i64],
720        edge_types: &[super::types::EdgeType],
721    ) -> Result<Vec<Edge>, MemoryError> {
722        if entity_ids.is_empty() {
723            return Ok(Vec::new());
724        }
725
726        // Build a parameterized IN clause with backend-appropriate placeholders.
727        // We cannot use the sql! macro here because the placeholder count is dynamic.
728        let n_ids = entity_ids.len();
729        let n_types = edge_types.len();
730
731        // Append recall_include_imported filter when ingest-origin edges should be excluded.
732        let origin_filter = if self.recall_include_imported {
733            String::new()
734        } else {
735            " AND origin = 'conversation'".to_owned()
736        };
737
738        let edge_cols = edge_select_cols("");
739        let sql = if n_types == 0 {
740            // placeholders used twice (source IN and target IN)
741            let placeholders = placeholder_list(1, n_ids);
742            let placeholders2 = placeholder_list(n_ids + 1, n_ids);
743            format!(
744                "SELECT {edge_cols}
745                 FROM graph_edges
746                 WHERE valid_to IS NULL{origin_filter}
747                   AND (source_entity_id IN ({placeholders}) OR target_entity_id IN ({placeholders2}))"
748            )
749        } else {
750            let placeholders = placeholder_list(1, n_ids);
751            let placeholders2 = placeholder_list(n_ids + 1, n_ids);
752            let type_placeholders = placeholder_list(n_ids * 2 + 1, n_types);
753            format!(
754                "SELECT {edge_cols}
755                 FROM graph_edges
756                 WHERE valid_to IS NULL{origin_filter}
757                   AND (source_entity_id IN ({placeholders}) OR target_entity_id IN ({placeholders2}))
758                   AND edge_type IN ({type_placeholders})"
759            )
760        };
761
762        // Bind entity IDs twice (source IN and target IN clauses) then edge types.
763        let mut query = zeph_db::query_as::<_, EdgeRow>(sqlx::AssertSqlSafe(sql));
764        for id in entity_ids {
765            query = query.bind(*id);
766        }
767        for id in entity_ids {
768            query = query.bind(*id);
769        }
770        for et in edge_types {
771            query = query.bind(et.as_str());
772        }
773
774        // Bound worst-case per-chunk blocking on pool.acquire() + query execution: a
775        // stuck pool turns into a typed MemoryError::Timeout instead of an indefinite
776        // hang, so the caller (SemanticMemory recall) can fail fast and proceed.
777        let rows: Vec<EdgeRow> = tokio::time::timeout(
778            std::time::Duration::from_millis(500),
779            query.fetch_all(&self.pool),
780        )
781        .await
782        .map_err(|_| MemoryError::Timeout("graph pool acquire timed out after 500ms".into()))??;
783        Ok(rows.into_iter().map(edge_from_row).collect())
784    }
785
786    /// Get all active edges where entity is source or target.
787    ///
788    /// When `recall_include_imported` is `false`, imported (non-conversation) edges are excluded.
789    ///
790    /// # Errors
791    ///
792    /// Returns an error if the database query fails.
793    #[tracing::instrument(name = "memory.graph.store.edges_for_entity", skip_all)]
794    pub async fn edges_for_entity(&self, entity_id: i64) -> Result<Vec<Edge>, MemoryError> {
795        let origin_filter = if self.recall_include_imported {
796            ""
797        } else {
798            " AND origin = 'conversation'"
799        };
800        let sql = format!(
801            "SELECT {} \
802             FROM graph_edges \
803             WHERE valid_to IS NULL{origin_filter} \
804               AND (source_entity_id = ? OR target_entity_id = ?)",
805            edge_select_cols("")
806        );
807        let query_sql = zeph_db::rewrite_placeholders(&sql);
808        let rows: Vec<EdgeRow> = zeph_db::query_as::<_, EdgeRow>(sqlx::AssertSqlSafe(query_sql))
809            .bind(entity_id)
810            .bind(entity_id)
811            .fetch_all(&self.pool)
812            .await?;
813        Ok(rows.into_iter().map(edge_from_row).collect())
814    }
815
816    /// Get all edges (active and expired) where entity is source or target, ordered by
817    /// `valid_from DESC`. Used by the `/graph history <name>` slash command.
818    ///
819    /// # Errors
820    ///
821    /// Returns an error if the database query fails or if `limit` overflows `i64`.
822    // Raw access — does not apply recall_include_imported filter; for filtered recall use query_batch_edges / edges_for_entity.
823    #[tracing::instrument(name = "memory.graph.store.edge_history_for_entity", skip_all)]
824    pub async fn edge_history_for_entity(
825        &self,
826        entity_id: i64,
827        limit: usize,
828    ) -> Result<Vec<Edge>, MemoryError> {
829        let limit = i64::try_from(limit)?;
830        let raw = format!(
831            "SELECT {} \
832             FROM graph_edges \
833             WHERE source_entity_id = ? OR target_entity_id = ? \
834             ORDER BY graph_edges.valid_from DESC \
835             LIMIT ?",
836            edge_select_cols("")
837        );
838        let query_sql = zeph_db::rewrite_placeholders(&raw);
839        let rows: Vec<EdgeRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
840            .bind(entity_id)
841            .bind(entity_id)
842            .bind(limit)
843            .fetch_all(&self.pool)
844            .await?;
845        Ok(rows.into_iter().map(edge_from_row).collect())
846    }
847
848    /// Get all active edges between two entities (both directions).
849    ///
850    /// # Errors
851    ///
852    /// Returns an error if the database query fails.
853    // Raw access — does not apply recall_include_imported filter; for filtered recall use query_batch_edges / edges_for_entity.
854    #[tracing::instrument(name = "memory.graph.store.edges_between", skip_all)]
855    pub async fn edges_between(
856        &self,
857        entity_a: i64,
858        entity_b: i64,
859    ) -> Result<Vec<Edge>, MemoryError> {
860        let raw = format!(
861            "SELECT {} \
862             FROM graph_edges \
863             WHERE valid_to IS NULL \
864               AND ((source_entity_id = ? AND target_entity_id = ?) \
865                 OR (source_entity_id = ? AND target_entity_id = ?))",
866            edge_select_cols("")
867        );
868        let query_sql = zeph_db::rewrite_placeholders(&raw);
869        let rows: Vec<EdgeRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
870            .bind(entity_a)
871            .bind(entity_b)
872            .bind(entity_b)
873            .bind(entity_a)
874            .fetch_all(&self.pool)
875            .await?;
876        Ok(rows.into_iter().map(edge_from_row).collect())
877    }
878
879    /// Get active edges from `source` to `target` in the exact direction (no reverse).
880    ///
881    /// # Errors
882    ///
883    /// Returns an error if the database query fails.
884    // Raw access — does not apply recall_include_imported filter; for filtered recall use query_batch_edges / edges_for_entity.
885    #[tracing::instrument(name = "memory.graph.store.edges_exact", skip_all)]
886    pub async fn edges_exact(
887        &self,
888        source_entity_id: i64,
889        target_entity_id: i64,
890    ) -> Result<Vec<Edge>, MemoryError> {
891        let raw = format!(
892            "SELECT {} \
893             FROM graph_edges \
894             WHERE valid_to IS NULL \
895               AND source_entity_id = ? \
896               AND target_entity_id = ?",
897            edge_select_cols("")
898        );
899        let query_sql = zeph_db::rewrite_placeholders(&raw);
900        let rows: Vec<EdgeRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
901            .bind(source_entity_id)
902            .bind(target_entity_id)
903            .fetch_all(&self.pool)
904            .await?;
905        Ok(rows.into_iter().map(edge_from_row).collect())
906    }
907
908    /// Count active (non-invalidated) edges.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if the database query fails.
913    #[tracing::instrument(name = "memory.graph.store.active_edge_count", skip_all)]
914    pub async fn active_edge_count(&self) -> Result<i64, MemoryError> {
915        let count: i64 = zeph_db::query_scalar(sql!(
916            "SELECT COUNT(*) FROM graph_edges WHERE valid_to IS NULL"
917        ))
918        .fetch_one(&self.pool)
919        .await?;
920        Ok(count)
921    }
922
923    /// Return per-type active edge counts as `(edge_type, count)` pairs.
924    ///
925    /// # Errors
926    ///
927    /// Returns an error if the database query fails.
928    #[tracing::instrument(name = "memory.graph.store.edge_type_distribution", skip_all)]
929    pub async fn edge_type_distribution(&self) -> Result<Vec<(String, i64)>, MemoryError> {
930        let rows: Vec<(String, i64)> = zeph_db::query_as(
931            sql!("SELECT edge_type, COUNT(*) FROM graph_edges WHERE valid_to IS NULL GROUP BY edge_type ORDER BY edge_type"),
932        )
933        .fetch_all(&self.pool)
934        .await?;
935        Ok(rows)
936    }
937
938    // ── Communities ───────────────────────────────────────────────────────────
939
940    /// Insert or update a community by name.
941    ///
942    /// `fingerprint` is a BLAKE3 hex string computed from sorted entity IDs and
943    /// intra-community edge IDs. Pass `None` to leave the fingerprint unchanged (e.g. when
944    /// `assign_to_community` adds an entity without a full re-detection pass).
945    ///
946    /// # Errors
947    ///
948    /// Returns an error if the database query fails or JSON serialization fails.
949    #[tracing::instrument(name = "memory.graph.store.upsert_community", skip_all)]
950    pub async fn upsert_community(
951        &self,
952        name: &str,
953        summary: &str,
954        entity_ids: &[i64],
955        fingerprint: Option<&str>,
956    ) -> Result<i64, MemoryError> {
957        let entity_ids_json = serde_json::to_string(entity_ids)?;
958        let id: i64 = zeph_db::query_scalar(sql!(
959            "INSERT INTO graph_communities (name, summary, entity_ids, fingerprint)
960             VALUES (?, ?, ?, ?)
961             ON CONFLICT(name) DO UPDATE SET
962               summary = excluded.summary,
963               entity_ids = excluded.entity_ids,
964               fingerprint = COALESCE(excluded.fingerprint, graph_communities.fingerprint),
965               updated_at = CURRENT_TIMESTAMP
966             RETURNING id"
967        ))
968        .bind(name)
969        .bind(summary)
970        .bind(entity_ids_json)
971        .bind(fingerprint)
972        .fetch_one(&self.pool)
973        .await?;
974        Ok(id)
975    }
976
977    /// Return a map of `fingerprint -> community_id` for all communities with a non-NULL
978    /// fingerprint. Used by `detect_communities` to skip unchanged partitions.
979    ///
980    /// # Errors
981    ///
982    /// Returns an error if the database query fails.
983    #[tracing::instrument(name = "memory.graph.store.community_fingerprints", skip_all)]
984    pub async fn community_fingerprints(&self) -> Result<HashMap<String, i64>, MemoryError> {
985        let rows: Vec<(String, i64)> = zeph_db::query_as(sql!(
986            "SELECT fingerprint, id FROM graph_communities WHERE fingerprint IS NOT NULL"
987        ))
988        .fetch_all(&self.pool)
989        .await?;
990        Ok(rows.into_iter().collect())
991    }
992
993    /// Delete a single community by its primary key.
994    ///
995    /// # Errors
996    ///
997    /// Returns an error if the database query fails.
998    #[tracing::instrument(name = "memory.graph.store.delete_community_by_id", skip_all)]
999    pub async fn delete_community_by_id(&self, id: i64) -> Result<(), MemoryError> {
1000        zeph_db::query(sql!("DELETE FROM graph_communities WHERE id = ?"))
1001            .bind(id)
1002            .execute(&self.pool)
1003            .await?;
1004        Ok(())
1005    }
1006
1007    /// Set the fingerprint of a community to `NULL`, invalidating the incremental cache.
1008    ///
1009    /// Used by `assign_to_community` when an entity is added without a full re-detection pass,
1010    /// ensuring the next `detect_communities` run re-summarizes the affected community.
1011    ///
1012    /// # Errors
1013    ///
1014    /// Returns an error if the database query fails.
1015    #[tracing::instrument(name = "memory.graph.store.clear_community_fingerprint", skip_all)]
1016    pub async fn clear_community_fingerprint(&self, id: i64) -> Result<(), MemoryError> {
1017        zeph_db::query(sql!(
1018            "UPDATE graph_communities SET fingerprint = NULL WHERE id = ?"
1019        ))
1020        .bind(id)
1021        .execute(&self.pool)
1022        .await?;
1023        Ok(())
1024    }
1025
1026    /// Find the first community that contains the given `entity_id`.
1027    ///
1028    /// Uses `json_each()` to push the membership search into `SQLite`, avoiding a full
1029    /// table scan with per-row JSON parsing.
1030    ///
1031    /// # Errors
1032    ///
1033    /// Returns an error if the database query fails or JSON parsing fails.
1034    #[tracing::instrument(name = "memory.graph.store.community_for_entity", skip_all)]
1035    pub async fn community_for_entity(
1036        &self,
1037        entity_id: i64,
1038    ) -> Result<Option<Community>, MemoryError> {
1039        // NOTE: `json_each()` is `SQLite`-only syntax; this query is not yet dialect-gated for
1040        // Postgres (see `community_ids_sql` for the established sqlite/postgres split pattern
1041        // used elsewhere) — pre-existing gap, out of scope for the `TIMESTAMPTZ` decode fix here.
1042        let raw = format!(
1043            "SELECT {} \
1044             FROM graph_communities c, json_each(c.entity_ids) j \
1045             WHERE CAST(j.value AS INTEGER) = ? \
1046             LIMIT 1",
1047            community_select_cols("c.")
1048        );
1049        let query_sql = zeph_db::rewrite_placeholders(&raw);
1050        let row: Option<CommunityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
1051            .bind(entity_id)
1052            .fetch_optional(&self.pool)
1053            .await?;
1054        row.map(community_from_row).transpose()
1055    }
1056
1057    /// Get all communities.
1058    ///
1059    /// # Errors
1060    ///
1061    /// Returns an error if the database query fails or JSON parsing fails.
1062    #[tracing::instrument(name = "memory.graph.store.all_communities", skip_all)]
1063    pub async fn all_communities(&self) -> Result<Vec<Community>, MemoryError> {
1064        let raw = format!(
1065            "SELECT {} FROM graph_communities ORDER BY id ASC",
1066            community_select_cols("")
1067        );
1068        let rows: Vec<CommunityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(raw))
1069            .fetch_all(&self.pool)
1070            .await?;
1071
1072        rows.into_iter().map(community_from_row).collect()
1073    }
1074
1075    /// Count the total number of communities.
1076    ///
1077    /// # Errors
1078    ///
1079    /// Returns an error if the database query fails.
1080    #[tracing::instrument(name = "memory.graph.store.community_count", skip_all)]
1081    pub async fn community_count(&self) -> Result<i64, MemoryError> {
1082        let count: i64 = zeph_db::query_scalar(sql!("SELECT COUNT(*) FROM graph_communities"))
1083            .fetch_one(&self.pool)
1084            .await?;
1085        Ok(count)
1086    }
1087
1088    // ── Metadata ──────────────────────────────────────────────────────────────
1089
1090    /// Get a metadata value by key.
1091    ///
1092    /// # Errors
1093    ///
1094    /// Returns an error if the database query fails.
1095    #[tracing::instrument(name = "memory.graph.store.get_metadata", skip_all)]
1096    pub async fn get_metadata(&self, key: &str) -> Result<Option<String>, MemoryError> {
1097        let val: Option<String> =
1098            zeph_db::query_scalar(sql!("SELECT value FROM graph_metadata WHERE key = ?"))
1099                .bind(key)
1100                .fetch_optional(&self.pool)
1101                .await?;
1102        Ok(val)
1103    }
1104
1105    /// Set a metadata value by key (upsert).
1106    ///
1107    /// # Errors
1108    ///
1109    /// Returns an error if the database query fails.
1110    #[tracing::instrument(name = "memory.graph.store.set_metadata", skip_all)]
1111    pub async fn set_metadata(&self, key: &str, value: &str) -> Result<(), MemoryError> {
1112        zeph_db::query(sql!(
1113            "INSERT INTO graph_metadata (key, value) VALUES (?, ?)
1114             ON CONFLICT(key) DO UPDATE SET value = excluded.value"
1115        ))
1116        .bind(key)
1117        .bind(value)
1118        .execute(&self.pool)
1119        .await?;
1120        Ok(())
1121    }
1122
1123    /// Get the current extraction count from metadata.
1124    ///
1125    /// Returns 0 if the counter has not been initialized.
1126    ///
1127    /// # Errors
1128    ///
1129    /// Returns an error if the database query fails.
1130    #[tracing::instrument(name = "memory.graph.store.extraction_count", skip_all)]
1131    pub async fn extraction_count(&self) -> Result<i64, MemoryError> {
1132        let val = self.get_metadata("extraction_count").await?;
1133        Ok(val.and_then(|v| v.parse::<i64>().ok()).unwrap_or(0))
1134    }
1135
1136    /// Stream all active (non-invalidated) edges.
1137    pub fn all_active_edges_stream(&self) -> impl Stream<Item = Result<Edge, MemoryError>> + '_ {
1138        use futures::StreamExt as _;
1139        let raw = format!(
1140            "SELECT {} FROM graph_edges WHERE valid_to IS NULL ORDER BY id ASC",
1141            edge_select_cols("")
1142        );
1143        zeph_db::query_as::<_, EdgeRow>(sqlx::AssertSqlSafe(raw))
1144            .fetch(&self.pool)
1145            .map(|r| r.map_err(MemoryError::from).map(edge_from_row))
1146    }
1147
1148    /// Fetch a chunk of active edges using keyset pagination.
1149    ///
1150    /// Returns edges with `id > after_id` in ascending order, up to `limit` rows.
1151    /// Starting with `after_id = 0` returns the first chunk. Pass the last `id` from
1152    /// the returned chunk as `after_id` for the next page. An empty result means all
1153    /// edges have been consumed.
1154    ///
1155    /// Keyset pagination is O(1) per page (index seek on `id`) vs OFFSET which is O(N).
1156    /// It is also stable under concurrent inserts: new edges get monotonically higher IDs
1157    /// and will appear in subsequent chunks or after the last chunk, never causing
1158    /// duplicates. Concurrent invalidations (setting `valid_to`) may cause a single edge
1159    /// to be skipped, which is acceptable — LPA operates on an eventual-consistency snapshot.
1160    ///
1161    /// # Errors
1162    ///
1163    /// Returns an error if the database query fails.
1164    #[tracing::instrument(name = "memory.graph.store.edges_after_id", skip_all)]
1165    pub async fn edges_after_id(
1166        &self,
1167        after_id: i64,
1168        limit: i64,
1169    ) -> Result<Vec<Edge>, MemoryError> {
1170        let raw = format!(
1171            "SELECT {} \
1172             FROM graph_edges \
1173             WHERE valid_to IS NULL AND id > ? \
1174             ORDER BY id ASC \
1175             LIMIT ?",
1176            edge_select_cols("")
1177        );
1178        let query_sql = zeph_db::rewrite_placeholders(&raw);
1179        let rows: Vec<EdgeRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
1180            .bind(after_id)
1181            .bind(limit)
1182            .fetch_all(&self.pool)
1183            .await?;
1184        Ok(rows.into_iter().map(edge_from_row).collect())
1185    }
1186
1187    /// Find a community by its primary key.
1188    ///
1189    /// # Errors
1190    ///
1191    /// Returns an error if the database query fails or JSON parsing fails.
1192    #[tracing::instrument(name = "memory.graph.store.find_community_by_id", skip_all)]
1193    pub async fn find_community_by_id(&self, id: i64) -> Result<Option<Community>, MemoryError> {
1194        let raw = format!(
1195            "SELECT {} FROM graph_communities WHERE id = ?",
1196            community_select_cols("")
1197        );
1198        let query_sql = zeph_db::rewrite_placeholders(&raw);
1199        let row: Option<CommunityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
1200            .bind(id)
1201            .fetch_optional(&self.pool)
1202            .await?;
1203        row.map(community_from_row).transpose()
1204    }
1205
1206    /// Delete all communities (full rebuild before upsert).
1207    ///
1208    /// # Errors
1209    ///
1210    /// Returns an error if the database query fails.
1211    #[tracing::instrument(name = "memory.graph.store.delete_all_communities", skip_all)]
1212    pub async fn delete_all_communities(&self) -> Result<(), MemoryError> {
1213        zeph_db::query(sql!("DELETE FROM graph_communities"))
1214            .execute(&self.pool)
1215            .await?;
1216        Ok(())
1217    }
1218
1219    // ── A-MEM Retrieval Tracking ──────────────────────────────────────────────
1220
1221    /// Find entities matching `query` and return them with normalized FTS5 scores.
1222    ///
1223    /// Returns `Vec<(Entity, fts_score)>` where `fts_score` is normalized to `[0.0, 1.0]`
1224    /// by dividing each negated BM25 value by the maximum in the result set.
1225    /// Alias matches receive a fixed score of `0.5` (relative to FTS matches before normalization).
1226    ///
1227    /// Uses `UNION ALL` with outer `ORDER BY` to preserve FTS5 ordering through the LIMIT.
1228    ///
1229    /// # Errors
1230    ///
1231    /// Returns an error if the database query fails.
1232    #[tracing::instrument(name = "memory.graph.store.find_entities_ranked", skip_all)]
1233    pub async fn find_entities_ranked(
1234        &self,
1235        query: &str,
1236        limit: usize,
1237    ) -> Result<Vec<(Entity, f32)>, MemoryError> {
1238        let query = &query[..query.floor_char_boundary(512)];
1239        let Some(fts_query) = build_fts_query(query) else {
1240            return Ok(vec![]);
1241        };
1242
1243        let limit_i64 = i64::try_from(limit)?;
1244        let ranked_fts_sql = build_ranked_fts_sql();
1245        let rows: Vec<EntityFtsRow> = zeph_db::query_as(sqlx::AssertSqlSafe(ranked_fts_sql))
1246            .bind(&fts_query)
1247            .bind(format!(
1248                "%{}%",
1249                query
1250                    .trim()
1251                    .replace('\\', "\\\\")
1252                    .replace('%', "\\%")
1253                    .replace('_', "\\_")
1254            ))
1255            .bind(limit_i64)
1256            .fetch_all(&self.pool)
1257            .await?;
1258
1259        if rows.is_empty() {
1260            return Ok(vec![]);
1261        }
1262
1263        Ok(normalize_and_dedup(rows))
1264    }
1265
1266    /// Compute structural scores (degree + edge type diversity) for a batch of entity IDs.
1267    ///
1268    /// Returns `HashMap<entity_id, structural_score>` where score is in `[0.0, 1.0]`.
1269    /// Formula: `0.6 * (degree / max_degree) + 0.4 * (type_diversity / 4.0)`.
1270    /// Entities with no edges receive score `0.0`.
1271    ///
1272    /// # Errors
1273    ///
1274    /// Returns an error if the database query fails.
1275    #[tracing::instrument(name = "memory.graph.store.entity_structural_scores", skip_all, fields(count = entity_ids.len()))]
1276    pub async fn entity_structural_scores(
1277        &self,
1278        entity_ids: &[i64],
1279    ) -> Result<HashMap<i64, f32>, MemoryError> {
1280        // Each query binds entity_ids three times (three IN clauses).
1281        // Stay safely under SQLite 999-variable limit: 999 / 3 = 333, use 163 for headroom.
1282        const MAX_BATCH: usize = 163;
1283
1284        if entity_ids.is_empty() {
1285            return Ok(HashMap::new());
1286        }
1287
1288        let mut all_rows: Vec<(i64, i64, i64)> = Vec::new();
1289        for chunk in entity_ids.chunks(MAX_BATCH) {
1290            let n = chunk.len();
1291            // Three copies of chunk IDs: positions 1..n, n+1..2n, 2n+1..3n
1292            let ph1 = placeholder_list(1, n);
1293            let ph2 = placeholder_list(n + 1, n);
1294            let ph3 = placeholder_list(n * 2 + 1, n);
1295
1296            // Build query: count degree and distinct edge types for each entity.
1297            let sql = format!(
1298                "SELECT entity_id,
1299                        COUNT(*) AS degree,
1300                        COUNT(DISTINCT edge_type) AS type_diversity
1301                 FROM (
1302                     SELECT source_entity_id AS entity_id, edge_type
1303                     FROM graph_edges
1304                     WHERE valid_to IS NULL AND source_entity_id IN ({ph1})
1305                     UNION ALL
1306                     SELECT target_entity_id AS entity_id, edge_type
1307                     FROM graph_edges
1308                     WHERE valid_to IS NULL AND target_entity_id IN ({ph2})
1309                 )
1310                 WHERE entity_id IN ({ph3})
1311                 GROUP BY entity_id"
1312            );
1313
1314            let mut query = zeph_db::query_as::<_, (i64, i64, i64)>(sqlx::AssertSqlSafe(sql));
1315            // Bind chunk three times (three IN clauses)
1316            for id in chunk {
1317                query = query.bind(*id);
1318            }
1319            for id in chunk {
1320                query = query.bind(*id);
1321            }
1322            for id in chunk {
1323                query = query.bind(*id);
1324            }
1325
1326            let chunk_rows: Vec<(i64, i64, i64)> = query.fetch_all(&self.pool).await?;
1327            all_rows.extend(chunk_rows);
1328        }
1329
1330        if all_rows.is_empty() {
1331            return Ok(entity_ids.iter().map(|&id| (id, 0.0_f32)).collect());
1332        }
1333
1334        let max_degree = all_rows
1335            .iter()
1336            .map(|(_, d, _)| *d)
1337            .max()
1338            .unwrap_or(1)
1339            .max(1);
1340
1341        let mut scores: HashMap<i64, f32> = entity_ids.iter().map(|&id| (id, 0.0_f32)).collect();
1342        for (entity_id, degree, type_diversity) in all_rows {
1343            #[allow(clippy::cast_precision_loss)]
1344            let norm_degree = degree as f32 / max_degree as f32;
1345            #[allow(clippy::cast_precision_loss)]
1346            let norm_diversity = (type_diversity as f32 / 4.0).min(1.0);
1347            let score = 0.6 * norm_degree + 0.4 * norm_diversity;
1348            scores.insert(entity_id, score);
1349        }
1350
1351        Ok(scores)
1352    }
1353
1354    /// Look up community IDs for a batch of entity IDs.
1355    ///
1356    /// Returns `HashMap<entity_id, community_id>`. Entities not assigned to any community
1357    /// are absent from the map (treated as `None` by callers — no community cap applied).
1358    ///
1359    /// # Errors
1360    ///
1361    /// Returns an error if the database query fails.
1362    #[cfg(any(feature = "sqlite", feature = "postgres"))]
1363    #[tracing::instrument(name = "memory.graph.store.entity_community_ids", skip_all, fields(count = entity_ids.len()))]
1364    pub async fn entity_community_ids(
1365        &self,
1366        entity_ids: &[i64],
1367    ) -> Result<HashMap<i64, i64>, MemoryError> {
1368        if entity_ids.is_empty() {
1369            return Ok(HashMap::new());
1370        }
1371
1372        let mut result: HashMap<i64, i64> = HashMap::new();
1373        for chunk in entity_ids.chunks(SQLITE_BATCH_LIMIT_2X) {
1374            let placeholders = placeholder_list(1, chunk.len());
1375
1376            let community_sql = community_ids_sql(&placeholders);
1377            let mut query = zeph_db::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(community_sql));
1378            for id in chunk {
1379                query = query.bind(*id);
1380            }
1381
1382            let rows: Vec<(i64, i64)> = query.fetch_all(&self.pool).await?;
1383            result.extend(rows);
1384        }
1385
1386        Ok(result)
1387    }
1388
1389    /// Increment `retrieval_count` and set `last_retrieved_at` for a batch of edge IDs.
1390    ///
1391    /// Fire-and-forget: errors are logged but not propagated. Caller should log the warning.
1392    /// Batched with [`SQLITE_BATCH_LIMIT_2X`] to stay safely under `SQLite` bind variable limit.
1393    /// Each chunk's write is bounded by a 500ms timeout (matching
1394    /// [`Self::qdrant_point_ids_for_entities`]) so a stuck pool surfaces as a typed
1395    /// [`MemoryError::Timeout`] instead of hanging the `graph_recall_astar` hot path.
1396    ///
1397    /// # Errors
1398    ///
1399    /// Returns an error if the database query fails or a chunk write times out.
1400    #[tracing::instrument(name = "memory.graph.store.record_edge_retrieval", skip_all, fields(count = edge_ids.len()))]
1401    pub async fn record_edge_retrieval(&self, edge_ids: &[i64]) -> Result<(), MemoryError> {
1402        let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1403        for chunk in edge_ids.chunks(SQLITE_BATCH_LIMIT_2X) {
1404            let edge_placeholders = placeholder_list(1, chunk.len());
1405            let retrieval_sql = format!(
1406                "UPDATE graph_edges \
1407                 SET retrieval_count = retrieval_count + 1, \
1408                     last_retrieved_at = {epoch_now} \
1409                 WHERE id IN ({edge_placeholders})"
1410            );
1411            let mut q = zeph_db::query(sqlx::AssertSqlSafe(retrieval_sql));
1412            for id in chunk {
1413                q = q.bind(*id);
1414            }
1415            tokio::time::timeout(std::time::Duration::from_millis(500), q.execute(&self.pool))
1416                .await
1417                .map_err(|_| {
1418                    MemoryError::Timeout(
1419                        "record_edge_retrieval: write timed out after 500ms".into(),
1420                    )
1421                })??;
1422        }
1423        Ok(())
1424    }
1425
1426    /// Increment `weight` on the set of edges traversed during the current recall (HL-F2, #3344).
1427    ///
1428    /// Mirrors [`Self::record_edge_retrieval`] in shape: same [`SQLITE_BATCH_LIMIT_2X`] chunking,
1429    /// same `WHERE id IN (…) AND valid_to IS NULL` filter (defensive — traversed edges should
1430    /// already be active, but this prevents reinforcing tombstoned edges).
1431    ///
1432    /// No-op when `edge_ids` is empty or `delta == 0.0`.
1433    ///
1434    /// Each chunk's write is bounded by a 500ms timeout (matching
1435    /// [`Self::qdrant_point_ids_for_entities`]) so a stuck pool surfaces as a typed
1436    /// [`MemoryError::Timeout`] instead of hanging the `graph_recall_astar` hot path.
1437    ///
1438    /// # Errors
1439    ///
1440    /// Returns an error if the underlying `UPDATE` fails or a chunk write times out.
1441    #[tracing::instrument(
1442        name = "memory.graph.hebbian_increment",
1443        skip_all,
1444        fields(edge_count = edge_ids.len())
1445    )]
1446    pub async fn apply_hebbian_increment(
1447        &self,
1448        edge_ids: &[i64],
1449        delta: f32,
1450    ) -> Result<(), MemoryError> {
1451        // SQLITE_BATCH_LIMIT_2X chosen to stay under SQLite's 999 host-parameter limit
1452        // ($1 = delta, $2..$N+1 = edge ids — 1 + 490 = 491 params per chunk).
1453        if edge_ids.is_empty() || delta == 0.0 {
1454            return Ok(());
1455        }
1456        for chunk in edge_ids.chunks(SQLITE_BATCH_LIMIT_2X) {
1457            let edge_placeholders = placeholder_list(2, chunk.len());
1458            let sql = format!(
1459                "UPDATE graph_edges \
1460                 SET weight = weight + $1 \
1461                 WHERE id IN ({edge_placeholders}) \
1462                   AND valid_to IS NULL"
1463            );
1464            let mut q = zeph_db::query(sqlx::AssertSqlSafe(sql));
1465            q = q.bind(f64::from(delta));
1466            for id in chunk {
1467                q = q.bind(*id);
1468            }
1469            tokio::time::timeout(std::time::Duration::from_millis(500), q.execute(&self.pool))
1470                .await
1471                .map_err(|_| {
1472                    MemoryError::Timeout(
1473                        "apply_hebbian_increment: write timed out after 500ms".into(),
1474                    )
1475                })??;
1476        }
1477        Ok(())
1478    }
1479
1480    /// Return the subset of `ids` that exist in `graph_entities`.
1481    ///
1482    /// Useful for cross-referencing Qdrant-side entity IDs against the `SQLite` truth.
1483    /// Processes in chunks of [`SQLITE_BATCH_LIMIT_2X`] to stay under the `SQLite` variable
1484    /// limit (~32 k).
1485    ///
1486    /// # Errors
1487    ///
1488    /// Returns an error if the database query fails.
1489    #[tracing::instrument(name = "memory.graph.store.entity_ids_in", skip_all, fields(count = ids.len()))]
1490    pub async fn entity_ids_in(&self, ids: &[i64]) -> Result<Vec<i64>, MemoryError> {
1491        if ids.is_empty() {
1492            return Ok(Vec::new());
1493        }
1494        // TODO: chunk when ids.len() > 5_000 to guard against extreme cases
1495        let mut result = Vec::with_capacity(ids.len());
1496        for chunk in ids.chunks(SQLITE_BATCH_LIMIT_2X) {
1497            let placeholders = zeph_db::placeholder_list(1, chunk.len());
1498            let sql = format!("SELECT id FROM graph_entities WHERE id IN ({placeholders})");
1499            let mut q = zeph_db::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
1500            for id in chunk {
1501                q = q.bind(*id);
1502            }
1503            let rows = q.fetch_all(&self.pool).await?;
1504            for (id,) in rows {
1505                result.push(id);
1506            }
1507        }
1508        Ok(result)
1509    }
1510
1511    /// Resolve entity IDs to their Qdrant point IDs in a single batched `SELECT` (HL-F5, #3346).
1512    ///
1513    /// Entities without a `qdrant_point_id` are silently omitted from the result.
1514    ///
1515    /// # Errors
1516    ///
1517    /// Returns an error if the database query fails.
1518    #[tracing::instrument(name = "memory.graph.store.qdrant_point_ids_for_entities", skip_all, fields(count = entity_ids.len()))]
1519    pub async fn qdrant_point_ids_for_entities(
1520        &self,
1521        entity_ids: &[i64],
1522    ) -> Result<HashMap<i64, String>, MemoryError> {
1523        // Chunk to stay under SQLite variable limit.
1524        if entity_ids.is_empty() {
1525            return Ok(HashMap::new());
1526        }
1527        let mut result: HashMap<i64, String> = HashMap::with_capacity(entity_ids.len());
1528        for chunk in entity_ids.chunks(SQLITE_BATCH_LIMIT_2X) {
1529            let placeholders = zeph_db::placeholder_list(1, chunk.len());
1530            let sql = format!(
1531                "SELECT id, qdrant_point_id \
1532                 FROM graph_entities \
1533                 WHERE id IN ({placeholders}) \
1534                   AND qdrant_point_id IS NOT NULL"
1535            );
1536            let mut q = zeph_db::query_as::<_, (i64, String)>(sqlx::AssertSqlSafe(sql));
1537            for id in chunk {
1538                q = q.bind(*id);
1539            }
1540            // Bound worst-case per-chunk blocking on pool.acquire() + query execution: a
1541            // stuck pool turns into a typed MemoryError::Timeout instead of an indefinite
1542            // hang, so callers (e.g. hela_spreading_recall's per-step circuit breaker) can
1543            // fail fast and proceed.
1544            let rows: Vec<(i64, String)> = tokio::time::timeout(
1545                std::time::Duration::from_millis(500),
1546                q.fetch_all(&self.pool),
1547            )
1548            .await
1549            .map_err(|_| MemoryError::Timeout("qdrant_point_ids_for_entities".into()))??;
1550            for (entity_id, point_id) in rows {
1551                result.insert(entity_id, point_id);
1552            }
1553        }
1554        Ok(result)
1555    }
1556
1557    /// Apply multiplicative decay to `retrieval_count` for un-retrieved active edges.
1558    ///
1559    /// Only edges with `retrieval_count > 0` and `last_retrieved_at < (now - interval_secs)`
1560    /// are updated. Returns the number of rows affected.
1561    ///
1562    /// # Errors
1563    ///
1564    /// Returns an error if the database query fails.
1565    #[tracing::instrument(name = "memory.graph.store.decay_edge_retrieval_counts", skip_all)]
1566    pub async fn decay_edge_retrieval_counts(
1567        &self,
1568        decay_lambda: f64,
1569        interval_secs: u64,
1570    ) -> Result<usize, MemoryError> {
1571        let epoch_now_decay = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1572        let greatest_fn = <ActiveDialect as zeph_db::dialect::Dialect>::GREATEST_FN;
1573        let decay_raw = format!(
1574            "UPDATE graph_edges \
1575             SET retrieval_count = {greatest_fn}(CAST(retrieval_count * ? AS INTEGER), 0) \
1576             WHERE valid_to IS NULL \
1577               AND retrieval_count > 0 \
1578               AND (last_retrieved_at IS NULL OR last_retrieved_at < {epoch_now_decay} - ?)"
1579        );
1580        let decay_sql = zeph_db::rewrite_placeholders(&decay_raw);
1581        let result = zeph_db::query(sqlx::AssertSqlSafe(decay_sql))
1582            .bind(decay_lambda)
1583            .bind(i64::try_from(interval_secs).unwrap_or(i64::MAX))
1584            .execute(&self.pool)
1585            .await?;
1586        Ok(usize::try_from(result.rows_affected())?)
1587    }
1588
1589    /// Delete expired edges older than `retention_days` and return count deleted.
1590    ///
1591    /// # Errors
1592    ///
1593    /// Returns an error if the database query fails.
1594    #[tracing::instrument(name = "memory.graph.store.delete_expired_edges", skip_all)]
1595    pub async fn delete_expired_edges(&self, retention_days: u32) -> Result<usize, MemoryError> {
1596        let retention_secs = i64::from(retention_days) * 86400;
1597        let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1598        let expired_at_epoch =
1599            <ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("expired_at");
1600        let raw = format!(
1601            "DELETE FROM graph_edges
1602             WHERE expired_at IS NOT NULL
1603               AND {expired_at_epoch} < {epoch_now} - ?"
1604        );
1605        let sql = zeph_db::rewrite_placeholders(&raw);
1606        let result = zeph_db::query(sqlx::AssertSqlSafe(sql))
1607            .bind(retention_secs)
1608            .execute(&self.pool)
1609            .await?;
1610        Ok(usize::try_from(result.rows_affected())?)
1611    }
1612
1613    /// Delete orphan entities (no active edges, last seen more than `retention_days` ago).
1614    ///
1615    /// # Errors
1616    ///
1617    /// Returns an error if the database query fails.
1618    #[tracing::instrument(name = "memory.graph.store.delete_orphan_entities", skip_all)]
1619    pub async fn delete_orphan_entities(&self, retention_days: u32) -> Result<usize, MemoryError> {
1620        let retention_secs = i64::from(retention_days) * 86400;
1621        let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
1622        let last_seen_at_epoch =
1623            <ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("last_seen_at");
1624        let raw = format!(
1625            "DELETE FROM graph_entities
1626             WHERE id NOT IN (
1627                 SELECT DISTINCT source_entity_id FROM graph_edges WHERE valid_to IS NULL
1628                 UNION
1629                 SELECT DISTINCT target_entity_id FROM graph_edges WHERE valid_to IS NULL
1630             )
1631             AND {last_seen_at_epoch} < {epoch_now} - ?"
1632        );
1633        let sql = zeph_db::rewrite_placeholders(&raw);
1634        let result = zeph_db::query(sqlx::AssertSqlSafe(sql))
1635            .bind(retention_secs)
1636            .execute(&self.pool)
1637            .await?;
1638        Ok(usize::try_from(result.rows_affected())?)
1639    }
1640
1641    /// Delete all edges and orphaned entities stamped with `import_batch_id`.
1642    ///
1643    /// Sequence:
1644    /// 1. Delete all `graph_edges` where `import_batch_id = batch_id`.
1645    /// 2. Delete `graph_entities` where `import_batch_id = batch_id` AND they are
1646    ///    now orphaned (no remaining edges reference them).
1647    ///
1648    /// Rows with `origin = 'conversation'` are never touched by this method since
1649    /// they carry a `NULL` or different `import_batch_id`.
1650    /// Returns `(edges_deleted, entities_deleted)`.
1651    ///
1652    /// # Errors
1653    ///
1654    /// Returns [`MemoryError::Sqlx`] on database failure.
1655    #[tracing::instrument(name = "memory.graph.store.delete_batch", skip_all)]
1656    pub async fn delete_batch(&self, batch_id: &str) -> Result<(u64, u64), MemoryError> {
1657        let edge_result = zeph_db::query(sql!("DELETE FROM graph_edges WHERE import_batch_id = ?"))
1658            .bind(batch_id)
1659            .execute(&self.pool)
1660            .await?;
1661        let edges_deleted = edge_result.rows_affected();
1662
1663        let entity_result = zeph_db::query(sql!(
1664            "DELETE FROM graph_entities
1665             WHERE import_batch_id = ?
1666               AND id NOT IN (
1667                   SELECT DISTINCT source_entity_id FROM graph_edges
1668                   UNION
1669                   SELECT DISTINCT target_entity_id FROM graph_edges
1670               )"
1671        ))
1672        .bind(batch_id)
1673        .execute(&self.pool)
1674        .await?;
1675        let entities_deleted = entity_result.rows_affected();
1676
1677        Ok((edges_deleted, entities_deleted))
1678    }
1679
1680    /// Delete all graph edges and orphaned entities for `batch_id` within a caller-provided
1681    /// transaction.
1682    ///
1683    /// Executes the same two-step DELETE as [`Self::delete_batch`] but uses `tx` so the
1684    /// caller can combine it with other writes in a single atomic unit.
1685    /// Returns `(edges_deleted, entities_deleted)`.
1686    ///
1687    /// # Errors
1688    ///
1689    /// Returns [`MemoryError::Sqlx`] on database failure.
1690    #[tracing::instrument(name = "memory.graph.store.delete_batch_in_tx", skip_all)]
1691    pub async fn delete_batch_in_tx(
1692        &self,
1693        batch_id: &str,
1694        tx: &mut zeph_db::DbTransaction<'_>,
1695    ) -> Result<(u64, u64), MemoryError> {
1696        let edges_deleted =
1697            zeph_db::query(sql!("DELETE FROM graph_edges WHERE import_batch_id = ?"))
1698                .bind(batch_id)
1699                .execute(&mut **tx)
1700                .await?
1701                .rows_affected();
1702
1703        let entities_deleted = zeph_db::query(sql!(
1704            "DELETE FROM graph_entities
1705             WHERE import_batch_id = ?
1706               AND id NOT IN (
1707                   SELECT DISTINCT source_entity_id FROM graph_edges
1708                   UNION
1709                   SELECT DISTINCT target_entity_id FROM graph_edges
1710               )"
1711        ))
1712        .bind(batch_id)
1713        .execute(&mut **tx)
1714        .await?
1715        .rows_affected();
1716
1717        Ok((edges_deleted, entities_deleted))
1718    }
1719
1720    /// Delete the oldest excess entities when count exceeds `max_entities`.
1721    ///
1722    /// Entities are ranked by ascending edge count, then ascending `last_seen_at` (LRU).
1723    /// Only deletes when `entity_count() > max_entities`.
1724    ///
1725    /// # Errors
1726    ///
1727    /// Returns an error if the database query fails.
1728    #[tracing::instrument(name = "memory.graph.store.cap_entities", skip_all)]
1729    pub async fn cap_entities(&self, max_entities: usize) -> Result<usize, MemoryError> {
1730        let current = self.entity_count().await?;
1731        let max = i64::try_from(max_entities)?;
1732        if current <= max {
1733            return Ok(0);
1734        }
1735        let excess = current - max;
1736        let result = zeph_db::query(sql!(
1737            "DELETE FROM graph_entities
1738             WHERE id IN (
1739                 SELECT e.id
1740                 FROM graph_entities e
1741                 LEFT JOIN (
1742                     SELECT source_entity_id AS eid, COUNT(*) AS cnt
1743                     FROM graph_edges WHERE valid_to IS NULL GROUP BY source_entity_id
1744                     UNION ALL
1745                     SELECT target_entity_id AS eid, COUNT(*) AS cnt
1746                     FROM graph_edges WHERE valid_to IS NULL GROUP BY target_entity_id
1747                 ) edge_counts ON e.id = edge_counts.eid
1748                 ORDER BY COALESCE(edge_counts.cnt, 0) ASC, e.last_seen_at ASC
1749                 LIMIT ?
1750             )"
1751        ))
1752        .bind(excess)
1753        .execute(&self.pool)
1754        .await?;
1755        Ok(usize::try_from(result.rows_affected())?)
1756    }
1757
1758    // ── Temporal Edge Queries ─────────────────────────────────────────────────
1759
1760    /// Return all edges for `entity_id` (as source or target) that were valid at `timestamp`.
1761    ///
1762    /// An edge is valid at `timestamp` when:
1763    /// - `valid_from <= timestamp`, AND
1764    /// - `valid_to IS NULL` (open-ended) OR `valid_to > timestamp`.
1765    ///
1766    /// `timestamp` must be a `SQLite` datetime string: `"YYYY-MM-DD HH:MM:SS"`.
1767    ///
1768    /// # Errors
1769    ///
1770    /// Returns an error if the database query fails.
1771    #[tracing::instrument(name = "memory.graph.store.edges_at_timestamp", skip_all)]
1772    pub async fn edges_at_timestamp(
1773        &self,
1774        entity_id: i64,
1775        timestamp: &str,
1776    ) -> Result<Vec<Edge>, MemoryError> {
1777        // Split into two UNIONed branches to leverage the partial indexes from migration 030:
1778        //   Branch 1 (active edges):     idx_graph_edges_valid + idx_graph_edges_source/target
1779        //   Branch 2 (historical edges): idx_graph_edges_src_temporal / idx_graph_edges_tgt_temporal
1780        //
1781        // `timestamp` is a Rust-formatted string bound against `valid_from`/`valid_to`
1782        // (`TIMESTAMPTZ` on Postgres); Postgres has no implicit text->timestamptz cast for a
1783        // bound parameter, so an explicit `TIMESTAMPTZ_CAST` is required on each placeholder —
1784        // mirrors `agent_sessions.rs`'s fix for the same class of mismatch.
1785        let ts_cast = <ActiveDialect as zeph_db::dialect::Dialect>::TIMESTAMPTZ_CAST;
1786        let edge_cols = edge_select_cols("");
1787        let raw = format!(
1788            "SELECT {edge_cols}
1789             FROM graph_edges
1790             WHERE valid_to IS NULL
1791               AND valid_from <= ?{ts_cast}
1792               AND (source_entity_id = ? OR target_entity_id = ?)
1793             UNION ALL
1794             SELECT {edge_cols}
1795             FROM graph_edges
1796             WHERE valid_to IS NOT NULL
1797               AND valid_from <= ?{ts_cast}
1798               AND valid_to > ?{ts_cast}
1799               AND (source_entity_id = ? OR target_entity_id = ?)"
1800        );
1801        let query_sql = zeph_db::rewrite_placeholders(&raw);
1802        let rows: Vec<EdgeRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
1803            .bind(timestamp)
1804            .bind(entity_id)
1805            .bind(entity_id)
1806            .bind(timestamp)
1807            .bind(timestamp)
1808            .bind(entity_id)
1809            .bind(entity_id)
1810            .fetch_all(&self.pool)
1811            .await?;
1812        Ok(rows.into_iter().map(edge_from_row).collect())
1813    }
1814
1815    /// Return all edge versions (active and expired) for the given `(source, predicate)` pair.
1816    ///
1817    /// The optional `relation` filter restricts results to a specific relation label.
1818    /// Results are ordered by `valid_from DESC` (most recent first).
1819    ///
1820    /// # Errors
1821    ///
1822    /// Returns an error if the database query fails.
1823    #[tracing::instrument(name = "memory.graph.store.edge_history", skip_all)]
1824    pub async fn edge_history(
1825        &self,
1826        source_entity_id: i64,
1827        predicate: &str,
1828        relation: Option<&str>,
1829        limit: usize,
1830    ) -> Result<Vec<Edge>, MemoryError> {
1831        // Escape LIKE wildcards so `%` and `_` in the predicate are treated as literals.
1832        let escaped = predicate
1833            .replace('\\', "\\\\")
1834            .replace('%', "\\%")
1835            .replace('_', "\\_");
1836        let like_pattern = format!("%{escaped}%");
1837        let limit = i64::try_from(limit)?;
1838        let edge_cols = edge_select_cols("");
1839        let rows: Vec<EdgeRow> = if let Some(rel) = relation {
1840            let raw = format!(
1841                "SELECT {edge_cols}
1842                 FROM graph_edges
1843                 WHERE source_entity_id = ?
1844                   AND fact LIKE ? ESCAPE '\\'
1845                   AND relation = ?
1846                 ORDER BY graph_edges.valid_from DESC
1847                 LIMIT ?"
1848            );
1849            let query_sql = zeph_db::rewrite_placeholders(&raw);
1850            zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
1851                .bind(source_entity_id)
1852                .bind(&like_pattern)
1853                .bind(rel)
1854                .bind(limit)
1855                .fetch_all(&self.pool)
1856                .await?
1857        } else {
1858            let raw = format!(
1859                "SELECT {edge_cols}
1860                 FROM graph_edges
1861                 WHERE source_entity_id = ?
1862                   AND fact LIKE ? ESCAPE '\\'
1863                 ORDER BY graph_edges.valid_from DESC
1864                 LIMIT ?"
1865            );
1866            let query_sql = zeph_db::rewrite_placeholders(&raw);
1867            zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
1868                .bind(source_entity_id)
1869                .bind(&like_pattern)
1870                .bind(limit)
1871                .fetch_all(&self.pool)
1872                .await?
1873        };
1874        Ok(rows.into_iter().map(edge_from_row).collect())
1875    }
1876
1877    // ── BFS Traversal ─────────────────────────────────────────────────────────
1878
1879    /// Breadth-first traversal from `start_entity_id` up to `max_hops` hops.
1880    ///
1881    /// Returns all reachable entities and the active edges connecting them.
1882    /// Implements BFS iteratively in Rust to guarantee cycle safety regardless
1883    /// of `SQLite` CTE limitations.
1884    ///
1885    /// **`SQLite` bind parameter limit**: each BFS hop binds the frontier IDs three times in the
1886    /// neighbour query. At ~300+ frontier entities per hop, the IN clause may approach `SQLite`'s
1887    /// default `SQLITE_MAX_VARIABLE_NUMBER` limit of 999. Acceptable for Phase 1 (small graphs,
1888    /// `max_hops` typically 2–3). For large graphs, consider batching or a temp-table approach.
1889    ///
1890    /// # Errors
1891    ///
1892    /// Returns an error if any database query fails.
1893    #[tracing::instrument(name = "memory.graph.store.bfs", skip_all)]
1894    pub async fn bfs(
1895        &self,
1896        start_entity_id: i64,
1897        max_hops: u32,
1898    ) -> Result<(Vec<Entity>, Vec<Edge>), MemoryError> {
1899        self.bfs_with_depth(start_entity_id, max_hops)
1900            .await
1901            .map(|(e, ed, _)| (e, ed))
1902    }
1903
1904    /// BFS traversal returning entities, edges, and a depth map (`entity_id` → hop distance).
1905    ///
1906    /// The depth map records the minimum hop distance from `start_entity_id` to each visited
1907    /// entity. The start entity itself has depth 0.
1908    ///
1909    /// **`SQLite` bind parameter limit**: see [`Self::bfs`] for notes on frontier size limits.
1910    ///
1911    /// # Errors
1912    ///
1913    /// Returns an error if any database query fails.
1914    #[allow(clippy::type_complexity)]
1915    #[tracing::instrument(name = "memory.graph.store.bfs_with_depth", skip_all)]
1916    pub async fn bfs_with_depth(
1917        &self,
1918        start_entity_id: i64,
1919        max_hops: u32,
1920    ) -> Result<(Vec<Entity>, Vec<Edge>, std::collections::HashMap<i64, u32>), MemoryError> {
1921        self.bfs_core(start_entity_id, max_hops, None, &[]).await
1922    }
1923
1924    /// BFS traversal considering only edges that were valid at `timestamp`.
1925    ///
1926    /// Equivalent to [`Self::bfs_with_depth`] but replaces the `valid_to IS NULL` filter with
1927    /// the temporal range predicate `valid_from <= ts AND (valid_to IS NULL OR valid_to > ts)`.
1928    ///
1929    /// `timestamp` must be a `SQLite` datetime string: `"YYYY-MM-DD HH:MM:SS"`.
1930    ///
1931    /// # Errors
1932    ///
1933    /// Returns an error if any database query fails.
1934    #[allow(clippy::type_complexity)]
1935    #[tracing::instrument(name = "memory.graph.store.bfs_at_timestamp", skip_all)]
1936    pub async fn bfs_at_timestamp(
1937        &self,
1938        start_entity_id: i64,
1939        max_hops: u32,
1940        timestamp: &str,
1941    ) -> Result<(Vec<Entity>, Vec<Edge>, std::collections::HashMap<i64, u32>), MemoryError> {
1942        self.bfs_core(start_entity_id, max_hops, Some(timestamp), &[])
1943            .await
1944    }
1945
1946    /// BFS traversal scoped to specific MAGMA edge types.
1947    ///
1948    /// When `edge_types` is empty, behaves identically to [`Self::bfs_with_depth`] (traverses all
1949    /// active edges). When `edge_types` is non-empty, only traverses edges whose `edge_type`
1950    /// matches one of the provided types.
1951    ///
1952    /// This enables subgraph-scoped retrieval: a causal query traverses only causal + semantic
1953    /// edges, a temporal query only temporal + semantic edges, etc.
1954    ///
1955    /// Note: Semantic is typically included in `edge_types` by the caller to ensure recall is
1956    /// never worse than the untyped BFS. See `classify_graph_subgraph` in `router.rs`.
1957    ///
1958    /// # Errors
1959    ///
1960    /// Returns an error if any database query fails.
1961    #[allow(clippy::type_complexity)]
1962    #[tracing::instrument(name = "memory.graph.store.bfs_typed", skip_all)]
1963    pub async fn bfs_typed(
1964        &self,
1965        start_entity_id: i64,
1966        max_hops: u32,
1967        edge_types: &[EdgeType],
1968    ) -> Result<(Vec<Entity>, Vec<Edge>, std::collections::HashMap<i64, u32>), MemoryError> {
1969        self.bfs_core(start_entity_id, max_hops, None, edge_types)
1970            .await
1971    }
1972
1973    /// Shared BFS implementation, optionally scoped to specific edge types.
1974    ///
1975    /// When `at_timestamp` is `None`, only active edges (`valid_to IS NULL`) are traversed.
1976    /// When `at_timestamp` is `Some(ts)`, edges valid at `ts` are traversed (temporal BFS).
1977    /// When `edge_types` is empty, all edge types are traversed; otherwise the `edge_type IN
1978    /// (...)` clause restricts traversal to the given types (`EdgeType::as_str()` values —
1979    /// no user input reaches SQL).
1980    ///
1981    /// All IDs used in dynamic SQL come from our own database — no user input reaches the
1982    /// format string, so there is no SQL injection risk.
1983    #[allow(clippy::type_complexity)]
1984    #[tracing::instrument(name = "memory.graph.store.bfs_core", skip_all)]
1985    async fn bfs_core(
1986        &self,
1987        start_entity_id: i64,
1988        max_hops: u32,
1989        at_timestamp: Option<&str>,
1990        edge_types: &[EdgeType],
1991    ) -> Result<(Vec<Entity>, Vec<Edge>, std::collections::HashMap<i64, u32>), MemoryError> {
1992        use std::collections::HashMap;
1993
1994        // SQLite binds frontier IDs 3× per hop; at >333 IDs the IN clause exceeds
1995        // SQLITE_MAX_VARIABLE_NUMBER (999). Cap to 300 to stay safely within the limit.
1996        const MAX_FRONTIER: usize = 300;
1997
1998        let type_strs: Vec<&str> = edge_types.iter().map(|t| t.as_str()).collect();
1999        let n_types = type_strs.len();
2000        // type_in is constant for the entire BFS — positions 1..=n_types never change.
2001        let type_in = (n_types > 0).then(|| placeholder_list(1, n_types));
2002        let id_start = n_types + 1;
2003
2004        let mut depth_map: HashMap<i64, u32> = HashMap::new();
2005        let mut frontier: Vec<i64> = vec![start_entity_id];
2006        depth_map.insert(start_entity_id, 0);
2007
2008        for hop in 0..max_hops {
2009            if frontier.is_empty() {
2010                break;
2011            }
2012            frontier.truncate(MAX_FRONTIER);
2013            // IDs come from our own DB — no user input, no injection risk.
2014            // Positions: types first (if any), then 3 copies of frontier IDs, then an
2015            // optional timestamp.
2016            let n_frontier = frontier.len();
2017            let fp1 = placeholder_list(id_start, n_frontier);
2018            let fp2 = placeholder_list(id_start + n_frontier, n_frontier);
2019            let fp3 = placeholder_list(id_start + n_frontier * 2, n_frontier);
2020
2021            let type_filter = type_in
2022                .as_ref()
2023                .map(|type_in| format!("edge_type IN ({type_in}) AND "))
2024                .unwrap_or_default();
2025            let edge_filter = if at_timestamp.is_some() {
2026                let ts_pos = id_start + n_frontier * 3;
2027                format!(
2028                    "{type_filter}valid_from <= {ts} AND (valid_to IS NULL OR valid_to > {ts})",
2029                    ts = numbered_placeholder(ts_pos),
2030                )
2031            } else {
2032                format!("{type_filter}valid_to IS NULL")
2033            };
2034            let neighbour_sql = format!(
2035                "SELECT DISTINCT CASE
2036                     WHEN source_entity_id IN ({fp1}) THEN target_entity_id
2037                     ELSE source_entity_id
2038                 END as neighbour_id
2039                 FROM graph_edges
2040                 WHERE {edge_filter}
2041                   AND (source_entity_id IN ({fp2}) OR target_entity_id IN ({fp3}))"
2042            );
2043            let mut q = zeph_db::query_scalar::<_, i64>(sqlx::AssertSqlSafe(neighbour_sql));
2044            for t in &type_strs {
2045                q = q.bind(*t);
2046            }
2047            for id in &frontier {
2048                q = q.bind(*id);
2049            }
2050            for id in &frontier {
2051                q = q.bind(*id);
2052            }
2053            for id in &frontier {
2054                q = q.bind(*id);
2055            }
2056            if let Some(ts) = at_timestamp {
2057                q = q.bind(ts);
2058            }
2059            let neighbours: Vec<i64> = q.fetch_all(&self.pool).await?;
2060            let mut next_frontier: Vec<i64> = Vec::new();
2061            for nbr in neighbours {
2062                if let std::collections::hash_map::Entry::Vacant(e) = depth_map.entry(nbr) {
2063                    e.insert(hop + 1);
2064                    next_frontier.push(nbr);
2065                }
2066            }
2067            frontier = next_frontier;
2068        }
2069
2070        self.bfs_fetch_results(depth_map, at_timestamp, &type_strs)
2071            .await
2072    }
2073
2074    /// Fetch entities and edges for a completed BFS depth map, optionally filtered by
2075    /// `edge_type` (empty `type_strs` = no filter, all types included).
2076    ///
2077    /// # Errors
2078    ///
2079    /// Returns an error if any database query fails.
2080    #[allow(clippy::type_complexity)]
2081    #[tracing::instrument(name = "memory.graph.store.bfs_fetch_results", skip_all)]
2082    async fn bfs_fetch_results(
2083        &self,
2084        depth_map: std::collections::HashMap<i64, u32>,
2085        at_timestamp: Option<&str>,
2086        type_strs: &[&str],
2087    ) -> Result<(Vec<Entity>, Vec<Edge>, std::collections::HashMap<i64, u32>), MemoryError> {
2088        let mut visited_ids: Vec<i64> = depth_map.keys().copied().collect();
2089        if visited_ids.is_empty() {
2090            return Ok((Vec::new(), Vec::new(), depth_map));
2091        }
2092        // Edge query binds visited_ids twice — cap at 499 to stay under SQLite 999 limit.
2093        if visited_ids.len() > 499 {
2094            tracing::warn!(
2095                total = visited_ids.len(),
2096                retained = 499,
2097                "bfs_fetch_results: visited entity set truncated to 499 to stay within SQLite bind limit; \
2098                 some reachable entities will be dropped from results"
2099            );
2100            visited_ids.truncate(499);
2101        }
2102
2103        let n_types = type_strs.len();
2104        let n_visited = visited_ids.len();
2105
2106        // Bind order: types first (if any), then visited_ids twice, then optional timestamp.
2107        let type_in = (n_types > 0).then(|| placeholder_list(1, n_types));
2108        let id_start = n_types + 1;
2109        let ph_ids1 = placeholder_list(id_start, n_visited);
2110        let ph_ids2 = placeholder_list(id_start + n_visited, n_visited);
2111
2112        let type_filter = type_in
2113            .as_ref()
2114            .map(|type_in| format!("edge_type IN ({type_in}) AND "))
2115            .unwrap_or_default();
2116        let edge_filter = if at_timestamp.is_some() {
2117            let ts_pos = id_start + n_visited * 2;
2118            format!(
2119                "{type_filter}valid_from <= {ts} AND (valid_to IS NULL OR valid_to > {ts})",
2120                ts = numbered_placeholder(ts_pos),
2121            )
2122        } else {
2123            format!("{type_filter}valid_to IS NULL")
2124        };
2125
2126        let edge_sql = format!(
2127            "SELECT {edge_cols}
2128             FROM graph_edges
2129             WHERE {edge_filter}
2130               AND source_entity_id IN ({ph_ids1})
2131               AND target_entity_id IN ({ph_ids2})",
2132            edge_cols = edge_select_cols(""),
2133        );
2134        let mut edge_query = zeph_db::query_as::<_, EdgeRow>(sqlx::AssertSqlSafe(edge_sql));
2135        for t in type_strs {
2136            edge_query = edge_query.bind(*t);
2137        }
2138        for id in &visited_ids {
2139            edge_query = edge_query.bind(*id);
2140        }
2141        for id in &visited_ids {
2142            edge_query = edge_query.bind(*id);
2143        }
2144        if let Some(ts) = at_timestamp {
2145            edge_query = edge_query.bind(ts);
2146        }
2147        let edge_rows: Vec<EdgeRow> = edge_query.fetch_all(&self.pool).await?;
2148
2149        let entity_sql = format!(
2150            "SELECT {entity_cols} FROM graph_entities WHERE id IN ({ph})",
2151            entity_cols = entity_select_cols(),
2152            ph = placeholder_list(1, n_visited),
2153        );
2154        let mut entity_query = zeph_db::query_as::<_, EntityRow>(sqlx::AssertSqlSafe(entity_sql));
2155        for id in &visited_ids {
2156            entity_query = entity_query.bind(*id);
2157        }
2158        let entity_rows: Vec<EntityRow> = entity_query.fetch_all(&self.pool).await?;
2159
2160        let entities: Vec<Entity> = entity_rows
2161            .into_iter()
2162            .map(entity_from_row)
2163            .collect::<Result<Vec<_>, _>>()?;
2164        let edges: Vec<Edge> = edge_rows.into_iter().map(edge_from_row).collect();
2165
2166        Ok((entities, edges, depth_map))
2167    }
2168
2169    // ── Backfill helpers ──────────────────────────────────────────────────────
2170
2171    /// Find an entity by name only (no type filter).
2172    ///
2173    /// Uses a two-phase lookup to ensure exact name matches are always prioritised:
2174    /// 1. Exact case-insensitive match on `name` or `canonical_name`.
2175    /// 2. If no exact match found, falls back to FTS5 prefix search (see `find_entities_fuzzy`).
2176    ///
2177    /// This prevents FTS5 from returning a different entity whose *summary* mentions the
2178    /// searched name (e.g. searching "Alice" returning "Google" because Google's summary
2179    /// contains "Alice").
2180    ///
2181    /// # Errors
2182    ///
2183    /// Returns an error if the database query fails.
2184    #[tracing::instrument(name = "memory.graph.store.find_entity_by_name", skip_all)]
2185    pub async fn find_entity_by_name(&self, name: &str) -> Result<Vec<Entity>, MemoryError> {
2186        let find_by_name_sql = format!(
2187            "SELECT {} \
2188             FROM graph_entities \
2189             WHERE name = ? {cn} OR canonical_name = ? {cn} \
2190             LIMIT 5",
2191            entity_select_cols(),
2192            cn = <ActiveDialect as zeph_db::dialect::Dialect>::COLLATE_NOCASE,
2193        );
2194        let query_sql = zeph_db::rewrite_placeholders(&find_by_name_sql);
2195        let rows: Vec<EntityRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
2196            .bind(name)
2197            .bind(name)
2198            .fetch_all(&self.pool)
2199            .await?;
2200
2201        if !rows.is_empty() {
2202            return rows.into_iter().map(entity_from_row).collect();
2203        }
2204
2205        self.find_entities_fuzzy(name, 5).await
2206    }
2207
2208    /// Return up to `limit` messages that have not yet been processed by graph extraction.
2209    ///
2210    /// Reads the `graph_processed` column added by migration 021.
2211    ///
2212    /// # Errors
2213    ///
2214    /// Returns an error if the database query fails.
2215    #[tracing::instrument(
2216        name = "memory.graph.store.unprocessed_messages_for_backfill",
2217        skip_all
2218    )]
2219    pub async fn unprocessed_messages_for_backfill(
2220        &self,
2221        limit: usize,
2222    ) -> Result<Vec<(crate::types::MessageId, String)>, MemoryError> {
2223        let limit = i64::try_from(limit)?;
2224        let rows: Vec<(i64, String)> = zeph_db::query_as(sql!(
2225            "SELECT id, content FROM messages
2226             WHERE graph_processed = FALSE
2227             ORDER BY id ASC
2228             LIMIT ?"
2229        ))
2230        .bind(limit)
2231        .fetch_all(&self.pool)
2232        .await?;
2233        Ok(rows
2234            .into_iter()
2235            .map(|(id, content)| (crate::types::MessageId(id), content))
2236            .collect())
2237    }
2238
2239    /// Return the count of messages not yet processed by graph extraction.
2240    ///
2241    /// # Errors
2242    ///
2243    /// Returns an error if the database query fails.
2244    #[tracing::instrument(name = "memory.graph.store.unprocessed_message_count", skip_all)]
2245    pub async fn unprocessed_message_count(&self) -> Result<i64, MemoryError> {
2246        let count: i64 = zeph_db::query_scalar(sql!(
2247            "SELECT COUNT(*) FROM messages WHERE graph_processed = FALSE"
2248        ))
2249        .fetch_one(&self.pool)
2250        .await?;
2251        Ok(count)
2252    }
2253
2254    /// Mark a batch of messages as graph-processed.
2255    ///
2256    /// # Errors
2257    ///
2258    /// Returns an error if the database query fails.
2259    #[tracing::instrument(name = "memory.graph.store.mark_messages_graph_processed", skip_all, fields(count = ids.len()))]
2260    pub async fn mark_messages_graph_processed(
2261        &self,
2262        ids: &[crate::types::MessageId],
2263    ) -> Result<(), MemoryError> {
2264        if ids.is_empty() {
2265            return Ok(());
2266        }
2267        for chunk in ids.chunks(SQLITE_BATCH_LIMIT_2X) {
2268            let placeholders = placeholder_list(1, chunk.len());
2269            let sql =
2270                format!("UPDATE messages SET graph_processed = TRUE WHERE id IN ({placeholders})");
2271            let mut query = zeph_db::query(sqlx::AssertSqlSafe(sql));
2272            for id in chunk {
2273                query = query.bind(id.0);
2274            }
2275            query.execute(&self.pool).await?;
2276        }
2277        Ok(())
2278    }
2279}
2280
2281// ── Dialect helpers ───────────────────────────────────────────────────────────
2282
2283#[cfg(feature = "sqlite")]
2284fn community_ids_sql(placeholders: &str) -> String {
2285    format!(
2286        "SELECT CAST(j.value AS INTEGER) AS entity_id, c.id AS community_id
2287         FROM graph_communities c, json_each(c.entity_ids) j
2288         WHERE CAST(j.value AS INTEGER) IN ({placeholders})"
2289    )
2290}
2291
2292#[cfg(feature = "postgres")]
2293fn community_ids_sql(placeholders: &str) -> String {
2294    format!(
2295        "SELECT (j.value)::bigint AS entity_id, c.id AS community_id
2296         FROM graph_communities c,
2297              jsonb_array_elements_text(c.entity_ids::jsonb) j(value)
2298         WHERE (j.value)::bigint IN ({placeholders})"
2299    )
2300}
2301
2302// ── Row types for zeph_db::query_as ─────────────────────────────────────────────
2303
2304#[derive(zeph_db::FromRow)]
2305struct EntityRow {
2306    id: i64,
2307    name: String,
2308    canonical_name: String,
2309    entity_type: String,
2310    summary: Option<String>,
2311    first_seen_at: String,
2312    last_seen_at: String,
2313    qdrant_point_id: Option<String>,
2314}
2315
2316/// `graph_entities` projection for [`EntityRow`], dialect-cast for `TIMESTAMPTZ` columns.
2317///
2318/// `first_seen_at`/`last_seen_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on `SQLite`); both are
2319/// projected through `Dialect::select_as_text` and aliased back to their original names so
2320/// `#[derive(FromRow)]` still binds them into `EntityRow`'s `String` fields.
2321fn entity_select_cols() -> String {
2322    let first_seen_sel =
2323        <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("first_seen_at");
2324    let last_seen_sel =
2325        <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("last_seen_at");
2326    format!(
2327        "id, name, canonical_name, entity_type, summary, \
2328         {first_seen_sel} AS first_seen_at, {last_seen_sel} AS last_seen_at, qdrant_point_id"
2329    )
2330}
2331
2332fn entity_from_row(row: EntityRow) -> Result<Entity, MemoryError> {
2333    let entity_type = row
2334        .entity_type
2335        .parse::<EntityType>()
2336        .map_err(MemoryError::GraphStore)?;
2337    Ok(Entity {
2338        id: EntityId(row.id),
2339        name: row.name,
2340        canonical_name: row.canonical_name,
2341        entity_type,
2342        summary: row.summary,
2343        first_seen_at: row.first_seen_at,
2344        last_seen_at: row.last_seen_at,
2345        qdrant_point_id: row.qdrant_point_id,
2346    })
2347}
2348
2349#[derive(zeph_db::FromRow)]
2350struct AliasRow {
2351    id: i64,
2352    entity_id: i64,
2353    alias_name: String,
2354    created_at: String,
2355}
2356
2357/// `graph_entity_aliases` projection for [`AliasRow`], dialect-cast for `TIMESTAMPTZ` columns.
2358///
2359/// `created_at` is `TIMESTAMPTZ` on Postgres — see [`entity_select_cols`].
2360fn alias_select_cols() -> String {
2361    let created_at_sel = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
2362    format!("id, entity_id, alias_name, {created_at_sel} AS created_at")
2363}
2364
2365fn alias_from_row(row: AliasRow) -> EntityAlias {
2366    EntityAlias {
2367        id: row.id,
2368        entity_id: EntityId(row.entity_id),
2369        alias_name: row.alias_name,
2370        created_at: row.created_at,
2371    }
2372}
2373
2374#[derive(zeph_db::FromRow)]
2375struct EdgeRow {
2376    id: i64,
2377    source_entity_id: i64,
2378    target_entity_id: i64,
2379    relation: String,
2380    fact: String,
2381    confidence: f64,
2382    valid_from: String,
2383    valid_to: Option<String>,
2384    created_at: String,
2385    expired_at: Option<String>,
2386    #[sqlx(rename = "episode_id")]
2387    source_message_id: Option<i64>,
2388    qdrant_point_id: Option<String>,
2389    edge_type: String,
2390    retrieval_count: i32,
2391    last_retrieved_at: Option<i64>,
2392    // `INTEGER` (`INT4`) on Postgres, so it decodes as `Option<i32>`, not `Option<i64>`.
2393    superseded_by: Option<i32>,
2394    canonical_relation: Option<String>,
2395    supersedes: Option<i64>,
2396    // Hebbian reinforcement weight (HL-F1, #3344). SQLite REAL maps to f64 via sqlx.
2397    weight: f64,
2398    // SYNAPSE multi-timescale variables (#3709).
2399    confidence_fast: f64,
2400    confidence_slow: f64,
2401    // `INTEGER` (`INT4`) on Postgres, so it decodes as `Option<i32>`, not `Option<i64>`.
2402    turn_index: Option<i32>,
2403}
2404
2405/// `graph_edges` projection for [`EdgeRow`], dialect-cast for `TIMESTAMPTZ` columns.
2406///
2407/// `valid_from`/`valid_to`/`created_at`/`expired_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on
2408/// `SQLite`); each is projected through `Dialect::select_as_text` and aliased back to its
2409/// original name so `#[derive(FromRow)]` still binds them into `EdgeRow`'s `String`/
2410/// `Option<String>` fields. `table_prefix` (e.g. `"ge."` or `""`) is prepended to each source
2411/// column reference so this helper works both in plain `FROM graph_edges` queries and in joins
2412/// with an alias — see [`community_select_cols`].
2413fn edge_select_cols(table_prefix: &str) -> String {
2414    let valid_from_sel = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text(&format!(
2415        "{table_prefix}valid_from"
2416    ));
2417    let valid_to_sel = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text(&format!(
2418        "{table_prefix}valid_to"
2419    ));
2420    let created_at_sel = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text(&format!(
2421        "{table_prefix}created_at"
2422    ));
2423    let expired_at_sel = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text(&format!(
2424        "{table_prefix}expired_at"
2425    ));
2426    format!(
2427        "{table_prefix}id, {table_prefix}source_entity_id, {table_prefix}target_entity_id, \
2428         {table_prefix}relation, {table_prefix}fact, {table_prefix}confidence, \
2429         {valid_from_sel} AS valid_from, {valid_to_sel} AS valid_to, \
2430         {created_at_sel} AS created_at, {expired_at_sel} AS expired_at, \
2431         {table_prefix}episode_id, {table_prefix}qdrant_point_id, {table_prefix}edge_type, \
2432         {table_prefix}retrieval_count, {table_prefix}last_retrieved_at, \
2433         {table_prefix}superseded_by, {table_prefix}canonical_relation, {table_prefix}supersedes, \
2434         CAST({table_prefix}weight AS DOUBLE PRECISION) AS weight, \
2435         CAST({table_prefix}confidence_fast AS DOUBLE PRECISION) AS confidence_fast, \
2436         CAST({table_prefix}confidence_slow AS DOUBLE PRECISION) AS confidence_slow, \
2437         {table_prefix}turn_index"
2438    )
2439}
2440
2441fn edge_from_row(row: EdgeRow) -> Edge {
2442    let edge_type = row
2443        .edge_type
2444        .parse::<EdgeType>()
2445        .unwrap_or(EdgeType::Semantic);
2446    let canonical_relation = row
2447        .canonical_relation
2448        .unwrap_or_else(|| row.relation.clone());
2449    Edge {
2450        id: row.id,
2451        source_entity_id: row.source_entity_id,
2452        target_entity_id: row.target_entity_id,
2453        canonical_relation,
2454        relation: row.relation,
2455        fact: row.fact,
2456        #[allow(clippy::cast_possible_truncation)]
2457        confidence: row.confidence as f32,
2458        valid_from: row.valid_from,
2459        valid_to: row.valid_to,
2460        created_at: row.created_at,
2461        expired_at: row.expired_at,
2462        source_message_id: row.source_message_id.map(MessageId),
2463        qdrant_point_id: row.qdrant_point_id,
2464        edge_type,
2465        retrieval_count: row.retrieval_count,
2466        last_retrieved_at: row.last_retrieved_at,
2467        superseded_by: row.superseded_by.map(i64::from),
2468        supersedes: row.supersedes,
2469        #[allow(clippy::cast_possible_truncation)]
2470        weight: row.weight as f32,
2471        #[allow(clippy::cast_possible_truncation)]
2472        confidence_fast: row.confidence_fast as f32,
2473        #[allow(clippy::cast_possible_truncation)]
2474        confidence_slow: row.confidence_slow as f32,
2475        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2476        turn_index: row.turn_index.map(|v| v as u32),
2477    }
2478}
2479
2480#[derive(zeph_db::FromRow)]
2481struct CommunityRow {
2482    id: i64,
2483    name: String,
2484    summary: String,
2485    entity_ids: String,
2486    fingerprint: Option<String>,
2487    created_at: String,
2488    updated_at: String,
2489}
2490
2491/// `graph_communities` projection for [`CommunityRow`], dialect-cast for `TIMESTAMPTZ` columns.
2492///
2493/// `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres — see [`entity_select_cols`].
2494/// `table_prefix` (e.g. `"c."` or `""`) is prepended to each source column reference so this
2495/// helper works both in plain `FROM graph_communities` queries and in joins with an alias.
2496fn community_select_cols(table_prefix: &str) -> String {
2497    let created_at_sel = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text(&format!(
2498        "{table_prefix}created_at"
2499    ));
2500    let updated_at_sel = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text(&format!(
2501        "{table_prefix}updated_at"
2502    ));
2503    format!(
2504        "{table_prefix}id, {table_prefix}name, {table_prefix}summary, {table_prefix}entity_ids, \
2505         {table_prefix}fingerprint, {created_at_sel} AS created_at, {updated_at_sel} AS updated_at"
2506    )
2507}
2508
2509fn community_from_row(row: CommunityRow) -> Result<Community, MemoryError> {
2510    let raw_ids: Vec<i64> = serde_json::from_str(&row.entity_ids)?;
2511    let entity_ids = raw_ids.into_iter().map(EntityId).collect();
2512    Ok(Community {
2513        id: row.id,
2514        name: row.name,
2515        summary: row.summary,
2516        entity_ids,
2517        fingerprint: row.fingerprint,
2518        created_at: row.created_at,
2519        updated_at: row.updated_at,
2520    })
2521}
2522
2523// ── GAAMA Episode methods ──────────────────────────────────────────────────────
2524
2525impl GraphStore {
2526    /// Ensure a GAAMA episode exists for this conversation, returning its ID.
2527    ///
2528    /// Idempotent: inserts on first call, returns existing ID on subsequent calls.
2529    ///
2530    /// # Errors
2531    ///
2532    /// Returns an error if the database query fails.
2533    #[tracing::instrument(name = "memory.graph.store.ensure_episode", skip_all)]
2534    pub async fn ensure_episode(&self, conversation_id: i64) -> Result<i64, MemoryError> {
2535        // Ensure the conversation row exists before inserting into graph_episodes,
2536        // which has a FK referencing conversations(id). On a fresh database the agent
2537        // may run graph extraction before the conversation row is committed.
2538        zeph_db::query(sql!(
2539            "INSERT INTO conversations (id) VALUES (?)
2540             ON CONFLICT (id) DO NOTHING"
2541        ))
2542        .bind(conversation_id)
2543        .execute(&self.pool)
2544        .await?;
2545
2546        let id: i64 = zeph_db::query_scalar(sql!(
2547            "INSERT INTO graph_episodes (conversation_id)
2548             VALUES (?)
2549             ON CONFLICT(conversation_id) DO UPDATE SET conversation_id = excluded.conversation_id
2550             RETURNING id"
2551        ))
2552        .bind(conversation_id)
2553        .fetch_one(&self.pool)
2554        .await?;
2555        Ok(id)
2556    }
2557
2558    /// Record that an entity was observed in an episode.
2559    ///
2560    /// Idempotent: does nothing if the link already exists.
2561    ///
2562    /// # Errors
2563    ///
2564    /// Returns an error if the database query fails.
2565    #[tracing::instrument(name = "memory.graph.store.link_entity_to_episode", skip_all)]
2566    pub async fn link_entity_to_episode(
2567        &self,
2568        episode_id: i64,
2569        entity_id: i64,
2570    ) -> Result<(), MemoryError> {
2571        zeph_db::query(sql!(
2572            "INSERT INTO graph_episode_entities (episode_id, entity_id)
2573             VALUES (?, ?)
2574             ON CONFLICT (episode_id, entity_id) DO NOTHING"
2575        ))
2576        .bind(episode_id)
2577        .bind(entity_id)
2578        .execute(&self.pool)
2579        .await?;
2580        Ok(())
2581    }
2582
2583    /// Return all episodes in which an entity appears.
2584    ///
2585    /// # Errors
2586    ///
2587    /// Returns an error if the database query fails.
2588    #[tracing::instrument(name = "memory.graph.store.episodes_for_entity", skip_all)]
2589    pub async fn episodes_for_entity(
2590        &self,
2591        entity_id: i64,
2592    ) -> Result<Vec<super::types::Episode>, MemoryError> {
2593        #[derive(zeph_db::FromRow)]
2594        struct EpisodeRow {
2595            id: i64,
2596            conversation_id: i64,
2597            created_at: String,
2598            closed_at: Option<String>,
2599        }
2600        // `created_at`/`closed_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project
2601        // both through `Dialect::select_as_text`, aliased back to their original names so
2602        // `#[derive(FromRow)]` still binds them into `EpisodeRow`'s `String`/`Option<String>`
2603        // fields.
2604        let created_at_sel =
2605            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("e.created_at");
2606        let closed_at_sel =
2607            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("e.closed_at");
2608        let raw = format!(
2609            "SELECT e.id, e.conversation_id, {created_at_sel} AS created_at, \
2610                    {closed_at_sel} AS closed_at
2611             FROM graph_episodes e
2612             JOIN graph_episode_entities ee ON ee.episode_id = e.id
2613             WHERE ee.entity_id = ?"
2614        );
2615        let query_sql = zeph_db::rewrite_placeholders(&raw);
2616        let rows: Vec<EpisodeRow> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
2617            .bind(entity_id)
2618            .fetch_all(&self.pool)
2619            .await?;
2620        Ok(rows
2621            .into_iter()
2622            .map(|r| super::types::Episode {
2623                id: r.id,
2624                conversation_id: r.conversation_id,
2625                created_at: r.created_at,
2626                closed_at: r.closed_at,
2627            })
2628            .collect())
2629    }
2630
2631    /// Insert a new edge using APEX-MEM append-only supersede semantics (FR-001).
2632    ///
2633    /// If a byte-identical active edge exists for `(src, tgt, canonical_relation, edge_type)`,
2634    /// records a reassertion in `edge_reassertions` and returns the existing edge id (FR-015).
2635    ///
2636    /// If a different active edge exists for the same `(src, canonical_relation, edge_type)` key,
2637    /// invalidates it with a supersession pointer, inserts the new edge, and sets `supersedes` on
2638    /// the new row to link the chain. Checks that the resulting chain depth would not exceed
2639    /// [`SUPERSEDE_DEPTH_CAP`] and that no cycle would be introduced before committing.
2640    ///
2641    /// Optionally increments [`ApexMetrics::supersedes_total`] when `metrics` is provided and a
2642    /// supersession actually occurs.
2643    ///
2644    /// # Errors
2645    ///
2646    /// - [`MemoryError::SupersedeCycle`] — the new edge would create a supersede cycle.
2647    /// - [`MemoryError::SupersedeDepthExceeded`] — chain depth cap would be exceeded.
2648    /// - [`MemoryError::Sqlx`] / [`MemoryError::Db`] — database errors.
2649    #[allow(clippy::too_many_arguments)]
2650    // TODO(B3): refactor into a builder or config struct to reduce argument count
2651    // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
2652    #[tracing::instrument(name = "memory.graph.insert_or_supersede", skip_all)]
2653    pub async fn insert_or_supersede_with_metrics(
2654        &self,
2655        source_entity_id: i64,
2656        target_entity_id: i64,
2657        relation: &str,
2658        canonical_relation: &str,
2659        fact: &str,
2660        confidence: f32,
2661        episode_id: Option<MessageId>,
2662        edge_type: EdgeType,
2663        set_supersedes: bool,
2664        metrics: Option<&ApexMetrics>,
2665    ) -> Result<i64, MemoryError> {
2666        self.insert_or_supersede_with_turn_index_and_metrics(
2667            source_entity_id,
2668            target_entity_id,
2669            relation,
2670            canonical_relation,
2671            fact,
2672            confidence,
2673            episode_id,
2674            edge_type,
2675            set_supersedes,
2676            metrics,
2677            None,
2678            None,
2679        )
2680        .await
2681    }
2682
2683    /// Insert or supersede an edge, recording turn-level provenance (#3710).
2684    ///
2685    /// `turn_index` is stored in the new edge row when a supersession chain is started.
2686    /// Pass `None` to omit provenance (equivalent to [`Self::insert_or_supersede_with_metrics`]).
2687    ///
2688    /// `provenance` stamps `origin`, `import_batch_id`, and `source_uri` on the newly inserted
2689    /// row. Pass `None` for conversation-origin edges (the default).
2690    ///
2691    /// # Errors
2692    ///
2693    /// Returns an error if the database transaction fails.
2694    #[allow(clippy::too_many_arguments)]
2695    #[tracing::instrument(name = "memory.graph.store.insert_or_supersede", skip_all)]
2696    pub async fn insert_or_supersede_with_turn_index_and_metrics(
2697        &self,
2698        source_entity_id: i64,
2699        target_entity_id: i64,
2700        relation: &str,
2701        canonical_relation: &str,
2702        fact: &str,
2703        confidence: f32,
2704        episode_id: Option<MessageId>,
2705        edge_type: EdgeType,
2706        set_supersedes: bool,
2707        metrics: Option<&ApexMetrics>,
2708        turn_index: Option<u32>,
2709        provenance: Option<&GraphProvenance>,
2710    ) -> Result<i64, MemoryError> {
2711        if source_entity_id == target_entity_id {
2712            return Err(MemoryError::InvalidInput(format!(
2713                "self-loop edge rejected: source and target are the same entity (id={source_entity_id})"
2714            )));
2715        }
2716        let confidence = confidence.clamp(0.0, 1.0);
2717        let edge_type_str = edge_type.as_str();
2718        let episode_raw: Option<i64> = episode_id.map(|m| m.0);
2719
2720        let mut tx = zeph_db::begin(&self.pool).await?;
2721
2722        if let Some(existing_id) = find_identical_active_edge(
2723            &mut tx,
2724            source_entity_id,
2725            target_entity_id,
2726            canonical_relation,
2727            edge_type_str,
2728            fact,
2729        )
2730        .await?
2731        {
2732            record_reassertion(
2733                &mut tx,
2734                existing_id,
2735                episode_raw,
2736                confidence,
2737                self.benna_fast_rate,
2738                self.benna_slow_rate,
2739            )
2740            .await?;
2741            tx.commit().await?;
2742            return Ok(existing_id);
2743        }
2744
2745        let prior_head =
2746            find_prior_active_head(&mut tx, source_entity_id, canonical_relation, edge_type_str)
2747                .await?;
2748
2749        // Cycle guard: depth cap also bounds cycles. Run inside the transaction using sqlx
2750        // directly to avoid a second pool acquire (SQLite pool is typically size 1 in tests).
2751        if let Some(head_id) = prior_head {
2752            check_supersede_depth_in_tx(&mut tx, head_id).await?;
2753        }
2754
2755        // Expire the prior head BEFORE inserting the new row so that the partial unique index
2756        // uq_graph_edges_active_head is not violated. SQLite enforces unique indexes at statement
2757        // level, not at transaction commit, so the deactivation must precede the INSERT.
2758        if let Some(head_id) = prior_head {
2759            expire_prior_head(&mut tx, head_id).await?;
2760        }
2761
2762        let supersedes_val: Option<i64> = if set_supersedes { prior_head } else { None };
2763        let turn_index_raw: Option<i64> = turn_index.map(i64::from);
2764        let (prov_origin, prov_batch, prov_uri) = provenance_parts(provenance);
2765        let new_id = insert_new_edge(
2766            &mut tx,
2767            source_entity_id,
2768            target_entity_id,
2769            relation,
2770            canonical_relation,
2771            fact,
2772            confidence,
2773            episode_raw,
2774            edge_type_str,
2775            supersedes_val,
2776            turn_index_raw,
2777            prov_origin,
2778            prov_batch,
2779            prov_uri,
2780        )
2781        .await?;
2782
2783        if let Some(head_id) = prior_head {
2784            set_superseded_by(&mut tx, head_id, new_id).await?;
2785            if let Some(m) = metrics {
2786                m.supersedes_total
2787                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2788            }
2789        }
2790
2791        tx.commit().await?;
2792        Ok(new_id)
2793    }
2794
2795    /// Convenience wrapper: calls [`Self::insert_or_supersede_with_metrics`] with `metrics = None`.
2796    ///
2797    /// # Errors
2798    ///
2799    /// Propagates errors from [`Self::insert_or_supersede_with_metrics`].
2800    #[allow(clippy::too_many_arguments)]
2801    // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
2802    #[tracing::instrument(name = "memory.graph.store.insert_or_supersede", skip_all)]
2803    pub async fn insert_or_supersede(
2804        &self,
2805        source_entity_id: i64,
2806        target_entity_id: i64,
2807        relation: &str,
2808        canonical_relation: &str,
2809        fact: &str,
2810        confidence: f32,
2811        episode_id: Option<MessageId>,
2812        edge_type: EdgeType,
2813        set_supersedes: bool,
2814    ) -> Result<i64, MemoryError> {
2815        self.insert_or_supersede_with_metrics(
2816            source_entity_id,
2817            target_entity_id,
2818            relation,
2819            canonical_relation,
2820            fact,
2821            confidence,
2822            episode_id,
2823            edge_type,
2824            set_supersedes,
2825            None,
2826        )
2827        .await
2828    }
2829
2830    /// Insert or supersede an edge, then run implicit conflict detection if a detector is provided.
2831    ///
2832    /// Calls [`Self::insert_or_supersede_with_metrics`] first, then — when `detector` is `Some` and
2833    /// the ontology confirms the predicate is cardinality-1 — queries active edges on the same source
2834    /// entity, detects conflict candidates, and stages them in `implicit_conflict_candidates`.
2835    ///
2836    /// Conflict detection failures are logged and swallowed so they never block the write path.
2837    ///
2838    /// # Errors
2839    ///
2840    /// Propagates errors from [`Self::insert_or_supersede_with_metrics`].
2841    #[allow(clippy::too_many_arguments)]
2842    // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
2843    #[tracing::instrument(name = "memory.graph.store.insert_or_supersede_conflict", skip_all)]
2844    pub async fn insert_or_supersede_with_conflict_detection(
2845        &self,
2846        source_entity_id: i64,
2847        target_entity_id: i64,
2848        relation: &str,
2849        canonical_relation: &str,
2850        fact: &str,
2851        confidence: f32,
2852        episode_id: Option<MessageId>,
2853        edge_type: EdgeType,
2854        set_supersedes: bool,
2855        metrics: Option<&ApexMetrics>,
2856        detector: Option<&crate::graph::implicit_conflict::ImplicitConflictDetector>,
2857        ontology: Option<&crate::graph::ontology::OntologyTable>,
2858    ) -> Result<i64, MemoryError> {
2859        let new_id = self
2860            .insert_or_supersede_with_metrics(
2861                source_entity_id,
2862                target_entity_id,
2863                relation,
2864                canonical_relation,
2865                fact,
2866                confidence,
2867                episode_id,
2868                edge_type,
2869                set_supersedes,
2870                metrics,
2871            )
2872            .await?;
2873
2874        if let (Some(det), Some(onto)) = (detector, ontology)
2875            && det.is_enabled()
2876        {
2877            let is_cardinality_n =
2878                onto.cardinality(canonical_relation) == crate::graph::ontology::Cardinality::Many;
2879
2880            // Query existing active edges for the same source entity (excluding the new one).
2881            let existing_raw = zeph_db::query(sql!(
2882                "SELECT id, canonical_relation FROM graph_edges
2883                     WHERE source_entity_id = ?
2884                       AND id != ?
2885                       AND expired_at IS NULL"
2886            ))
2887            .bind(source_entity_id)
2888            .bind(new_id)
2889            .fetch_all(&self.pool)
2890            .await;
2891
2892            match existing_raw {
2893                Ok(rows) => {
2894                    use sqlx::Row as _;
2895                    let existing: Vec<(i64, String)> = rows
2896                        .into_iter()
2897                        .map(|r| {
2898                            let id: i64 = r.try_get("id").unwrap_or(0);
2899                            let rel: String = r.try_get("canonical_relation").unwrap_or_default();
2900                            (id, rel)
2901                        })
2902                        .collect();
2903                    let existing_refs: Vec<(i64, &str)> = existing
2904                        .iter()
2905                        .map(|(id, rel)| (*id, rel.as_str()))
2906                        .collect();
2907
2908                    let candidates = det.detect_candidates(
2909                        new_id,
2910                        canonical_relation,
2911                        &existing_refs,
2912                        is_cardinality_n,
2913                    );
2914
2915                    if !candidates.is_empty() {
2916                        let ttl = det.candidate_ttl_days();
2917                        match zeph_db::begin(&self.pool).await {
2918                            Ok(mut tx) => {
2919                                match det.stage_candidates(&candidates, &mut tx, ttl).await {
2920                                    Ok(()) => {
2921                                        if let Err(e) = tx.commit().await {
2922                                            tracing::warn!(
2923                                                error = %e,
2924                                                "implicit conflict tx commit failed (non-fatal)"
2925                                            );
2926                                        }
2927                                    }
2928                                    Err(e) => {
2929                                        tracing::warn!(
2930                                            error = %e,
2931                                            "implicit conflict staging failed (non-fatal)"
2932                                        );
2933                                    }
2934                                }
2935                            }
2936                            Err(e) => {
2937                                tracing::warn!(
2938                                    error = %e,
2939                                    "implicit conflict: tx begin failed (non-fatal)"
2940                                );
2941                            }
2942                        }
2943                    }
2944                }
2945                Err(e) => {
2946                    tracing::warn!(
2947                        error = %e,
2948                        "implicit conflict: failed to query existing edges (non-fatal)"
2949                    );
2950                }
2951            }
2952        }
2953
2954        Ok(new_id)
2955    }
2956
2957    /// Walk the `supersedes` chain from `head_id` using a single recursive CTE and return its depth.
2958    ///
2959    /// Returns `0` when the edge has no `supersedes` pointer (it is the root).
2960    /// The CTE is capped at `SUPERSEDE_DEPTH_CAP + 1` to prevent unbounded recursion.
2961    ///
2962    /// # Errors
2963    ///
2964    /// Returns [`MemoryError::SupersedeCycle`] when the CTE detects a cycle (depth exceeds cap
2965    /// but the chain has not terminated), or a database error on query failure.
2966    #[tracing::instrument(name = "memory.graph.store.check_supersede_depth", skip_all)]
2967    pub async fn check_supersede_depth(&self, head_id: i64) -> Result<usize, MemoryError> {
2968        Self::check_supersede_depth_with_pool(&self.pool, head_id).await
2969    }
2970
2971    #[tracing::instrument(name = "memory.graph.store.check_supersede_depth_pool", skip_all)]
2972    async fn check_supersede_depth_with_pool(
2973        pool: &zeph_db::DbPool,
2974        head_id: i64,
2975    ) -> Result<usize, MemoryError> {
2976        let cap = i64::try_from(SUPERSEDE_DEPTH_CAP + 1).unwrap_or(i64::MAX);
2977        // CTE walks the supersedes chain starting at head_id (depth=0).
2978        // Each hop to a superseded ancestor increments depth. NULL supersedes terminates the walk.
2979        let depth: Option<i64> = zeph_db::query_scalar(sql!(
2980            "WITH RECURSIVE chain(id, depth) AS (
2981               SELECT id, 0 FROM graph_edges WHERE id = ?
2982               UNION ALL
2983               SELECT e.supersedes, c.depth + 1
2984               FROM graph_edges e JOIN chain c ON e.id = c.id
2985               WHERE e.supersedes IS NOT NULL AND c.depth < ?
2986             )
2987             SELECT MAX(depth) FROM chain"
2988        ))
2989        .bind(head_id)
2990        .bind(cap)
2991        .fetch_optional(pool)
2992        .await?
2993        .flatten();
2994
2995        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
2996        let d = depth.unwrap_or(0) as usize;
2997        if d > SUPERSEDE_DEPTH_CAP {
2998            return Err(MemoryError::SupersedeCycle(head_id));
2999        }
3000        Ok(d)
3001    }
3002
3003    // ── MemCoT provenance helpers (issue #3574 / #3575) ───────────────────────
3004
3005    /// Fetch `(edge_id, source_message_id)` pairs for a batch of edge ids.
3006    ///
3007    /// Returns only rows where `source_message_id` (`episode_id` column) is not NULL.
3008    /// Called from [`crate::semantic::SemanticMemory::recall_graph_view`] for Zoom-In provenance.
3009    ///
3010    /// # Errors
3011    ///
3012    /// Returns [`crate::error::MemoryError`] if the SQL query fails.
3013    #[tracing::instrument(name = "memory.graph.store.source_message_ids_for_edges", skip_all, fields(count = edge_ids.len()))]
3014    pub async fn source_message_ids_for_edges(
3015        &self,
3016        edge_ids: &[i64],
3017    ) -> Result<Vec<(i64, crate::types::MessageId)>, crate::error::MemoryError> {
3018        if edge_ids.is_empty() {
3019            return Ok(Vec::new());
3020        }
3021        let placeholders = placeholder_list(1, edge_ids.len());
3022        let sql = format!(
3023            "SELECT id, episode_id FROM graph_edges \
3024             WHERE id IN ({placeholders}) AND episode_id IS NOT NULL"
3025        );
3026        let mut q =
3027            zeph_db::query_as::<_, (i64, crate::types::MessageId)>(sqlx::AssertSqlSafe(sql));
3028        for &eid in edge_ids {
3029            q = q.bind(eid);
3030        }
3031        let rows = q.fetch_all(&self.pool).await?;
3032        Ok(rows)
3033    }
3034
3035    /// Return the `source_entity_id` for a given edge id, or `None` if not found.
3036    ///
3037    /// Used by `recall_graph_view` with `ZoomOut` to seed the 1-hop BFS.
3038    ///
3039    /// # Errors
3040    ///
3041    /// Returns [`crate::error::MemoryError`] if the SQL query fails.
3042    #[tracing::instrument(name = "memory.graph.store.source_entity_id_for_edge", skip_all)]
3043    pub async fn source_entity_id_for_edge(
3044        &self,
3045        edge_id: i64,
3046    ) -> Result<Option<i64>, crate::error::MemoryError> {
3047        let row: Option<i64> = zeph_db::query_scalar(sql!(
3048            "SELECT source_entity_id FROM graph_edges WHERE id = ? AND valid_to IS NULL LIMIT 1"
3049        ))
3050        .bind(edge_id)
3051        .fetch_optional(&self.pool)
3052        .await?;
3053        Ok(row)
3054    }
3055
3056    /// Fetch all active edges exactly 1 hop away from `entity_id`, filtered by `edge_types`.
3057    ///
3058    /// Returns a flat list of [`crate::recall_view::RecalledFact`] wrapping `GraphFact` values
3059    /// with `hop_distance = 1`. Used by `recall_graph_view` for `ZoomOut` neighbor expansion.
3060    ///
3061    /// # Errors
3062    ///
3063    /// Returns [`crate::error::MemoryError`] if the SQL query or entity name lookup fails.
3064    #[tracing::instrument(name = "memory.graph.store.bfs_edges_at_depth", skip_all)]
3065    pub async fn bfs_edges_at_depth(
3066        &self,
3067        entity_id: i64,
3068        _depth: u32,
3069        edge_types: &[crate::graph::types::EdgeType],
3070    ) -> Result<Vec<crate::recall_view::RecalledFact>, crate::error::MemoryError> {
3071        let type_strs: Vec<&str> = if edge_types.is_empty() {
3072            vec!["semantic", "temporal", "causal", "entity"]
3073        } else {
3074            edge_types.iter().map(|et| et.as_str()).collect()
3075        };
3076
3077        let ph = placeholder_list(1, type_strs.len());
3078        let src_pos = type_strs.len() + 1;
3079        let tgt_pos = src_pos + 1;
3080        let src_ph = numbered_placeholder(src_pos);
3081        let tgt_ph = numbered_placeholder(tgt_pos);
3082
3083        let sql = format!(
3084            "SELECT {edge_cols}
3085             FROM graph_edges ge
3086             WHERE ge.edge_type IN ({ph})
3087               AND ge.valid_to IS NULL
3088               AND (ge.source_entity_id = {src_ph} OR ge.target_entity_id = {tgt_ph})
3089             LIMIT 200",
3090            edge_cols = edge_select_cols("ge.")
3091        );
3092
3093        let mut q = zeph_db::query_as::<_, EdgeRow>(sqlx::AssertSqlSafe(sql));
3094        for t in &type_strs {
3095            q = q.bind(*t);
3096        }
3097        q = q.bind(entity_id).bind(entity_id);
3098
3099        let rows = q.fetch_all(&self.pool).await?;
3100
3101        // Resolve entity names from ids.
3102        let all_ids: Vec<i64> = rows
3103            .iter()
3104            .flat_map(|r| [r.source_entity_id, r.target_entity_id])
3105            .collect::<std::collections::HashSet<_>>()
3106            .into_iter()
3107            .collect();
3108
3109        let mut name_map: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
3110        for chunk in all_ids.chunks(SQLITE_BATCH_LIMIT_2X) {
3111            let ph2 = placeholder_list(1, chunk.len());
3112            let name_sql =
3113                format!("SELECT id, canonical_name FROM graph_entities WHERE id IN ({ph2})");
3114            let mut nq = zeph_db::query_as::<_, (i64, String)>(sqlx::AssertSqlSafe(name_sql));
3115            for &id in chunk {
3116                nq = nq.bind(id);
3117            }
3118            let name_rows = nq.fetch_all(&self.pool).await?;
3119            for (id, name) in name_rows {
3120                name_map.insert(id, name);
3121            }
3122        }
3123
3124        let facts: Vec<crate::recall_view::RecalledFact> = rows
3125            .into_iter()
3126            .filter_map(|row| {
3127                let edge = edge_from_row(row);
3128                let entity_name = name_map.get(&edge.source_entity_id).cloned()?;
3129                let target_name = name_map.get(&edge.target_entity_id).cloned()?;
3130                if entity_name.is_empty() || target_name.is_empty() {
3131                    return None;
3132                }
3133                let fact = crate::graph::types::GraphFact {
3134                    entity_name,
3135                    relation: edge.canonical_relation.clone(),
3136                    target_name,
3137                    fact: edge.fact.clone(),
3138                    entity_match_score: 0.5,
3139                    hop_distance: 1,
3140                    confidence: edge.confidence,
3141                    valid_from: if edge.valid_from.is_empty() {
3142                        None
3143                    } else {
3144                        Some(edge.valid_from.clone())
3145                    },
3146                    edge_type: edge.edge_type,
3147                    retrieval_count: edge.retrieval_count,
3148                    edge_id: Some(edge.id),
3149                };
3150                Some(crate::recall_view::RecalledFact::from_graph_fact(fact))
3151            })
3152            .collect();
3153
3154        Ok(facts)
3155    }
3156}
3157
3158// ── insert_or_supersede helpers ───────────────────────────────────────────────
3159// All helpers take `&mut zeph_db::DbTransaction` so they run inside the caller's transaction
3160// without acquiring a second connection (SQLite pool is typically size 1 in tests).
3161
3162type Tx<'a> = zeph_db::DbTransaction<'a>;
3163
3164/// Look up a byte-identical active edge (FR-015 reassertion path).
3165///
3166/// Returns the existing edge ID when all columns match exactly, or `None` otherwise.
3167#[tracing::instrument(name = "memory.graph.store.find_identical_active_edge", skip_all)]
3168async fn find_identical_active_edge(
3169    tx: &mut Tx<'_>,
3170    src: i64,
3171    tgt: i64,
3172    canon: &str,
3173    edge_type_str: &str,
3174    fact: &str,
3175) -> Result<Option<i64>, MemoryError> {
3176    Ok(zeph_db::query_scalar(sql!(
3177        "SELECT id FROM graph_edges
3178         WHERE source_entity_id = ?
3179           AND target_entity_id = ?
3180           AND canonical_relation = ?
3181           AND edge_type = ?
3182           AND fact = ?
3183           AND valid_to IS NULL
3184           AND expired_at IS NULL
3185         LIMIT 1"
3186    ))
3187    .bind(src)
3188    .bind(tgt)
3189    .bind(canon)
3190    .bind(edge_type_str)
3191    .bind(fact)
3192    .fetch_optional(&mut **tx)
3193    .await?)
3194}
3195
3196/// Record a reassertion event for an existing edge (FR-015) and apply the Benna-Fusi
3197/// multi-timescale update to `confidence_fast`/`confidence_slow` in `graph_edges` (#3709).
3198///
3199/// This ensures SYNAPSE synaptic variables are updated on every reassertion, including the
3200/// APEX append-only path where identical edges are not superseded but re-confirmed.
3201#[tracing::instrument(name = "memory.graph.store.record_reassertion", skip_all)]
3202async fn record_reassertion(
3203    tx: &mut Tx<'_>,
3204    head_id: i64,
3205    episode_raw: Option<i64>,
3206    confidence: f32,
3207    benna_fast_rate: f32,
3208    benna_slow_rate: f32,
3209) -> Result<(), MemoryError> {
3210    #[allow(clippy::cast_possible_wrap)]
3211    let asserted_at = std::time::SystemTime::now()
3212        .duration_since(std::time::UNIX_EPOCH)
3213        .unwrap_or_default()
3214        .as_secs() as i64;
3215    zeph_db::query(sql!(
3216        "INSERT INTO edge_reassertions (head_edge_id, asserted_at, episode_id, confidence)
3217         VALUES (?, ?, ?, ?)"
3218    ))
3219    .bind(head_id)
3220    .bind(asserted_at)
3221    .bind(episode_raw)
3222    .bind(f64::from(confidence))
3223    .execute(&mut **tx)
3224    .await?;
3225
3226    // Apply Benna-Fusi update to the head edge's synaptic variables.
3227    let existing: Option<(f64, f64)> = zeph_db::query_as(sql!(
3228        "SELECT CAST(confidence_fast AS DOUBLE PRECISION), \
3229                CAST(confidence_slow AS DOUBLE PRECISION) FROM graph_edges WHERE id = ? LIMIT 1"
3230    ))
3231    .bind(head_id)
3232    .fetch_optional(&mut **tx)
3233    .await?;
3234
3235    if let Some((stored_fast, stored_slow)) = existing {
3236        #[allow(clippy::cast_possible_truncation)]
3237        let stored_fast_f32 = (stored_fast as f32).clamp(0.0, 1.0);
3238        #[allow(clippy::cast_possible_truncation)]
3239        let stored_slow_f32 = (stored_slow as f32).clamp(0.0, 1.0);
3240        let new_fast = stored_fast_f32 + benna_fast_rate * (confidence - stored_fast_f32);
3241        let new_slow = stored_slow_f32 + benna_slow_rate * (new_fast - stored_slow_f32);
3242        zeph_db::query(sql!(
3243            "UPDATE graph_edges SET confidence_fast = ?, confidence_slow = ? WHERE id = ?"
3244        ))
3245        .bind(f64::from(new_fast))
3246        .bind(f64::from(new_slow))
3247        .bind(head_id)
3248        .execute(&mut **tx)
3249        .await?;
3250    }
3251
3252    Ok(())
3253}
3254
3255/// Find the current active head edge for `(src, canonical_relation, edge_type)`.
3256#[tracing::instrument(name = "memory.graph.store.find_prior_active_head", skip_all)]
3257async fn find_prior_active_head(
3258    tx: &mut Tx<'_>,
3259    src: i64,
3260    canon: &str,
3261    edge_type_str: &str,
3262) -> Result<Option<i64>, MemoryError> {
3263    Ok(zeph_db::query_scalar(sql!(
3264        "SELECT id FROM graph_edges
3265         WHERE source_entity_id = ?
3266           AND canonical_relation = ?
3267           AND edge_type = ?
3268           AND valid_to IS NULL
3269           AND expired_at IS NULL
3270         ORDER BY created_at DESC
3271         LIMIT 1"
3272    ))
3273    .bind(src)
3274    .bind(canon)
3275    .bind(edge_type_str)
3276    .fetch_optional(&mut **tx)
3277    .await?)
3278}
3279
3280/// Verify the supersede chain depth for `head_id` does not exceed [`SUPERSEDE_DEPTH_CAP`].
3281///
3282/// Returns `Err(MemoryError::SupersedeDepthExceeded)` when the cap would be exceeded.
3283#[tracing::instrument(name = "memory.graph.store.check_supersede_depth_in_tx", skip_all)]
3284async fn check_supersede_depth_in_tx(tx: &mut Tx<'_>, head_id: i64) -> Result<(), MemoryError> {
3285    let cap = i64::try_from(SUPERSEDE_DEPTH_CAP + 1).unwrap_or(i64::MAX);
3286    let depth: Option<i64> = zeph_db::query_scalar(sql!(
3287        "WITH RECURSIVE chain(id, depth) AS (
3288           SELECT supersedes, 1 FROM graph_edges WHERE id = ? AND supersedes IS NOT NULL
3289           UNION ALL
3290           SELECT e.supersedes, c.depth + 1
3291           FROM graph_edges e JOIN chain c ON e.id = c.id
3292           WHERE e.supersedes IS NOT NULL AND c.depth < ?
3293         )
3294         SELECT MAX(depth) FROM chain"
3295    ))
3296    .bind(head_id)
3297    .bind(cap)
3298    .fetch_optional(&mut **tx)
3299    .await?
3300    .flatten();
3301    let d = usize::try_from(depth.unwrap_or(0)).unwrap_or(usize::MAX);
3302    if d > SUPERSEDE_DEPTH_CAP {
3303        return Err(MemoryError::SupersedeDepthExceeded(head_id));
3304    }
3305    Ok(())
3306}
3307
3308/// Insert a new edge row and return its generated ID.
3309#[allow(clippy::too_many_arguments)]
3310#[tracing::instrument(name = "memory.graph.store.insert_new_edge", skip_all)]
3311async fn insert_new_edge(
3312    tx: &mut Tx<'_>,
3313    src: i64,
3314    tgt: i64,
3315    relation: &str,
3316    canonical_relation: &str,
3317    fact: &str,
3318    confidence: f32,
3319    episode_raw: Option<i64>,
3320    edge_type_str: &str,
3321    supersedes_val: Option<i64>,
3322    turn_index: Option<i64>,
3323    origin: &str,
3324    import_batch_id: Option<&str>,
3325    source_uri: Option<&str>,
3326) -> Result<i64, MemoryError> {
3327    Ok(zeph_db::query_scalar(sql!(
3328        "INSERT INTO graph_edges
3329         (source_entity_id, target_entity_id, relation, canonical_relation, fact,
3330          confidence, confidence_fast, confidence_slow, episode_id, edge_type, supersedes, turn_index,
3331          origin, import_batch_id, source_uri)
3332         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3333         RETURNING id"
3334    ))
3335    .bind(src)
3336    .bind(tgt)
3337    .bind(relation)
3338    .bind(canonical_relation)
3339    .bind(fact)
3340    .bind(f64::from(confidence))
3341    .bind(f64::from(confidence))
3342    .bind(f64::from(confidence))
3343    .bind(episode_raw)
3344    .bind(edge_type_str)
3345    .bind(supersedes_val)
3346    .bind(turn_index)
3347    .bind(origin)
3348    .bind(import_batch_id)
3349    .bind(source_uri)
3350    .fetch_one(&mut **tx)
3351    .await?)
3352}
3353
3354/// Deactivate the prior head by setting `valid_to` and `expired_at`, leaving `superseded_by`
3355/// unset. Must be called **before** inserting the new row to avoid violating
3356/// `uq_graph_edges_active_head` — the unique index is enforced at statement level, not at commit.
3357#[tracing::instrument(name = "memory.graph.store.expire_prior_head", skip_all)]
3358async fn expire_prior_head(tx: &mut Tx<'_>, head_id: i64) -> Result<(), MemoryError> {
3359    zeph_db::query(sql!(
3360        "UPDATE graph_edges
3361         SET valid_to = CURRENT_TIMESTAMP,
3362             expired_at = CURRENT_TIMESTAMP
3363         WHERE id = ?"
3364    ))
3365    .bind(head_id)
3366    .execute(&mut **tx)
3367    .await?;
3368    Ok(())
3369}
3370
3371/// Back-fill `superseded_by` on the already-expired prior head after the new row has been
3372/// inserted and its ID is known.
3373#[tracing::instrument(name = "memory.graph.store.set_superseded_by", skip_all)]
3374async fn set_superseded_by(tx: &mut Tx<'_>, head_id: i64, new_id: i64) -> Result<(), MemoryError> {
3375    zeph_db::query(sql!(
3376        "UPDATE graph_edges SET superseded_by = ? WHERE id = ?"
3377    ))
3378    .bind(new_id)
3379    .bind(head_id)
3380    .execute(&mut **tx)
3381    .await?;
3382    Ok(())
3383}
3384
3385// ── FTS helpers ───────────────────────────────────────────────────────────────
3386
3387/// Row type for the UNION ALL FTS5 query in `find_entities_ranked`.
3388type EntityFtsRow = (
3389    i64,
3390    String,
3391    String,
3392    String,
3393    Option<String>,
3394    String,
3395    String,
3396    Option<String>,
3397    f64,
3398);
3399
3400/// Sanitize and tokenize `query` into an FTS5 query string.
3401///
3402/// Returns `None` when the input is empty or contains only FTS5 operator tokens,
3403/// indicating no search should be attempted.
3404fn build_fts_query(query: &str) -> Option<String> {
3405    const FTS5_OPERATORS: &[&str] = &["AND", "OR", "NOT", "NEAR"];
3406    let sanitized = sanitize_fts_query(query);
3407    if sanitized.is_empty() {
3408        return None;
3409    }
3410    let fts_query: String = sanitized
3411        .split_whitespace()
3412        .filter(|t| !FTS5_OPERATORS.contains(t))
3413        .map(|t| format!("{t}*"))
3414        .collect::<Vec<_>>()
3415        .join(" ");
3416    if fts_query.is_empty() {
3417        None
3418    } else {
3419        Some(fts_query)
3420    }
3421}
3422
3423/// Build the UNION ALL FTS5 SQL for entity ranked search.
3424///
3425/// The SQL selects entities by direct FTS5 match (negated BM25) and by alias LIKE match
3426/// (fixed score 0.5), then orders by score descending with a LIMIT applied outside.
3427fn build_ranked_fts_sql() -> String {
3428    format!(
3429        "SELECT * FROM ( \
3430             SELECT e.id, e.name, e.canonical_name, e.entity_type, e.summary, \
3431                    e.first_seen_at, e.last_seen_at, e.qdrant_point_id, \
3432                    -bm25(graph_entities_fts, 10.0, 1.0) AS fts_rank \
3433             FROM graph_entities_fts fts \
3434             JOIN graph_entities e ON e.id = fts.rowid \
3435             WHERE graph_entities_fts MATCH ? \
3436             UNION ALL \
3437             SELECT e.id, e.name, e.canonical_name, e.entity_type, e.summary, \
3438                    e.first_seen_at, e.last_seen_at, e.qdrant_point_id, \
3439                    0.5 AS fts_rank \
3440             FROM graph_entity_aliases a \
3441             JOIN graph_entities e ON e.id = a.entity_id \
3442             WHERE a.alias_name LIKE ? ESCAPE '\\' {} \
3443         ) \
3444         ORDER BY fts_rank DESC \
3445         LIMIT ?",
3446        <ActiveDialect as zeph_db::dialect::Dialect>::COLLATE_NOCASE,
3447    )
3448}
3449
3450/// Normalize FTS scores to `[0, 1]` and deduplicate by entity ID.
3451///
3452/// Deduplication keeps the first (highest-ranked) occurrence of each entity ID.
3453fn normalize_and_dedup(rows: Vec<EntityFtsRow>) -> Vec<(Entity, f32)> {
3454    // Guard against div-by-zero when all scores are 0.
3455    let max_score: f64 = rows.iter().map(|r| r.8).fold(0.0_f64, f64::max);
3456    let max_score = if max_score <= 0.0 { 1.0 } else { max_score };
3457
3458    let mut seen_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
3459    let mut result: Vec<(Entity, f32)> = Vec::with_capacity(rows.len());
3460    for (
3461        id,
3462        name,
3463        canonical_name,
3464        entity_type_str,
3465        summary,
3466        first_seen_at,
3467        last_seen_at,
3468        qdrant_point_id,
3469        raw_score,
3470    ) in rows
3471    {
3472        if !seen_ids.insert(id) {
3473            continue;
3474        }
3475        let entity_type = entity_type_str
3476            .parse()
3477            .unwrap_or(super::types::EntityType::Concept);
3478        let entity = Entity {
3479            id: EntityId(id),
3480            name,
3481            canonical_name,
3482            entity_type,
3483            summary,
3484            first_seen_at,
3485            last_seen_at,
3486            qdrant_point_id,
3487        };
3488        #[allow(clippy::cast_possible_truncation)]
3489        let normalized = (raw_score / max_score).clamp(0.0, 1.0) as f32;
3490        result.push((entity, normalized));
3491    }
3492    result
3493}
3494
3495// ── Tests ─────────────────────────────────────────────────────────────────────
3496
3497#[cfg(test)]
3498mod tests;