Skip to main content

apollo/memory/
surreal.rs

1//! SurrealDB-backed memory storage using the local RocksDB engine.
2
3use anyhow::Result;
4use async_trait::async_trait;
5use base64::engine::general_purpose::STANDARD as BASE64;
6use base64::Engine as _;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use surrealdb::engine::local::RocksDb;
12use surrealdb::Surreal;
13use tokio::sync::{OnceCell, RwLock};
14
15use super::traits::*;
16
17#[derive(Clone)]
18pub struct SurrealMemory {
19    path: PathBuf,
20    cell: Arc<OnceCell<Surreal<surrealdb::engine::local::Db>>>,
21    /// Namespace → cached vectors, the candidate set every similarity search
22    /// scans. Shared across clones, because `SurrealMemory` is cloned freely
23    /// and all clones address the same embedded database.
24    vectors: Arc<RwLock<HashMap<String, Vec<CachedVector>>>>,
25}
26
27/// One namespace entry in the vector cache.
28///
29/// `dim` is stored alongside the vector so a namespace can hold vectors from
30/// several embedding models at once, which is exactly what an MTREE index could
31/// not do. Filtering happens per entry at scan time.
32struct CachedVector {
33    key: String,
34    dim: i64,
35    vector: Vec<f32>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39struct MemoryRow {
40    namespace: String,
41    key: String,
42    value: String,
43    metadata: Option<serde_json::Value>,
44    created_at: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct ConversationRow {
49    chat_id: String,
50    sender_id: String,
51    role: String,
52    content: String,
53    seq: i64,
54    created_at: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct StickerRow {
59    sticker_id: String,
60    file_id: String,
61    description: String,
62    analyzed_at: String,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66struct EmbeddingRow {
67    namespace: String,
68    key: String,
69    /// Packed little-endian `f32` payload, base64-encoded — the canonical
70    /// vector encoding.
71    ///
72    /// One SurrealDB `Value` per row instead of one per component. Reading a
73    /// 1536-element `array` back out costs ~0.27ms per row in `Value`
74    /// materialization alone, which dominated the old scan.
75    ///
76    /// It is a base64 `string` and not a native `bytes` field because neither
77    /// reachable `Bytes` type round-trips: `surrealdb::Bytes` is a newtype with
78    /// a derived `Serialize`, so it stores as an array of 6144 integers — the
79    /// exact cost this encoding exists to avoid — and `surrealdb::sql::Bytes`,
80    /// which does emit a native bytes value, is not exported by the 2.3 SDK.
81    vector_b64: String,
82    text: String,
83    created_at: String,
84    #[serde(default)]
85    dim: Option<i64>,
86    #[serde(default)]
87    model: Option<String>,
88}
89
90/// Row shape used to populate the vector cache for a namespace.
91///
92/// `vector_b64` is the current encoding; `vector` is only present on rows
93/// written before it existed. Selecting both keeps legacy rows scoreable
94/// without a migration pass, and costs nothing for new rows, which do not carry
95/// a `vector` field at all.
96#[derive(Debug, Clone, Deserialize)]
97struct EmbeddingCandidate {
98    key: String,
99    dim: i64,
100    #[serde(default)]
101    vector_b64: Option<String>,
102    #[serde(default)]
103    vector: Option<Vec<f32>>,
104}
105
106/// The fields a search returns that are not worth caching, fetched for the
107/// top-`k` winners only.
108#[derive(Debug, Clone, Deserialize)]
109struct EmbeddingDetail {
110    key: String,
111    text: String,
112    created_at: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116struct FileIndexRow {
117    path: String,
118    hash: String,
119    last_indexed: String,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123struct ChunkRow {
124    file_path: String,
125    start_line: u32,
126    end_line: u32,
127    content: String,
128    embedding: Option<Vec<f32>>,
129    created_at: String,
130}
131
132impl SurrealMemory {
133    pub async fn db(&self) -> Result<Surreal<surrealdb::engine::local::Db>> {
134        let db = self
135            .cell
136            .get_or_try_init(|| async {
137                let db = Surreal::new::<RocksDb>(self.path.as_path()).await?;
138                db.use_ns("claw").use_db("memory").await?;
139                db.query(SCHEMA_SQL).await?;
140                Ok::<_, anyhow::Error>(db)
141            })
142            .await?;
143        Ok(db.clone())
144    }
145
146    pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
147        let path = path.as_ref().to_path_buf();
148        if let Some(parent) = path.parent() {
149            if !parent.as_os_str().is_empty() {
150                tokio::fs::create_dir_all(parent).await?;
151            }
152        }
153        Ok(Self {
154            path,
155            cell: Arc::new(OnceCell::new()),
156            vectors: Arc::new(RwLock::new(HashMap::new())),
157        })
158    }
159
160    fn memory_id(namespace: &str, key: &str) -> String {
161        format!("{namespace}::{key}")
162    }
163
164    /// Populate the vector cache for `namespace` if it is not already loaded.
165    ///
166    /// This is the only full read of the `embeddings` table, and it happens
167    /// once per namespace per process. Two concurrent first searches may both
168    /// run it; they produce the same contents, so the duplicate is wasteful but
169    /// not wrong, and that is cheaper than serialising every search behind a
170    /// write lock.
171    async fn ensure_vectors_loaded(&self, namespace: &str) -> Result<()> {
172        if self.vectors.read().await.contains_key(namespace) {
173            return Ok(());
174        }
175
176        // Rows with no `dim` are skipped by the query, matching the behaviour
177        // of the previous per-search filter: a vector whose dimension was never
178        // recorded is not comparable to anything and must not be ranked.
179        let mut result = self
180            .db()
181            .await?
182            .query(
183                "SELECT key, dim, vector_b64, vector FROM embeddings \
184                 WHERE namespace = $namespace AND dim != NONE",
185            )
186            .bind(("namespace", namespace.to_string()))
187            .await?;
188        let rows: Vec<EmbeddingCandidate> = result.take(0)?;
189
190        let mut raw: Vec<u8> = Vec::new();
191        let mut entries: Vec<CachedVector> = Vec::with_capacity(rows.len());
192        for row in rows {
193            let mut vector = Vec::new();
194            match (&row.vector_b64, row.vector) {
195                (Some(encoded), _) => unpack_vector_into(encoded, &mut raw, &mut vector),
196                (None, Some(v)) => vector = v,
197                // Neither encoding present: unscoreable, so it never enters the
198                // candidate set.
199                (None, None) => continue,
200            }
201            entries.push(CachedVector {
202                key: row.key,
203                dim: row.dim,
204                vector,
205            });
206        }
207
208        self.vectors
209            .write()
210            .await
211            .insert(namespace.to_string(), entries);
212        Ok(())
213    }
214
215    /// Write-through: keep a loaded namespace in step with a new or replaced
216    /// vector, so a store followed by a search sees the store.
217    ///
218    /// A namespace that is not loaded is left alone — it will read the new row
219    /// from the table when it is first loaded.
220    async fn cache_vector(&self, namespace: &str, key: &str, vector: &[f32]) {
221        let mut cache = self.vectors.write().await;
222        let Some(entries) = cache.get_mut(namespace) else {
223            return;
224        };
225        let entry = CachedVector {
226            key: key.to_string(),
227            dim: vector.len() as i64,
228            vector: vector.to_vec(),
229        };
230        match entries.iter_mut().find(|e| e.key == key) {
231            Some(existing) => *existing = entry,
232            None => entries.push(entry),
233        }
234    }
235}
236
237const SCHEMA_SQL: &str = r#"
238    DEFINE TABLE IF NOT EXISTS memories SCHEMALESS;
239    DEFINE FIELD IF NOT EXISTS namespace ON memories TYPE string;
240    DEFINE FIELD IF NOT EXISTS key ON memories TYPE string;
241    DEFINE FIELD IF NOT EXISTS value ON memories TYPE string;
242    DEFINE FIELD IF NOT EXISTS metadata ON memories TYPE option<object>;
243    DEFINE FIELD IF NOT EXISTS created_at ON memories TYPE string;
244    DEFINE INDEX IF NOT EXISTS memory_lookup_idx ON memories FIELDS namespace, key UNIQUE;
245    DEFINE INDEX IF NOT EXISTS memory_namespace_idx ON memories FIELDS namespace;
246
247    DEFINE TABLE IF NOT EXISTS conversations SCHEMALESS;
248    DEFINE FIELD IF NOT EXISTS chat_id ON conversations TYPE string;
249    DEFINE FIELD IF NOT EXISTS sender_id ON conversations TYPE string;
250    DEFINE FIELD IF NOT EXISTS role ON conversations TYPE string;
251    DEFINE FIELD IF NOT EXISTS content ON conversations TYPE string;
252    DEFINE FIELD IF NOT EXISTS seq ON conversations TYPE int;
253    DEFINE FIELD IF NOT EXISTS created_at ON conversations TYPE string;
254    DEFINE INDEX IF NOT EXISTS conversation_chat_idx ON conversations FIELDS chat_id, seq;
255
256    DEFINE TABLE IF NOT EXISTS sticker_cache SCHEMALESS;
257    DEFINE FIELD IF NOT EXISTS sticker_id ON sticker_cache TYPE string;
258    DEFINE FIELD IF NOT EXISTS file_id ON sticker_cache TYPE string;
259    DEFINE FIELD IF NOT EXISTS description ON sticker_cache TYPE string;
260    DEFINE FIELD IF NOT EXISTS analyzed_at ON sticker_cache TYPE string;
261    DEFINE INDEX IF NOT EXISTS sticker_id_idx ON sticker_cache FIELDS sticker_id UNIQUE;
262
263    DEFINE TABLE IF NOT EXISTS embeddings SCHEMALESS;
264    DEFINE FIELD IF NOT EXISTS namespace ON embeddings TYPE string;
265    DEFINE FIELD IF NOT EXISTS key ON embeddings TYPE string;
266    -- `vector` is the legacy array encoding. New writes emit `blob` instead, so
267    -- the field is optional and only present on rows written before the change.
268    DEFINE FIELD IF NOT EXISTS vector ON embeddings TYPE option<array>;
269    DEFINE FIELD IF NOT EXISTS vector_b64 ON embeddings TYPE option<string>;
270    DEFINE FIELD IF NOT EXISTS text ON embeddings TYPE string;
271    DEFINE FIELD IF NOT EXISTS created_at ON embeddings TYPE string;
272    DEFINE FIELD IF NOT EXISTS dim ON embeddings TYPE option<int>;
273    DEFINE FIELD IF NOT EXISTS model ON embeddings TYPE option<string>;
274    DEFINE INDEX IF NOT EXISTS embedding_lookup_idx ON embeddings FIELDS namespace, key UNIQUE;
275    DEFINE INDEX IF NOT EXISTS embedding_namespace_idx ON embeddings FIELDS namespace;
276    DEFINE INDEX IF NOT EXISTS embedding_namespace_dim_idx ON embeddings FIELDS namespace, dim;
277    -- No MTREE index on `vector`, and this is now settled by measurement, not
278    -- by assumption. See `bench_mtree_feasibility` and
279    -- `bench_search_embeddings_scaling` in this file; numbers from a release
280    -- build on macOS arm64 at dim 1536.
281    --
282    -- `DEFINE INDEX ... MTREE DIMENSION 1536 DIST COSINE` is accepted, but:
283    --   1. It locks the whole table to that one dimension. Storing a 3-dim
284    --      vector afterwards fails with "Incorrect vector dimension (3).
285    --      Expected a vector of 1536 dimension." So "one index per distinct
286    --      dimension" is not possible on a shared table at all — it would
287    --      need dimension-partitioned tables, not just extra indexes.
288    --   2. It is ~25x more expensive to write: 29.76ms/row indexed versus
289    --      ~1.2ms/row unindexed.
290    --   3. It does not even pay off on reads. At 2000 rows,
291    --      `vector <|10,COSINE|> $q` had a 2558ms median against 961ms for
292    --      the brute-force scan — 2.7x slower.
293    --
294    -- The scan itself is not cheap (~0.27ms per candidate row, so ~30ms at
295    -- 100 rows and ~2.5s at 10k), but the cost is materializing a
296    -- 1536-element array as SurrealDB `Value`s per row, not the cosine
297    -- arithmetic. Scoring in-engine with
298    -- `vector::similarity::cosine(...) ORDER BY score LIMIT k` was measured
299    -- too and is ~1.6x slower still, because it walks the same arrays.
300    -- Anything that actually fixes this has to make a row cheaper to read
301    -- (a compact encoding rather than an array of `Value`s), which MTREE
302    -- does not do.
303
304    DEFINE TABLE IF NOT EXISTS files SCHEMALESS;
305    DEFINE FIELD IF NOT EXISTS path ON files TYPE string;
306    DEFINE FIELD IF NOT EXISTS hash ON files TYPE string;
307    DEFINE FIELD IF NOT EXISTS last_indexed ON files TYPE string;
308    DEFINE INDEX IF NOT EXISTS file_path_idx ON files FIELDS path UNIQUE;
309
310    DEFINE TABLE IF NOT EXISTS chunks SCHEMALESS;
311    DEFINE FIELD IF NOT EXISTS file_path ON chunks TYPE string;
312    DEFINE FIELD IF NOT EXISTS start_line ON chunks TYPE int;
313    DEFINE FIELD IF NOT EXISTS end_line ON chunks TYPE int;
314    DEFINE FIELD IF NOT EXISTS content ON chunks TYPE string;
315    DEFINE FIELD IF NOT EXISTS embedding ON chunks TYPE option<array>;
316    DEFINE FIELD IF NOT EXISTS created_at ON chunks TYPE string;
317    DEFINE INDEX IF NOT EXISTS chunk_file_idx ON chunks FIELDS file_path;
318
319    DEFINE TABLE IF NOT EXISTS cron_jobs SCHEMALESS;
320    DEFINE FIELD IF NOT EXISTS name ON cron_jobs TYPE string;
321    DEFINE FIELD IF NOT EXISTS schedule ON cron_jobs TYPE string;
322    DEFINE FIELD IF NOT EXISTS task ON cron_jobs TYPE string;
323    DEFINE FIELD IF NOT EXISTS channel ON cron_jobs TYPE string;
324    DEFINE FIELD IF NOT EXISTS model ON cron_jobs TYPE string;
325    DEFINE FIELD IF NOT EXISTS enabled ON cron_jobs TYPE bool;
326    DEFINE FIELD IF NOT EXISTS last_run ON cron_jobs TYPE option<string>;
327    DEFINE FIELD IF NOT EXISTS next_run ON cron_jobs TYPE option<string>;
328    DEFINE INDEX IF NOT EXISTS cron_name_idx ON cron_jobs FIELDS name UNIQUE;
329
330    DEFINE TABLE IF NOT EXISTS memory_nodes SCHEMALESS;
331    DEFINE FIELD IF NOT EXISTS id ON memory_nodes TYPE string;
332    DEFINE FIELD IF NOT EXISTS kind ON memory_nodes TYPE string;
333    DEFINE FIELD IF NOT EXISTS text ON memory_nodes TYPE string;
334    DEFINE FIELD IF NOT EXISTS confidence ON memory_nodes TYPE float;
335    DEFINE FIELD IF NOT EXISTS status ON memory_nodes TYPE string;
336    DEFINE FIELD IF NOT EXISTS created_at ON memory_nodes TYPE string;
337    DEFINE INDEX IF NOT EXISTS memory_node_id_idx ON memory_nodes FIELDS id UNIQUE;
338
339    DEFINE TABLE IF NOT EXISTS memory_edges SCHEMALESS;
340    DEFINE FIELD IF NOT EXISTS from_id ON memory_edges TYPE string;
341    DEFINE FIELD IF NOT EXISTS to_id ON memory_edges TYPE string;
342    DEFINE FIELD IF NOT EXISTS rel ON memory_edges TYPE string;
343    DEFINE FIELD IF NOT EXISTS created_at ON memory_edges TYPE string;
344
345    DEFINE ANALYZER IF NOT EXISTS memory_analyzer TOKENIZERS blank, class FILTERS lowercase, snowball(english);
346    DEFINE INDEX IF NOT EXISTS memory_fts_idx ON memories FIELDS value
347        SEARCH ANALYZER memory_analyzer BM25;
348"#;
349
350fn parse_timestamp(value: &str) -> chrono::DateTime<chrono::Utc> {
351    chrono::DateTime::parse_from_rfc3339(value)
352        .map(|dt| dt.with_timezone(&chrono::Utc))
353        .unwrap_or_else(|_| chrono::Utc::now())
354}
355
356#[async_trait]
357impl MemoryBackend for SurrealMemory {
358    fn as_any(&self) -> &dyn std::any::Any {
359        self
360    }
361
362    async fn store(
363        &self,
364        namespace: &str,
365        key: &str,
366        value: &str,
367        metadata: Option<serde_json::Value>,
368    ) -> Result<()> {
369        let created_at = chrono::Utc::now().to_rfc3339();
370        let row = MemoryRow {
371            namespace: namespace.to_string(),
372            key: key.to_string(),
373            value: value.to_string(),
374            metadata,
375            created_at,
376        };
377        let _: Option<MemoryRow> = self
378            .db()
379            .await?
380            .upsert(("memories", Self::memory_id(namespace, key)))
381            .content(row)
382            .await?;
383        Ok(())
384    }
385
386    async fn recall(&self, namespace: &str, key: &str) -> Result<Option<MemoryEntry>> {
387        let row: Option<MemoryRow> = self
388            .db()
389            .await?
390            .select(("memories", Self::memory_id(namespace, key)))
391            .await?;
392        Ok(row.map(|entry| MemoryEntry {
393            key: entry.key,
394            value: entry.value,
395            metadata: entry.metadata,
396            created_at: parse_timestamp(&entry.created_at),
397        }))
398    }
399
400    async fn search(&self, namespace: &str, query: &str, limit: usize) -> Result<Vec<MemoryEntry>> {
401        // Try full-text search with BM25 ranking first
402        let mut result = self
403            .db()
404            .await?
405            .query(
406                "SELECT *, search::score(1) AS score
407                 FROM memories
408                 WHERE namespace = $namespace
409                   AND value @1@ $query
410                 ORDER BY score DESC
411                 LIMIT $limit",
412            )
413            .bind(("namespace", namespace.to_string()))
414            .bind(("query", query.to_string()))
415            .bind(("limit", limit as i64))
416            .await?;
417        let rows: Vec<MemoryRow> = result.take(0)?;
418
419        if !rows.is_empty() {
420            return Ok(rows
421                .into_iter()
422                .map(|entry| MemoryEntry {
423                    key: entry.key,
424                    value: entry.value,
425                    metadata: entry.metadata,
426                    created_at: parse_timestamp(&entry.created_at),
427                })
428                .collect());
429        }
430
431        // Fallback to CONTAINS for partial matches
432        let query_lower = query.to_lowercase();
433        let mut result = self.db().await?
434            .query(
435                "SELECT * FROM memories
436                 WHERE namespace = $namespace
437                   AND (string::lowercase(key) CONTAINS $query OR string::lowercase(value) CONTAINS $query)
438                 ORDER BY created_at DESC
439                 LIMIT $limit"
440            )
441            .bind(("namespace", namespace.to_string()))
442            .bind(("query", query_lower))
443            .bind(("limit", limit as i64))
444            .await?;
445        let rows: Vec<MemoryRow> = result.take(0)?;
446        Ok(rows
447            .into_iter()
448            .map(|entry| MemoryEntry {
449                key: entry.key,
450                value: entry.value,
451                metadata: entry.metadata,
452                created_at: parse_timestamp(&entry.created_at),
453            })
454            .collect())
455    }
456
457    async fn forget(&self, namespace: &str, key: &str) -> Result<()> {
458        let _: Option<MemoryRow> = self
459            .db()
460            .await?
461            .delete(("memories", Self::memory_id(namespace, key)))
462            .await?;
463        Ok(())
464    }
465
466    async fn list(&self, namespace: &str) -> Result<Vec<MemoryEntry>> {
467        let mut result = self.db().await?
468            .query("SELECT key, value, metadata, created_at FROM memories WHERE namespace = $namespace ORDER BY created_at DESC")
469            .bind(("namespace", namespace.to_string()))
470            .await?;
471        let rows: Vec<MemoryRow> = result.take(0)?;
472        Ok(rows
473            .into_iter()
474            .map(|entry| MemoryEntry {
475                key: entry.key,
476                value: entry.value,
477                metadata: entry.metadata,
478                created_at: parse_timestamp(&entry.created_at),
479            })
480            .collect())
481    }
482
483    async fn store_conversation(
484        &self,
485        chat_id: &str,
486        sender_id: &str,
487        role: &str,
488        content: &str,
489    ) -> Result<()> {
490        let now = chrono::Utc::now();
491        let row = ConversationRow {
492            chat_id: chat_id.to_string(),
493            sender_id: sender_id.to_string(),
494            role: role.to_string(),
495            content: content.to_string(),
496            seq: now.timestamp_millis(),
497            created_at: now.to_rfc3339(),
498        };
499        let _: Option<ConversationRow> = self
500            .db()
501            .await?
502            .create("conversations")
503            .content(row)
504            .await?;
505        Ok(())
506    }
507
508    async fn store_conversation_batch(&self, entries: &[(&str, &str, &str, &str)]) -> Result<()> {
509        for (offset, (chat_id, sender_id, role, content)) in entries.iter().enumerate() {
510            let now = chrono::Utc::now();
511            let row = ConversationRow {
512                chat_id: (*chat_id).to_string(),
513                sender_id: (*sender_id).to_string(),
514                role: (*role).to_string(),
515                content: (*content).to_string(),
516                seq: now.timestamp_millis() + offset as i64,
517                created_at: now.to_rfc3339(),
518            };
519            let _: Option<ConversationRow> = self
520                .db()
521                .await?
522                .create("conversations")
523                .content(row)
524                .await?;
525        }
526        Ok(())
527    }
528
529    async fn get_conversation_history(
530        &self,
531        chat_id: &str,
532        limit: usize,
533    ) -> Result<Vec<(String, String)>> {
534        let mut result = self
535            .db()
536            .await?
537            .query(
538                "SELECT * FROM conversations
539                 WHERE chat_id = $chat_id
540                 ORDER BY seq DESC
541                 LIMIT $limit",
542            )
543            .bind(("chat_id", chat_id.to_string()))
544            .bind(("limit", limit as i64))
545            .await?;
546        let mut rows: Vec<ConversationRow> = result.take(0)?;
547        rows.reverse();
548        Ok(rows
549            .into_iter()
550            .map(|row| (row.role, row.content))
551            .collect())
552    }
553
554    async fn clear_conversation(&self, chat_id: &str) -> Result<()> {
555        self.db()
556            .await?
557            .query("DELETE FROM conversations WHERE chat_id = $chat_id")
558            .bind(("chat_id", chat_id.to_string()))
559            .await?
560            .check()?;
561        Ok(())
562    }
563
564    async fn search_conversations(
565        &self,
566        query: &str,
567        limit: usize,
568        chat_id: Option<&str>,
569    ) -> Result<Vec<ConversationSearchHit>> {
570        let query = query.to_lowercase();
571        let mut result = if let Some(chat_id) = chat_id {
572            self.db()
573                .await?
574                .query(
575                    "SELECT * FROM conversations
576                     WHERE chat_id = $chat_id
577                       AND string::lowercase(content) CONTAINS $query
578                     ORDER BY seq DESC
579                     LIMIT $limit",
580                )
581                .bind(("chat_id", chat_id.to_string()))
582                .bind(("query", query))
583                .bind(("limit", limit as i64))
584                .await?
585        } else {
586            self.db()
587                .await?
588                .query(
589                    "SELECT * FROM conversations
590                     WHERE string::lowercase(content) CONTAINS $query
591                     ORDER BY seq DESC
592                     LIMIT $limit",
593                )
594                .bind(("query", query))
595                .bind(("limit", limit as i64))
596                .await?
597        };
598        let rows: Vec<ConversationRow> = result.take(0)?;
599        Ok(rows
600            .into_iter()
601            .map(|row| ConversationSearchHit {
602                chat_id: row.chat_id,
603                role: row.role,
604                content: row.content,
605                created_at: parse_timestamp(&row.created_at),
606            })
607            .collect())
608    }
609
610    async fn get_sticker_cache(&self, sticker_id: &str) -> Result<Option<String>> {
611        let row: Option<StickerRow> = self
612            .db()
613            .await?
614            .select(("sticker_cache", sticker_id))
615            .await?;
616        Ok(row.map(|entry| entry.description))
617    }
618
619    async fn store_sticker_cache(
620        &self,
621        sticker_id: &str,
622        file_id: &str,
623        description: &str,
624    ) -> Result<()> {
625        let row = StickerRow {
626            sticker_id: sticker_id.to_string(),
627            file_id: file_id.to_string(),
628            description: description.to_string(),
629            analyzed_at: chrono::Utc::now().to_rfc3339(),
630        };
631        let _: Option<StickerRow> = self
632            .db()
633            .await?
634            .upsert(("sticker_cache", sticker_id))
635            .content(row)
636            .await?;
637        Ok(())
638    }
639
640    // ── Embeddings ──
641
642    async fn store_embedding(
643        &self,
644        namespace: &str,
645        key: &str,
646        vector: &[f32],
647        text: &str,
648        model: &str,
649    ) -> Result<()> {
650        let row = EmbeddingRow {
651            namespace: namespace.to_string(),
652            key: key.to_string(),
653            dim: Some(vector.len() as i64),
654            model: Some(model.to_string()),
655            vector_b64: pack_vector(vector),
656            text: text.to_string(),
657            created_at: chrono::Utc::now().to_rfc3339(),
658        };
659        let id = Self::memory_id(namespace, key);
660        let _: Option<EmbeddingRow> = self
661            .db()
662            .await?
663            .upsert(("embeddings", &id))
664            .content(row)
665            .await?;
666        self.cache_vector(namespace, key, vector).await;
667        Ok(())
668    }
669
670    async fn search_embeddings(
671        &self,
672        namespace: &str,
673        query_vector: &[f32],
674        limit: usize,
675    ) -> Result<Vec<EmbeddingEntry>> {
676        if query_vector.is_empty() {
677            return Ok(Vec::new());
678        }
679        let dim = query_vector.len() as i64;
680
681        // Exact brute-force cosine scan, but over an in-process cache rather
682        // than over SurrealDB rows.
683        //
684        // Reading candidates out of SurrealDB is the bottleneck, and it is not
685        // only the vector payload: a `SELECT namespace, key` that projects no
686        // vector at all still costs ~0.07ms per row, so the query layer alone
687        // puts a ~700ms floor under a 10k-row search. No row encoding gets
688        // below that, which is why the vectors are held in memory instead. The
689        // packed encoding still pays for itself — it halves both the cost of
690        // the one-time load and the on-disk size.
691        //
692        // SurrealDB's KNN operator is not used here. `vector <|K,COSINE|> $v`
693        // only produces matches when an MTREE index is defined on the field,
694        // and there deliberately is none (see SCHEMA_SQL) — the earlier form
695        // `vector <| $v |>` did not even parse, so the KNN path silently
696        // errored and every search already fell through to this scan.
697        //
698        // The `dim` filter is what keeps the comparison meaningful: vectors
699        // produced by a different embedding model have a different length and
700        // are not comparable, so they must never enter the candidate set.
701        // Rows written before `dim` was recorded have no dimension and are
702        // therefore excluded; they are picked up again on their next write.
703        self.ensure_vectors_loaded(namespace).await?;
704
705        // `cosine_similarity` returns None for mismatched lengths rather than
706        // scoring them, so an entry that slipped past the `dim` filter is
707        // dropped instead of ranked.
708        let ranked: Vec<(String, Vec<f32>)> = {
709            let cache = self.vectors.read().await;
710            let Some(entries) = cache.get(namespace) else {
711                return Ok(Vec::new());
712            };
713            let query_magnitude = magnitude(query_vector);
714            let mut scored: Vec<(f32, usize)> = Vec::with_capacity(entries.len());
715            for (idx, entry) in entries.iter().enumerate() {
716                if entry.dim != dim {
717                    continue;
718                }
719                let sim =
720                    cosine_similarity_with_magnitude(query_vector, query_magnitude, &entry.vector);
721                if let Some(sim) = sim {
722                    scored.push((sim, idx));
723                }
724            }
725            scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
726            scored.truncate(limit);
727            scored
728                .into_iter()
729                .map(|(_, idx)| (entries[idx].key.clone(), entries[idx].vector.clone()))
730                .collect()
731        };
732
733        if ranked.is_empty() {
734            return Ok(Vec::new());
735        }
736
737        // `text` and `created_at` are deliberately not cached. They are read
738        // back for the handful of winners only, which keeps the cache
739        // proportional to the vectors alone and keeps the returned text from
740        // ever going stale against the table.
741        // Looked up by namespace and key rather than by record id: rows created
742        // outside `store_embedding` do not necessarily follow the `ns::key` id
743        // convention, and dropping them here would silently lose a hit that the
744        // scan had already ranked.
745        let keys: Vec<String> = ranked.iter().map(|(key, _)| key.clone()).collect();
746        let mut result = self
747            .db()
748            .await?
749            .query(
750                "SELECT key, text, created_at FROM embeddings \
751                 WHERE namespace = $namespace AND key IN $keys",
752            )
753            .bind(("namespace", namespace.to_string()))
754            .bind(("keys", keys))
755            .await?;
756        let rows: Vec<EmbeddingDetail> = result.take(0)?;
757        let details: std::collections::HashMap<String, EmbeddingDetail> =
758            rows.into_iter().map(|r| (r.key.clone(), r)).collect();
759
760        Ok(ranked
761            .into_iter()
762            .filter_map(|(key, vector)| {
763                let detail = details.get(&key)?;
764                Some(EmbeddingEntry {
765                    namespace: namespace.to_string(),
766                    key,
767                    vector,
768                    text: detail.text.clone(),
769                    created_at: parse_timestamp(&detail.created_at),
770                })
771            })
772            .collect())
773    }
774
775    // ── File indexing ──
776
777    async fn store_file_index(&self, path: &str, hash: &str) -> Result<()> {
778        let row = FileIndexRow {
779            path: path.to_string(),
780            hash: hash.to_string(),
781            last_indexed: chrono::Utc::now().to_rfc3339(),
782        };
783        // Use path hash as record ID to avoid special chars
784        let id = format!("{:x}", md5_hash(path));
785        let _: Option<FileIndexRow> = self.db().await?.upsert(("files", &id)).content(row).await?;
786        Ok(())
787    }
788
789    async fn get_file_index(&self, path: &str) -> Result<Option<FileIndex>> {
790        let id = format!("{:x}", md5_hash(path));
791        let row: Option<FileIndexRow> = self.db().await?.select(("files", &id)).await?;
792        Ok(row.map(|r| FileIndex {
793            path: r.path,
794            hash: r.hash,
795            last_indexed: parse_timestamp(&r.last_indexed),
796        }))
797    }
798
799    // ── Code chunks ──
800
801    async fn store_chunk(
802        &self,
803        file_path: &str,
804        start_line: u32,
805        end_line: u32,
806        content: &str,
807        embedding: Option<&[f32]>,
808    ) -> Result<()> {
809        let row = ChunkRow {
810            file_path: file_path.to_string(),
811            start_line,
812            end_line,
813            content: content.to_string(),
814            embedding: embedding.map(|e| e.to_vec()),
815            created_at: chrono::Utc::now().to_rfc3339(),
816        };
817        let _: Option<ChunkRow> = self.db().await?.create("chunks").content(row).await?;
818        Ok(())
819    }
820
821    async fn get_chunks_for_file(&self, file_path: &str) -> Result<Vec<Chunk>> {
822        let mut result = self
823            .db()
824            .await?
825            .query("SELECT * FROM chunks WHERE file_path = $file_path ORDER BY start_line ASC")
826            .bind(("file_path", file_path.to_string()))
827            .await?;
828        let rows: Vec<ChunkRow> = result.take(0)?;
829        Ok(rows
830            .into_iter()
831            .map(|r| Chunk {
832                file_path: r.file_path,
833                start_line: r.start_line,
834                end_line: r.end_line,
835                content: r.content,
836                embedding: r.embedding,
837                created_at: parse_timestamp(&r.created_at),
838            })
839            .collect())
840    }
841
842    async fn delete_chunks_for_file(&self, file_path: &str) -> Result<()> {
843        self.db()
844            .await?
845            .query("DELETE FROM chunks WHERE file_path = $file_path")
846            .bind(("file_path", file_path.to_string()))
847            .await?;
848        Ok(())
849    }
850}
851
852/// Pack an `f32` vector into base64-encoded little-endian bytes.
853///
854/// Endianness is fixed rather than native so a database file stays readable if
855/// it is moved between a little- and a big-endian host.
856fn pack_vector(vector: &[f32]) -> String {
857    let mut bytes = Vec::with_capacity(vector.len() * 4);
858    for x in vector {
859        bytes.extend_from_slice(&x.to_le_bytes());
860    }
861    BASE64.encode(&bytes)
862}
863
864/// Decode a packed vector into `out`, replacing its contents.
865///
866/// Undecodable input yields an empty vector, and a trailing partial component
867/// is ignored. Either way the resulting length does not match the query, so
868/// `cosine_similarity` drops the row rather than scoring a truncated vector.
869fn unpack_vector_into(encoded: &str, scratch: &mut Vec<u8>, out: &mut Vec<f32>) {
870    out.clear();
871    scratch.clear();
872    if BASE64.decode_vec(encoded, scratch).is_err() {
873        return;
874    }
875    out.reserve(scratch.len() / 4);
876    for chunk in scratch.chunks_exact(4) {
877        out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
878    }
879}
880
881/// Allocating form of `unpack_vector_into`, for callers with nothing to reuse.
882#[cfg(test)]
883fn unpack_vector(encoded: &str) -> Vec<f32> {
884    let mut scratch = Vec::new();
885    let mut out = Vec::new();
886    unpack_vector_into(encoded, &mut scratch, &mut out);
887    out
888}
889
890/// Cosine similarity, or `None` when the two vectors are not comparable.
891///
892/// Vectors of different lengths come from different embedding models and have
893/// no meaningful similarity; returning `None` forces callers to drop the pair
894/// rather than treat a fabricated score as a real one.
895///
896/// The scan calls `cosine_similarity_with_magnitude` directly; this wrapper is
897/// the form the tests pin that behaviour against.
898#[cfg(test)]
899fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f32> {
900    cosine_similarity_with_magnitude(a, magnitude(a), b)
901}
902
903/// Cosine similarity with the query's magnitude supplied by the caller.
904///
905/// The query is fixed for a whole scan, so its magnitude is a loop invariant
906/// worth about a third of the arithmetic. Hoisting it is bit-identical: it is
907/// the same value computed by the same operations in the same order, just once
908/// instead of once per candidate.
909fn cosine_similarity_with_magnitude(a: &[f32], ma: f32, b: &[f32]) -> Option<f32> {
910    if a.len() != b.len() || a.is_empty() {
911        return None;
912    }
913
914    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
915    let mb: f32 = magnitude(b);
916
917    if ma == 0.0 || mb == 0.0 {
918        return Some(0.0);
919    }
920
921    Some(dot / (ma * mb))
922}
923
924fn magnitude(v: &[f32]) -> f32 {
925    v.iter().map(|x| x * x).sum::<f32>().sqrt()
926}
927
928/// Simple hash for file path → record ID
929fn md5_hash(input: &str) -> u64 {
930    use std::hash::{Hash, Hasher};
931    let mut hasher = std::collections::hash_map::DefaultHasher::new();
932    input.hash(&mut hasher);
933    hasher.finish()
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    #[test]
941    fn test_md5_hash_deterministic() {
942        let h1 = md5_hash("test-path");
943        let h2 = md5_hash("test-path");
944        assert_eq!(h1, h2);
945    }
946
947    #[test]
948    fn test_md5_hash_different_inputs() {
949        let h1 = md5_hash("path-a");
950        let h2 = md5_hash("path-b");
951        assert_ne!(h1, h2);
952    }
953
954    #[tokio::test]
955    async fn test_surreal_store_and_recall() {
956        let dir = tempfile::tempdir().unwrap();
957        let mem = SurrealMemory::new(dir.path()).await.unwrap();
958
959        mem.store("test", "greeting", "hello world", None)
960            .await
961            .unwrap();
962        let val = mem.recall("test", "greeting").await.unwrap();
963        assert!(val.is_some());
964        assert_eq!(val.unwrap().value, "hello world");
965    }
966
967    #[tokio::test]
968    async fn test_surreal_recall_missing() {
969        let dir = tempfile::tempdir().unwrap();
970        let mem = SurrealMemory::new(dir.path()).await.unwrap();
971
972        let val = mem.recall("test", "missing-key").await.unwrap();
973        assert!(val.is_none());
974    }
975
976    #[tokio::test]
977    async fn test_surreal_search() {
978        let dir = tempfile::tempdir().unwrap();
979        let mem = SurrealMemory::new(dir.path()).await.unwrap();
980
981        mem.store("ns", "k1", "the quick brown fox", None)
982            .await
983            .unwrap();
984        mem.store("ns", "k2", "lazy dog sleeps", None)
985            .await
986            .unwrap();
987        mem.store("ns", "k3", "fox runs fast", None).await.unwrap();
988
989        let results: Vec<MemoryEntry> = mem.search("ns", "fox", 10).await.unwrap();
990        assert!(!results.is_empty());
991        assert!(results.iter().any(|e| e.value.contains("fox")));
992    }
993
994    #[tokio::test]
995    async fn test_surreal_delete() {
996        let dir = tempfile::tempdir().unwrap();
997        let mem = SurrealMemory::new(dir.path()).await.unwrap();
998
999        mem.store("ns", "del-key", "to delete", None).await.unwrap();
1000        assert!(mem.recall("ns", "del-key").await.unwrap().is_some());
1001
1002        mem.forget("ns", "del-key").await.unwrap();
1003        assert!(mem.recall("ns", "del-key").await.unwrap().is_none());
1004    }
1005
1006    #[tokio::test]
1007    async fn test_surreal_conversation_history() {
1008        let dir = tempfile::tempdir().unwrap();
1009        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1010
1011        mem.store_conversation("chat-1", "user-1", "user", "Hello")
1012            .await
1013            .unwrap();
1014        // Small delay to ensure different seq timestamps
1015        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1016        mem.store_conversation("chat-1", "assistant", "assistant", "Hi there")
1017            .await
1018            .unwrap();
1019
1020        let history: Vec<(String, String)> =
1021            mem.get_conversation_history("chat-1", 10).await.unwrap();
1022        assert_eq!(history.len(), 2);
1023        assert_eq!(history[0].1, "Hello");
1024        assert_eq!(history[1].1, "Hi there");
1025    }
1026
1027    #[tokio::test]
1028    async fn test_surreal_clear_conversation_is_scoped_to_one_chat() {
1029        let dir = tempfile::tempdir().unwrap();
1030        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1031
1032        mem.store_conversation("chat-1", "user-1", "user", "keep me out")
1033            .await
1034            .unwrap();
1035        mem.store_conversation("chat-2", "user-1", "user", "keep me")
1036            .await
1037            .unwrap();
1038
1039        mem.clear_conversation("chat-1").await.unwrap();
1040
1041        assert!(mem
1042            .get_conversation_history("chat-1", 10)
1043            .await
1044            .unwrap()
1045            .is_empty());
1046        let survivors = mem.get_conversation_history("chat-2", 10).await.unwrap();
1047        assert_eq!(survivors.len(), 1);
1048        assert_eq!(survivors[0].1, "keep me");
1049
1050        // Clearing an empty chat is not an error.
1051        mem.clear_conversation("chat-1").await.unwrap();
1052    }
1053
1054    #[tokio::test]
1055    async fn test_surreal_embeddings() {
1056        let dir = tempfile::tempdir().unwrap();
1057        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1058
1059        let vec1 = vec![1.0, 0.0, 0.0];
1060        let vec2 = vec![0.0, 1.0, 0.0];
1061        let vec3 = vec![0.9, 0.1, 0.0];
1062
1063        mem.store_embedding("ns", "e1", &vec1, "first", "test-model")
1064            .await
1065            .unwrap();
1066        mem.store_embedding("ns", "e2", &vec2, "second", "test-model")
1067            .await
1068            .unwrap();
1069        mem.store_embedding("ns", "e3", &vec3, "third", "test-model")
1070            .await
1071            .unwrap();
1072
1073        let results = mem.search_embeddings("ns", &vec1, 2).await.unwrap();
1074        assert!(!results.is_empty());
1075        // The closest to [1,0,0] should be e1 or e3
1076        assert!(results[0].key == "e1" || results[0].key == "e3");
1077    }
1078
1079    /// The scan reads a cache, so a write after the cache is warm has to be
1080    /// visible to the next search, and a rewrite of an existing key has to
1081    /// replace it rather than add a duplicate.
1082    #[tokio::test]
1083    async fn test_search_embeddings_sees_writes_after_cache_is_warm() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1086
1087        mem.store_embedding("ns", "a", &[1.0, 0.0, 0.0], "a", "m")
1088            .await
1089            .unwrap();
1090        // Warms the cache for "ns".
1091        assert_eq!(
1092            mem.search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
1093                .await
1094                .unwrap()
1095                .len(),
1096            1
1097        );
1098
1099        mem.store_embedding("ns", "b", &[0.0, 1.0, 0.0], "b", "m")
1100            .await
1101            .unwrap();
1102        let results = mem
1103            .search_embeddings("ns", &[0.0, 1.0, 0.0], 10)
1104            .await
1105            .unwrap();
1106        assert_eq!(results.len(), 2);
1107        assert_eq!(results[0].key, "b");
1108
1109        // Rewriting "b" to point the other way must move it, not duplicate it.
1110        mem.store_embedding("ns", "b", &[1.0, 0.0, 0.0], "b2", "m")
1111            .await
1112            .unwrap();
1113        let results = mem
1114            .search_embeddings("ns", &[0.0, 1.0, 0.0], 10)
1115            .await
1116            .unwrap();
1117        assert_eq!(results.len(), 2);
1118        assert_eq!(results[0].key, "a");
1119        assert_eq!(results.iter().filter(|r| r.key == "b").count(), 1);
1120        assert_eq!(
1121            results.iter().find(|r| r.key == "b").unwrap().text,
1122            "b2",
1123            "text must come from the table, not a stale cache entry"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_pack_vector_round_trips() {
1129        let v = vec![1.0f32, -0.5, 0.0, 1e-8, f32::MAX, f32::MIN];
1130        assert_eq!(unpack_vector(&pack_vector(&v)), v);
1131        assert!(unpack_vector(&pack_vector(&[])).is_empty());
1132        // Undecodable input must not produce a partial vector that could be
1133        // scored against a query of the same length by accident.
1134        assert!(unpack_vector("not base64 !!!").is_empty());
1135    }
1136
1137    /// Rows written before the packed encoding existed still carry `vector` as
1138    /// an array and no `vector_b64`. They must keep ranking normally rather
1139    /// than silently vanishing from every search.
1140    #[tokio::test]
1141    async fn test_search_embeddings_reads_legacy_array_rows() {
1142        let dir = tempfile::tempdir().unwrap();
1143        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1144        let db = mem.db().await.unwrap();
1145
1146        db.query(
1147            "CREATE embeddings:legacy SET namespace = 'ns', key = 'legacy',
1148             vector = [1.0, 0.0, 0.0], text = 'legacy row',
1149             created_at = '2024-01-01T00:00:00Z', dim = 3",
1150        )
1151        .await
1152        .unwrap();
1153        mem.store_embedding("ns", "modern", &[0.0, 1.0, 0.0], "modern row", "m")
1154            .await
1155            .unwrap();
1156
1157        // Both encodings compete in the same ranking, and the query is aligned
1158        // with the legacy row, so the legacy row must win.
1159        let results = mem
1160            .search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
1161            .await
1162            .unwrap();
1163        assert_eq!(results.len(), 2);
1164        assert_eq!(results[0].key, "legacy");
1165        assert_eq!(results[0].vector, vec![1.0, 0.0, 0.0]);
1166    }
1167
1168    #[test]
1169    fn test_cosine_similarity_rejects_mismatched_lengths() {
1170        assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0, 0.0]), None);
1171        assert_eq!(cosine_similarity(&[], &[]), None);
1172        assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]), Some(1.0));
1173        assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), Some(0.0));
1174    }
1175
1176    #[tokio::test]
1177    async fn test_search_embeddings_excludes_other_dimensions() {
1178        let dir = tempfile::tempdir().unwrap();
1179        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1180
1181        // Simulates a provider switch: 3-dim rows from the old model coexist
1182        // with 8-dim rows from the new one.
1183        let old_dim = vec![1.0f32, 0.0, 0.0];
1184        let new_dim = vec![0.25f32; 8];
1185        mem.store_embedding("ns", "old", &old_dim, "old model", "old-model")
1186            .await
1187            .unwrap();
1188        mem.store_embedding("ns", "new", &new_dim, "new model", "new-model")
1189            .await
1190            .unwrap();
1191
1192        let results = mem.search_embeddings("ns", &new_dim, 10).await.unwrap();
1193        assert!(
1194            results.iter().all(|e| e.key != "old"),
1195            "a vector of a different dimension must never be returned"
1196        );
1197        assert!(results.iter().any(|e| e.key == "new"));
1198
1199        // And symmetrically for the old dimension.
1200        let results = mem.search_embeddings("ns", &old_dim, 10).await.unwrap();
1201        assert!(results.iter().all(|e| e.key != "new"));
1202        assert!(results.iter().any(|e| e.key == "old"));
1203    }
1204
1205    #[tokio::test]
1206    async fn test_search_embeddings_skips_rows_without_dimension() {
1207        let dir = tempfile::tempdir().unwrap();
1208        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1209        let db = mem.db().await.unwrap();
1210
1211        // A legacy row written before `dim`/`model` were recorded.
1212        db.query(
1213            "CREATE embeddings:legacy SET namespace = 'ns', key = 'legacy',
1214             vector = [1.0, 0.0, 0.0], text = 'legacy', created_at = '2024-01-01T00:00:00Z'",
1215        )
1216        .await
1217        .unwrap();
1218
1219        let results = mem
1220            .search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
1221            .await
1222            .unwrap();
1223        assert!(
1224            results.is_empty(),
1225            "rows with no recorded dimension must not be returned"
1226        );
1227    }
1228
1229    #[tokio::test]
1230    async fn test_surreal_file_index() {
1231        let dir = tempfile::tempdir().unwrap();
1232        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1233
1234        mem.store_file_index("/src/main.rs", "abc123")
1235            .await
1236            .unwrap();
1237        let idx = mem.get_file_index("/src/main.rs").await.unwrap();
1238        assert!(idx.is_some());
1239        assert_eq!(idx.unwrap().hash, "abc123");
1240
1241        let missing = mem.get_file_index("/src/nonexistent.rs").await.unwrap();
1242        assert!(missing.is_none());
1243    }
1244
1245    #[tokio::test]
1246    async fn test_surreal_chunks() {
1247        let dir = tempfile::tempdir().unwrap();
1248        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1249
1250        mem.store_chunk("/src/lib.rs", 1, 10, "fn main() {}", None)
1251            .await
1252            .unwrap();
1253        mem.store_chunk("/src/lib.rs", 11, 20, "fn helper() {}", None)
1254            .await
1255            .unwrap();
1256
1257        let chunks = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
1258        assert_eq!(chunks.len(), 2);
1259        assert_eq!(chunks[0].start_line, 1);
1260        assert_eq!(chunks[1].start_line, 11);
1261
1262        mem.delete_chunks_for_file("/src/lib.rs").await.unwrap();
1263        let empty = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
1264        assert!(empty.is_empty());
1265    }
1266
1267    #[tokio::test]
1268    async fn test_surreal_sticker_cache() {
1269        let dir = tempfile::tempdir().unwrap();
1270        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1271
1272        mem.store_sticker_cache("stk-1", "file-1", "A happy cat")
1273            .await
1274            .unwrap();
1275        let desc: Option<String> = mem.get_sticker_cache("stk-1").await.unwrap();
1276        assert_eq!(desc, Some("A happy cat".to_string()));
1277
1278        let missing: Option<String> = mem.get_sticker_cache("stk-999").await.unwrap();
1279        assert!(missing.is_none());
1280    }
1281
1282    // Reproducible xorshift PRNG so the corpus is realistic (non-zero, spread
1283    // over the unit sphere) without pulling in a `rand` dev-dependency.
1284    struct Xorshift(u64);
1285
1286    impl Xorshift {
1287        fn next_f32(&mut self) -> f32 {
1288            let mut x = self.0;
1289            x ^= x << 13;
1290            x ^= x >> 7;
1291            x ^= x << 17;
1292            self.0 = x;
1293            ((x >> 40) as f32 / 16_777_216.0) * 2.0 - 1.0
1294        }
1295
1296        fn unit_vector(&mut self, dim: usize) -> Vec<f32> {
1297            let mut v: Vec<f32> = (0..dim).map(|_| self.next_f32()).collect();
1298            let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1299            if norm > 0.0 {
1300                for x in v.iter_mut() {
1301                    *x /= norm;
1302                }
1303            }
1304            v
1305        }
1306    }
1307
1308    /// Latency of `search_embeddings` against the brute-force cosine scan at
1309    /// growing corpus sizes. Ignored by default: it writes hundreds of MB and
1310    /// takes minutes.
1311    ///
1312    /// Run with `cargo test --release -p apollo-agent bench_search_embeddings
1313    /// -- --ignored --nocapture`. Debug numbers are not meaningful.
1314    #[tokio::test]
1315    #[ignore = "benchmark: slow, writes a large corpus"]
1316    async fn bench_search_embeddings_scaling() {
1317        const DIM: usize = 1536;
1318        const QUERIES: usize = 100;
1319        // A 10k-row corpus at 1536 dims costs ~900MB on disk, so 50k needs
1320        // ~4.5GB free. Override with APOLLO_BENCH_SIZES=100,1000 on a small
1321        // disk.
1322        let sizes: Vec<usize> = match std::env::var("APOLLO_BENCH_SIZES") {
1323            Ok(s) => s.split(',').filter_map(|p| p.trim().parse().ok()).collect(),
1324            Err(_) => vec![100, 1_000, 10_000, 50_000],
1325        };
1326
1327        for size in sizes {
1328            let dir = tempfile::tempdir().unwrap();
1329            let mem = SurrealMemory::new(dir.path()).await.unwrap();
1330            let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1331
1332            let write_start = std::time::Instant::now();
1333            for i in 0..size {
1334                let v = rng.unit_vector(DIM);
1335                mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1336                    .await
1337                    .unwrap();
1338            }
1339            let write_elapsed = write_start.elapsed();
1340
1341            let queries: Vec<Vec<f32>> = (0..QUERIES).map(|_| rng.unit_vector(DIM)).collect();
1342            let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1343            for q in &queries {
1344                let start = std::time::Instant::now();
1345                let hits = mem.search_embeddings("bench", q, 10).await.unwrap();
1346                samples.push(start.elapsed().as_micros());
1347                assert_eq!(hits.len(), 10.min(size));
1348            }
1349            let (median, p95, max) = percentiles(&mut samples);
1350
1351            println!(
1352                "packed size={size:>6} dim={DIM} search_median={median:>8.2}ms \
1353                 search_p95={p95:>8.2}ms search_max={max:>8.2}ms insert_total={:>8.2}s \
1354                 insert_per_row={:>6.2}ms disk={:>7.1}MB",
1355                write_elapsed.as_secs_f64(),
1356                write_elapsed.as_secs_f64() * 1000.0 / size as f64,
1357                dir_size_mb(dir.path()),
1358            );
1359            drop(mem);
1360            drop(dir);
1361
1362            // The same corpus in the previous `array` encoding, on the same
1363            // machine in the same run, so the comparison is not against numbers
1364            // recorded on another day.
1365            //
1366            // Both arms search the same in-memory cache, so their `median` is
1367            // expected to match — the encoding no longer affects a warm search.
1368            // What this arm measures is what the encoding still does affect:
1369            // insert cost, on-disk size, and the cold first query, which loads
1370            // the cache and shows up in `search_max`.
1371            let dir = tempfile::tempdir().unwrap();
1372            let mem = SurrealMemory::new(dir.path()).await.unwrap();
1373            let db = mem.db().await.unwrap();
1374            let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1375
1376            let write_start = std::time::Instant::now();
1377            for i in 0..size {
1378                let v = rng.unit_vector(DIM);
1379                db.query(
1380                    "CREATE type::thing('embeddings', $id) SET namespace = 'bench', \
1381                     key = $id, vector = $v, text = 'text', \
1382                     created_at = '2024-01-01T00:00:00Z', dim = $dim, model = 'bench-model'",
1383                )
1384                .bind(("id", format!("k{i}")))
1385                .bind(("v", v))
1386                .bind(("dim", DIM as i64))
1387                .await
1388                .unwrap();
1389            }
1390            let write_elapsed = write_start.elapsed();
1391
1392            let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1393            for q in &queries {
1394                let start = std::time::Instant::now();
1395                let hits = mem.search_embeddings("bench", q, 10).await.unwrap();
1396                samples.push(start.elapsed().as_micros());
1397                assert_eq!(hits.len(), 10.min(size));
1398            }
1399            let (median, p95, max) = percentiles(&mut samples);
1400            println!(
1401                "array  size={size:>6} dim={DIM} search_median={median:>8.2}ms \
1402                 search_p95={p95:>8.2}ms search_max={max:>8.2}ms insert_total={:>8.2}s \
1403                 insert_per_row={:>6.2}ms disk={:>7.1}MB",
1404                write_elapsed.as_secs_f64(),
1405                write_elapsed.as_secs_f64() * 1000.0 / size as f64,
1406                dir_size_mb(dir.path()),
1407            );
1408        }
1409    }
1410
1411    fn dir_size_mb(path: &Path) -> f64 {
1412        fn walk(path: &Path) -> u64 {
1413            let Ok(entries) = std::fs::read_dir(path) else {
1414                return 0;
1415            };
1416            entries
1417                .flatten()
1418                .map(|e| match e.metadata() {
1419                    Ok(m) if m.is_dir() => walk(&e.path()),
1420                    Ok(m) => m.len(),
1421                    Err(_) => 0,
1422                })
1423                .sum()
1424        }
1425        walk(path) as f64 / (1024.0 * 1024.0)
1426    }
1427
1428    /// Feasibility spike for a per-dimension MTREE index: does SurrealDB
1429    /// accept a mixed-dimension table once an MTREE of a fixed DIMENSION
1430    /// exists on `vector`, and does KNN beat the scan?
1431    #[tokio::test]
1432    #[ignore = "benchmark: slow, writes a large corpus"]
1433    async fn bench_mtree_feasibility() {
1434        const DIM: usize = 1536;
1435        const SIZE: usize = 2_000;
1436
1437        let dir = tempfile::tempdir().unwrap();
1438        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1439        let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1440        let db = mem.db().await.unwrap();
1441
1442        let define = db
1443            .query(format!(
1444                "DEFINE INDEX IF NOT EXISTS embedding_vector_mtree_{DIM} ON embeddings \
1445                 FIELDS vector MTREE DIMENSION {DIM} DIST COSINE"
1446            ))
1447            .await;
1448        println!("define_mtree_ok={}", define.is_ok());
1449        if let Err(e) = &define {
1450            println!("define_mtree_err={e}");
1451            return;
1452        }
1453
1454        let start = std::time::Instant::now();
1455        for i in 0..SIZE {
1456            let v = rng.unit_vector(DIM);
1457            mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1458                .await
1459                .unwrap();
1460        }
1461        println!(
1462            "mtree_insert_total={:.2}s per_row={:.2}ms",
1463            start.elapsed().as_secs_f64(),
1464            start.elapsed().as_secs_f64() * 1000.0 / SIZE as f64
1465        );
1466
1467        // Does a vector of another dimension survive in the same table?
1468        let mismatched = mem
1469            .store_embedding("bench", "other-dim", &[1.0, 0.0, 0.0], "t", "small-model")
1470            .await;
1471        println!("mixed_dim_insert_ok={}", mismatched.is_ok());
1472        if let Err(e) = &mismatched {
1473            println!("mixed_dim_insert_err={e}");
1474        }
1475
1476        let mut knn: Vec<u128> = Vec::new();
1477        let mut scan: Vec<u128> = Vec::new();
1478        for _ in 0..20 {
1479            let q = rng.unit_vector(DIM);
1480
1481            let start = std::time::Instant::now();
1482            let mut result = db
1483                .query(
1484                    "SELECT namespace, key, text, created_at FROM embeddings \
1485                     WHERE vector <|10,COSINE|> $q",
1486                )
1487                .bind(("q", q.clone()))
1488                .await
1489                .unwrap();
1490            let rows: Vec<serde_json::Value> = result.take(0).unwrap();
1491            knn.push(start.elapsed().as_micros());
1492            assert!(!rows.is_empty(), "KNN returned nothing — index not used");
1493
1494            let start = std::time::Instant::now();
1495            mem.search_embeddings("bench", &q, 10).await.unwrap();
1496            scan.push(start.elapsed().as_micros());
1497        }
1498        let (km, kp, kx) = percentiles(&mut knn);
1499        let (sm, sp, sx) = percentiles(&mut scan);
1500        println!("size={SIZE} knn_median={km:.2}ms knn_p95={kp:.2}ms knn_max={kx:.2}ms");
1501        println!("size={SIZE} scan_median={sm:.2}ms scan_p95={sp:.2}ms scan_max={sx:.2}ms");
1502    }
1503
1504    fn percentiles(samples: &mut [u128]) -> (f64, f64, f64) {
1505        samples.sort_unstable();
1506        (
1507            samples[samples.len() / 2] as f64 / 1000.0,
1508            samples[(samples.len() * 95) / 100] as f64 / 1000.0,
1509            *samples.last().unwrap() as f64 / 1000.0,
1510        )
1511    }
1512}