1use crate::models::ConfidenceSource;
35use crate::models::field_names;
36use anyhow::Result;
37use rusqlite::Connection;
38use serde::{Deserialize, Serialize};
39
40use crate::db;
41use crate::llm::OllamaClient;
42use crate::models::{Memory, Tier};
43
44const CURATOR_SOURCE_LABEL: &str = "ai-memory curator (autonomy)";
47
48pub const CONSOLIDATE_JACCARD_THRESHOLD: f64 = 0.55;
61
62pub const CONSOLIDATE_COSINE_THRESHOLD: f64 = 0.75;
74
75pub const CONSOLIDATE_MAX_CLUSTER_SIZE: usize = 8;
78
79pub const CURATOR_NAMESPACE: &str = "_curator";
83
84#[allow(dead_code)]
91pub trait AutonomyLlm {
92 fn auto_tag(&self, title: &str, content: &str) -> Result<Vec<String>>;
94
95 fn detect_contradiction(&self, mem_a: &str, mem_b: &str) -> Result<bool>;
97
98 fn summarize_memories(&self, memories: &[(String, String)]) -> Result<String>;
100}
101
102impl AutonomyLlm for OllamaClient {
103 fn auto_tag(&self, title: &str, content: &str) -> Result<Vec<String>> {
104 Self::auto_tag(self, title, content, None)
108 }
109 fn detect_contradiction(&self, mem_a: &str, mem_b: &str) -> Result<bool> {
110 Self::detect_contradiction(self, mem_a, mem_b)
111 }
112 fn summarize_memories(&self, memories: &[(String, String)]) -> Result<String> {
113 Self::summarize_memories(self, memories)
114 }
115}
116
117#[allow(clippy::large_enum_variant)]
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "action", rename_all = "snake_case")]
130pub enum RollbackEntry {
131 Consolidate {
134 originals: Vec<Memory>,
135 result_id: String,
136 },
137 Forget { snapshot: Memory },
140 PriorityAdjust {
142 memory_id: String,
143 before: i32,
144 after: i32,
145 },
146}
147
148impl RollbackEntry {
149 fn action_tag(&self) -> &'static str {
150 match self {
151 Self::Consolidate { .. } => crate::audit::OP_CONSOLIDATE,
152 Self::Forget { .. } => "forget",
153 Self::PriorityAdjust { .. } => "priority_adjust",
154 }
155 }
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162pub struct AutonomyPassReport {
163 pub clusters_formed: usize,
164 pub memories_consolidated: usize,
165 pub memories_forgotten: usize,
166 pub priority_adjustments: usize,
167 pub rollback_entries_written: usize,
168 pub errors: Vec<String>,
169}
170
171pub fn run_autonomy_passes(
179 conn: &Connection,
180 llm: &dyn AutonomyLlm,
181 candidates: &[Memory],
182 dry_run: bool,
183) -> AutonomyPassReport {
184 let mut report = AutonomyPassReport::default();
185
186 let clusters = find_consolidation_clusters(conn, candidates);
188 report.clusters_formed = clusters.len();
189 for cluster in clusters {
190 match consolidate_cluster(conn, llm, &cluster, dry_run) {
191 Ok(Some(entry)) => {
192 if !dry_run && let Err(e) = persist_rollback_entry(conn, &entry) {
193 report.errors.push(rollback_log_write_failed(&e));
194 } else {
195 report.rollback_entries_written += 1;
196 }
197 if let RollbackEntry::Consolidate { originals, .. } = entry {
198 report.memories_consolidated += originals.len();
199 }
200 }
201 Ok(None) => {}
202 Err(e) => report.errors.push(format!("consolidate failed: {e}")),
203 }
204 }
205
206 for mem in candidates {
208 match forget_if_superseded(conn, mem, candidates, dry_run) {
209 Ok(Some(entry)) => {
210 if !dry_run && let Err(e) = persist_rollback_entry(conn, &entry) {
211 report.errors.push(rollback_log_write_failed(&e));
212 } else {
213 report.rollback_entries_written += 1;
214 }
215 report.memories_forgotten += 1;
216 }
217 Ok(None) => {}
218 Err(e) => report.errors.push(format!("forget failed: {e}")),
219 }
220 }
221
222 #[allow(unused_assignments)]
224 for mem in candidates {
225 match apply_priority_feedback(conn, mem, dry_run) {
226 Ok(Some(entry)) => {
227 if !dry_run && let Err(e) = persist_rollback_entry(conn, &entry) {
228 report.errors.push(rollback_log_write_failed(&e));
229 } else {
230 report.rollback_entries_written += 1;
231 }
232 report.priority_adjustments += 1;
233 }
234 Ok(None) => {}
235 Err(e) => report.errors.push(format!("priority feedback failed: {e}")),
236 }
237 }
238
239 report
240}
241
242fn find_consolidation_clusters(conn: &Connection, candidates: &[Memory]) -> Vec<Vec<Memory>> {
263 let mut by_ns: std::collections::HashMap<&str, Vec<&Memory>> = std::collections::HashMap::new();
265 for m in candidates {
266 if m.namespace.starts_with('_') {
267 continue;
268 }
269 by_ns.entry(&m.namespace).or_default().push(m);
270 }
271
272 let mut clusters: Vec<Vec<Memory>> = Vec::new();
273 for (_ns, group) in by_ns {
274 let mut used = vec![false; group.len()];
275 for i in 0..group.len() {
276 if used[i] {
277 continue;
278 }
279 let mut cluster = vec![group[i].clone()];
280 used[i] = true;
281 let seed_emb = db::get_embedding(conn, &group[i].id).ok().flatten();
286 for j in (i + 1)..group.len() {
287 if used[j] {
288 continue;
289 }
290 if cluster.len() >= CONSOLIDATE_MAX_CLUSTER_SIZE {
291 break;
292 }
293 let j_sim = jaccard_similarity(&group[i].content, &group[j].content);
295 if j_sim < CONSOLIDATE_JACCARD_THRESHOLD {
296 continue;
297 }
298 let pair_emb = db::get_embedding(conn, &group[j].id).ok().flatten();
301 let matches_cluster = match (seed_emb.as_ref(), pair_emb.as_ref()) {
302 (Some(a), Some(b)) => {
303 let cos = f64::from(crate::embeddings::Embedder::cosine_similarity(a, b));
304 cos >= CONSOLIDATE_COSINE_THRESHOLD
305 }
306 _ => true,
310 };
311 if matches_cluster {
312 cluster.push(group[j].clone());
313 used[j] = true;
314 }
315 }
316 if cluster.len() >= 2 {
317 clusters.push(cluster);
318 }
319 }
320 }
321 clusters
322}
323
324fn jaccard_similarity(a: &str, b: &str) -> f64 {
325 use std::collections::HashSet;
326 let tokens = |s: &str| -> HashSet<String> {
327 s.split(|c: char| !c.is_alphanumeric())
328 .filter(|t| t.len() >= 3)
329 .map(str::to_lowercase)
330 .collect()
331 };
332 let ta = tokens(a);
333 let tb = tokens(b);
334 if ta.is_empty() && tb.is_empty() {
335 return 0.0;
336 }
337 let inter = ta.intersection(&tb).count();
338 let union = ta.union(&tb).count();
339 if union == 0 {
340 0.0
341 } else {
342 #[allow(clippy::cast_precision_loss)]
343 let result = inter as f64 / union as f64;
344 result
345 }
346}
347
348fn consolidate_cluster(
349 conn: &Connection,
350 llm: &dyn AutonomyLlm,
351 cluster: &[Memory],
352 dry_run: bool,
353) -> Result<Option<RollbackEntry>> {
354 if cluster.len() < 2 {
355 return Ok(None);
356 }
357 if cluster.iter().any(|m| m.namespace.starts_with('_')) {
360 return Ok(None);
361 }
362
363 let input: Vec<(String, String)> = cluster
364 .iter()
365 .map(|m| (m.title.clone(), m.content.clone()))
366 .collect();
367 let summary = llm.summarize_memories(&input)?;
368 let base_title = cluster
373 .iter()
374 .map(|m| m.title.as_str())
375 .next()
376 .unwrap_or("(consolidated)");
377 let title = format!("[consolidated] {base_title}");
378
379 if dry_run {
380 return Ok(Some(RollbackEntry::Consolidate {
381 originals: cluster.to_vec(),
382 result_id: "dry-run".to_string(),
383 }));
384 }
385
386 let ids: Vec<String> = cluster.iter().map(|m| m.id.clone()).collect();
387 let namespace = cluster[0].namespace.clone();
388 let tier = cluster
390 .iter()
391 .map(|m| m.tier.clone())
392 .max_by_key(tier_rank)
393 .unwrap_or(Tier::Mid);
394
395 let result_id = db::consolidate(
396 conn,
397 &ids,
398 &title,
399 &summary,
400 &namespace,
401 &tier,
402 CURATOR_SOURCE_LABEL,
403 crate::identity::sentinels::AI_CURATOR,
404 )?;
405
406 Ok(Some(RollbackEntry::Consolidate {
407 originals: cluster.to_vec(),
408 result_id,
409 }))
410}
411
412fn tier_rank(t: &Tier) -> u8 {
413 match t {
414 Tier::Short => 0,
415 Tier::Mid => 1,
416 Tier::Long => 2,
417 }
418}
419
420fn forget_if_superseded(
421 conn: &Connection,
422 mem: &Memory,
423 all: &[Memory],
424 dry_run: bool,
425) -> Result<Option<RollbackEntry>> {
426 let contradictions = mem
430 .metadata
431 .get(field_names::CONFIRMED_CONTRADICTIONS)
432 .and_then(|v| v.as_array())
433 .cloned()
434 .unwrap_or_default();
435 if contradictions.is_empty() {
436 return Ok(None);
437 }
438
439 let by_id: std::collections::HashMap<&str, &Memory> =
444 all.iter().map(|m| (m.id.as_str(), m)).collect();
445 let mut superseder: Option<&Memory> = None;
446 for v in contradictions {
447 let Some(other_id) = v.as_str() else {
448 continue;
449 };
450 if let Some(other) = by_id.get(other_id)
451 && other.updated_at > mem.updated_at
452 && other.confidence >= mem.confidence
453 {
454 superseder = Some(other);
455 break;
456 }
457 }
458 let Some(_) = superseder else {
459 return Ok(None);
460 };
461
462 if dry_run {
463 return Ok(Some(RollbackEntry::Forget {
464 snapshot: mem.clone(),
465 }));
466 }
467
468 db::delete(conn, &mem.id)?;
476
477 Ok(Some(RollbackEntry::Forget {
478 snapshot: mem.clone(),
479 }))
480}
481
482fn apply_priority_feedback(
483 conn: &Connection,
484 mem: &Memory,
485 dry_run: bool,
486) -> Result<Option<RollbackEntry>> {
487 let now = chrono::Utc::now();
492 let before = mem.priority;
493 let mut after = before;
494
495 let last_accessed = mem
496 .last_accessed_at
497 .as_deref()
498 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
499 .map(chrono::DateTime::<chrono::Utc>::from);
500
501 let created = chrono::DateTime::parse_from_rfc3339(&mem.created_at)
502 .ok()
503 .map(chrono::DateTime::<chrono::Utc>::from);
504
505 let recent = last_accessed.is_some_and(|t| (now - t).num_days() <= 7);
506 let cold_enough = created.is_some_and(|t| (now - t).num_days() >= 30);
507
508 if mem.access_count >= 10 && recent && after < 10 {
509 after = after.saturating_add(1).min(10);
510 } else if mem.access_count == 0 && cold_enough && after > 1 {
511 after = after.saturating_sub(1).max(1);
512 }
513
514 if after == before {
515 return Ok(None);
516 }
517
518 if !dry_run {
519 db::update(
520 conn,
521 &mem.id,
522 None,
523 None,
524 None,
525 None,
526 None,
527 Some(after),
528 None,
529 None,
530 None,
531 )?;
532 }
533
534 Ok(Some(RollbackEntry::PriorityAdjust {
535 memory_id: mem.id.clone(),
536 before,
537 after,
538 }))
539}
540
541fn rollback_log_write_failed(e: &dyn std::fmt::Display) -> String {
545 format!("rollback-log write failed: {e}")
546}
547
548fn persist_rollback_entry(conn: &Connection, entry: &RollbackEntry) -> Result<()> {
549 let now = chrono::Utc::now();
550 let ts = now.to_rfc3339();
551 let mem = Memory {
552 id: uuid::Uuid::new_v4().to_string(),
553 tier: Tier::Long,
554 namespace: format!("{CURATOR_NAMESPACE}/rollback"),
555 title: format!("curator {} @ {}", entry.action_tag(), ts),
556 content: serde_json::to_string(entry)?,
557 tags: vec![
558 "_curator".to_string(),
559 "_rollback".to_string(),
560 entry.action_tag().to_string(),
561 ],
562 priority: 3,
563 confidence: 1.0,
564 source: CURATOR_SOURCE_LABEL.to_string(),
565 access_count: 0,
566 created_at: ts.clone(),
567 updated_at: ts,
568 last_accessed_at: None,
569 expires_at: None,
570 metadata: serde_json::json!({
571 "agent_id": crate::identity::sentinels::AI_CURATOR,
572 "action": entry.action_tag(),
573 }),
574 reflection_depth: 0,
575 memory_kind: crate::models::MemoryKind::Observation,
576 entity_id: None,
577 persona_version: None,
578 citations: Vec::new(),
579 source_uri: None,
580 source_span: None,
581 confidence_source: ConfidenceSource::CallerProvided,
582 confidence_signals: None,
583 confidence_decayed_at: None,
584 version: 1,
585 };
586 db::insert(conn, &mem)?;
587 Ok(())
588}
589
590pub fn persist_self_report(
593 conn: &Connection,
594 cycle_duration_ms: u128,
595 pass_report: &AutonomyPassReport,
596 auto_tagged: usize,
597 contradictions_found: usize,
598 personas_generated: usize,
605 errors_total: usize,
606) -> Result<()> {
607 let now = chrono::Utc::now();
608 let ts = now.to_rfc3339();
609 let body = serde_json::json!({
610 "cycle_ts": ts,
611 "cycle_duration_ms": cycle_duration_ms,
612 "auto_tagged": auto_tagged,
613 "contradictions_found": contradictions_found,
614 "personas_generated": personas_generated,
615 "clusters_formed": pass_report.clusters_formed,
616 "memories_consolidated": pass_report.memories_consolidated,
617 "memories_forgotten": pass_report.memories_forgotten,
618 "priority_adjustments": pass_report.priority_adjustments,
619 "rollback_entries_written": pass_report.rollback_entries_written,
620 "errors_total": errors_total,
621 });
622 let mem = Memory {
623 id: uuid::Uuid::new_v4().to_string(),
624 tier: Tier::Mid,
625 namespace: format!("{CURATOR_NAMESPACE}/reports"),
626 title: format!("curator cycle @ {ts}"),
627 content: serde_json::to_string_pretty(&body)?,
628 tags: vec!["_curator".to_string(), "_report".to_string()],
629 priority: 2,
630 confidence: 1.0,
631 source: CURATOR_SOURCE_LABEL.to_string(),
632 access_count: 0,
633 created_at: ts.clone(),
634 updated_at: ts,
635 last_accessed_at: None,
636 expires_at: None,
637 metadata: serde_json::json!({"agent_id": crate::identity::sentinels::AI_CURATOR}),
638 reflection_depth: 0,
639 memory_kind: crate::models::MemoryKind::Observation,
640 entity_id: None,
641 persona_version: None,
642 citations: Vec::new(),
643 source_uri: None,
644 source_span: None,
645 confidence_source: ConfidenceSource::CallerProvided,
646 confidence_signals: None,
647 confidence_decayed_at: None,
648 version: 1,
649 };
650 db::insert(conn, &mem)?;
651 Ok(())
652}
653
654pub fn reverse_rollback_entry(conn: &Connection, entry: &RollbackEntry) -> Result<bool> {
666 match entry {
667 RollbackEntry::Consolidate {
668 originals,
669 result_id,
670 } => {
671 for m in originals {
673 check_no_collision(conn, &m.title, &m.namespace, &m.id)?;
674 }
675 let existed = db::delete(conn, result_id)?;
677 for m in originals {
678 db::insert(conn, m)?;
679 }
680 Ok(existed)
681 }
682 RollbackEntry::Forget { snapshot } => {
683 check_no_collision(conn, &snapshot.title, &snapshot.namespace, &snapshot.id)?;
684 db::insert(conn, snapshot)?;
685 Ok(true)
686 }
687 RollbackEntry::PriorityAdjust {
688 memory_id,
689 before,
690 after: _,
691 } => {
692 let _ = db::update(
693 conn,
694 memory_id,
695 None,
696 None,
697 None,
698 None,
699 None,
700 Some(*before),
701 None,
702 None,
703 None,
704 )?;
705 Ok(true)
706 }
707 }
708}
709
710fn check_no_collision(
713 conn: &Connection,
714 title: &str,
715 namespace: &str,
716 expected_id: &str,
717) -> Result<()> {
718 let rows = db::list(
719 conn,
720 Some(namespace),
721 None,
722 50,
723 0,
724 None,
725 None,
726 None,
727 None,
728 None,
729 )?;
730 for row in rows {
731 if row.namespace == namespace && row.title == title && row.id != expected_id {
732 anyhow::bail!(
733 "rollback aborted: memory {} now occupies (title={:?}, namespace={:?}) — \
734 reverting would overwrite it. Resolve the conflict manually.",
735 row.id,
736 title,
737 namespace
738 );
739 }
740 }
741 Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use std::sync::Mutex;
748
749 struct StubLlm {
752 #[allow(dead_code)]
758 auto_tag_result: Vec<String>,
759 summary: String,
760 #[allow(dead_code)]
761 contradiction_sentinel: String,
762 calls: Mutex<Vec<String>>,
763 }
764
765 impl StubLlm {
766 fn new(summary: &str) -> Self {
767 Self {
768 auto_tag_result: vec!["auto".to_string(), "stub".to_string()],
769 summary: summary.to_string(),
770 contradiction_sentinel: "CONTRADICTS".to_string(),
771 calls: Mutex::new(Vec::new()),
772 }
773 }
774 }
775
776 impl AutonomyLlm for StubLlm {
777 fn auto_tag(&self, title: &str, _content: &str) -> Result<Vec<String>> {
778 self.calls.lock().unwrap().push(format!("auto_tag:{title}"));
779 Ok(self.auto_tag_result.clone())
780 }
781 fn detect_contradiction(&self, a: &str, b: &str) -> Result<bool> {
782 self.calls
783 .lock()
784 .unwrap()
785 .push("detect_contradiction".to_string());
786 Ok(
787 a.contains(&self.contradiction_sentinel)
788 || b.contains(&self.contradiction_sentinel),
789 )
790 }
791 fn summarize_memories(&self, memories: &[(String, String)]) -> Result<String> {
792 self.calls
793 .lock()
794 .unwrap()
795 .push(format!("summarize:{}", memories.len()));
796 Ok(self.summary.clone())
797 }
798 }
799
800 fn sample_mem(id: &str, ns: &str, title: &str, content: &str, tier: Tier) -> Memory {
801 let now = chrono::Utc::now().to_rfc3339();
802 Memory {
803 id: id.to_string(),
804 tier,
805 namespace: ns.to_string(),
806 title: title.to_string(),
807 content: content.to_string(),
808 tags: vec!["t".to_string()],
809 priority: 5,
810 confidence: 1.0,
811 source: "test".to_string(),
812 access_count: 0,
813 created_at: now.clone(),
814 updated_at: now,
815 last_accessed_at: None,
816 expires_at: None,
817 metadata: serde_json::json!({"agent_id":"ai:test"}),
818 reflection_depth: 0,
819 memory_kind: crate::models::MemoryKind::Observation,
820 entity_id: None,
821 persona_version: None,
822 citations: Vec::new(),
823 source_uri: None,
824 source_span: None,
825 confidence_source: ConfidenceSource::CallerProvided,
826 confidence_signals: None,
827 confidence_decayed_at: None,
828 version: 1,
829 }
830 }
831
832 fn setup_conn() -> (tempfile::NamedTempFile, Connection) {
833 let tmp = tempfile::NamedTempFile::new().unwrap();
834 let conn = db::open(tmp.path()).unwrap();
835 (tmp, conn)
836 }
837
838 #[test]
839 fn jaccard_similarity_basic() {
840 let sim = jaccard_similarity(
841 "the quick brown fox jumps over",
842 "quick brown fox over the lazy",
843 );
844 assert!(sim > 0.4, "unexpected sim {sim}");
845 }
846
847 #[test]
848 fn jaccard_similarity_empty() {
849 assert!((jaccard_similarity("", "") - 0.0).abs() < 1e-9);
850 assert!((jaccard_similarity("abc", "") - 0.0).abs() < 1e-9);
851 }
852
853 #[test]
854 fn consolidation_clusters_group_by_namespace() {
855 let a = sample_mem(
856 "a",
857 "ns1",
858 "A",
859 "the quick brown fox jumps over lazy dog",
860 Tier::Mid,
861 );
862 let b = sample_mem(
863 "b",
864 "ns1",
865 "B",
866 "quick brown fox over lazy dog jumps",
867 Tier::Mid,
868 );
869 let c = sample_mem(
870 "c",
871 "ns2",
872 "C",
873 "the quick brown fox jumps over lazy dog",
874 Tier::Mid,
875 );
876 let (_tmp, conn) = setup_conn();
877 let clusters = find_consolidation_clusters(&conn, &[a, b, c]);
878 assert_eq!(clusters.len(), 1);
880 assert_eq!(clusters[0].len(), 2);
881 }
882
883 #[test]
884 fn consolidation_skips_reserved_namespace() {
885 let a = sample_mem("a", "_curator/reports", "A", "content aaaa bbbb", Tier::Mid);
886 let b = sample_mem("b", "_curator/reports", "B", "content aaaa bbbb", Tier::Mid);
887 let (_tmp, conn) = setup_conn();
888 let clusters = find_consolidation_clusters(&conn, &[a, b]);
889 assert!(clusters.is_empty());
890 }
891
892 fn synth_emb(values: &[f32]) -> Vec<f32> {
901 let norm: f32 = values.iter().map(|v| v * v).sum::<f32>().sqrt();
902 if norm < 1e-12 {
903 return values.to_vec();
904 }
905 values.iter().map(|v| v / norm).collect()
906 }
907
908 #[test]
914 fn test_consolidation_uses_cosine_when_embeddings_present() {
915 let (_tmp, conn) = setup_conn();
916 let a = sample_mem(
920 "a",
921 "ns1",
922 "A",
923 "the quick brown fox jumps over lazy dog",
924 Tier::Mid,
925 );
926 let b = sample_mem(
927 "b",
928 "ns1",
929 "B",
930 "the quick brown fox jumps over lazy dog",
931 Tier::Mid,
932 );
933
934 db::insert(&conn, &a).unwrap();
935 db::insert(&conn, &b).unwrap();
936 db::set_embedding(&conn, &a.id, &synth_emb(&[1.0, 0.0, 0.0, 0.0])).unwrap();
938 db::set_embedding(&conn, &b.id, &synth_emb(&[0.0, 1.0, 0.0, 0.0])).unwrap();
939
940 let clusters = find_consolidation_clusters(&conn, &[a, b]);
941 assert!(
942 clusters.is_empty(),
943 "cosine-dissimilar embeddings must defeat the Jaccard-only cluster (cosine is primary)",
944 );
945
946 let c = sample_mem(
950 "c",
951 "ns2",
952 "C",
953 "the quick brown fox jumps over lazy dog",
954 Tier::Mid,
955 );
956 let d = sample_mem(
957 "d",
958 "ns2",
959 "D",
960 "the quick brown fox jumps over lazy dog",
961 Tier::Mid,
962 );
963 db::insert(&conn, &c).unwrap();
964 db::insert(&conn, &d).unwrap();
965 db::set_embedding(&conn, &c.id, &synth_emb(&[1.0, 0.0, 0.0, 0.0])).unwrap();
967 db::set_embedding(&conn, &d.id, &synth_emb(&[0.99, 0.1, 0.0, 0.0])).unwrap();
968
969 let clusters2 = find_consolidation_clusters(&conn, &[c, d]);
970 assert_eq!(
971 clusters2.len(),
972 1,
973 "cosine-similar embeddings on a Jaccard-similar pair must cluster"
974 );
975 assert_eq!(clusters2[0].len(), 2);
976 }
977
978 #[test]
983 fn test_consolidation_falls_back_to_jaccard_no_embeddings() {
984 let (_tmp, conn) = setup_conn();
985 let a = sample_mem(
986 "a",
987 "ns",
988 "A",
989 "kubernetes rolling canary deploy strategy keyword keyword",
990 Tier::Long,
991 );
992 let b = sample_mem(
993 "b",
994 "ns",
995 "B",
996 "kubernetes rolling canary deploy strategy keyword keyword",
997 Tier::Long,
998 );
999 db::insert(&conn, &a).unwrap();
1002 db::insert(&conn, &b).unwrap();
1003
1004 let clusters = find_consolidation_clusters(&conn, &[a, b]);
1005 assert_eq!(
1006 clusters.len(),
1007 1,
1008 "keyword-tier corpus (no embeddings) must still cluster via Jaccard"
1009 );
1010 assert_eq!(clusters[0].len(), 2);
1011 }
1012
1013 #[test]
1014 fn rollback_entry_serialises() {
1015 let e = RollbackEntry::PriorityAdjust {
1016 memory_id: "m1".to_string(),
1017 before: 5,
1018 after: 6,
1019 };
1020 let json = serde_json::to_string(&e).unwrap();
1021 assert!(json.contains("priority_adjust"));
1022 let back: RollbackEntry = serde_json::from_str(&json).unwrap();
1023 assert_eq!(back.action_tag(), "priority_adjust");
1024 }
1025
1026 #[test]
1027 fn consolidate_cluster_merges_two_memories() {
1028 let (_tmp, conn) = setup_conn();
1029 let a = sample_mem(
1030 "a",
1031 "app",
1032 "Deploy plan",
1033 "kubernetes rolling deploy with canary",
1034 Tier::Long,
1035 );
1036 let b = sample_mem(
1037 "b",
1038 "app",
1039 "Deploy process",
1040 "kubernetes deploy rolling canary strategy",
1041 Tier::Long,
1042 );
1043 db::insert(&conn, &a).unwrap();
1044 db::insert(&conn, &b).unwrap();
1045 let llm = StubLlm::new("consolidated deploy plan");
1046 let cluster = vec![a.clone(), b.clone()];
1047 let entry = consolidate_cluster(&conn, &llm, &cluster, false)
1048 .unwrap()
1049 .expect("expected rollback entry");
1050 match entry {
1051 RollbackEntry::Consolidate {
1052 originals,
1053 result_id,
1054 } => {
1055 assert_eq!(originals.len(), 2);
1056 assert_ne!(result_id, "dry-run");
1057 let got = db::get(&conn, &result_id).unwrap().expect("result memory");
1058 assert_eq!(got.namespace, "app");
1059 assert!(got.title.starts_with("[consolidated]"));
1060 assert!(got.content.contains("consolidated deploy plan"));
1061 }
1062 _ => panic!("expected Consolidate"),
1063 }
1064 }
1065
1066 #[test]
1067 fn dry_run_does_not_write() {
1068 let (_tmp, conn) = setup_conn();
1069 let a = sample_mem(
1070 "a",
1071 "app",
1072 "Deploy plan",
1073 "kubernetes rolling deploy with canary",
1074 Tier::Long,
1075 );
1076 let b = sample_mem(
1077 "b",
1078 "app",
1079 "Deploy process",
1080 "kubernetes deploy rolling canary strategy",
1081 Tier::Long,
1082 );
1083 db::insert(&conn, &a).unwrap();
1084 db::insert(&conn, &b).unwrap();
1085 let llm = StubLlm::new("never persisted");
1086 let cluster = vec![a.clone(), b.clone()];
1087 let entry = consolidate_cluster(&conn, &llm, &cluster, true)
1088 .unwrap()
1089 .expect("dry-run returns entry");
1090 if let RollbackEntry::Consolidate { result_id, .. } = entry {
1091 assert_eq!(result_id, "dry-run");
1092 }
1093 assert!(db::get(&conn, "a").unwrap().is_some());
1095 assert!(db::get(&conn, "b").unwrap().is_some());
1096 }
1097
1098 #[test]
1099 fn reverse_consolidation_restores_originals() {
1100 let (_tmp, conn) = setup_conn();
1101 let a = sample_mem(
1102 "a",
1103 "app",
1104 "Deploy plan",
1105 "kubernetes rolling deploy canary",
1106 Tier::Long,
1107 );
1108 let b = sample_mem(
1109 "b",
1110 "app",
1111 "Deploy process",
1112 "kubernetes rolling canary strategy",
1113 Tier::Long,
1114 );
1115 db::insert(&conn, &a).unwrap();
1116 db::insert(&conn, &b).unwrap();
1117
1118 let llm = StubLlm::new("summary");
1119 let cluster = vec![a.clone(), b.clone()];
1120 let entry = consolidate_cluster(&conn, &llm, &cluster, false)
1121 .unwrap()
1122 .expect("entry");
1123
1124 if let RollbackEntry::Consolidate {
1127 result_id,
1128 originals,
1129 } = &entry
1130 {
1131 assert!(db::get(&conn, result_id).unwrap().is_some());
1132 for orig in originals {
1133 assert!(
1134 db::get(&conn, &orig.id).unwrap().is_none(),
1135 "{} should be merged-away",
1136 orig.id
1137 );
1138 }
1139 }
1140
1141 reverse_rollback_entry(&conn, &entry).unwrap();
1143 assert!(db::get(&conn, "a").unwrap().is_some());
1144 assert!(db::get(&conn, "b").unwrap().is_some());
1145 if let RollbackEntry::Consolidate { result_id, .. } = &entry {
1146 assert!(db::get(&conn, result_id).unwrap().is_none());
1147 }
1148 }
1149
1150 #[test]
1151 fn full_autonomy_cycle_end_to_end() {
1152 let (_tmp, conn) = setup_conn();
1153 let llm = StubLlm::new("consolidated");
1154
1155 let m_a = sample_mem(
1158 "ma",
1159 "deploy",
1160 "canary deploy plan",
1161 "kubernetes canary rolling deploy strategy",
1162 Tier::Long,
1163 );
1164 let m_b = sample_mem(
1165 "mb",
1166 "deploy",
1167 "canary deploy overview",
1168 "kubernetes rolling canary deploy strategy",
1169 Tier::Long,
1170 );
1171 let m_chat = sample_mem(
1172 "mchat",
1173 "chat",
1174 "hello",
1175 "hi there chat only content here",
1176 Tier::Mid,
1177 );
1178
1179 let mut m_old = sample_mem(
1182 "mold",
1183 "facts",
1184 "fact v1",
1185 "the sky is green always uniformly",
1186 Tier::Long,
1187 );
1188 let m_new_id = "mnew";
1189 m_old.metadata["confirmed_contradictions"] = serde_json::json!([m_new_id]);
1190 m_old.updated_at = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
1193 let m_new = sample_mem(
1194 m_new_id,
1195 "facts",
1196 "fact v2",
1197 "the sky is blue most of the time for sure",
1198 Tier::Long,
1199 );
1200
1201 for m in [&m_a, &m_b, &m_chat, &m_old, &m_new] {
1202 db::insert(&conn, m).unwrap();
1203 }
1204
1205 let candidates = vec![
1206 m_a.clone(),
1207 m_b.clone(),
1208 m_chat.clone(),
1209 m_old.clone(),
1210 m_new.clone(),
1211 ];
1212 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
1213
1214 assert!(report.clusters_formed >= 1);
1216 assert!(report.memories_consolidated >= 2);
1217 assert!(
1219 report.memories_forgotten >= 1,
1220 "expected ≥1 forget, got {report:?}"
1221 );
1222 assert!(report.rollback_entries_written >= report.clusters_formed);
1224 let log = db::list(
1226 &conn,
1227 Some("_curator/rollback"),
1228 None,
1229 100,
1230 0,
1231 None,
1232 None,
1233 None,
1234 None,
1235 None,
1236 )
1237 .unwrap();
1238 assert!(!log.is_empty(), "rollback log should be populated");
1239 }
1240
1241 #[test]
1242 fn self_report_written_to_reports_namespace() {
1243 let (_tmp, conn) = setup_conn();
1244 let pass = AutonomyPassReport {
1245 clusters_formed: 1,
1246 memories_consolidated: 2,
1247 memories_forgotten: 0,
1248 priority_adjustments: 1,
1249 rollback_entries_written: 2,
1250 errors: vec![],
1251 };
1252 persist_self_report(&conn, 1234, &pass, 3, 0, 0, 0).unwrap();
1253 let reports = db::list(
1254 &conn,
1255 Some("_curator/reports"),
1256 None,
1257 10,
1258 0,
1259 None,
1260 None,
1261 None,
1262 None,
1263 None,
1264 )
1265 .unwrap();
1266 assert_eq!(reports.len(), 1);
1267 assert!(reports[0].content.contains("memories_consolidated"));
1268 }
1269
1270 #[test]
1271 fn smart_tier_mock_cycle_summarize() {
1272 let (_tmp, conn) = setup_conn();
1274 let a = sample_mem(
1276 "mem-a",
1277 "app",
1278 "Deploy A",
1279 "kubernetes deployment rolling canary strategy kubernetes rolling deploy canary",
1280 Tier::Mid,
1281 );
1282 let b = sample_mem(
1283 "mem-b",
1284 "app",
1285 "Deploy B",
1286 "kubernetes deployment rolling canary approach kubernetes rolling canary deploy",
1287 Tier::Mid,
1288 );
1289 db::insert(&conn, &a).unwrap();
1290 db::insert(&conn, &b).unwrap();
1291
1292 let llm = StubLlm::new("LLM-generated consolidated summary");
1293 let candidates = vec![a, b];
1294
1295 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
1296
1297 assert!(report.clusters_formed > 0);
1299 assert!(report.memories_consolidated > 0);
1300 }
1301
1302 #[test]
1303 fn autonomy_cycle_with_mock_ollama() {
1304 let (_tmp, conn) = setup_conn();
1306 let a = sample_mem(
1307 "id-1",
1308 "ns1",
1309 "Title A",
1310 "content similar enough for clustering test similar clustering",
1311 Tier::Mid,
1312 );
1313 let b = sample_mem(
1314 "id-2",
1315 "ns1",
1316 "Title B",
1317 "content similar enough for clustering test similar clustering",
1318 Tier::Mid,
1319 );
1320 db::insert(&conn, &a).unwrap();
1321 db::insert(&conn, &b).unwrap();
1322
1323 let llm = StubLlm::new("mock summary result");
1324 let candidates = vec![a, b];
1325
1326 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
1327
1328 assert_eq!(report.errors.len(), 0, "autonomy cycle should not error");
1330 assert!(
1331 report.rollback_entries_written > 0,
1332 "autonomy cycle should write rollback entries"
1333 );
1334 }
1335
1336 #[test]
1337 fn rollback_log_captures_consolidation() {
1338 let (_tmp, conn) = setup_conn();
1340 let a = sample_mem(
1341 "a",
1342 "test-ns",
1343 "Memory A",
1344 "test content aaaa bbbb cccc aaaa bbbb",
1345 Tier::Mid,
1346 );
1347 let b = sample_mem(
1348 "b",
1349 "test-ns",
1350 "Memory B",
1351 "test content aaaa bbbb cccc aaaa bbbb",
1352 Tier::Mid,
1353 );
1354 db::insert(&conn, &a).unwrap();
1355 db::insert(&conn, &b).unwrap();
1356
1357 let llm = StubLlm::new("consolidated");
1358 let cluster = vec![a.clone(), b.clone()];
1359 let entry = consolidate_cluster(&conn, &llm, &cluster, false)
1360 .unwrap()
1361 .expect("rollback entry");
1362
1363 persist_rollback_entry(&conn, &entry).unwrap();
1365
1366 let log = db::list(
1368 &conn,
1369 Some("_curator/rollback"),
1370 None,
1371 100,
1372 0,
1373 None,
1374 None,
1375 None,
1376 None,
1377 None,
1378 )
1379 .unwrap();
1380 assert_eq!(log.len(), 1);
1381 assert!(log[0].content.contains("consolidate"));
1382 }
1383
1384 #[test]
1385 fn priority_feedback_adjusts_memory() {
1386 let (_tmp, conn) = setup_conn();
1391 let mut mem = sample_mem("id", "ns", "Title", "content", Tier::Mid);
1392 mem.priority = 5;
1393 mem.access_count = 100;
1394 mem.last_accessed_at = Some(chrono::Utc::now().to_rfc3339());
1395 db::insert(&conn, &mem).unwrap();
1396
1397 let entry = apply_priority_feedback(&conn, &mem, false)
1398 .unwrap()
1399 .expect("priority feedback should produce entry");
1400
1401 match entry {
1402 RollbackEntry::PriorityAdjust {
1403 memory_id,
1404 before,
1405 after,
1406 } => {
1407 assert_eq!(memory_id, "id");
1408 assert_eq!(before, 5);
1409 assert!(after > before, "high access should increase priority");
1410 }
1411 _ => panic!("expected PriorityAdjust"),
1412 }
1413 }
1414
1415 #[test]
1416 fn dry_run_autonomy_does_not_write() {
1417 let (_tmp, conn) = setup_conn();
1419 let a = sample_mem(
1420 "a",
1421 "test-ns",
1422 "Memory A",
1423 "test content aaaa bbbb cccc aaaa bbbb",
1424 Tier::Mid,
1425 );
1426 let b = sample_mem(
1427 "b",
1428 "test-ns",
1429 "Memory B",
1430 "test content aaaa bbbb cccc aaaa bbbb",
1431 Tier::Mid,
1432 );
1433 db::insert(&conn, &a).unwrap();
1434 db::insert(&conn, &b).unwrap();
1435
1436 let initial_count = db::list(
1437 &conn,
1438 Some("test-ns"),
1439 None,
1440 100,
1441 0,
1442 None,
1443 None,
1444 None,
1445 None,
1446 None,
1447 )
1448 .unwrap()
1449 .len();
1450
1451 let llm = StubLlm::new("consolidated");
1452 let candidates = vec![a, b];
1453 let _report = run_autonomy_passes(&conn, &llm, &candidates, true);
1454
1455 let final_count = db::list(
1456 &conn,
1457 Some("test-ns"),
1458 None,
1459 100,
1460 0,
1461 None,
1462 None,
1463 None,
1464 None,
1465 None,
1466 )
1467 .unwrap()
1468 .len();
1469
1470 assert_eq!(
1471 initial_count, final_count,
1472 "dry-run should not modify database"
1473 );
1474 }
1475
1476 #[test]
1477 fn autonomy_passes_report_aggregates_errors() {
1478 let (_tmp, conn) = setup_conn();
1480 let mem = sample_mem("id", "ns", "Title", "content", Tier::Mid);
1481 let llm = StubLlm::new("summary");
1482 let candidates = vec![mem];
1483 let report = run_autonomy_passes(&conn, &llm, &candidates, false);
1484
1485 assert!(report.clusters_formed > 0 || report.clusters_formed == 0);
1487 }
1488
1489 #[test]
1498 fn reverse_priority_adjust_restores_before_value() {
1499 let (_tmp, conn) = setup_conn();
1500 let mut mem = sample_mem("pa-id", "ns", "Title", "content", Tier::Mid);
1501 mem.priority = 7;
1502 db::insert(&conn, &mem).unwrap();
1503 db::update(
1505 &conn,
1506 &mem.id,
1507 None,
1508 None,
1509 None,
1510 None,
1511 None,
1512 Some(9),
1513 None,
1514 None,
1515 None,
1516 )
1517 .unwrap();
1518 assert_eq!(db::get(&conn, &mem.id).unwrap().unwrap().priority, 9);
1519
1520 let entry = RollbackEntry::PriorityAdjust {
1521 memory_id: mem.id.clone(),
1522 before: 7,
1523 after: 9,
1524 };
1525 let applied = reverse_rollback_entry(&conn, &entry).unwrap();
1526 assert!(applied);
1527 assert_eq!(db::get(&conn, &mem.id).unwrap().unwrap().priority, 7);
1528 }
1529
1530 #[test]
1533 fn reverse_forget_restores_snapshot() {
1534 let (_tmp, conn) = setup_conn();
1535 let mem = sample_mem(
1536 "forget-id",
1537 "factual",
1538 "Snapshot",
1539 "saved content body abc",
1540 Tier::Long,
1541 );
1542 db::insert(&conn, &mem).unwrap();
1543 db::delete(&conn, &mem.id).unwrap();
1545 assert!(db::get(&conn, &mem.id).unwrap().is_none());
1546
1547 let entry = RollbackEntry::Forget {
1548 snapshot: mem.clone(),
1549 };
1550 let applied = reverse_rollback_entry(&conn, &entry).unwrap();
1551 assert!(applied);
1552 let restored = db::get(&conn, &mem.id).unwrap().expect("snapshot restored");
1553 assert_eq!(restored.title, "Snapshot");
1554 assert_eq!(restored.namespace, "factual");
1555 }
1556
1557 #[test]
1562 fn reverse_consolidate_collision_aborts() {
1563 let (_tmp, conn) = setup_conn();
1564 let original = sample_mem(
1565 "o1",
1566 "app",
1567 "Deploy plan",
1568 "kubernetes rolling deploy canary",
1569 Tier::Long,
1570 );
1571 let merged_id = "merged".to_string();
1572 let entry = RollbackEntry::Consolidate {
1573 originals: vec![original.clone()],
1574 result_id: merged_id.clone(),
1575 };
1576
1577 let collider = sample_mem(
1580 "collider-id",
1581 "app",
1582 "Deploy plan",
1583 "different content here entirely",
1584 Tier::Long,
1585 );
1586 db::insert(&conn, &collider).unwrap();
1587
1588 let err = reverse_rollback_entry(&conn, &entry).expect_err("collision must abort");
1589 let msg = format!("{err}");
1590 assert!(msg.contains("rollback aborted"), "unexpected msg: {msg}");
1591 assert!(db::get(&conn, "collider-id").unwrap().is_some());
1593 }
1594
1595 #[test]
1599 fn consolidate_cluster_returns_none_for_singleton() {
1600 let (_tmp, conn) = setup_conn();
1601 let llm = StubLlm::new("never called");
1602 let solo = sample_mem("a", "ns", "T", "content body word word", Tier::Mid);
1603 let result = consolidate_cluster(&conn, &llm, std::slice::from_ref(&solo), false).unwrap();
1604 assert!(result.is_none());
1605 }
1606
1607 #[test]
1611 fn consolidate_cluster_skips_reserved_namespace_defensive() {
1612 let (_tmp, conn) = setup_conn();
1613 let llm = StubLlm::new("never called");
1614 let a = sample_mem("a", "_curator/rollback", "T1", "abc abc abc abc", Tier::Mid);
1615 let b = sample_mem("b", "_curator/rollback", "T2", "abc abc abc abc", Tier::Mid);
1616 let result = consolidate_cluster(&conn, &llm, &[a, b], false).unwrap();
1617 assert!(
1618 result.is_none(),
1619 "reserved-namespace cluster must be skipped"
1620 );
1621 }
1622
1623 #[test]
1627 fn forget_if_superseded_dry_run_returns_entry_without_delete() {
1628 let (_tmp, conn) = setup_conn();
1629 let mut older = sample_mem("old", "facts", "fact v1", "the sky is green", Tier::Long);
1630 older.metadata["confirmed_contradictions"] = serde_json::json!(["new"]);
1631 older.updated_at = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
1632 let newer = sample_mem("new", "facts", "fact v2", "the sky is blue", Tier::Long);
1633 db::insert(&conn, &older).unwrap();
1634 db::insert(&conn, &newer).unwrap();
1635
1636 let result = forget_if_superseded(&conn, &older, &[older.clone(), newer], true).unwrap();
1637 match result {
1638 Some(RollbackEntry::Forget { snapshot }) => {
1639 assert_eq!(snapshot.id, "old");
1640 }
1641 _ => panic!("expected Forget entry from dry-run forget"),
1642 }
1643 assert!(db::get(&conn, "old").unwrap().is_some());
1645 }
1646
1647 #[test]
1651 fn forget_if_superseded_skips_non_string_contradiction_ids() {
1652 let (_tmp, conn) = setup_conn();
1653 let mut mem = sample_mem("m", "facts", "T", "content body word", Tier::Mid);
1654 mem.metadata["confirmed_contradictions"] = serde_json::json!([42, "missing-id"]);
1656 let result = forget_if_superseded(&conn, &mem, std::slice::from_ref(&mem), false).unwrap();
1657 assert!(result.is_none());
1659 }
1660
1661 #[test]
1667 fn stub_llm_auto_tag_and_detect_contradiction() {
1668 let llm = StubLlm::new("summary");
1669 let tags = AutonomyLlm::auto_tag(&llm, "Some Title", "body").unwrap();
1671 assert_eq!(tags, vec!["auto".to_string(), "stub".to_string()]);
1672 assert!(AutonomyLlm::detect_contradiction(&llm, "this CONTRADICTS that", "ok").unwrap());
1674 assert!(!AutonomyLlm::detect_contradiction(&llm, "ok", "fine").unwrap());
1675 let calls = llm.calls.lock().unwrap();
1677 assert!(calls.iter().any(|c| c.starts_with("auto_tag:")));
1678 assert!(calls.iter().any(|c| c == "detect_contradiction"));
1679 }
1680
1681 #[test]
1687 fn run_autonomy_passes_dry_run_writes_no_changes() {
1688 let (_tmp, conn) = setup_conn();
1689 let m_a = sample_mem(
1691 "ma",
1692 "deploy",
1693 "canary deploy plan",
1694 "kubernetes canary rolling deploy strategy",
1695 Tier::Long,
1696 );
1697 let m_b = sample_mem(
1698 "mb",
1699 "deploy",
1700 "canary deploy overview",
1701 "kubernetes rolling canary deploy strategy",
1702 Tier::Long,
1703 );
1704 let mut m_old = sample_mem(
1706 "mold",
1707 "facts",
1708 "fact v1",
1709 "the sky is green always uniformly",
1710 Tier::Long,
1711 );
1712 m_old.metadata["confirmed_contradictions"] = serde_json::json!(["mnew"]);
1713 m_old.updated_at = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
1714 let m_new = sample_mem(
1715 "mnew",
1716 "facts",
1717 "fact v2",
1718 "the sky is blue most of the time",
1719 Tier::Long,
1720 );
1721 let mut m_hot = sample_mem(
1723 "hot",
1724 "ns",
1725 "Hot",
1726 "this is hot content for priority bump",
1727 Tier::Mid,
1728 );
1729 m_hot.priority = 5;
1730 m_hot.access_count = 100;
1731 m_hot.last_accessed_at = Some(chrono::Utc::now().to_rfc3339());
1732
1733 for m in [&m_a, &m_b, &m_old, &m_new, &m_hot] {
1734 db::insert(&conn, m).unwrap();
1735 }
1736 let candidates = vec![
1737 m_a.clone(),
1738 m_b.clone(),
1739 m_old.clone(),
1740 m_new.clone(),
1741 m_hot.clone(),
1742 ];
1743
1744 let pre_priority = db::get(&conn, &m_hot.id).unwrap().unwrap().priority;
1746 assert!(db::get(&conn, "mold").unwrap().is_some());
1747
1748 let llm = StubLlm::new("dry-run summary");
1749 let report = run_autonomy_passes(&conn, &llm, &candidates, true);
1750
1751 assert!(report.clusters_formed >= 1);
1753 let log = db::list(
1757 &conn,
1758 Some("_curator/rollback"),
1759 None,
1760 100,
1761 0,
1762 None,
1763 None,
1764 None,
1765 None,
1766 None,
1767 )
1768 .unwrap();
1769 assert!(log.is_empty(), "dry-run must not persist rollback memories");
1770
1771 assert_eq!(
1773 db::get(&conn, &m_hot.id).unwrap().unwrap().priority,
1774 pre_priority
1775 );
1776 assert!(db::get(&conn, "mold").unwrap().is_some());
1777 assert!(db::get(&conn, "ma").unwrap().is_some());
1778 }
1779
1780 #[test]
1786 fn consolidation_cluster_respects_max_size_cap() {
1787 let n = CONSOLIDATE_MAX_CLUSTER_SIZE + 4;
1788 let mut candidates: Vec<Memory> = Vec::with_capacity(n);
1789 for i in 0..n {
1790 candidates.push(sample_mem(
1791 &format!("m{i}"),
1792 "deploy",
1793 &format!("title-{i}"),
1794 "kubernetes rolling canary deploy strategy",
1795 Tier::Long,
1796 ));
1797 }
1798 let (_tmp, conn) = setup_conn();
1799 let clusters = find_consolidation_clusters(&conn, &candidates);
1800 assert!(!clusters.is_empty());
1801 for c in &clusters {
1802 assert!(
1803 c.len() <= CONSOLIDATE_MAX_CLUSTER_SIZE,
1804 "cluster size {} exceeded cap {}",
1805 c.len(),
1806 CONSOLIDATE_MAX_CLUSTER_SIZE
1807 );
1808 }
1809 }
1810
1811 #[test]
1816 fn priority_feedback_decrements_cold_old_memory() {
1817 let (_tmp, conn) = setup_conn();
1818 let mut mem = sample_mem(
1819 "cold-id",
1820 "ns",
1821 "Cold",
1822 "content body content body",
1823 Tier::Mid,
1824 );
1825 mem.priority = 5;
1826 mem.access_count = 0;
1827 mem.created_at = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
1828 db::insert(&conn, &mem).unwrap();
1829
1830 let entry = apply_priority_feedback(&conn, &mem, false)
1831 .unwrap()
1832 .expect("cold memory must produce a -1 adjustment");
1833 match entry {
1834 RollbackEntry::PriorityAdjust {
1835 memory_id,
1836 before,
1837 after,
1838 } => {
1839 assert_eq!(memory_id, "cold-id");
1840 assert_eq!(before, 5);
1841 assert_eq!(after, 4);
1842 }
1843 _ => panic!("expected PriorityAdjust"),
1844 }
1845 }
1846}