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