Skip to main content

ai_memory/
autonomy.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Full-autonomy loop — stacks on the Track A curator daemon (#278).
5//!
6//! This module provides the four passes beyond auto-tag that are
7//! required to earn a defensible "100% autonomous" claim:
8//!
9//! 1. **Consolidation** — find near-duplicate memories in the same
10//!    namespace, LLM-summarise them into a single canonical memory,
11//!    archive the originals. Uses `db::consolidate` for the DB work
12//!    and `AutonomyLlm::summarize_memories` for the synthesis.
13//! 2. **Forgetting of superseded memories** — when a memory carries
14//!    `metadata.confirmed_contradictions`, demote or forget the older
15//!    contradicted entry (the curator keeps the fresher one). Uses
16//!    `db::forget_count` with a targeted id list.
17//! 3. **Priority feedback** — nudge `priority` up for memories that
18//!    are getting recalled, nudge it down for cold ones. Purely
19//!    arithmetic; no LLM call.
20//! 4. **Rollback log + self-report** — every autonomous action lands
21//!    in a `_curator/rollback/<ts>` memory describing what happened
22//!    and how to reverse it, and every cycle lands in
23//!    `_curator/reports/<ts>` as a summary the operator (and other
24//!    agents) can recall.
25//!
26//! ## Trait boundary — `AutonomyLlm`
27//!
28//! The curator previously coupled directly to `llm::OllamaClient`,
29//! which blocked unit-testable end-to-end coverage. This module
30//! defines a narrow trait that both `OllamaClient` (in prod) and
31//! the [`tests::StubLlm`] (in tests) implement. The autonomy passes
32//! are generic over `&dyn AutonomyLlm`.
33
34use anyhow::Result;
35use rusqlite::Connection;
36use serde::{Deserialize, Serialize};
37
38use crate::db;
39use crate::llm::OllamaClient;
40use crate::models::{Memory, Tier};
41
42/// Minimum Jaccard-keyword overlap required to treat two memories as
43/// "near-duplicates" candidates for a consolidation cluster. Tuned
44/// loosely — actual merge decision is still gated by an LLM pass.
45pub const CONSOLIDATE_JACCARD_THRESHOLD: f64 = 0.55;
46
47/// Cap on the number of memories in a single consolidation cluster —
48/// prevents pathological mega-merges that would destroy provenance.
49pub const CONSOLIDATE_MAX_CLUSTER_SIZE: usize = 8;
50
51/// Reserved namespace prefix the curator writes to. Excluded from
52/// further curator passes (the curator never acts on its own rollback
53/// / report memories).
54pub const CURATOR_NAMESPACE: &str = "_curator";
55
56/// LLM surface the autonomy passes use. Implemented for `OllamaClient`
57/// in prod and stubbed in tests. The `auto_tag` and `detect_contradiction`
58/// methods are here for completeness — the autonomy passes themselves
59/// currently only call `summarize_memories`, but exposing the three
60/// together keeps the trait a single, testable LLM boundary that the
61/// curator's `run_once` path can switch to in a follow-up PR.
62#[allow(dead_code)]
63pub trait AutonomyLlm {
64    /// Generate tags for a memory.
65    fn auto_tag(&self, title: &str, content: &str) -> Result<Vec<String>>;
66
67    /// Return true iff the two pieces of content contradict each other.
68    fn detect_contradiction(&self, mem_a: &str, mem_b: &str) -> Result<bool>;
69
70    /// Produce a consolidated summary of N memories.
71    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/// Rollback-log entry stored as a memory in `_curator/rollback/<rfc3339>`.
87///
88/// Serialised as JSON in the memory's `content`. The memory's `metadata`
89/// carries the `action` discriminator so operators can filter the
90/// rollback log by kind via the normal `memory_list` + `tags_filter`
91/// path.
92///
93/// The `Consolidate` variant is deliberately large (carries full
94/// pre-merge memory snapshots) compared to `PriorityAdjust`. That's the
95/// cost of being able to reverse a merge without network round-trips.
96#[allow(clippy::large_enum_variant)]
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(tag = "action", rename_all = "snake_case")]
99pub enum RollbackEntry {
100    /// A consolidation was applied. `originals` are the full Memory
101    /// snapshots pre-merge; `result_id` is the consolidated memory id.
102    Consolidate {
103        originals: Vec<Memory>,
104        result_id: String,
105    },
106    /// A memory was forgotten (archived). `snapshot` is the memory as
107    /// it was immediately before forgetting.
108    Forget { snapshot: Memory },
109    /// A priority adjustment. `memory_id`, `before`, `after`.
110    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/// Structured outcome of a single autonomy pass. Aggregated into the
128/// curator cycle's `CuratorReport` and also written back as a self-
129/// report memory.
130#[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
140/// Run all autonomy passes over the provided candidates in order:
141/// consolidate → forget superseded → priority feedback → record
142/// rollback log → write self-report. `dry_run` suppresses all writes.
143///
144/// Returns an `AutonomyPassReport` rather than `Result<…>` because
145/// per-pass errors are already aggregated into `report.errors`;
146/// the function itself cannot fail at the outer level.
147pub 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    // Pass 1 — consolidation.
156    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    // Pass 2 — forget superseded.
178    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    // Pass 3 — priority feedback.
196    #[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    // Group by namespace first — we never merge across namespaces.
219    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    // Skip clusters inside reserved namespaces (defensive; already
292    // filtered at find_consolidation_clusters).
293    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    // Prefix the consolidated title so it never collides with one of
303    // the source memories' (title, namespace) UNIQUE key. Source
304    // rows still exist at INSERT time — db::consolidate deletes them
305    // only after the new row lands.
306    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    // Tier = max of cluster (consolidate never downgrades).
323    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    // Only act on memories whose `confirmed_contradictions` list is
361    // non-empty — i.e., a previous detect_contradiction pass already
362    // flagged this pair.
363    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    // The current memory is superseded if a contradicting memory is
374    // both newer AND has higher-or-equal confidence. We never forget
375    // based on the contradicting memory alone — the decision requires
376    // both freshness and trust.
377    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    // IMPORTANT: `db::delete` hard-deletes (no archive row). Recovery
403    // for a forgotten memory relies on the RollbackEntry::Forget
404    // snapshot we return — the caller persists it in `_curator/rollback`
405    // with the full pre-forget memory embedded. That rollback entry
406    // is long-tier so it's not auto-GC'd; `ai-memory curator --rollback
407    // <id>` reverses the forget from that snapshot. (#300 item 1:
408    // comment previously claimed db::delete archives; it does not.)
409    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    // Access-signal policy:
422    //   access_count >= 10 AND last_accessed_at within 7d → +1 (cap 10)
423    //   access_count == 0 AND created_at older than 30d     → -1 (floor 1)
424    //   else no change.
425    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
506/// Write the cycle's report as a memory in `_curator/reports/<ts>`
507/// so other agents can recall "what did the curator do".
508pub 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
551/// Reverse a single rollback-log entry. Returns `true` if a reverse
552/// action was applied, `false` if the entry was already superseded
553/// (idempotent rollback).
554///
555/// Collision safety (#300 item 2): before re-inserting a snapshot we
556/// check whether another memory now owns the same
557/// `(title, namespace)` key. If it does, we refuse to overwrite —
558/// `db::insert` is an UPSERT on that key and would silently replace
559/// the unrelated memory's content. We return an error so the operator
560/// can resolve the conflict manually (delete the offender or rename
561/// one of them) rather than clobbering user data.
562pub fn reverse_rollback_entry(conn: &Connection, entry: &RollbackEntry) -> Result<bool> {
563    match entry {
564        RollbackEntry::Consolidate {
565            originals,
566            result_id,
567        } => {
568            // Pre-flight: no title+ns collision against a different id?
569            for m in originals {
570                check_no_collision(conn, &m.title, &m.namespace, &m.id)?;
571            }
572            // Delete the consolidated memory; re-insert the originals.
573            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
607/// Refuse to overwrite a memory that took the (title, namespace) slot
608/// after the rollback target was forgotten/consolidated.
609fn 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    /// In-test LLM stub. Deterministic: returns fixed tags + treats
647    /// "contradict" as a sentinel in content to flag contradictions.
648    struct StubLlm {
649        // Read by the trait impls below; the test paths in this module exercise
650        // `summarize_memories` only, so rustc 1.93+ flags these reads as dead.
651        // Curator and MCP integration tests (in `mcp.rs`/`curator.rs`) cover
652        // `auto_tag` and `detect_contradiction`; this stub keeps the protocol
653        // complete so any future autonomy test can exercise either method.
654        #[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        // ns1 should cluster a+b; ns2 has only one memory so no cluster.
764        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        // Originals still present, no consolidated row added.
857        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        // After consolidation, originals should be gone (merged into
888        // the result id).
889        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        // Rollback: originals come back, result is removed.
905        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        // Seed: two near-duplicates in "deploy", one unrelated doc in
919        // "chat", and a pair with a confirmed-contradictions pointer.
920        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        // Superseded pair: m_old is older AND has a confirmed
943        // contradiction against m_new.
944        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        // Push m_old's updated_at to the past so m_new's default now
954        // is strictly newer.
955        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        // Consolidated at least once (deploy cluster).
978        assert!(report.clusters_formed >= 1);
979        assert!(report.memories_consolidated >= 2);
980        // Forgot m_old because it's superseded by m_new.
981        assert!(
982            report.memories_forgotten >= 1,
983            "expected ≥1 forget, got {report:?}"
984        );
985        // Rollback entries written for each action.
986        assert!(report.rollback_entries_written >= report.clusters_formed);
987        // Rollback-log memories exist.
988        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        // Test that autonomy invokes the LLM's summarize_memories in consolidation.
1036        let (_tmp, conn) = setup_conn();
1037        // Use similar enough content to exceed the Jaccard threshold (0.55)
1038        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        // Key assertions: LLM was used (clusters formed and consolidation happened)
1061        assert!(report.clusters_formed > 0);
1062        assert!(report.memories_consolidated > 0);
1063    }
1064
1065    #[test]
1066    fn autonomy_cycle_with_mock_ollama() {
1067        // Test run_autonomy_passes end-to-end with StubLlm
1068        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        // Report should reflect successful cycle
1092        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        // Verify rollback log correctly records a consolidation
1102        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 the entry
1127        persist_rollback_entry(&conn, &entry).unwrap();
1128
1129        // Verify it's in the rollback log
1130        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        // Verify priority feedback changes memory priority based on access.
1150        // Policy at apply_priority_feedback: access_count >= 10 AND
1151        // last_accessed_at within 7d → +1. Set both signals for the bump
1152        // path, plus an explicit recent-access timestamp.
1153        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        // Verify dry-run mode prevents all writes to DB
1181        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        // Verify error aggregation in AutonomyPassReport
1242        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        // At minimum, report structure should be valid
1249        assert!(report.clusters_formed > 0 || report.clusters_formed == 0);
1250    }
1251
1252    // ---- Wave 9 (Closer A9) — RollbackEntry::reverse_* matrix +
1253    // edge cases for consolidate_cluster / forget_if_superseded /
1254    // StubLlm impls. These target the lines uncovered after W8.
1255
1256    /// Reversing a `PriorityAdjust` entry rewrites the priority back to
1257    /// the captured `before` value. Covers `reverse_rollback_entry`'s
1258    /// `PriorityAdjust` branch which the W8 suite never exercised end-
1259    /// to-end.
1260    #[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        // Bump the row to priority=9 to simulate a prior +2 adjustment.
1267        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    /// Reversing a `Forget` entry re-inserts the snapshot. Covers the
1294    /// happy path through `check_no_collision` + `db::insert` round-trip.
1295    #[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        // Simulate the forget happening: hard-delete.
1307        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    /// Reversing a `Consolidate` aborts with an error when the
1321    /// (title, namespace) slot of an original is already taken by an
1322    /// unrelated memory id — this is `check_no_collision`'s defensive
1323    /// bail (line ~629) which the W8 suite never reached.
1324    #[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        // Stand up a different memory at (title=Deploy plan, namespace=app)
1341        // — the collision target for the rollback.
1342        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        // Collider is untouched.
1355        assert!(db::get(&conn, "collider-id").unwrap().is_some());
1356    }
1357
1358    /// `consolidate_cluster` short-circuits to `None` when the cluster
1359    /// has fewer than two members. Covers the `cluster.len() < 2` early
1360    /// return.
1361    #[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    /// `consolidate_cluster` defensively skips clusters whose members
1371    /// are in a reserved (`_`-prefixed) namespace. Covers the second
1372    /// early return path (line ~294).
1373    #[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    /// In dry_run mode, `forget_if_superseded` returns a `Forget`
1387    /// rollback entry **without** deleting the underlying row. Covers
1388    /// the dry-run branch (lines ~397-399) of `forget_if_superseded`.
1389    #[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        // Dry-run preserves the row.
1407        assert!(db::get(&conn, "old").unwrap().is_some());
1408    }
1409
1410    /// `forget_if_superseded` skips non-string entries in the
1411    /// `confirmed_contradictions` array — covers the `let Some(...) =
1412    /// v.as_str() else { continue; };` branch (line ~382).
1413    #[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        // Mix invalid (number) and valid-but-missing (no matching id) entries.
1418        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        // No superseder identified (numeric id skipped, "missing-id" not in `all`).
1421        assert!(result.is_none());
1422    }
1423
1424    /// Exercise the `StubLlm::auto_tag` and `StubLlm::detect_contradiction`
1425    /// trait impls directly — they exist for completeness of the
1426    /// `AutonomyLlm` trait surface but the autonomy code itself only
1427    /// calls `summarize_memories`, so without a direct hit they are
1428    /// uncovered (lines ~674-687).
1429    #[test]
1430    fn stub_llm_auto_tag_and_detect_contradiction() {
1431        let llm = StubLlm::new("summary");
1432        // auto_tag returns the canned tags.
1433        let tags = AutonomyLlm::auto_tag(&llm, "Some Title", "body").unwrap();
1434        assert_eq!(tags, vec!["auto".to_string(), "stub".to_string()]);
1435        // detect_contradiction is sentinel-driven.
1436        assert!(AutonomyLlm::detect_contradiction(&llm, "this CONTRADICTS that", "ok").unwrap());
1437        assert!(!AutonomyLlm::detect_contradiction(&llm, "ok", "fine").unwrap());
1438        // The call log captures both invocations.
1439        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    /// `run_autonomy_passes` with `dry_run=true` and a candidate set that
1445    /// triggers all three pass kinds (consolidate cluster + supersedure
1446    /// pair + recent-and-hot priority bump candidate) writes nothing to
1447    /// the DB but still emits a non-trivial report. This stresses the
1448    /// dry_run branches of every pass at once.
1449    #[test]
1450    fn run_autonomy_passes_dry_run_writes_no_changes() {
1451        let (_tmp, conn) = setup_conn();
1452        // Cluster pair.
1453        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        // Superseded pair.
1468        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        // Hot priority candidate.
1485        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        // Snapshot pre-state.
1508        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        // Report still reflects the would-be actions.
1515        assert!(report.clusters_formed >= 1);
1516        // Dry-run path produces no rollback-log writes (the persist call
1517        // is gated on `!dry_run`, and even though the counter is bumped,
1518        // the rollback memories themselves never land).
1519        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        // Pre-state survives.
1535        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    /// `run_autonomy_passes` honours an effective max-ops bound in
1544    /// practice: the cluster-size cap (`CONSOLIDATE_MAX_CLUSTER_SIZE = 8`)
1545    /// prevents a pathological single mega-cluster, even when many
1546    /// near-duplicates would otherwise merge. We seed N>cap candidates
1547    /// and assert the consolidated cluster never exceeds the cap.
1548    #[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    /// `apply_priority_feedback` on a cold-and-old memory floors the
1574    /// priority by -1. Complements the existing hot-and-recent test
1575    /// (`priority_feedback_adjusts_memory`) — the cold branch is
1576    /// otherwise unreached.
1577    #[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}