1use anyhow::Result;
35use rusqlite::Connection;
36use serde::{Deserialize, Serialize};
37
38use crate::db;
39use crate::llm::OllamaClient;
40use crate::models::{Memory, Tier};
41
42pub const CONSOLIDATE_JACCARD_THRESHOLD: f64 = 0.55;
46
47pub const CONSOLIDATE_MAX_CLUSTER_SIZE: usize = 8;
50
51pub const CURATOR_NAMESPACE: &str = "_curator";
55
56#[allow(dead_code)]
63pub trait AutonomyLlm {
64 fn auto_tag(&self, title: &str, content: &str) -> Result<Vec<String>>;
66
67 fn detect_contradiction(&self, mem_a: &str, mem_b: &str) -> Result<bool>;
69
70 fn summarize_memories(&self, memories: &[(String, String)]) -> Result<String>;
72}
73
74impl AutonomyLlm for OllamaClient {
75 fn auto_tag(&self, title: &str, content: &str) -> Result<Vec<String>> {
76 Self::auto_tag(self, title, content)
77 }
78 fn detect_contradiction(&self, mem_a: &str, mem_b: &str) -> Result<bool> {
79 Self::detect_contradiction(self, mem_a, mem_b)
80 }
81 fn summarize_memories(&self, memories: &[(String, String)]) -> Result<String> {
82 Self::summarize_memories(self, memories)
83 }
84}
85
86#[allow(clippy::large_enum_variant)]
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(tag = "action", rename_all = "snake_case")]
99pub enum RollbackEntry {
100 Consolidate {
103 originals: Vec<Memory>,
104 result_id: String,
105 },
106 Forget { snapshot: Memory },
109 PriorityAdjust {
111 memory_id: String,
112 before: i32,
113 after: i32,
114 },
115}
116
117impl RollbackEntry {
118 fn action_tag(&self) -> &'static str {
119 match self {
120 Self::Consolidate { .. } => "consolidate",
121 Self::Forget { .. } => "forget",
122 Self::PriorityAdjust { .. } => "priority_adjust",
123 }
124 }
125}
126
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct AutonomyPassReport {
132 pub clusters_formed: usize,
133 pub memories_consolidated: usize,
134 pub memories_forgotten: usize,
135 pub priority_adjustments: usize,
136 pub rollback_entries_written: usize,
137 pub errors: Vec<String>,
138}
139
140pub fn run_autonomy_passes(
148 conn: &Connection,
149 llm: &dyn AutonomyLlm,
150 candidates: &[Memory],
151 dry_run: bool,
152) -> AutonomyPassReport {
153 let mut report = AutonomyPassReport::default();
154
155 let clusters = find_consolidation_clusters(candidates);
157 report.clusters_formed = clusters.len();
158 for cluster in clusters {
159 match consolidate_cluster(conn, llm, &cluster, dry_run) {
160 Ok(Some(entry)) => {
161 if !dry_run && let Err(e) = persist_rollback_entry(conn, &entry) {
162 report
163 .errors
164 .push(format!("rollback-log write failed: {e}"));
165 } else {
166 report.rollback_entries_written += 1;
167 }
168 if let RollbackEntry::Consolidate { originals, .. } = entry {
169 report.memories_consolidated += originals.len();
170 }
171 }
172 Ok(None) => {}
173 Err(e) => report.errors.push(format!("consolidate failed: {e}")),
174 }
175 }
176
177 for mem in candidates {
179 match forget_if_superseded(conn, mem, candidates, dry_run) {
180 Ok(Some(entry)) => {
181 if !dry_run && let Err(e) = persist_rollback_entry(conn, &entry) {
182 report
183 .errors
184 .push(format!("rollback-log write failed: {e}"));
185 } else {
186 report.rollback_entries_written += 1;
187 }
188 report.memories_forgotten += 1;
189 }
190 Ok(None) => {}
191 Err(e) => report.errors.push(format!("forget failed: {e}")),
192 }
193 }
194
195 #[allow(unused_assignments)]
197 for mem in candidates {
198 match apply_priority_feedback(conn, mem, dry_run) {
199 Ok(Some(entry)) => {
200 if !dry_run && let Err(e) = persist_rollback_entry(conn, &entry) {
201 report
202 .errors
203 .push(format!("rollback-log write failed: {e}"));
204 } else {
205 report.rollback_entries_written += 1;
206 }
207 report.priority_adjustments += 1;
208 }
209 Ok(None) => {}
210 Err(e) => report.errors.push(format!("priority feedback failed: {e}")),
211 }
212 }
213
214 report
215}
216
217fn find_consolidation_clusters(candidates: &[Memory]) -> Vec<Vec<Memory>> {
218 let mut by_ns: std::collections::HashMap<&str, Vec<&Memory>> = std::collections::HashMap::new();
220 for m in candidates {
221 if m.namespace.starts_with('_') {
222 continue;
223 }
224 by_ns.entry(&m.namespace).or_default().push(m);
225 }
226
227 let mut clusters: Vec<Vec<Memory>> = Vec::new();
228 for (_ns, group) in by_ns {
229 let mut used = vec![false; group.len()];
230 for i in 0..group.len() {
231 if used[i] {
232 continue;
233 }
234 let mut cluster = vec![group[i].clone()];
235 used[i] = true;
236 for j in (i + 1)..group.len() {
237 if used[j] {
238 continue;
239 }
240 if cluster.len() >= CONSOLIDATE_MAX_CLUSTER_SIZE {
241 break;
242 }
243 if jaccard_similarity(&group[i].content, &group[j].content)
244 >= CONSOLIDATE_JACCARD_THRESHOLD
245 {
246 cluster.push(group[j].clone());
247 used[j] = true;
248 }
249 }
250 if cluster.len() >= 2 {
251 clusters.push(cluster);
252 }
253 }
254 }
255 clusters
256}
257
258fn jaccard_similarity(a: &str, b: &str) -> f64 {
259 use std::collections::HashSet;
260 let tokens = |s: &str| -> HashSet<String> {
261 s.split(|c: char| !c.is_alphanumeric())
262 .filter(|t| t.len() >= 3)
263 .map(str::to_lowercase)
264 .collect()
265 };
266 let ta = tokens(a);
267 let tb = tokens(b);
268 if ta.is_empty() && tb.is_empty() {
269 return 0.0;
270 }
271 let inter = ta.intersection(&tb).count();
272 let union = ta.union(&tb).count();
273 if union == 0 {
274 0.0
275 } else {
276 #[allow(clippy::cast_precision_loss)]
277 let result = inter as f64 / union as f64;
278 result
279 }
280}
281
282fn consolidate_cluster(
283 conn: &Connection,
284 llm: &dyn AutonomyLlm,
285 cluster: &[Memory],
286 dry_run: bool,
287) -> Result<Option<RollbackEntry>> {
288 if cluster.len() < 2 {
289 return Ok(None);
290 }
291 if cluster.iter().any(|m| m.namespace.starts_with('_')) {
294 return Ok(None);
295 }
296
297 let input: Vec<(String, String)> = cluster
298 .iter()
299 .map(|m| (m.title.clone(), m.content.clone()))
300 .collect();
301 let summary = llm.summarize_memories(&input)?;
302 let base_title = cluster
307 .iter()
308 .map(|m| m.title.as_str())
309 .next()
310 .unwrap_or("(consolidated)");
311 let title = format!("[consolidated] {base_title}");
312
313 if dry_run {
314 return Ok(Some(RollbackEntry::Consolidate {
315 originals: cluster.to_vec(),
316 result_id: "dry-run".to_string(),
317 }));
318 }
319
320 let ids: Vec<String> = cluster.iter().map(|m| m.id.clone()).collect();
321 let namespace = cluster[0].namespace.clone();
322 let tier = cluster
324 .iter()
325 .map(|m| m.tier.clone())
326 .max_by_key(tier_rank)
327 .unwrap_or(Tier::Mid);
328
329 let result_id = db::consolidate(
330 conn,
331 &ids,
332 &title,
333 &summary,
334 &namespace,
335 &tier,
336 "ai-memory curator (autonomy)",
337 "ai:curator",
338 )?;
339
340 Ok(Some(RollbackEntry::Consolidate {
341 originals: cluster.to_vec(),
342 result_id,
343 }))
344}
345
346fn tier_rank(t: &Tier) -> u8 {
347 match t {
348 Tier::Short => 0,
349 Tier::Mid => 1,
350 Tier::Long => 2,
351 }
352}
353
354fn forget_if_superseded(
355 conn: &Connection,
356 mem: &Memory,
357 all: &[Memory],
358 dry_run: bool,
359) -> Result<Option<RollbackEntry>> {
360 let contradictions = mem
364 .metadata
365 .get("confirmed_contradictions")
366 .and_then(|v| v.as_array())
367 .cloned()
368 .unwrap_or_default();
369 if contradictions.is_empty() {
370 return Ok(None);
371 }
372
373 let by_id: std::collections::HashMap<&str, &Memory> =
378 all.iter().map(|m| (m.id.as_str(), m)).collect();
379 let mut superseder: Option<&Memory> = None;
380 for v in contradictions {
381 let Some(other_id) = v.as_str() else {
382 continue;
383 };
384 if let Some(other) = by_id.get(other_id)
385 && other.updated_at > mem.updated_at
386 && other.confidence >= mem.confidence
387 {
388 superseder = Some(other);
389 break;
390 }
391 }
392 let Some(_) = superseder else {
393 return Ok(None);
394 };
395
396 if dry_run {
397 return Ok(Some(RollbackEntry::Forget {
398 snapshot: mem.clone(),
399 }));
400 }
401
402 db::delete(conn, &mem.id)?;
410
411 Ok(Some(RollbackEntry::Forget {
412 snapshot: mem.clone(),
413 }))
414}
415
416fn apply_priority_feedback(
417 conn: &Connection,
418 mem: &Memory,
419 dry_run: bool,
420) -> Result<Option<RollbackEntry>> {
421 let now = chrono::Utc::now();
426 let before = mem.priority;
427 let mut after = before;
428
429 let last_accessed = mem
430 .last_accessed_at
431 .as_deref()
432 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
433 .map(chrono::DateTime::<chrono::Utc>::from);
434
435 let created = chrono::DateTime::parse_from_rfc3339(&mem.created_at)
436 .ok()
437 .map(chrono::DateTime::<chrono::Utc>::from);
438
439 let recent = last_accessed.is_some_and(|t| (now - t).num_days() <= 7);
440 let cold_enough = created.is_some_and(|t| (now - t).num_days() >= 30);
441
442 if mem.access_count >= 10 && recent && after < 10 {
443 after = after.saturating_add(1).min(10);
444 } else if mem.access_count == 0 && cold_enough && after > 1 {
445 after = after.saturating_sub(1).max(1);
446 }
447
448 if after == before {
449 return Ok(None);
450 }
451
452 if !dry_run {
453 db::update(
454 conn,
455 &mem.id,
456 None,
457 None,
458 None,
459 None,
460 None,
461 Some(after),
462 None,
463 None,
464 None,
465 )?;
466 }
467
468 Ok(Some(RollbackEntry::PriorityAdjust {
469 memory_id: mem.id.clone(),
470 before,
471 after,
472 }))
473}
474
475fn persist_rollback_entry(conn: &Connection, entry: &RollbackEntry) -> Result<()> {
476 let now = chrono::Utc::now();
477 let ts = now.to_rfc3339();
478 let mem = Memory {
479 id: uuid::Uuid::new_v4().to_string(),
480 tier: Tier::Long,
481 namespace: format!("{CURATOR_NAMESPACE}/rollback"),
482 title: format!("curator {} @ {}", entry.action_tag(), ts),
483 content: serde_json::to_string(entry)?,
484 tags: vec![
485 "_curator".to_string(),
486 "_rollback".to_string(),
487 entry.action_tag().to_string(),
488 ],
489 priority: 3,
490 confidence: 1.0,
491 source: "ai-memory curator (autonomy)".to_string(),
492 access_count: 0,
493 created_at: ts.clone(),
494 updated_at: ts,
495 last_accessed_at: None,
496 expires_at: None,
497 metadata: serde_json::json!({
498 "agent_id": "ai:curator",
499 "action": entry.action_tag(),
500 }),
501 };
502 db::insert(conn, &mem)?;
503 Ok(())
504}
505
506pub fn persist_self_report(
509 conn: &Connection,
510 cycle_duration_ms: u128,
511 pass_report: &AutonomyPassReport,
512 auto_tagged: usize,
513 contradictions_found: usize,
514 errors_total: usize,
515) -> Result<()> {
516 let now = chrono::Utc::now();
517 let ts = now.to_rfc3339();
518 let body = serde_json::json!({
519 "cycle_ts": ts,
520 "cycle_duration_ms": cycle_duration_ms,
521 "auto_tagged": auto_tagged,
522 "contradictions_found": contradictions_found,
523 "clusters_formed": pass_report.clusters_formed,
524 "memories_consolidated": pass_report.memories_consolidated,
525 "memories_forgotten": pass_report.memories_forgotten,
526 "priority_adjustments": pass_report.priority_adjustments,
527 "rollback_entries_written": pass_report.rollback_entries_written,
528 "errors_total": errors_total,
529 });
530 let mem = Memory {
531 id: uuid::Uuid::new_v4().to_string(),
532 tier: Tier::Mid,
533 namespace: format!("{CURATOR_NAMESPACE}/reports"),
534 title: format!("curator cycle @ {ts}"),
535 content: serde_json::to_string_pretty(&body)?,
536 tags: vec!["_curator".to_string(), "_report".to_string()],
537 priority: 2,
538 confidence: 1.0,
539 source: "ai-memory curator (autonomy)".to_string(),
540 access_count: 0,
541 created_at: ts.clone(),
542 updated_at: ts,
543 last_accessed_at: None,
544 expires_at: None,
545 metadata: serde_json::json!({"agent_id": "ai:curator"}),
546 };
547 db::insert(conn, &mem)?;
548 Ok(())
549}
550
551pub fn reverse_rollback_entry(conn: &Connection, entry: &RollbackEntry) -> Result<bool> {
563 match entry {
564 RollbackEntry::Consolidate {
565 originals,
566 result_id,
567 } => {
568 for m in originals {
570 check_no_collision(conn, &m.title, &m.namespace, &m.id)?;
571 }
572 let existed = db::delete(conn, result_id)?;
574 for m in originals {
575 db::insert(conn, m)?;
576 }
577 Ok(existed)
578 }
579 RollbackEntry::Forget { snapshot } => {
580 check_no_collision(conn, &snapshot.title, &snapshot.namespace, &snapshot.id)?;
581 db::insert(conn, snapshot)?;
582 Ok(true)
583 }
584 RollbackEntry::PriorityAdjust {
585 memory_id,
586 before,
587 after: _,
588 } => {
589 let _ = db::update(
590 conn,
591 memory_id,
592 None,
593 None,
594 None,
595 None,
596 None,
597 Some(*before),
598 None,
599 None,
600 None,
601 )?;
602 Ok(true)
603 }
604 }
605}
606
607fn check_no_collision(
610 conn: &Connection,
611 title: &str,
612 namespace: &str,
613 expected_id: &str,
614) -> Result<()> {
615 let rows = db::list(
616 conn,
617 Some(namespace),
618 None,
619 50,
620 0,
621 None,
622 None,
623 None,
624 None,
625 None,
626 )?;
627 for row in rows {
628 if row.namespace == namespace && row.title == title && row.id != expected_id {
629 anyhow::bail!(
630 "rollback aborted: memory {} now occupies (title={:?}, namespace={:?}) — \
631 reverting would overwrite it. Resolve the conflict manually.",
632 row.id,
633 title,
634 namespace
635 );
636 }
637 }
638 Ok(())
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644 use std::sync::Mutex;
645
646 struct StubLlm {
649 #[allow(dead_code)]
655 auto_tag_result: Vec<String>,
656 summary: String,
657 #[allow(dead_code)]
658 contradiction_sentinel: String,
659 calls: Mutex<Vec<String>>,
660 }
661
662 impl StubLlm {
663 fn new(summary: &str) -> Self {
664 Self {
665 auto_tag_result: vec!["auto".to_string(), "stub".to_string()],
666 summary: summary.to_string(),
667 contradiction_sentinel: "CONTRADICTS".to_string(),
668 calls: Mutex::new(Vec::new()),
669 }
670 }
671 }
672
673 impl AutonomyLlm for StubLlm {
674 fn auto_tag(&self, title: &str, _content: &str) -> Result<Vec<String>> {
675 self.calls.lock().unwrap().push(format!("auto_tag:{title}"));
676 Ok(self.auto_tag_result.clone())
677 }
678 fn detect_contradiction(&self, a: &str, b: &str) -> Result<bool> {
679 self.calls
680 .lock()
681 .unwrap()
682 .push("detect_contradiction".to_string());
683 Ok(
684 a.contains(&self.contradiction_sentinel)
685 || b.contains(&self.contradiction_sentinel),
686 )
687 }
688 fn summarize_memories(&self, memories: &[(String, String)]) -> Result<String> {
689 self.calls
690 .lock()
691 .unwrap()
692 .push(format!("summarize:{}", memories.len()));
693 Ok(self.summary.clone())
694 }
695 }
696
697 fn sample_mem(id: &str, ns: &str, title: &str, content: &str, tier: Tier) -> Memory {
698 let now = chrono::Utc::now().to_rfc3339();
699 Memory {
700 id: id.to_string(),
701 tier,
702 namespace: ns.to_string(),
703 title: title.to_string(),
704 content: content.to_string(),
705 tags: vec!["t".to_string()],
706 priority: 5,
707 confidence: 1.0,
708 source: "test".to_string(),
709 access_count: 0,
710 created_at: now.clone(),
711 updated_at: now,
712 last_accessed_at: None,
713 expires_at: None,
714 metadata: serde_json::json!({"agent_id":"ai:test"}),
715 }
716 }
717
718 fn setup_conn() -> (tempfile::NamedTempFile, Connection) {
719 let tmp = tempfile::NamedTempFile::new().unwrap();
720 let conn = db::open(tmp.path()).unwrap();
721 (tmp, conn)
722 }
723
724 #[test]
725 fn jaccard_similarity_basic() {
726 let sim = jaccard_similarity(
727 "the quick brown fox jumps over",
728 "quick brown fox over the lazy",
729 );
730 assert!(sim > 0.4, "unexpected sim {sim}");
731 }
732
733 #[test]
734 fn jaccard_similarity_empty() {
735 assert!((jaccard_similarity("", "") - 0.0).abs() < 1e-9);
736 assert!((jaccard_similarity("abc", "") - 0.0).abs() < 1e-9);
737 }
738
739 #[test]
740 fn consolidation_clusters_group_by_namespace() {
741 let a = sample_mem(
742 "a",
743 "ns1",
744 "A",
745 "the quick brown fox jumps over lazy dog",
746 Tier::Mid,
747 );
748 let b = sample_mem(
749 "b",
750 "ns1",
751 "B",
752 "quick brown fox over lazy dog jumps",
753 Tier::Mid,
754 );
755 let c = sample_mem(
756 "c",
757 "ns2",
758 "C",
759 "the quick brown fox jumps over lazy dog",
760 Tier::Mid,
761 );
762 let clusters = find_consolidation_clusters(&[a, b, c]);
763 assert_eq!(clusters.len(), 1);
765 assert_eq!(clusters[0].len(), 2);
766 }
767
768 #[test]
769 fn consolidation_skips_reserved_namespace() {
770 let a = sample_mem("a", "_curator/reports", "A", "content aaaa bbbb", Tier::Mid);
771 let b = sample_mem("b", "_curator/reports", "B", "content aaaa bbbb", Tier::Mid);
772 let clusters = find_consolidation_clusters(&[a, b]);
773 assert!(clusters.is_empty());
774 }
775
776 #[test]
777 fn rollback_entry_serialises() {
778 let e = RollbackEntry::PriorityAdjust {
779 memory_id: "m1".to_string(),
780 before: 5,
781 after: 6,
782 };
783 let json = serde_json::to_string(&e).unwrap();
784 assert!(json.contains("priority_adjust"));
785 let back: RollbackEntry = serde_json::from_str(&json).unwrap();
786 assert_eq!(back.action_tag(), "priority_adjust");
787 }
788
789 #[test]
790 fn consolidate_cluster_merges_two_memories() {
791 let (_tmp, conn) = setup_conn();
792 let a = sample_mem(
793 "a",
794 "app",
795 "Deploy plan",
796 "kubernetes rolling deploy with canary",
797 Tier::Long,
798 );
799 let b = sample_mem(
800 "b",
801 "app",
802 "Deploy process",
803 "kubernetes deploy rolling canary strategy",
804 Tier::Long,
805 );
806 db::insert(&conn, &a).unwrap();
807 db::insert(&conn, &b).unwrap();
808 let llm = StubLlm::new("consolidated deploy plan");
809 let cluster = vec![a.clone(), b.clone()];
810 let entry = consolidate_cluster(&conn, &llm, &cluster, false)
811 .unwrap()
812 .expect("expected rollback entry");
813 match entry {
814 RollbackEntry::Consolidate {
815 originals,
816 result_id,
817 } => {
818 assert_eq!(originals.len(), 2);
819 assert_ne!(result_id, "dry-run");
820 let got = db::get(&conn, &result_id).unwrap().expect("result memory");
821 assert_eq!(got.namespace, "app");
822 assert!(got.title.starts_with("[consolidated]"));
823 assert!(got.content.contains("consolidated deploy plan"));
824 }
825 _ => panic!("expected Consolidate"),
826 }
827 }
828
829 #[test]
830 fn dry_run_does_not_write() {
831 let (_tmp, conn) = setup_conn();
832 let a = sample_mem(
833 "a",
834 "app",
835 "Deploy plan",
836 "kubernetes rolling deploy with canary",
837 Tier::Long,
838 );
839 let b = sample_mem(
840 "b",
841 "app",
842 "Deploy process",
843 "kubernetes deploy rolling canary strategy",
844 Tier::Long,
845 );
846 db::insert(&conn, &a).unwrap();
847 db::insert(&conn, &b).unwrap();
848 let llm = StubLlm::new("never persisted");
849 let cluster = vec![a.clone(), b.clone()];
850 let entry = consolidate_cluster(&conn, &llm, &cluster, true)
851 .unwrap()
852 .expect("dry-run returns entry");
853 if let RollbackEntry::Consolidate { result_id, .. } = entry {
854 assert_eq!(result_id, "dry-run");
855 }
856 assert!(db::get(&conn, "a").unwrap().is_some());
858 assert!(db::get(&conn, "b").unwrap().is_some());
859 }
860
861 #[test]
862 fn reverse_consolidation_restores_originals() {
863 let (_tmp, conn) = setup_conn();
864 let a = sample_mem(
865 "a",
866 "app",
867 "Deploy plan",
868 "kubernetes rolling deploy canary",
869 Tier::Long,
870 );
871 let b = sample_mem(
872 "b",
873 "app",
874 "Deploy process",
875 "kubernetes rolling canary strategy",
876 Tier::Long,
877 );
878 db::insert(&conn, &a).unwrap();
879 db::insert(&conn, &b).unwrap();
880
881 let llm = StubLlm::new("summary");
882 let cluster = vec![a.clone(), b.clone()];
883 let entry = consolidate_cluster(&conn, &llm, &cluster, false)
884 .unwrap()
885 .expect("entry");
886
887 if let RollbackEntry::Consolidate {
890 result_id,
891 originals,
892 } = &entry
893 {
894 assert!(db::get(&conn, result_id).unwrap().is_some());
895 for orig in originals {
896 assert!(
897 db::get(&conn, &orig.id).unwrap().is_none(),
898 "{} should be merged-away",
899 orig.id
900 );
901 }
902 }
903
904 reverse_rollback_entry(&conn, &entry).unwrap();
906 assert!(db::get(&conn, "a").unwrap().is_some());
907 assert!(db::get(&conn, "b").unwrap().is_some());
908 if let RollbackEntry::Consolidate { result_id, .. } = &entry {
909 assert!(db::get(&conn, result_id).unwrap().is_none());
910 }
911 }
912
913 #[test]
914 fn full_autonomy_cycle_end_to_end() {
915 let (_tmp, conn) = setup_conn();
916 let llm = StubLlm::new("consolidated");
917
918 let m_a = sample_mem(
921 "ma",
922 "deploy",
923 "canary deploy plan",
924 "kubernetes canary rolling deploy strategy",
925 Tier::Long,
926 );
927 let m_b = sample_mem(
928 "mb",
929 "deploy",
930 "canary deploy overview",
931 "kubernetes rolling canary deploy strategy",
932 Tier::Long,
933 );
934 let m_chat = sample_mem(
935 "mchat",
936 "chat",
937 "hello",
938 "hi there chat only content here",
939 Tier::Mid,
940 );
941
942 let mut m_old = sample_mem(
945 "mold",
946 "facts",
947 "fact v1",
948 "the sky is green always uniformly",
949 Tier::Long,
950 );
951 let m_new_id = "mnew";
952 m_old.metadata["confirmed_contradictions"] = serde_json::json!([m_new_id]);
953 m_old.updated_at = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
956 let m_new = sample_mem(
957 m_new_id,
958 "facts",
959 "fact v2",
960 "the sky is blue most of the time for sure",
961 Tier::Long,
962 );
963
964 for m in [&m_a, &m_b, &m_chat, &m_old, &m_new] {
965 db::insert(&conn, m).unwrap();
966 }
967
968 let candidates = vec![
969 m_a.clone(),
970 m_b.clone(),
971 m_chat.clone(),
972 m_old.clone(),
973 m_new.clone(),
974 ];
975 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
976
977 assert!(report.clusters_formed >= 1);
979 assert!(report.memories_consolidated >= 2);
980 assert!(
982 report.memories_forgotten >= 1,
983 "expected ≥1 forget, got {report:?}"
984 );
985 assert!(report.rollback_entries_written >= report.clusters_formed);
987 let log = db::list(
989 &conn,
990 Some("_curator/rollback"),
991 None,
992 100,
993 0,
994 None,
995 None,
996 None,
997 None,
998 None,
999 )
1000 .unwrap();
1001 assert!(!log.is_empty(), "rollback log should be populated");
1002 }
1003
1004 #[test]
1005 fn self_report_written_to_reports_namespace() {
1006 let (_tmp, conn) = setup_conn();
1007 let pass = AutonomyPassReport {
1008 clusters_formed: 1,
1009 memories_consolidated: 2,
1010 memories_forgotten: 0,
1011 priority_adjustments: 1,
1012 rollback_entries_written: 2,
1013 errors: vec![],
1014 };
1015 persist_self_report(&conn, 1234, &pass, 3, 0, 0).unwrap();
1016 let reports = db::list(
1017 &conn,
1018 Some("_curator/reports"),
1019 None,
1020 10,
1021 0,
1022 None,
1023 None,
1024 None,
1025 None,
1026 None,
1027 )
1028 .unwrap();
1029 assert_eq!(reports.len(), 1);
1030 assert!(reports[0].content.contains("memories_consolidated"));
1031 }
1032
1033 #[test]
1034 fn smart_tier_mock_cycle_summarize() {
1035 let (_tmp, conn) = setup_conn();
1037 let a = sample_mem(
1039 "mem-a",
1040 "app",
1041 "Deploy A",
1042 "kubernetes deployment rolling canary strategy kubernetes rolling deploy canary",
1043 Tier::Mid,
1044 );
1045 let b = sample_mem(
1046 "mem-b",
1047 "app",
1048 "Deploy B",
1049 "kubernetes deployment rolling canary approach kubernetes rolling canary deploy",
1050 Tier::Mid,
1051 );
1052 db::insert(&conn, &a).unwrap();
1053 db::insert(&conn, &b).unwrap();
1054
1055 let llm = StubLlm::new("LLM-generated consolidated summary");
1056 let candidates = vec![a, b];
1057
1058 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
1059
1060 assert!(report.clusters_formed > 0);
1062 assert!(report.memories_consolidated > 0);
1063 }
1064
1065 #[test]
1066 fn autonomy_cycle_with_mock_ollama() {
1067 let (_tmp, conn) = setup_conn();
1069 let a = sample_mem(
1070 "id-1",
1071 "ns1",
1072 "Title A",
1073 "content similar enough for clustering test similar clustering",
1074 Tier::Mid,
1075 );
1076 let b = sample_mem(
1077 "id-2",
1078 "ns1",
1079 "Title B",
1080 "content similar enough for clustering test similar clustering",
1081 Tier::Mid,
1082 );
1083 db::insert(&conn, &a).unwrap();
1084 db::insert(&conn, &b).unwrap();
1085
1086 let llm = StubLlm::new("mock summary result");
1087 let candidates = vec![a, b];
1088
1089 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
1090
1091 assert_eq!(report.errors.len(), 0, "autonomy cycle should not error");
1093 assert!(
1094 report.rollback_entries_written > 0,
1095 "autonomy cycle should write rollback entries"
1096 );
1097 }
1098
1099 #[test]
1100 fn rollback_log_captures_consolidation() {
1101 let (_tmp, conn) = setup_conn();
1103 let a = sample_mem(
1104 "a",
1105 "test-ns",
1106 "Memory A",
1107 "test content aaaa bbbb cccc aaaa bbbb",
1108 Tier::Mid,
1109 );
1110 let b = sample_mem(
1111 "b",
1112 "test-ns",
1113 "Memory B",
1114 "test content aaaa bbbb cccc aaaa bbbb",
1115 Tier::Mid,
1116 );
1117 db::insert(&conn, &a).unwrap();
1118 db::insert(&conn, &b).unwrap();
1119
1120 let llm = StubLlm::new("consolidated");
1121 let cluster = vec![a.clone(), b.clone()];
1122 let entry = consolidate_cluster(&conn, &llm, &cluster, false)
1123 .unwrap()
1124 .expect("rollback entry");
1125
1126 persist_rollback_entry(&conn, &entry).unwrap();
1128
1129 let log = db::list(
1131 &conn,
1132 Some("_curator/rollback"),
1133 None,
1134 100,
1135 0,
1136 None,
1137 None,
1138 None,
1139 None,
1140 None,
1141 )
1142 .unwrap();
1143 assert_eq!(log.len(), 1);
1144 assert!(log[0].content.contains("consolidate"));
1145 }
1146
1147 #[test]
1148 fn priority_feedback_adjusts_memory() {
1149 let (_tmp, conn) = setup_conn();
1154 let mut mem = sample_mem("id", "ns", "Title", "content", Tier::Mid);
1155 mem.priority = 5;
1156 mem.access_count = 100;
1157 mem.last_accessed_at = Some(chrono::Utc::now().to_rfc3339());
1158 db::insert(&conn, &mem).unwrap();
1159
1160 let entry = apply_priority_feedback(&conn, &mem, false)
1161 .unwrap()
1162 .expect("priority feedback should produce entry");
1163
1164 match entry {
1165 RollbackEntry::PriorityAdjust {
1166 memory_id,
1167 before,
1168 after,
1169 } => {
1170 assert_eq!(memory_id, "id");
1171 assert_eq!(before, 5);
1172 assert!(after > before, "high access should increase priority");
1173 }
1174 _ => panic!("expected PriorityAdjust"),
1175 }
1176 }
1177
1178 #[test]
1179 fn dry_run_autonomy_does_not_write() {
1180 let (_tmp, conn) = setup_conn();
1182 let a = sample_mem(
1183 "a",
1184 "test-ns",
1185 "Memory A",
1186 "test content aaaa bbbb cccc aaaa bbbb",
1187 Tier::Mid,
1188 );
1189 let b = sample_mem(
1190 "b",
1191 "test-ns",
1192 "Memory B",
1193 "test content aaaa bbbb cccc aaaa bbbb",
1194 Tier::Mid,
1195 );
1196 db::insert(&conn, &a).unwrap();
1197 db::insert(&conn, &b).unwrap();
1198
1199 let initial_count = db::list(
1200 &conn,
1201 Some("test-ns"),
1202 None,
1203 100,
1204 0,
1205 None,
1206 None,
1207 None,
1208 None,
1209 None,
1210 )
1211 .unwrap()
1212 .len();
1213
1214 let llm = StubLlm::new("consolidated");
1215 let candidates = vec![a, b];
1216 let _report = run_autonomy_passes(&conn, &llm, &candidates, true);
1217
1218 let final_count = db::list(
1219 &conn,
1220 Some("test-ns"),
1221 None,
1222 100,
1223 0,
1224 None,
1225 None,
1226 None,
1227 None,
1228 None,
1229 )
1230 .unwrap()
1231 .len();
1232
1233 assert_eq!(
1234 initial_count, final_count,
1235 "dry-run should not modify database"
1236 );
1237 }
1238
1239 #[test]
1240 fn autonomy_passes_report_aggregates_errors() {
1241 let (_tmp, conn) = setup_conn();
1243 let mem = sample_mem("id", "ns", "Title", "content", Tier::Mid);
1244 let llm = StubLlm::new("summary");
1245 let candidates = vec![mem];
1246 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
1247
1248 assert!(report.clusters_formed > 0 || report.clusters_formed == 0);
1250 }
1251
1252 #[test]
1261 fn reverse_priority_adjust_restores_before_value() {
1262 let (_tmp, conn) = setup_conn();
1263 let mut mem = sample_mem("pa-id", "ns", "Title", "content", Tier::Mid);
1264 mem.priority = 7;
1265 db::insert(&conn, &mem).unwrap();
1266 db::update(
1268 &conn,
1269 &mem.id,
1270 None,
1271 None,
1272 None,
1273 None,
1274 None,
1275 Some(9),
1276 None,
1277 None,
1278 None,
1279 )
1280 .unwrap();
1281 assert_eq!(db::get(&conn, &mem.id).unwrap().unwrap().priority, 9);
1282
1283 let entry = RollbackEntry::PriorityAdjust {
1284 memory_id: mem.id.clone(),
1285 before: 7,
1286 after: 9,
1287 };
1288 let applied = reverse_rollback_entry(&conn, &entry).unwrap();
1289 assert!(applied);
1290 assert_eq!(db::get(&conn, &mem.id).unwrap().unwrap().priority, 7);
1291 }
1292
1293 #[test]
1296 fn reverse_forget_restores_snapshot() {
1297 let (_tmp, conn) = setup_conn();
1298 let mem = sample_mem(
1299 "forget-id",
1300 "factual",
1301 "Snapshot",
1302 "saved content body abc",
1303 Tier::Long,
1304 );
1305 db::insert(&conn, &mem).unwrap();
1306 db::delete(&conn, &mem.id).unwrap();
1308 assert!(db::get(&conn, &mem.id).unwrap().is_none());
1309
1310 let entry = RollbackEntry::Forget {
1311 snapshot: mem.clone(),
1312 };
1313 let applied = reverse_rollback_entry(&conn, &entry).unwrap();
1314 assert!(applied);
1315 let restored = db::get(&conn, &mem.id).unwrap().expect("snapshot restored");
1316 assert_eq!(restored.title, "Snapshot");
1317 assert_eq!(restored.namespace, "factual");
1318 }
1319
1320 #[test]
1325 fn reverse_consolidate_collision_aborts() {
1326 let (_tmp, conn) = setup_conn();
1327 let original = sample_mem(
1328 "o1",
1329 "app",
1330 "Deploy plan",
1331 "kubernetes rolling deploy canary",
1332 Tier::Long,
1333 );
1334 let merged_id = "merged".to_string();
1335 let entry = RollbackEntry::Consolidate {
1336 originals: vec![original.clone()],
1337 result_id: merged_id.clone(),
1338 };
1339
1340 let collider = sample_mem(
1343 "collider-id",
1344 "app",
1345 "Deploy plan",
1346 "different content here entirely",
1347 Tier::Long,
1348 );
1349 db::insert(&conn, &collider).unwrap();
1350
1351 let err = reverse_rollback_entry(&conn, &entry).expect_err("collision must abort");
1352 let msg = format!("{err}");
1353 assert!(msg.contains("rollback aborted"), "unexpected msg: {msg}");
1354 assert!(db::get(&conn, "collider-id").unwrap().is_some());
1356 }
1357
1358 #[test]
1362 fn consolidate_cluster_returns_none_for_singleton() {
1363 let (_tmp, conn) = setup_conn();
1364 let llm = StubLlm::new("never called");
1365 let solo = sample_mem("a", "ns", "T", "content body word word", Tier::Mid);
1366 let result = consolidate_cluster(&conn, &llm, std::slice::from_ref(&solo), false).unwrap();
1367 assert!(result.is_none());
1368 }
1369
1370 #[test]
1374 fn consolidate_cluster_skips_reserved_namespace_defensive() {
1375 let (_tmp, conn) = setup_conn();
1376 let llm = StubLlm::new("never called");
1377 let a = sample_mem("a", "_curator/rollback", "T1", "abc abc abc abc", Tier::Mid);
1378 let b = sample_mem("b", "_curator/rollback", "T2", "abc abc abc abc", Tier::Mid);
1379 let result = consolidate_cluster(&conn, &llm, &[a, b], false).unwrap();
1380 assert!(
1381 result.is_none(),
1382 "reserved-namespace cluster must be skipped"
1383 );
1384 }
1385
1386 #[test]
1390 fn forget_if_superseded_dry_run_returns_entry_without_delete() {
1391 let (_tmp, conn) = setup_conn();
1392 let mut older = sample_mem("old", "facts", "fact v1", "the sky is green", Tier::Long);
1393 older.metadata["confirmed_contradictions"] = serde_json::json!(["new"]);
1394 older.updated_at = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
1395 let newer = sample_mem("new", "facts", "fact v2", "the sky is blue", Tier::Long);
1396 db::insert(&conn, &older).unwrap();
1397 db::insert(&conn, &newer).unwrap();
1398
1399 let result = forget_if_superseded(&conn, &older, &[older.clone(), newer], true).unwrap();
1400 match result {
1401 Some(RollbackEntry::Forget { snapshot }) => {
1402 assert_eq!(snapshot.id, "old");
1403 }
1404 _ => panic!("expected Forget entry from dry-run forget"),
1405 }
1406 assert!(db::get(&conn, "old").unwrap().is_some());
1408 }
1409
1410 #[test]
1414 fn forget_if_superseded_skips_non_string_contradiction_ids() {
1415 let (_tmp, conn) = setup_conn();
1416 let mut mem = sample_mem("m", "facts", "T", "content body word", Tier::Mid);
1417 mem.metadata["confirmed_contradictions"] = serde_json::json!([42, "missing-id"]);
1419 let result = forget_if_superseded(&conn, &mem, std::slice::from_ref(&mem), false).unwrap();
1420 assert!(result.is_none());
1422 }
1423
1424 #[test]
1430 fn stub_llm_auto_tag_and_detect_contradiction() {
1431 let llm = StubLlm::new("summary");
1432 let tags = AutonomyLlm::auto_tag(&llm, "Some Title", "body").unwrap();
1434 assert_eq!(tags, vec!["auto".to_string(), "stub".to_string()]);
1435 assert!(AutonomyLlm::detect_contradiction(&llm, "this CONTRADICTS that", "ok").unwrap());
1437 assert!(!AutonomyLlm::detect_contradiction(&llm, "ok", "fine").unwrap());
1438 let calls = llm.calls.lock().unwrap();
1440 assert!(calls.iter().any(|c| c.starts_with("auto_tag:")));
1441 assert!(calls.iter().any(|c| c == "detect_contradiction"));
1442 }
1443
1444 #[test]
1450 fn run_autonomy_passes_dry_run_writes_no_changes() {
1451 let (_tmp, conn) = setup_conn();
1452 let m_a = sample_mem(
1454 "ma",
1455 "deploy",
1456 "canary deploy plan",
1457 "kubernetes canary rolling deploy strategy",
1458 Tier::Long,
1459 );
1460 let m_b = sample_mem(
1461 "mb",
1462 "deploy",
1463 "canary deploy overview",
1464 "kubernetes rolling canary deploy strategy",
1465 Tier::Long,
1466 );
1467 let mut m_old = sample_mem(
1469 "mold",
1470 "facts",
1471 "fact v1",
1472 "the sky is green always uniformly",
1473 Tier::Long,
1474 );
1475 m_old.metadata["confirmed_contradictions"] = serde_json::json!(["mnew"]);
1476 m_old.updated_at = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
1477 let m_new = sample_mem(
1478 "mnew",
1479 "facts",
1480 "fact v2",
1481 "the sky is blue most of the time",
1482 Tier::Long,
1483 );
1484 let mut m_hot = sample_mem(
1486 "hot",
1487 "ns",
1488 "Hot",
1489 "this is hot content for priority bump",
1490 Tier::Mid,
1491 );
1492 m_hot.priority = 5;
1493 m_hot.access_count = 100;
1494 m_hot.last_accessed_at = Some(chrono::Utc::now().to_rfc3339());
1495
1496 for m in [&m_a, &m_b, &m_old, &m_new, &m_hot] {
1497 db::insert(&conn, m).unwrap();
1498 }
1499 let candidates = vec![
1500 m_a.clone(),
1501 m_b.clone(),
1502 m_old.clone(),
1503 m_new.clone(),
1504 m_hot.clone(),
1505 ];
1506
1507 let pre_priority = db::get(&conn, &m_hot.id).unwrap().unwrap().priority;
1509 assert!(db::get(&conn, "mold").unwrap().is_some());
1510
1511 let llm = StubLlm::new("dry-run summary");
1512 let report = run_autonomy_passes(&conn, &llm, &candidates, true);
1513
1514 assert!(report.clusters_formed >= 1);
1516 let log = db::list(
1520 &conn,
1521 Some("_curator/rollback"),
1522 None,
1523 100,
1524 0,
1525 None,
1526 None,
1527 None,
1528 None,
1529 None,
1530 )
1531 .unwrap();
1532 assert!(log.is_empty(), "dry-run must not persist rollback memories");
1533
1534 assert_eq!(
1536 db::get(&conn, &m_hot.id).unwrap().unwrap().priority,
1537 pre_priority
1538 );
1539 assert!(db::get(&conn, "mold").unwrap().is_some());
1540 assert!(db::get(&conn, "ma").unwrap().is_some());
1541 }
1542
1543 #[test]
1549 fn consolidation_cluster_respects_max_size_cap() {
1550 let n = CONSOLIDATE_MAX_CLUSTER_SIZE + 4;
1551 let mut candidates: Vec<Memory> = Vec::with_capacity(n);
1552 for i in 0..n {
1553 candidates.push(sample_mem(
1554 &format!("m{i}"),
1555 "deploy",
1556 &format!("title-{i}"),
1557 "kubernetes rolling canary deploy strategy",
1558 Tier::Long,
1559 ));
1560 }
1561 let clusters = find_consolidation_clusters(&candidates);
1562 assert!(!clusters.is_empty());
1563 for c in &clusters {
1564 assert!(
1565 c.len() <= CONSOLIDATE_MAX_CLUSTER_SIZE,
1566 "cluster size {} exceeded cap {}",
1567 c.len(),
1568 CONSOLIDATE_MAX_CLUSTER_SIZE
1569 );
1570 }
1571 }
1572
1573 #[test]
1578 fn priority_feedback_decrements_cold_old_memory() {
1579 let (_tmp, conn) = setup_conn();
1580 let mut mem = sample_mem(
1581 "cold-id",
1582 "ns",
1583 "Cold",
1584 "content body content body",
1585 Tier::Mid,
1586 );
1587 mem.priority = 5;
1588 mem.access_count = 0;
1589 mem.created_at = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
1590 db::insert(&conn, &mem).unwrap();
1591
1592 let entry = apply_priority_feedback(&conn, &mem, false)
1593 .unwrap()
1594 .expect("cold memory must produce a -1 adjustment");
1595 match entry {
1596 RollbackEntry::PriorityAdjust {
1597 memory_id,
1598 before,
1599 after,
1600 } => {
1601 assert_eq!(memory_id, "cold-id");
1602 assert_eq!(before, 5);
1603 assert_eq!(after, 4);
1604 }
1605 _ => panic!("expected PriorityAdjust"),
1606 }
1607 }
1608}