Skip to main content

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