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