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
73fn 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 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
102async 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 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 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 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 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 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 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 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 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 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 *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 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}