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
25pub 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
36const 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
78async 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 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 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 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 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 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 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 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 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 *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 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}