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 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
44/// Source label stamped on memories the autonomy curator writes
45/// (one spelling across the three write paths — #1558).
46const CURATOR_SOURCE_LABEL: &str = "ai-memory curator (autonomy)";
47
48/// Minimum Jaccard-keyword overlap required to treat two memories as
49/// "near-duplicates" candidates for a consolidation cluster. Tuned
50/// loosely — actual merge decision is still gated by an LLM pass.
51///
52/// v0.7.0 R3-S2 — Jaccard is now a *cheap pre-filter* (O(N) per pair)
53/// when embeddings are available; cosine on the 384d MiniLM
54/// embeddings is the primary signal at
55/// [`CONSOLIDATE_COSINE_THRESHOLD`]. The Jaccard threshold is
56/// retained as the keyword-tier fall-back when no embeddings are
57/// present (so consolidation still works on a keyword-only
58/// deployment) and as a pre-filter to skip the embedding lookup on
59/// obviously-unrelated pairs.
60pub const CONSOLIDATE_JACCARD_THRESHOLD: f64 = 0.55;
61
62/// v0.7.0 R3-S2 — cosine similarity threshold (on 384d L2-normalised
63/// MiniLM embeddings) above which two memories cluster for
64/// consolidation. Default `0.75` per playbook §2.7 + ROADMAP §5.2:
65/// it captures rephrasings and semantically near-equivalent content
66/// without merging merely topically-adjacent memories.
67///
68/// Applied as the primary signal whenever both memories carry an
69/// embedding row in the DB (`db::get_embedding` returns `Some`).
70/// Jaccard is the cheap pre-filter (skips the embedding lookup) and
71/// the fall-back signal when embeddings are missing
72/// (keyword-tier deployments).
73pub const CONSOLIDATE_COSINE_THRESHOLD: f64 = 0.75;
74
75/// Cap on the number of memories in a single consolidation cluster —
76/// prevents pathological mega-merges that would destroy provenance.
77pub const CONSOLIDATE_MAX_CLUSTER_SIZE: usize = 8;
78
79/// Reserved namespace prefix the curator writes to. Excluded from
80/// further curator passes (the curator never acts on its own rollback
81/// / report memories).
82pub const CURATOR_NAMESPACE: &str = "_curator";
83
84/// LLM surface the autonomy passes use. Implemented for `OllamaClient`
85/// in prod and stubbed in tests. The `auto_tag` and `detect_contradiction`
86/// methods are here for completeness — the autonomy passes themselves
87/// currently only call `summarize_memories`, but exposing the three
88/// together keeps the trait a single, testable LLM boundary that the
89/// curator's `run_once` path can switch to in a follow-up PR.
90#[allow(dead_code)]
91pub trait AutonomyLlm {
92    /// Generate tags for a memory.
93    fn auto_tag(&self, title: &str, content: &str) -> Result<Vec<String>>;
94
95    /// Return true iff the two pieces of content contradict each other.
96    fn detect_contradiction(&self, mem_a: &str, mem_b: &str) -> Result<bool>;
97
98    /// Produce a consolidated summary of N memories.
99    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        // L15: autonomy-tier trait passes None so the client uses its
105        // configured default; callers that want a dedicated tag model
106        // call `OllamaClient::auto_tag` directly with `Some(model)`.
107        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/// Rollback-log entry stored as a memory in `_curator/rollback/<rfc3339>`.
118///
119/// Serialised as JSON in the memory's `content`. The memory's `metadata`
120/// carries the `action` discriminator so operators can filter the
121/// rollback log by kind via the normal `memory_list` + `tags_filter`
122/// path.
123///
124/// The `Consolidate` variant is deliberately large (carries full
125/// pre-merge memory snapshots) compared to `PriorityAdjust`. That's the
126/// cost of being able to reverse a merge without network round-trips.
127#[allow(clippy::large_enum_variant)]
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "action", rename_all = "snake_case")]
130pub enum RollbackEntry {
131    /// A consolidation was applied. `originals` are the full Memory
132    /// snapshots pre-merge; `result_id` is the consolidated memory id.
133    Consolidate {
134        originals: Vec<Memory>,
135        result_id: String,
136    },
137    /// A memory was forgotten (archived). `snapshot` is the memory as
138    /// it was immediately before forgetting.
139    Forget { snapshot: Memory },
140    /// A priority adjustment. `memory_id`, `before`, `after`.
141    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/// Structured outcome of a single autonomy pass. Aggregated into the
159/// curator cycle's `CuratorReport` and also written back as a self-
160/// report memory.
161#[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
171/// Run all autonomy passes over the provided candidates in order:
172/// consolidate → forget superseded → priority feedback → record
173/// rollback log → write self-report. `dry_run` suppresses all writes.
174///
175/// Returns an `AutonomyPassReport` rather than `Result<…>` because
176/// per-pass errors are already aggregated into `report.errors`;
177/// the function itself cannot fail at the outer level.
178pub 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    // Pass 1 — consolidation.
187    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    // Pass 2 — forget superseded.
207    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    // Pass 3 — priority feedback.
223    #[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
242/// v0.7.0 R3-S2 — Two-stage clustering per playbook §2.7 /
243/// ROADMAP §5.2:
244///
245///   1. **Jaccard pre-filter** (cheap, O(N) per pair) — pairs that
246///      fail [`CONSOLIDATE_JACCARD_THRESHOLD`] are dropped without
247///      paying the embedding lookup. This keeps the pass fast on the
248///      typical workload (most pairs are obviously unrelated).
249///   2. **Cosine primary** — pairs that survive Jaccard are scored
250///      against [`CONSOLIDATE_COSINE_THRESHOLD`] on their 384d
251///      MiniLM embeddings (`db::get_embedding`). Above-threshold
252///      pairs join the cluster.
253///
254/// When *either* memory in a pair has no embedding row (e.g.,
255/// keyword-tier deployment that never ran the embedder), the cosine
256/// stage is skipped for that pair and the Jaccard signal alone
257/// decides — preserving v0.6.x behaviour on keyword deployments
258/// while making cosine the primary signal anywhere the embedder is
259/// available. The function never errors on a DB read miss; it
260/// silently degrades to Jaccard so a partial-coverage corpus (some
261/// embedded, some not) still clusters productively.
262fn find_consolidation_clusters(conn: &Connection, candidates: &[Memory]) -> Vec<Vec<Memory>> {
263    // Group by namespace first — we never merge across namespaces.
264    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            // Cache the seed memory's embedding (looked up once per
282            // outer-loop iteration). `None` means "embedding missing
283            // for this memory" — we fall back to Jaccard-only on the
284            // inner pairs.
285            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                // Stage 1 — Jaccard pre-filter (cheap).
294                let j_sim = jaccard_similarity(&group[i].content, &group[j].content);
295                if j_sim < CONSOLIDATE_JACCARD_THRESHOLD {
296                    continue;
297                }
298                // Stage 2 — cosine primary, when embeddings exist
299                // for both sides of the pair.
300                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                    // At least one side has no embedding — fall back
307                    // to Jaccard-only (already passed the pre-filter
308                    // above so the pair clusters).
309                    _ => 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    // Skip clusters inside reserved namespaces (defensive; already
358    // filtered at find_consolidation_clusters).
359    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    // Prefix the consolidated title so it never collides with one of
369    // the source memories' (title, namespace) UNIQUE key. Source
370    // rows still exist at INSERT time — db::consolidate deletes them
371    // only after the new row lands.
372    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    // Tier = max of cluster (consolidate never downgrades).
389    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    // Only act on memories whose `confirmed_contradictions` list is
427    // non-empty — i.e., a previous detect_contradiction pass already
428    // flagged this pair.
429    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    // The current memory is superseded if a contradicting memory is
440    // both newer AND has higher-or-equal confidence. We never forget
441    // based on the contradicting memory alone — the decision requires
442    // both freshness and trust.
443    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    // IMPORTANT: `db::delete` hard-deletes (no archive row). Recovery
469    // for a forgotten memory relies on the RollbackEntry::Forget
470    // snapshot we return — the caller persists it in `_curator/rollback`
471    // with the full pre-forget memory embedded. That rollback entry
472    // is long-tier so it's not auto-GC'd; `ai-memory curator --rollback
473    // <id>` reverses the forget from that snapshot. (#300 item 1:
474    // comment previously claimed db::delete archives; it does not.)
475    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    // Access-signal policy:
488    //   access_count >= 10 AND last_accessed_at within 7d → +1 (cap 10)
489    //   access_count == 0 AND created_at older than 30d     → -1 (floor 1)
490    //   else no change.
491    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
541/// #1558 batch 5 wave 2 — canonical `"rollback-log write failed: {e}"`
542/// report-error line shared by the three [`persist_rollback_entry`]
543/// failure sites in the autonomy passes. Byte-identical message.
544fn 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
590/// Write the cycle's report as a memory in `_curator/reports/<ts>`
591/// so other agents can recall "what did the curator do".
592pub 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    // Issue #816 — count of `__persona_<entity_id>_v<n>` rows the
599    // curator's auto-persona sweep produced this cycle. Surfaces in the
600    // self-report JSON alongside the existing per-pass counters so an
601    // operator inspecting `_curator/reports/*` can audit auto-persona
602    // activity over time without joining against the persona rows
603    // themselves.
604    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
654/// Reverse a single rollback-log entry. Returns `true` if a reverse
655/// action was applied, `false` if the entry was already superseded
656/// (idempotent rollback).
657///
658/// Collision safety (#300 item 2): before re-inserting a snapshot we
659/// check whether another memory now owns the same
660/// `(title, namespace)` key. If it does, we refuse to overwrite —
661/// `db::insert` is an UPSERT on that key and would silently replace
662/// the unrelated memory's content. We return an error so the operator
663/// can resolve the conflict manually (delete the offender or rename
664/// one of them) rather than clobbering user data.
665pub fn reverse_rollback_entry(conn: &Connection, entry: &RollbackEntry) -> Result<bool> {
666    match entry {
667        RollbackEntry::Consolidate {
668            originals,
669            result_id,
670        } => {
671            // Pre-flight: no title+ns collision against a different id?
672            for m in originals {
673                check_no_collision(conn, &m.title, &m.namespace, &m.id)?;
674            }
675            // Delete the consolidated memory; re-insert the originals.
676            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
710/// Refuse to overwrite a memory that took the (title, namespace) slot
711/// after the rollback target was forgotten/consolidated.
712fn 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    /// In-test LLM stub. Deterministic: returns fixed tags + treats
750    /// "contradict" as a sentinel in content to flag contradictions.
751    struct StubLlm {
752        // Read by the trait impls below; the test paths in this module exercise
753        // `summarize_memories` only, so rustc 1.93+ flags these reads as dead.
754        // Curator and MCP integration tests (in `mcp.rs`/`curator.rs`) cover
755        // `auto_tag` and `detect_contradiction`; this stub keeps the protocol
756        // complete so any future autonomy test can exercise either method.
757        #[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        // ns1 should cluster a+b; ns2 has only one memory so no cluster.
879        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    // -----------------------------------------------------------------
893    // v0.7.0 R3-S2 — consolidation clustering uses cosine as primary
894    // when embeddings are present; falls back to Jaccard otherwise.
895    // -----------------------------------------------------------------
896
897    /// Build a synthetic L2-normalized embedding from a small seed
898    /// vector. Used to drive the cosine cluster path without
899    /// requiring an actual embedder load.
900    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_consolidation_uses_cosine_when_embeddings_present` —
909    /// two memories whose contents look *jaccard-similar* but whose
910    /// embeddings are deliberately *cosine-DISsimilar* must NOT
911    /// cluster. This proves cosine is the primary signal and Jaccard
912    /// alone no longer drives consolidation when embeddings exist.
913    #[test]
914    fn test_consolidation_uses_cosine_when_embeddings_present() {
915        let (_tmp, conn) = setup_conn();
916        // Same lexical content (Jaccard ≈ 1.0) so the pre-filter
917        // would pass — but we attach orthogonal embeddings so cosine
918        // is ~0, well below the 0.75 threshold.
919        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        // Orthogonal 4-d embeddings: cosine sim = 0.
937        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        // Symmetry: cosine-SIMilar embeddings on the same Jaccard
947        // pair MUST cluster. Reuse fresh memories to avoid the
948        // UPSERT collision.
949        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        // Nearly-identical embeddings: cosine sim ≈ 1.0.
966        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_consolidation_falls_back_to_jaccard_no_embeddings` —
979    /// keyword-tier corpus (no embeddings persisted) still clusters
980    /// via Jaccard alone. This preserves v0.6.x consolidation
981    /// behaviour on deployments that never run the embedder.
982    #[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        // Insert WITHOUT attaching embeddings — get_embedding returns
1000        // None, the cosine stage is skipped, Jaccard alone decides.
1001        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        // Originals still present, no consolidated row added.
1094        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        // After consolidation, originals should be gone (merged into
1125        // the result id).
1126        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        // Rollback: originals come back, result is removed.
1142        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        // Seed: two near-duplicates in "deploy", one unrelated doc in
1156        // "chat", and a pair with a confirmed-contradictions pointer.
1157        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        // Superseded pair: m_old is older AND has a confirmed
1180        // contradiction against m_new.
1181        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        // Push m_old's updated_at to the past so m_new's default now
1191        // is strictly newer.
1192        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        // Consolidated at least once (deploy cluster).
1215        assert!(report.clusters_formed >= 1);
1216        assert!(report.memories_consolidated >= 2);
1217        // Forgot m_old because it's superseded by m_new.
1218        assert!(
1219            report.memories_forgotten >= 1,
1220            "expected ≥1 forget, got {report:?}"
1221        );
1222        // Rollback entries written for each action.
1223        assert!(report.rollback_entries_written >= report.clusters_formed);
1224        // Rollback-log memories exist.
1225        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        // Test that autonomy invokes the LLM's summarize_memories in consolidation.
1273        let (_tmp, conn) = setup_conn();
1274        // Use similar enough content to exceed the Jaccard threshold (0.55)
1275        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        // Key assertions: LLM was used (clusters formed and consolidation happened)
1298        assert!(report.clusters_formed > 0);
1299        assert!(report.memories_consolidated > 0);
1300    }
1301
1302    #[test]
1303    fn autonomy_cycle_with_mock_ollama() {
1304        // Test run_autonomy_passes end-to-end with StubLlm
1305        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        // Report should reflect successful cycle
1329        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        // Verify rollback log correctly records a consolidation
1339        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 the entry
1364        persist_rollback_entry(&conn, &entry).unwrap();
1365
1366        // Verify it's in the rollback log
1367        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        // Verify priority feedback changes memory priority based on access.
1387        // Policy at apply_priority_feedback: access_count >= 10 AND
1388        // last_accessed_at within 7d → +1. Set both signals for the bump
1389        // path, plus an explicit recent-access timestamp.
1390        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        // Verify dry-run mode prevents all writes to DB
1418        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        // Verify error aggregation in AutonomyPassReport
1479        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        // At minimum, report structure should be valid
1486        assert!(report.clusters_formed > 0 || report.clusters_formed == 0);
1487    }
1488
1489    // ---- Wave 9 (Closer A9) — RollbackEntry::reverse_* matrix +
1490    // edge cases for consolidate_cluster / forget_if_superseded /
1491    // StubLlm impls. These target the lines uncovered after W8.
1492
1493    /// Reversing a `PriorityAdjust` entry rewrites the priority back to
1494    /// the captured `before` value. Covers `reverse_rollback_entry`'s
1495    /// `PriorityAdjust` branch which the W8 suite never exercised end-
1496    /// to-end.
1497    #[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        // Bump the row to priority=9 to simulate a prior +2 adjustment.
1504        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    /// Reversing a `Forget` entry re-inserts the snapshot. Covers the
1531    /// happy path through `check_no_collision` + `db::insert` round-trip.
1532    #[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        // Simulate the forget happening: hard-delete.
1544        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    /// Reversing a `Consolidate` aborts with an error when the
1558    /// (title, namespace) slot of an original is already taken by an
1559    /// unrelated memory id — this is `check_no_collision`'s defensive
1560    /// bail (line ~629) which the W8 suite never reached.
1561    #[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        // Stand up a different memory at (title=Deploy plan, namespace=app)
1578        // — the collision target for the rollback.
1579        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        // Collider is untouched.
1592        assert!(db::get(&conn, "collider-id").unwrap().is_some());
1593    }
1594
1595    /// `consolidate_cluster` short-circuits to `None` when the cluster
1596    /// has fewer than two members. Covers the `cluster.len() < 2` early
1597    /// return.
1598    #[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    /// `consolidate_cluster` defensively skips clusters whose members
1608    /// are in a reserved (`_`-prefixed) namespace. Covers the second
1609    /// early return path (line ~294).
1610    #[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    /// In dry_run mode, `forget_if_superseded` returns a `Forget`
1624    /// rollback entry **without** deleting the underlying row. Covers
1625    /// the dry-run branch (lines ~397-399) of `forget_if_superseded`.
1626    #[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        // Dry-run preserves the row.
1644        assert!(db::get(&conn, "old").unwrap().is_some());
1645    }
1646
1647    /// `forget_if_superseded` skips non-string entries in the
1648    /// `confirmed_contradictions` array — covers the `let Some(...) =
1649    /// v.as_str() else { continue; };` branch (line ~382).
1650    #[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        // Mix invalid (number) and valid-but-missing (no matching id) entries.
1655        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        // No superseder identified (numeric id skipped, "missing-id" not in `all`).
1658        assert!(result.is_none());
1659    }
1660
1661    /// Exercise the `StubLlm::auto_tag` and `StubLlm::detect_contradiction`
1662    /// trait impls directly — they exist for completeness of the
1663    /// `AutonomyLlm` trait surface but the autonomy code itself only
1664    /// calls `summarize_memories`, so without a direct hit they are
1665    /// uncovered (lines ~674-687).
1666    #[test]
1667    fn stub_llm_auto_tag_and_detect_contradiction() {
1668        let llm = StubLlm::new("summary");
1669        // auto_tag returns the canned tags.
1670        let tags = AutonomyLlm::auto_tag(&llm, "Some Title", "body").unwrap();
1671        assert_eq!(tags, vec!["auto".to_string(), "stub".to_string()]);
1672        // detect_contradiction is sentinel-driven.
1673        assert!(AutonomyLlm::detect_contradiction(&llm, "this CONTRADICTS that", "ok").unwrap());
1674        assert!(!AutonomyLlm::detect_contradiction(&llm, "ok", "fine").unwrap());
1675        // The call log captures both invocations.
1676        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    /// `run_autonomy_passes` with `dry_run=true` and a candidate set that
1682    /// triggers all three pass kinds (consolidate cluster + supersedure
1683    /// pair + recent-and-hot priority bump candidate) writes nothing to
1684    /// the DB but still emits a non-trivial report. This stresses the
1685    /// dry_run branches of every pass at once.
1686    #[test]
1687    fn run_autonomy_passes_dry_run_writes_no_changes() {
1688        let (_tmp, conn) = setup_conn();
1689        // Cluster pair.
1690        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        // Superseded pair.
1705        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        // Hot priority candidate.
1722        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        // Snapshot pre-state.
1745        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        // Report still reflects the would-be actions.
1752        assert!(report.clusters_formed >= 1);
1753        // Dry-run path produces no rollback-log writes (the persist call
1754        // is gated on `!dry_run`, and even though the counter is bumped,
1755        // the rollback memories themselves never land).
1756        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        // Pre-state survives.
1772        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    /// `run_autonomy_passes` honours an effective max-ops bound in
1781    /// practice: the cluster-size cap (`CONSOLIDATE_MAX_CLUSTER_SIZE = 8`)
1782    /// prevents a pathological single mega-cluster, even when many
1783    /// near-duplicates would otherwise merge. We seed N>cap candidates
1784    /// and assert the consolidated cluster never exceeds the cap.
1785    #[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    /// `apply_priority_feedback` on a cold-and-old memory floors the
1812    /// priority by -1. Complements the existing hot-and-recent test
1813    /// (`priority_feedback_adjusts_memory`) — the cold branch is
1814    /// otherwise unreached.
1815    #[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}