Skip to main content

elph_core/memz/
store.rs

1use anyhow::{Context, Result, bail};
2use rand::RngExt;
3use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::{Arc, Mutex, OnceLock};
7use std::time::{SystemTime, UNIX_EPOCH};
8use turso::{Builder, Connection, Database, params};
9use uuid::Uuid;
10
11use super::migrations;
12use super::scoring::{
13    compute_credit, compute_task_score, empty_baseline, initial_weight, update_baseline, update_weight,
14};
15use super::types::{
16    DecayResult, Memory, MemoryCategory, MemoryStats, MemzConfig, ReportCorrectionInput, ReportUserInput,
17    StartTaskResult, TaskBaseline, TaskEndInput, TopMemory, VectorType,
18};
19use super::util::{category_from_str, category_str, drain_rows, retrieval_sql, vec_buf};
20
21pub type EmbedFuture = Pin<Box<dyn Future<Output = Result<Vec<f32>>> + Send>>;
22pub type EmbedFn = Arc<dyn Fn(&str) -> EmbedFuture + Send + Sync>;
23
24type WeightUpdate = (String, f64);
25type SelfReportRow = (String, u8, f64);
26
27pub(crate) fn now_secs() -> i64 {
28    SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
29}
30
31fn new_id() -> String {
32    Uuid::now_v7().to_string()
33}
34
35/// Remove retrieval rows whose memory was deleted (prevents unbounded table growth).
36async fn delete_orphan_retrievals(conn: &Connection) -> Result<()> {
37    conn.execute(
38        "DELETE FROM memory_retrievals WHERE memory_id NOT IN (SELECT id FROM memories)",
39        (),
40    )
41    .await?;
42    Ok(())
43}
44
45async fn fetch_weights(conn: &Connection, ids: &[String]) -> Result<HashMap<String, f64>> {
46    if ids.is_empty() {
47        return Ok(HashMap::new());
48    }
49    let placeholders = std::iter::repeat_n("?", ids.len()).collect::<Vec<_>>().join(", ");
50    let sql = format!("SELECT id, weight FROM memories WHERE id IN ({placeholders})");
51    let mut rows = conn
52        .query(&sql, turso::params_from_iter(ids.iter().map(String::as_str)))
53        .await?;
54    let mut out = HashMap::with_capacity(ids.len());
55    while let Some(row) = rows.next().await? {
56        out.insert(row.get::<String>(0)?, row.get::<f64>(1)?);
57    }
58    drain_rows(&mut rows).await?;
59    Ok(out)
60}
61
62pub struct MemoryStore {
63    db_path: String,
64    #[allow(dead_code)]
65    session_id: String,
66    embed: EmbedFn,
67    vector_type: VectorType,
68    retrieval_sql: OnceLock<String>,
69    top_k: u32,
70    learning_rate: f64,
71    decay_rate: f64,
72
73    initialized: Mutex<bool>,
74    current_task_id: Mutex<Option<String>>,
75    baseline: Mutex<TaskBaseline>,
76}
77
78impl MemoryStore {
79    pub fn new(config: MemzConfig, embed: EmbedFn) -> Self {
80        Self {
81            db_path: config.db_path,
82            session_id: config.session_id,
83            embed,
84            vector_type: config.vector_type.unwrap_or(VectorType::Vector32),
85            retrieval_sql: OnceLock::new(),
86            top_k: config.top_k.unwrap_or(5),
87            learning_rate: config.learning_rate.unwrap_or(0.1),
88            decay_rate: config.decay_rate.unwrap_or(0.995),
89            initialized: Mutex::new(false),
90            current_task_id: Mutex::new(None),
91            baseline: Mutex::new(empty_baseline()),
92        }
93    }
94
95    pub(crate) fn vector_fn(&self) -> &'static str {
96        match self.vector_type {
97            VectorType::Vector32 => "vector32",
98            VectorType::Vector64 => "vector64",
99            VectorType::Vector8 => "vector8",
100            VectorType::Vector1 => "vector1",
101        }
102    }
103
104    pub(crate) fn retrieval_sql(&self) -> &str {
105        self.retrieval_sql.get_or_init(|| retrieval_sql(self.vector_fn()))
106    }
107
108    pub(crate) fn embed_fn(&self) -> &EmbedFn {
109        &self.embed
110    }
111
112    pub(crate) fn top_k(&self) -> u32 {
113        self.top_k
114    }
115
116    pub(crate) fn decay_rate(&self) -> f64 {
117        self.decay_rate
118    }
119
120    async fn open_db(&self) -> Result<Database> {
121        const MAX_RETRIES: u32 = 10;
122        const BASE_DELAY_MS: u64 = 50;
123
124        let mut attempt = 0u32;
125        loop {
126            let build = Builder::new_local(&self.db_path)
127                .experimental_multiprocess_wal(true)
128                .build()
129                .await;
130            match build {
131                Ok(db) => return Ok(db),
132                Err(e) => {
133                    if attempt >= MAX_RETRIES || !is_lock_err(&e.to_string()) {
134                        return Err(e).context("build failed");
135                    }
136                }
137            }
138            let jitter: f64 = rand::rng().random();
139            let delay = BASE_DELAY_MS as f64 * (1.0 + jitter) * (attempt as f64 + 1.0).min(5.0);
140            tokio::time::sleep(std::time::Duration::from_millis(delay as u64)).await;
141            attempt += 1;
142        }
143    }
144
145    /// Open short-lived conn, run fn, then drop both conn and db. Turso embedded driver
146    /// locks the file at connect()-time; keep `Database` alive for the whole operation.
147    /// Retry connect() w/ backoff if another process holds the lock.
148    pub(crate) async fn with_db<T, F, Fut>(&self, f: F) -> Result<T>
149    where
150        F: FnOnce(Connection) -> Fut,
151        Fut: Future<Output = Result<T>>,
152    {
153        const MAX_RETRIES: u32 = 10;
154        const BASE_DELAY_MS: u64 = 50;
155
156        let db = self.open_db().await?;
157        let conn = {
158            let mut attempt = 0u32;
159            loop {
160                match db.connect() {
161                    Ok(conn) => break conn,
162                    Err(e) => {
163                        if attempt >= MAX_RETRIES || !is_lock_err(&e.to_string()) {
164                            return Err(e).context("connect failed");
165                        }
166                    }
167                }
168                let jitter: f64 = rand::rng().random();
169                let delay = BASE_DELAY_MS as f64 * (1.0 + jitter) * (attempt as f64 + 1.0).min(5.0);
170                tokio::time::sleep(std::time::Duration::from_millis(delay as u64)).await;
171                attempt += 1;
172            }
173        };
174
175        conn.execute("PRAGMA busy_timeout = 5000", ()).await?;
176        f(conn).await
177    }
178
179    pub async fn init(&self) -> Result<()> {
180        if *self.initialized.lock().unwrap() {
181            return Ok(());
182        }
183        self.with_db(|conn| async move {
184            migrations::apply(&conn).await?;
185
186            // Load baseline
187            let mut rows = conn.query("SELECT value FROM meta WHERE key = 'baseline'", ()).await?;
188            let baseline = if let Some(row) = rows.next().await? {
189                Some(row.get::<String>(0)?)
190            } else {
191                None
192            };
193            drain_rows(&mut rows).await?;
194            Ok(baseline)
195        })
196        .await
197        .map(|maybe_raw: Option<String>| {
198            if let Some(raw) = maybe_raw
199                && let Ok(b) = serde_json::from_str::<TaskBaseline>(&raw)
200            {
201                *self.baseline.lock().unwrap() = b;
202            }
203        })?;
204
205        *self.initialized.lock().unwrap() = true;
206        Ok(())
207    }
208
209    // -------------------------------------------------------------------
210    // Task lifecycle
211    // -------------------------------------------------------------------
212
213    pub async fn start_task(&self, description: &str) -> Result<StartTaskResult> {
214        self.init().await?;
215        let task_id = new_id();
216        let now = now_secs();
217
218        // Embed outside with_db — no lock held during model inference
219        let task_embedding = (self.embed)(description).await?;
220        self.embed_pending().await?;
221
222        let decay_rate = self.decay_rate;
223        let top_k = self.top_k;
224        let emb_buf = vec_buf(&task_embedding);
225        let retrieval_sql = self.retrieval_sql().to_string();
226        let task_id_clone = task_id.clone();
227        let description = description.to_string();
228
229        let memories = self
230            .with_db(move |conn| async move {
231                conn.execute(
232                    "INSERT INTO tasks (id, description, embedding, started_at) VALUES (?, ?, ?, ?)",
233                    params![task_id_clone.as_str(), description.as_str(), emb_buf.as_slice(), now],
234                )
235                .await?;
236
237                let mut rows = conn
238                    .query(
239                        &retrieval_sql,
240                        params![emb_buf.as_slice(), emb_buf.as_slice(), decay_rate, now, top_k],
241                    )
242                    .await?;
243
244                let mut mems = Vec::new();
245                while let Some(row) = rows.next().await? {
246                    let distance: f64 = row.get(6)?;
247                    mems.push(Memory {
248                        id: row.get(0)?,
249                        content: row.get(1)?,
250                        category: category_from_str(&row.get::<String>(2)?),
251                        weight: row.get(3)?,
252                        score: 1.0 - distance,
253                        created_at: row.get(4)?,
254                        retrieval_count: row.get(5)?,
255                    });
256                }
257                drain_rows(&mut rows).await?;
258
259                for mem in &mems {
260                    conn.execute(
261                        "INSERT OR IGNORE INTO memory_retrievals (memory_id, task_id, similarity) VALUES (?, ?, ?)",
262                        params![mem.id.clone(), task_id_clone.clone(), mem.score],
263                    )
264                    .await?;
265
266                    conn.execute(
267                        "UPDATE memories SET last_retrieved = ?, retrieval_count = retrieval_count + 1 WHERE id = ?",
268                        params![now, mem.id.clone()],
269                    )
270                    .await?;
271                }
272
273                Ok(mems)
274            })
275            .await?;
276
277        *self.current_task_id.lock().unwrap() = Some(task_id.clone());
278        Ok(StartTaskResult { task_id, memories })
279    }
280
281    pub async fn report_correction(&self, input: ReportCorrectionInput) -> Result<String> {
282        self.init().await?;
283        let id = new_id();
284        let now = now_secs();
285        let tokens_wasted = input.tokens_wasted;
286        let _tools_wasted = input.tools_wasted;
287
288        let content = format!(
289            "{}\n\nFailed approach: {}\nWorking approach: {}",
290            input.lesson, input.what_failed, input.what_worked
291        );
292        let embedding = (self.embed)(&content).await?;
293        let emb_buf = vec_buf(&embedding);
294        let current_task = self.current_task_id.lock().unwrap().clone();
295
296        // AVG query in its own connection — mixing read query + write in one Turso
297        // session can leave the INSERT uncommitted when the connection drops.
298        let avg_tokens = self
299            .with_db(|conn| async move {
300                let mut rows = conn
301                    .query(
302                        "SELECT AVG(tokens_used) as avg FROM tasks WHERE tokens_used IS NOT NULL",
303                        (),
304                    )
305                    .await?;
306                let avg = match rows.next().await? {
307                    Some(row) => row.get::<Option<f64>>(0)?.unwrap_or(10_000.0),
308                    None => 10_000.0,
309                };
310                drain_rows(&mut rows).await?;
311                Ok(avg)
312            })
313            .await?;
314
315        let weight = initial_weight(
316            MemoryCategory::Correction,
317            None,
318            tokens_wasted.map(|t| t as f64),
319            Some(avg_tokens),
320        );
321        self.with_db(move |conn| async move {
322            let changes = conn
323                .execute(
324                    "INSERT INTO memories (id, content, embedding, category, weight, initial_cost, created_at, source_task) VALUES (?, ?, ?, 'correction', ?, ?, ?, ?)",
325                    params![id.clone(), content, emb_buf, weight, tokens_wasted.unwrap_or(0), now, current_task],
326                )
327                .await?;
328            if changes == 0 {
329                bail!("report_correction: INSERT affected 0 rows");
330            }
331            Ok(id)
332        })
333        .await
334    }
335
336    pub async fn report_user_input(&self, input: ReportUserInput) -> Result<String> {
337        self.init().await?;
338        let id = new_id();
339        let now = now_secs();
340
341        let embedding = (self.embed)(&input.lesson).await?;
342        let emb_buf = vec_buf(&embedding);
343        let weight = initial_weight(MemoryCategory::User, Some(input.source), None, None);
344        let current_task = self.current_task_id.lock().unwrap().clone();
345
346        self.with_db(move |conn| async move {
347            conn.execute(
348                "INSERT INTO memories (id, content, embedding, category, weight, created_at, source_task) VALUES (?, ?, ?, 'user', ?, ?, ?)",
349                params![id.clone(), input.lesson, emb_buf, weight, now, current_task],
350            )
351            .await?;
352            Ok(id)
353        })
354        .await
355    }
356
357    pub async fn end_task(&self, task_id: &str, input: TaskEndInput) -> Result<()> {
358        self.init().await?;
359        let now = now_secs();
360
361        let baseline_snapshot = *self.baseline.lock().unwrap();
362        let task_score = compute_task_score(
363            &baseline_snapshot,
364            input.tokens_used as f64,
365            input.errors as f64,
366            input.user_corrections as f64,
367            input.completed,
368        );
369        let new_baseline = update_baseline(
370            &baseline_snapshot,
371            input.tokens_used as f64,
372            input.errors as f64,
373            input.user_corrections as f64,
374        );
375        *self.baseline.lock().unwrap() = new_baseline;
376
377        let learning_rate = self.learning_rate;
378        let task_id_owned = task_id.to_string();
379        let task_id_check = task_id_owned.clone();
380        let baseline_json = serde_json::to_string(&new_baseline)?;
381
382        // Pre-fetch weights in a separate connection — read+write in one Turso session
383        // can prevent weight UPDATEs from persisting (same issue as report_correction).
384        let (weight_updates, self_report_entries): (Vec<WeightUpdate>, Vec<SelfReportRow>) =
385            if let Some(ref self_report) = input.self_report {
386                if self_report.is_empty() {
387                    (Vec::new(), Vec::new())
388                } else {
389                    let num_retrieved = self_report.len() as u32;
390                    let ids: Vec<String> = self_report.iter().map(|e| e.memory_id.clone()).collect();
391                    let weights = self
392                        .with_db(|conn| async move { fetch_weights(&conn, &ids).await })
393                        .await?;
394
395                    let mut weight_updates = Vec::with_capacity(self_report.len());
396                    let mut self_report_entries = Vec::with_capacity(self_report.len());
397                    for entry in self_report {
398                        let credit = compute_credit(task_score, entry.score as f64, num_retrieved);
399                        self_report_entries.push((entry.memory_id.clone(), entry.score, credit));
400                        if let Some(old) = weights.get(&entry.memory_id) {
401                            weight_updates.push((entry.memory_id.clone(), update_weight(*old, credit, learning_rate)));
402                        }
403                    }
404                    (weight_updates, self_report_entries)
405                }
406            } else {
407                (Vec::new(), Vec::new())
408            };
409
410        self.with_db(move |conn| async move {
411            conn.execute(
412                r#"
413                UPDATE tasks SET
414                  tokens_used = ?, tool_calls = ?, errors = ?,
415                  user_corrections = ?, completed = ?, task_score = ?, finished_at = ?
416                WHERE id = ?
417                "#,
418                params![
419                    input.tokens_used,
420                    input.tool_calls,
421                    input.errors,
422                    input.user_corrections,
423                    input.completed as i64,
424                    task_score,
425                    now,
426                    task_id_owned.clone(),
427                ],
428            )
429            .await?;
430
431            conn.execute(
432                "INSERT INTO meta (key, value) VALUES ('baseline', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
433                params![baseline_json],
434            )
435            .await?;
436
437            for (memory_id, new_weight) in &weight_updates {
438                conn.execute(
439                    "UPDATE memories SET weight = ? WHERE id = ?",
440                    params![new_weight, memory_id.clone()],
441                )
442                .await?;
443            }
444
445            for (memory_id, score, credit) in &self_report_entries {
446                conn.execute(
447                    "UPDATE memory_retrievals SET self_report = ?, credit = ? WHERE memory_id = ? AND task_id = ?",
448                    params![*score as f64, credit, memory_id.clone(), task_id_owned.clone()],
449                )
450                .await?;
451            }
452
453            Ok(())
454        })
455        .await?;
456
457        let mut cur = self.current_task_id.lock().unwrap();
458        if cur.as_deref() == Some(task_id_check.as_str()) {
459            *cur = None;
460        }
461        Ok(())
462    }
463
464    // -------------------------------------------------------------------
465    // Maintenance
466    // -------------------------------------------------------------------
467
468    pub async fn decay(&self) -> Result<DecayResult> {
469        self.init().await?;
470        let decay_rate = self.decay_rate;
471        self.with_db(move |conn| async move {
472            let decayed = conn
473                .execute("UPDATE memories SET weight = weight * ?", params![decay_rate])
474                .await?;
475            let deleted = conn
476                .execute("DELETE FROM memories WHERE weight < 0.15 AND retrieval_count > 5", ())
477                .await?;
478            delete_orphan_retrievals(&conn).await?;
479            Ok(DecayResult {
480                decayed: decayed as u32,
481                deleted: deleted as u32,
482            })
483        })
484        .await
485    }
486
487    pub async fn purge(&self, threshold: f64) -> Result<u32> {
488        self.init().await?;
489        self.with_db(move |conn| async move {
490            let n = conn
491                .execute("DELETE FROM memories WHERE weight < ?", params![threshold])
492                .await?;
493            delete_orphan_retrievals(&conn).await?;
494            Ok(n as u32)
495        })
496        .await
497    }
498
499    pub async fn get_stats(&self) -> Result<MemoryStats> {
500        self.init().await?;
501        self.with_db(|conn| async move {
502            let (mem_count, task_count, avg_score) = {
503                let mut rows = conn
504                    .query(
505                        "SELECT
506                            (SELECT COUNT(*) FROM memories),
507                            (SELECT COUNT(*) FROM tasks),
508                            (SELECT AVG(task_score) FROM tasks WHERE task_score IS NOT NULL)",
509                        (),
510                    )
511                    .await?;
512                let counts = rows.next().await?.context("no stats row")?;
513                let stats = (
514                    counts.get::<i64>(0)?,
515                    counts.get::<i64>(1)?,
516                    counts.get::<Option<f64>>(2)?.unwrap_or(0.0),
517                );
518                drain_rows(&mut rows).await?;
519                stats
520            };
521
522            let mut rows = conn
523                .query(
524                    "SELECT content, weight, retrieval_count FROM memories ORDER BY weight DESC LIMIT 10",
525                    (),
526                )
527                .await?;
528            let mut top_memories = Vec::new();
529            while let Some(row) = rows.next().await? {
530                top_memories.push(TopMemory {
531                    content: row.get(0)?,
532                    weight: row.get(1)?,
533                    retrieval_count: row.get(2)?,
534                });
535            }
536            drain_rows(&mut rows).await?;
537
538            Ok(MemoryStats {
539                total_memories: mem_count as u32,
540                task_count: task_count as u32,
541                avg_task_score: avg_score,
542                top_memories,
543            })
544        })
545        .await
546    }
547
548    // -------------------------------------------------------------------
549    // Hook-oriented methods (no embedding model needed for get/insert)
550    // -------------------------------------------------------------------
551
552    pub async fn get_top_by_weight(&self, limit: u32) -> Result<Vec<Memory>> {
553        self.init().await?;
554        self.with_db(move |conn| async move {
555            let mut rows = conn
556                .query(
557                    "SELECT id, content, category, weight, created_at, retrieval_count FROM memories ORDER BY weight DESC LIMIT ?",
558                    params![limit],
559                )
560                .await?;
561
562            let mut out = Vec::new();
563            while let Some(row) = rows.next().await? {
564                let weight: f64 = row.get(3)?;
565                out.push(Memory {
566                    id: row.get(0)?,
567                    content: row.get(1)?,
568                    category: category_from_str(&row.get::<String>(2)?),
569                    weight,
570                    score: weight,
571                    created_at: row.get(4)?,
572                    retrieval_count: row.get(5)?,
573                });
574            }
575            drain_rows(&mut rows).await?;
576            Ok(out)
577        })
578        .await
579    }
580
581    pub async fn insert_raw_memory(&self, content: &str, category: MemoryCategory, weight: f64) -> Result<String> {
582        self.init().await?;
583        let id = new_id();
584        let now = now_secs();
585        let content = content.to_string();
586        let cat = category_str(category).to_string();
587        let current_task = self.current_task_id.lock().unwrap().clone();
588
589        self.with_db(move |conn| async move {
590            conn.execute(
591                "INSERT INTO memories (id, content, embedding, category, weight, created_at, source_task) VALUES (?, ?, NULL, ?, ?, ?, ?)",
592                params![id.clone(), content, cat, weight, now, current_task],
593            )
594            .await?;
595            Ok(id)
596        })
597        .await
598    }
599
600    pub async fn embed_pending(&self) -> Result<usize> {
601        self.init().await?;
602
603        let rows: Vec<(String, String)> = self
604            .with_db(|conn| async move {
605                let mut r = conn
606                    .query("SELECT id, content FROM memories WHERE embedding IS NULL", ())
607                    .await?;
608                let mut out = Vec::new();
609                while let Some(row) = r.next().await? {
610                    out.push((row.get::<String>(0)?, row.get::<String>(1)?));
611                }
612                drain_rows(&mut r).await?;
613                Ok(out)
614            })
615            .await?;
616
617        if rows.is_empty() {
618            return Ok(0);
619        }
620
621        let mut embedded = Vec::with_capacity(rows.len());
622        for (id, content) in &rows {
623            let vec = (self.embed)(content).await?;
624            embedded.push((id.clone(), vec_buf(&vec)));
625        }
626
627        let n = rows.len();
628        self.with_db(move |conn| async move {
629            for (id, emb) in embedded {
630                conn.execute("UPDATE memories SET embedding = ? WHERE id = ?", params![emb, id])
631                    .await?;
632            }
633            Ok(())
634        })
635        .await?;
636
637        Ok(n)
638    }
639
640    pub async fn contradict_memory(&self, memory_id: &str, correction: Option<&str>) -> Result<(bool, Option<String>)> {
641        self.init().await?;
642        let mid = memory_id.to_string();
643
644        let deleted = self
645            .with_db(move |conn| async move {
646                let changes = conn
647                    .execute("DELETE FROM memories WHERE id = ?", params![mid.clone()])
648                    .await?;
649                conn.execute(
650                    "DELETE FROM memory_retrievals WHERE memory_id = ?",
651                    params![mid.clone()],
652                )
653                .await?;
654                Ok(changes > 0)
655            })
656            .await?;
657
658        let mut correction_id = None;
659        if let (Some(correction), true) = (correction, deleted) {
660            let embedding = (self.embed)(correction).await?;
661            let emb_buf = vec_buf(&embedding);
662            let id = new_id();
663            let now = now_secs();
664            let correction = correction.to_string();
665            let current_task = self.current_task_id.lock().unwrap().clone();
666            let id_clone = id.clone();
667
668            self.with_db(move |conn| async move {
669                conn.execute(
670                    "INSERT INTO memories (id, content, embedding, category, weight, created_at, source_task) VALUES (?, ?, ?, 'correction', 2.0, ?, ?)",
671                    params![id_clone, correction, emb_buf, now, current_task],
672                )
673                .await?;
674                Ok(())
675            })
676            .await?;
677
678            correction_id = Some(id);
679        }
680
681        Ok((deleted, correction_id))
682    }
683
684    pub async fn penalize_memory(&self, memory_id: &str, factor: f64) -> Result<()> {
685        self.init().await?;
686        let mid = memory_id.to_string();
687        self.with_db(move |conn| async move {
688            conn.execute(
689                "UPDATE memories SET weight = MAX(weight * ?, 0.1) WHERE id = ?",
690                params![factor, mid],
691            )
692            .await?;
693            Ok(())
694        })
695        .await
696    }
697
698    pub async fn close(&self) -> Result<()> {
699        // No persistent conn — with_db opens/closes per op.
700        *self.initialized.lock().unwrap() = false;
701        Ok(())
702    }
703}
704
705fn is_lock_err(msg: &str) -> bool {
706    msg.contains("locked") || msg.contains("Locking")
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use crate::memz::create_memory_store;
713    use crate::memz::types::{
714        MemzConfig, ReportCorrectionInput, ReportUserInput, SelfReportEntry, TaskEndInput, UserInputSource,
715    };
716    use std::sync::Arc;
717
718    fn mock_embed() -> EmbedFn {
719        Arc::new(|text: &str| {
720            let text = text.to_string();
721            Box::pin(async move {
722                let mut vec = vec![0.0f32; 4];
723                for (i, b) in text.bytes().enumerate() {
724                    vec[i % 4] += b as f32;
725                }
726                let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
727                if norm > 0.0 {
728                    for x in &mut vec {
729                        *x /= norm;
730                    }
731                }
732                Ok(vec)
733            })
734        })
735    }
736
737    fn test_config(db_path: &str) -> MemzConfig {
738        MemzConfig {
739            db_path: db_path.to_string(),
740            session_id: "test-session".to_string(),
741            vector_type: None,
742            dimensions: Some(4),
743            top_k: Some(3),
744            learning_rate: Some(0.1),
745            decay_rate: Some(0.995),
746        }
747    }
748
749    /// Holds a `tempfile::TempDir` so the DB path stays valid for the whole test.
750    struct TestCtx {
751        _dir: tempfile::TempDir,
752        db_path: String,
753    }
754
755    impl TestCtx {
756        fn new() -> Self {
757            let dir = tempfile::tempdir().expect("tempdir");
758            let db_path = dir.path().join("memory.db").to_string_lossy().into_owned();
759            Self { _dir: dir, db_path }
760        }
761
762        fn store(&self) -> MemoryStore {
763            create_memory_store(test_config(&self.db_path), mock_embed())
764        }
765
766        fn store_with(&self, mut config: MemzConfig) -> MemoryStore {
767            config.db_path = self.db_path.clone();
768            create_memory_store(config, mock_embed())
769        }
770    }
771
772    fn assert_uuid_v7(id: &str) {
773        let uuid = Uuid::parse_str(id).expect("valid uuid");
774        assert_eq!(uuid.get_version(), Some(uuid::Version::SortRand));
775    }
776
777    #[tokio::test]
778    async fn init_creates_schema() {
779        let ctx = TestCtx::new();
780        let store = ctx.store();
781        store.init().await.expect("init");
782
783        let stats = store.get_stats().await.expect("stats");
784        assert_eq!(stats.total_memories, 0);
785        assert_eq!(stats.task_count, 0);
786    }
787
788    #[tokio::test]
789    async fn ids_use_uuid_v7() {
790        let ctx = TestCtx::new();
791        let store = ctx.store();
792
793        let mem_id = store
794            .report_user_input(ReportUserInput {
795                lesson: "v7 id check".to_string(),
796                source: UserInputSource::UserInput,
797            })
798            .await
799            .expect("report");
800        assert_uuid_v7(&mem_id);
801
802        let start = store.start_task("v7 task").await.expect("start");
803        assert_uuid_v7(&start.task_id);
804    }
805
806    #[tokio::test]
807    async fn full_task_lifecycle_with_retrieval_and_weight_update() {
808        let ctx = TestCtx::new();
809        let store = ctx.store();
810
811        let mem_id = store
812            .report_user_input(ReportUserInput {
813                lesson: "Always use Result for fallible ops".to_string(),
814                source: UserInputSource::UserCorrection,
815            })
816            .await
817            .expect("report user input");
818
819        let start = store
820            .start_task("implement error handling in parser")
821            .await
822            .expect("start task");
823        assert!(!start.task_id.is_empty());
824        assert!(
825            start.memories.iter().any(|m| m.id == mem_id),
826            "relevant memory should be retrieved"
827        );
828
829        let mem = start.memories.iter().find(|m| m.id == mem_id).expect("memory");
830        let weight_before = mem.weight;
831
832        store
833            .end_task(
834                &start.task_id,
835                TaskEndInput {
836                    tokens_used: 500,
837                    tool_calls: 3,
838                    errors: 0,
839                    user_corrections: 0,
840                    completed: true,
841                    self_report: Some(vec![SelfReportEntry {
842                        memory_id: mem_id.clone(),
843                        score: 3,
844                    }]),
845                },
846            )
847            .await
848            .expect("end task");
849
850        let top = store.get_top_by_weight(5).await.expect("top");
851        let updated = top.iter().find(|m| m.id == mem_id).expect("updated memory");
852        let expected = update_weight(weight_before, compute_credit(1.0, 3.0, 1), 0.1);
853        assert!(
854            (updated.weight - expected).abs() < 1e-9,
855            "weight should be updated via EMA: got {}, expected {}",
856            updated.weight,
857            expected
858        );
859
860        let stats = store.get_stats().await.expect("stats");
861        assert_eq!(stats.task_count, 1);
862        assert_eq!(stats.total_memories, 1);
863        assert!(stats.avg_task_score > 0.0);
864    }
865
866    #[tokio::test]
867    async fn report_correction_inserts_without_prior_task() {
868        let ctx = TestCtx::new();
869        let store = ctx.store();
870
871        let id = store
872            .report_correction(ReportCorrectionInput {
873                lesson: "Use bcrypt".to_string(),
874                what_failed: "md5".to_string(),
875                what_worked: "bcrypt".to_string(),
876                tokens_wasted: Some(1000),
877                tools_wasted: None,
878            })
879            .await
880            .expect("correction");
881
882        let stats = store.get_stats().await.expect("stats");
883        assert_eq!(stats.total_memories, 1, "correction insert should persist (id={id})");
884
885        let user_id = store
886            .report_user_input(ReportUserInput {
887                lesson: "user lesson".to_string(),
888                source: UserInputSource::UserInput,
889            })
890            .await
891            .expect("user input");
892        let stats2 = store.get_stats().await.expect("stats2");
893        assert_eq!(
894            stats2.total_memories, 2,
895            "user insert should work alongside correction (user_id={user_id})"
896        );
897
898        let top = store.get_top_by_weight(2).await.expect("top");
899        assert!(top.iter().any(|m| m.id == id));
900    }
901
902    #[tokio::test]
903    async fn report_correction_sets_weight_from_tokens_wasted() {
904        let ctx = TestCtx::new();
905        let store = ctx.store();
906
907        let task = store.start_task("fix auth bug").await.expect("start");
908        store
909            .end_task(
910                &task.task_id,
911                TaskEndInput {
912                    tokens_used: 10_000,
913                    tool_calls: 5,
914                    errors: 0,
915                    user_corrections: 0,
916                    completed: true,
917                    self_report: None,
918                },
919            )
920            .await
921            .expect("end");
922
923        let id = store
924            .report_correction(ReportCorrectionInput {
925                lesson: "Use bcrypt not md5".to_string(),
926                what_failed: "md5 hash".to_string(),
927                what_worked: "bcrypt".to_string(),
928                tokens_wasted: Some(5000),
929                tools_wasted: Some(2),
930            })
931            .await
932            .expect("correction");
933
934        let stats = store.get_stats().await.expect("stats");
935        assert_eq!(stats.total_memories, 1, "correction memory should be stored");
936
937        let top = store.get_top_by_weight(1).await.expect("top");
938        assert_eq!(top[0].id, id);
939        assert!((top[0].weight - 1.5).abs() < f64::EPSILON);
940        assert!(top[0].content.contains("Failed approach"));
941    }
942
943    #[tokio::test]
944    async fn insert_raw_memory_and_embed_pending() {
945        let ctx = TestCtx::new();
946        let store = ctx.store();
947
948        let id = store
949            .insert_raw_memory("raw discovery note", MemoryCategory::Discovery, 1.5)
950            .await
951            .expect("insert raw");
952
953        let n = store.embed_pending().await.expect("embed pending");
954        assert_eq!(n, 1);
955
956        let start = store.start_task("discovery task").await.expect("start");
957        assert!(start.memories.iter().any(|m| m.id == id));
958    }
959
960    #[tokio::test]
961    async fn purge_cleans_orphan_memory_retrievals() {
962        let ctx = TestCtx::new();
963        let store = ctx.store();
964
965        let mem_id = store
966            .report_user_input(ReportUserInput {
967                lesson: "orphan retrieval test".to_string(),
968                source: UserInputSource::UserInput,
969            })
970            .await
971            .expect("report");
972
973        let start = store.start_task("task with retrieval").await.expect("start");
974        assert!(start.memories.iter().any(|m| m.id == mem_id));
975
976        store
977            .insert_raw_memory("purge me", MemoryCategory::Insight, 0.05)
978            .await
979            .expect("insert");
980
981        let purged = store.purge(0.1).await.expect("purge");
982        assert_eq!(purged, 1);
983
984        let orphans: i64 = store
985            .with_db(|conn| async move {
986                let mut rows = conn
987                    .query(
988                        "SELECT COUNT(*) FROM memory_retrievals WHERE memory_id NOT IN (SELECT id FROM memories)",
989                        (),
990                    )
991                    .await
992                    .map_err(anyhow::Error::from)?;
993                let row = rows
994                    .next()
995                    .await
996                    .map_err(anyhow::Error::from)?
997                    .ok_or_else(|| anyhow::anyhow!("no row"))?;
998                row.get(0).map_err(anyhow::Error::from)
999            })
1000            .await
1001            .expect("orphan count");
1002        assert_eq!(orphans, 0, "purge should remove orphan retrieval rows");
1003    }
1004
1005    #[tokio::test]
1006    async fn decay_and_purge_maintenance() {
1007        let ctx = TestCtx::new();
1008        let store = ctx.store();
1009
1010        store
1011            .insert_raw_memory("low weight memory", MemoryCategory::Insight, 0.1)
1012            .await
1013            .expect("insert");
1014
1015        let decayed = store.decay().await.expect("decay");
1016        assert_eq!(decayed.decayed, 1);
1017
1018        let purged = store.purge(0.2).await.expect("purge");
1019        assert_eq!(purged, 1);
1020
1021        let stats = store.get_stats().await.expect("stats");
1022        assert_eq!(stats.total_memories, 0);
1023    }
1024
1025    #[tokio::test]
1026    async fn contradict_memory_deletes_and_optionally_replaces() {
1027        let ctx = TestCtx::new();
1028        let store = ctx.store();
1029
1030        let id = store
1031            .report_user_input(ReportUserInput {
1032                lesson: "old fact".to_string(),
1033                source: UserInputSource::UserInput,
1034            })
1035            .await
1036            .expect("report");
1037
1038        let (deleted, correction_id) = store
1039            .contradict_memory(&id, Some("corrected fact"))
1040            .await
1041            .expect("contradict");
1042        assert!(deleted);
1043        assert!(correction_id.is_some());
1044
1045        let stats = store.get_stats().await.expect("stats");
1046        assert_eq!(stats.total_memories, 1);
1047    }
1048
1049    #[tokio::test]
1050    async fn penalize_memory_reduces_weight_with_floor() {
1051        let ctx = TestCtx::new();
1052        let store = ctx.store();
1053
1054        let id = store
1055            .insert_raw_memory("penalized", MemoryCategory::User, 2.0)
1056            .await
1057            .expect("insert");
1058
1059        store.penalize_memory(&id, 0.25).await.expect("penalize");
1060
1061        let top = store.get_top_by_weight(1).await.expect("top");
1062        assert!((top[0].weight - 0.5).abs() < f64::EPSILON);
1063
1064        store.penalize_memory(&id, 0.01).await.expect("penalize again");
1065        let top = store.get_top_by_weight(1).await.expect("top");
1066        assert!((top[0].weight - 0.1).abs() < f64::EPSILON);
1067    }
1068
1069    #[tokio::test]
1070    async fn baseline_persists_across_store_instances() {
1071        let ctx = TestCtx::new();
1072
1073        let store1 = ctx.store();
1074        let task = store1.start_task("first task").await.expect("start");
1075        store1
1076            .end_task(
1077                &task.task_id,
1078                TaskEndInput {
1079                    tokens_used: 1000,
1080                    tool_calls: 2,
1081                    errors: 1,
1082                    user_corrections: 0,
1083                    completed: true,
1084                    self_report: None,
1085                },
1086            )
1087            .await
1088            .expect("end");
1089        store1.close().await.expect("close");
1090
1091        let store2 = ctx.store();
1092        store2.init().await.expect("re-init");
1093        let task2 = store2.start_task("second task").await.expect("start");
1094        store2
1095            .end_task(
1096                &task2.task_id,
1097                TaskEndInput {
1098                    tokens_used: 800,
1099                    tool_calls: 1,
1100                    errors: 0,
1101                    user_corrections: 0,
1102                    completed: true,
1103                    self_report: None,
1104                },
1105            )
1106            .await
1107            .expect("end");
1108
1109        let stats = store2.get_stats().await.expect("stats");
1110        assert_eq!(stats.task_count, 2);
1111    }
1112
1113    #[tokio::test]
1114    async fn start_task_with_no_memories_returns_empty() {
1115        let ctx = TestCtx::new();
1116        let store = ctx.store();
1117
1118        let start = store.start_task("fresh task with no memories").await.expect("start");
1119        assert!(start.memories.is_empty());
1120    }
1121
1122    #[tokio::test]
1123    async fn top_k_limits_retrieved_memories() {
1124        let ctx = TestCtx::new();
1125        let mut config = test_config(&ctx.db_path);
1126        config.top_k = Some(2);
1127        let store = ctx.store_with(config);
1128
1129        for i in 0..5 {
1130            store
1131                .insert_raw_memory(&format!("memory number {i}"), MemoryCategory::Insight, 1.0)
1132                .await
1133                .expect("insert");
1134        }
1135        store.embed_pending().await.expect("embed");
1136
1137        let start = store.start_task("memory number").await.expect("start");
1138        assert_eq!(start.memories.len(), 2);
1139    }
1140
1141    #[tokio::test]
1142    async fn end_task_clears_current_task_id() {
1143        let ctx = TestCtx::new();
1144        let store = ctx.store();
1145
1146        let task = store.start_task("task").await.expect("start");
1147        store
1148            .end_task(
1149                &task.task_id,
1150                TaskEndInput {
1151                    tokens_used: 100,
1152                    tool_calls: 0,
1153                    errors: 0,
1154                    user_corrections: 0,
1155                    completed: true,
1156                    self_report: None,
1157                },
1158            )
1159            .await
1160            .expect("end");
1161
1162        let id = store
1163            .report_user_input(ReportUserInput {
1164                lesson: "after end".to_string(),
1165                source: UserInputSource::UserInput,
1166            })
1167            .await
1168            .expect("report");
1169        assert!(!id.is_empty());
1170    }
1171
1172    #[test]
1173    fn is_lock_err_detects_lock_messages() {
1174        assert!(is_lock_err("database is locked"));
1175        assert!(is_lock_err("Locking error"));
1176        assert!(!is_lock_err("syntax error"));
1177    }
1178}