1use 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
26pub struct GraphStore {
33 pool: DbPool,
34 benna_fast_rate: f32,
36 benna_slow_rate: f32,
38 recall_include_imported: bool,
41}
42
43const SQLITE_BATCH_LIMIT_2X: usize = 490;
49
50impl GraphStore {
51 #[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 #[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 #[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 #[must_use]
87 pub fn pool(&self) -> &DbPool {
88 &self.pool
89 }
90
91 #[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 #[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 #[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 #[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 #[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 #[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 const FTS5_OPERATORS: &[&str] = &["AND", "OR", "NOT", "NEAR"];
268 let query = &query[..query.floor_char_boundary(512)];
269 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[allow(clippy::too_many_arguments)]
530 #[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 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 #[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 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 #[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 #[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 #[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 #[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 let n_ids = entity_ids.len();
729 let n_types = edge_types.len();
730
731 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 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 #[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 #[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 #[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 #[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#[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#[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
2316fn 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
2357fn 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 superseded_by: Option<i32>,
2394 canonical_relation: Option<String>,
2395 supersedes: Option<i64>,
2396 weight: f64,
2398 confidence_fast: f64,
2400 confidence_slow: f64,
2401 turn_index: Option<i32>,
2403}
2404
2405fn 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
2491fn 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
2523impl GraphStore {
2526 #[tracing::instrument(name = "memory.graph.store.ensure_episode", skip_all)]
2534 pub async fn ensure_episode(&self, conversation_id: i64) -> Result<i64, MemoryError> {
2535 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 #[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 #[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 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 #[allow(clippy::too_many_arguments)]
2650 #[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 #[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 if let Some(head_id) = prior_head {
2752 check_supersede_depth_in_tx(&mut tx, head_id).await?;
2753 }
2754
2755 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 #[allow(clippy::too_many_arguments)]
2801 #[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 #[allow(clippy::too_many_arguments)]
2842 #[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 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 #[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 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 #[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 #[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 #[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 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
3158type Tx<'a> = zeph_db::DbTransaction<'a>;
3163
3164#[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#[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 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#[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#[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#[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#[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#[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
3385type EntityFtsRow = (
3389 i64,
3390 String,
3391 String,
3392 String,
3393 Option<String>,
3394 String,
3395 String,
3396 Option<String>,
3397 f64,
3398);
3399
3400fn 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
3423fn 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
3450fn normalize_and_dedup(rows: Vec<EntityFtsRow>) -> Vec<(Entity, f32)> {
3454 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#[cfg(test)]
3498mod tests;