Skip to main content

kimetsu_brain/
lifecycle.rs

1//! F3 Lifecycle & forgetting — Stories 3.1–3.4.
2//!
3//! # Story 3.1 — Active forgetting / compaction policy
4//!
5//! `forget_brain` identifies memories that are simultaneously:
6//!   1. Low-usefulness (`usefulness_score / use_count <= floor`, OR
7//!      `usefulness_score <= floor` when `use_count == 0`).
8//!   2. Stale: `last_useful_at` (or `created_at` when never cited) is older
9//!      than `min_age_days`.
10//!   3. NOT evergreen: `use_count < protect_use_count` (high-traffic memories
11//!      are protected even if the per-turn ratio is noisy).
12//!
13//! Forgetting is **archival, not destructive**: it calls the existing
14//! `invalidate_memory` path with reason `"forgotten/archived"`, emitting a
15//! `memory.invalidated` event into the event log. A full `rebuild_in_place`
16//! will replay the invalidation and arrive at the same state — rebuild-safe.
17//!
18//! The policy is **opt-in** via `[lifecycle] forget_enabled = true` in
19//! `project.toml`. The default is `false`, so existing installs are entirely
20//! unaffected until the operator explicitly enables it.
21//!
22//! # Story 3.2 — Regret-driven review
23//!
24//! `flagged_for_review` returns memories whose `retrieval.regret` event count
25//! (a memory was cited despite having been dropped from the context bundle)
26//! exceeds a threshold. These memories are surfaced in `brain status` and
27//! the review list — they are NOT auto-deleted.
28//!
29//! # Story 3.3 — Proposal-queue hygiene
30//!
31//! `gc_proposals` expires pending proposals older than `proposal_expiry_days`
32//! (via the existing `reject_proposal` path, reason `"expired"`) and
33//! optionally auto-accepts proposals whose `proposed_confidence` is above
34//! `proposal_auto_accept_confidence`.
35//!
36//! # Story 3.4 — Structured invalidation taxonomy
37//!
38//! `InvalidationReason` is a serde-tagged enum whose canonical snake_case
39//! string is what gets written to `invalidated_reason`. Back-compat: the
40//! column has always been free-text; rows written before this story parse
41//! as `InvalidationReason::Manual`. Analytics groups invalidations by reason.
42
43use std::path::Path;
44
45use kimetsu_core::KimetsuResult;
46use rusqlite::{Connection, OptionalExtension, params};
47use serde::{Deserialize, Serialize};
48use time::OffsetDateTime;
49use time::format_description::well_known::Rfc3339;
50
51use crate::project::{AcceptOverrides, reject_proposal};
52
53// ---------------------------------------------------------------------------
54// Story 3.4 — Structured invalidation taxonomy
55// ---------------------------------------------------------------------------
56
57/// Canonical invalidation reason enum.
58///
59/// The string representation (serde snake_case) is written to the
60/// `invalidated_reason` column. Rows from before this enum was introduced
61/// have free-text reasons — they parse as `Manual` when the text doesn't
62/// match a known variant.
63///
64/// Back-compat guarantee: `InvalidationReason::Manual` is the catch-all so
65/// existing rows and any hand-typed reason strings keep deserialising cleanly.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum InvalidationReason {
69    /// The memory is no longer accurate / the described behaviour changed.
70    Obsolete,
71    /// A newer memory supersedes or refines this one.
72    Superseded,
73    /// This memory directly contradicts another accepted memory.
74    Conflicted,
75    /// The memory was factually wrong.
76    Incorrect,
77    /// An exact or near-exact duplicate of another memory exists.
78    Duplicate,
79    /// Archived by the active-forgetting policy (low-usefulness + stale).
80    Forgotten,
81    /// Manually invalidated by a human (default / catch-all).
82    Manual,
83}
84
85impl InvalidationReason {
86    /// Return the canonical snake_case string written to the DB column.
87    pub fn as_str(&self) -> &'static str {
88        match self {
89            Self::Obsolete => "obsolete",
90            Self::Superseded => "superseded",
91            Self::Conflicted => "conflicted",
92            Self::Incorrect => "incorrect",
93            Self::Duplicate => "duplicate",
94            Self::Forgotten => "forgotten",
95            Self::Manual => "manual",
96        }
97    }
98
99    /// Parse a free-text `invalidated_reason` column value into the best
100    /// matching variant. Unknown / pre-taxonomy strings → `Manual`.
101    pub fn from_db(s: &str) -> Self {
102        let lower = s.to_ascii_lowercase();
103        match lower.as_str() {
104            "obsolete" => Self::Obsolete,
105            "superseded" => Self::Superseded,
106            "conflicted" => Self::Conflicted,
107            "incorrect" => Self::Incorrect,
108            "duplicate" => Self::Duplicate,
109            "forgotten" | "forgotten/archived" | "forgotten_archived" => Self::Forgotten,
110            _ => Self::Manual,
111        }
112    }
113}
114
115impl std::fmt::Display for InvalidationReason {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.write_str(self.as_str())
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Story 3.1 — Forget options / results
123// ---------------------------------------------------------------------------
124
125/// Options for the `forget_brain` policy run.
126///
127/// All thresholds come from `[lifecycle]` config; callers may also override
128/// them for testing.
129#[derive(Debug, Clone)]
130pub struct ForgetOptions {
131    /// Do not write anything — just report what WOULD be forgotten.
132    pub dry_run: bool,
133    /// Archive memories whose usefulness score (per-use ratio) is ≤ this.
134    /// Default from config: `forget_usefulness_floor`.
135    pub usefulness_floor: f32,
136    /// Only consider memories whose age (from `last_useful_at` or
137    /// `created_at`) is older than this many days.
138    /// Default from config: `forget_min_age_days`.
139    pub min_age_days: u32,
140    /// Memories with `use_count >= this` are PROTECTED (evergreen).
141    /// Default from config: `forget_protect_use_count`.
142    pub protect_use_count: u32,
143}
144
145impl Default for ForgetOptions {
146    fn default() -> Self {
147        Self {
148            dry_run: true, // safe default
149            usefulness_floor: -0.1,
150            min_age_days: 90,
151            protect_use_count: 10,
152        }
153    }
154}
155
156/// One candidate identified by the forgetting pass.
157#[derive(Debug, Clone, Serialize)]
158pub struct ForgetCandidate {
159    pub claim_revision: String,
160    pub memory_id: String,
161    pub scope: String,
162    pub kind: String,
163    /// First ~80 characters of the memory text.
164    pub text_preview: String,
165    pub use_count: u32,
166    pub usefulness_score: f32,
167    /// Age in days (from `last_useful_at` / `created_at`).
168    pub age_days: f64,
169}
170
171/// Result of a `forget_brain` call.
172#[derive(Debug, Clone, Default, Serialize)]
173pub struct ForgetSummary {
174    /// Memories identified as candidates.
175    pub candidates: Vec<ForgetCandidate>,
176    /// Memories that were actually archived (0 on dry_run).
177    pub archived: u32,
178    /// Memories that could not be archived due to errors.
179    pub failed: u32,
180    /// True when this was a dry-run (nothing written).
181    pub dry_run: bool,
182}
183
184/// Run the active-forgetting policy.
185///
186/// Identifies stale low-usefulness memories and (unless `opts.dry_run`)
187/// archives them via `invalidate_memory` with reason `"forgotten"`.
188///
189/// This function is **completely gated**: it early-returns Ok(empty) when
190/// the lifecycle section has `forget_enabled = false`, so callers that
191/// always pass the config option through will never archive anything unless
192/// the user has opted in.
193pub fn forget_brain(start: &Path, opts: ForgetOptions) -> KimetsuResult<ForgetSummary> {
194    let mut summary = ForgetSummary {
195        dry_run: opts.dry_run,
196        ..Default::default()
197    };
198
199    // Compute the age cutoff timestamp.
200    let now = OffsetDateTime::now_utc();
201    let cutoff = now - time::Duration::seconds(opts.min_age_days as i64 * 86_400);
202    let cutoff_iso = cutoff.format(&Rfc3339).unwrap_or_default();
203
204    // Query candidates.
205    let candidates = {
206        let (_paths, _config, conn) = crate::project::load_project(start)?;
207        query_forget_candidates(
208            &conn,
209            opts.usefulness_floor,
210            &cutoff_iso,
211            opts.protect_use_count,
212        )?
213    };
214
215    summary.candidates = candidates.clone();
216
217    if opts.dry_run {
218        return Ok(summary);
219    }
220
221    // Re-read eligibility while holding both the project and SQLite writer locks.
222    // The candidate scan is advisory; corrections/useful feedback may have landed.
223    let (paths, _, conn) = crate::project::load_project(start)?;
224    let _lock = crate::lock::ProjectLock::acquire(&paths, "archive", None)?;
225    crate::projector::with_write_txn(&conn, |conn| {
226        summary.archived = archive_candidates_locked(conn, &opts, &cutoff_iso, &candidates)?;
227        Ok(())
228    })?;
229
230    Ok(summary)
231}
232
233/// Called only inside the SQLite write transaction; selection must be revalidated.
234fn archive_candidates_locked(
235    conn: &Connection,
236    opts: &ForgetOptions,
237    cutoff_iso: &str,
238    candidates: &[ForgetCandidate],
239) -> KimetsuResult<u32> {
240    let eligible = query_forget_candidates(
241        conn,
242        opts.usefulness_floor,
243        cutoff_iso,
244        opts.protect_use_count,
245    )?;
246    let mut archived = 0;
247    for candidate in candidates {
248        if !eligible.iter().any(|c| {
249            c.memory_id == candidate.memory_id && c.claim_revision == candidate.claim_revision
250        }) {
251            continue;
252        }
253        let event = kimetsu_core::event::Event::new(
254            kimetsu_core::ids::RunId::new(),
255            "memory.invalidated",
256            serde_json::json!({"memory_id":candidate.memory_id,"reason":"forgotten"}),
257        );
258        crate::projector::apply_event(conn, &event)?;
259        archived += 1;
260    }
261    Ok(archived)
262}
263
264/// Explicit archival status; invalidated/corrected/superseded claims are excluded.
265pub fn list_archived(start: &Path) -> KimetsuResult<Vec<serde_json::Value>> {
266    let (_, _, conn) = crate::project::load_project_readonly(start)?;
267    let mut stmt=conn.prepare("SELECT memory_id,text,invalidated_at,valid_to FROM memories WHERE invalidated_at IS NOT NULL AND superseded_by IS NULL AND invalidated_reason IN ('forgotten','forgotten/archived','forgotten_archived') ORDER BY invalidated_at DESC")?;
268    let rows=stmt.query_map([],|r|Ok(serde_json::json!({"memory_id":r.get::<_,String>(0)?,"text":r.get::<_,String>(1)?,"archived_at":r.get::<_,String>(2)?,"valid_to":r.get::<_,Option<String>>(3)?,"status":"archived"})))?.collect::<Result<Vec<_>,_>>()?;
269    Ok(rows)
270}
271
272/// Restore archival state only; never reopen expiry or resurrect superseded claims.
273pub fn restore_memory(start: &Path, memory_id: &str) -> KimetsuResult<bool> {
274    let (paths, _, conn) = crate::project::load_project(start)?;
275    let _lock = crate::lock::ProjectLock::acquire(&paths, "restore", None)?;
276    let mut restored = false;
277    crate::projector::with_write_txn(&conn, |conn| {
278        let eligible:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM memories WHERE memory_id=?1 AND invalidated_at IS NOT NULL AND superseded_by IS NULL AND invalidated_reason IN ('forgotten','forgotten/archived','forgotten_archived'))",[memory_id],|r|r.get(0))?;
279        if !eligible {
280            return Ok(());
281        }
282        let event = kimetsu_core::event::Event::new(
283            kimetsu_core::ids::RunId::new(),
284            "memory.restored",
285            serde_json::json!({"memory_id":memory_id}),
286        );
287        crate::projector::apply_event(conn, &event)?;
288        restored = true;
289        Ok(())
290    })?;
291    Ok(restored)
292}
293
294/// Query candidates that meet the forget criteria.
295fn query_forget_candidates(
296    conn: &Connection,
297    usefulness_floor: f32,
298    cutoff_iso: &str,
299    protect_use_count: u32,
300) -> KimetsuResult<Vec<ForgetCandidate>> {
301    // Popularity protects non-negative evidence only. Negative evidence cannot
302    // refresh its own lifetime by being repeatedly exposed. Preferences and
303    // conventions require explicit correction, not automatic forgetting.
304    // Compare actual instants and take the latest timestamp, not first non-null.
305    let mut stmt = conn.prepare(
306        "WITH candidates AS (
307           SELECT *, MAX(
308             COALESCE(julianday(created_at), julianday('now')),
309             COALESCE(julianday(last_useful_at), 0),
310             CASE WHEN usefulness_score < 0 THEN 0
311                  ELSE COALESCE(julianday(last_used_at), 0) END
312           ) AS ref_day
313           FROM memories
314           WHERE invalidated_at IS NULL AND superseded_by IS NULL
315             AND kind NOT IN ('preference', 'convention')
316         )
317         SELECT memory_id, scope, kind, text, use_count, usefulness_score,
318                strftime('%Y-%m-%dT%H:%M:%fZ', ref_day) AS ref_ts
319         FROM candidates
320         WHERE (use_count < ?1 OR usefulness_score < 0)
321           AND (CAST(usefulness_score AS REAL) / MAX(CAST(use_count AS REAL), 1.0)) <= ?2
322           AND ref_day <= julianday(?3)
323         ORDER BY (CAST(usefulness_score AS REAL) / MAX(CAST(use_count AS REAL), 1.0)) ASC, memory_id",
324    )?;
325
326    let now = OffsetDateTime::now_utc();
327    let now_secs = now.unix_timestamp() as f64;
328
329    let rows = stmt.query_map(
330        params![
331            protect_use_count as i64,
332            usefulness_floor as f64,
333            cutoff_iso
334        ],
335        |row| {
336            Ok((
337                row.get::<_, String>(0)?,
338                row.get::<_, String>(1)?,
339                row.get::<_, String>(2)?,
340                row.get::<_, String>(3)?,
341                row.get::<_, i64>(4)?,
342                row.get::<_, f64>(5)?,
343                row.get::<_, String>(6)?,
344            ))
345        },
346    )?;
347
348    let mut candidates = Vec::new();
349    for row in rows {
350        let (memory_id, scope, kind, text, use_count, usefulness_score, ref_ts) = row?;
351        let age_days = if let Ok(ref_dt) = OffsetDateTime::parse(&ref_ts, &Rfc3339) {
352            let ref_secs = ref_dt.unix_timestamp() as f64;
353            (now_secs - ref_secs) / 86_400.0
354        } else {
355            0.0
356        };
357        let text_preview: String = text.chars().take(80).collect();
358        candidates.push(ForgetCandidate {
359            claim_revision: crate::projector::claim_revision_at(conn, &memory_id, None)?,
360            memory_id,
361            scope,
362            kind,
363            text_preview,
364            use_count: use_count as u32,
365            usefulness_score: usefulness_score as f32,
366            age_days,
367        });
368    }
369    Ok(candidates)
370}
371
372// ---------------------------------------------------------------------------
373// Story 3.2 — Regret-driven review
374// ---------------------------------------------------------------------------
375
376/// A memory flagged for review due to repeated retrieval regrets.
377#[derive(Debug, Clone, Serialize)]
378pub struct RegretFlaggedMemory {
379    pub memory_id: String,
380    pub scope: String,
381    pub kind: String,
382    pub text_preview: String,
383    pub confidence: f32,
384    pub regret_count: u64,
385    pub use_count: u32,
386    pub usefulness_score: f32,
387}
388
389/// Query memories that have accumulated ≥ `threshold` `retrieval.regret`
390/// events. These are surfaced for review but NOT auto-deleted.
391///
392/// A high-confidence memory that keeps being dropped (low retrieval score)
393/// but cited by the model anyway is a signal that the memory is right but
394/// the retrieval config is mis-calibrated — OR that the memory is
395/// over-confident. Either way it deserves human attention.
396pub fn regret_flagged_memories(
397    conn: &Connection,
398    threshold: u64,
399) -> KimetsuResult<Vec<RegretFlaggedMemory>> {
400    // Count regret events per memory_id from the events table.
401    let mut stmt = conn.prepare(
402        "SELECT json_extract(payload_json, '$.memory_id') AS mid,
403                COUNT(*) AS cnt
404         FROM events
405         WHERE kind = 'retrieval.regret'
406           AND mid IS NOT NULL
407         GROUP BY mid
408         HAVING cnt >= ?1
409         ORDER BY cnt DESC",
410    )?;
411
412    let rows = stmt.query_map(params![threshold as i64], |row| {
413        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
414    })?;
415
416    let mut flagged = Vec::new();
417    for row in rows {
418        let (memory_id, regret_count) = row?;
419        let mem_row: Option<(String, String, String, f32, i64, f64)> = conn
420            .query_row(
421                "SELECT scope, kind, text, confidence, use_count, usefulness_score
422                 FROM memories
423                 WHERE memory_id = ?1
424                   AND invalidated_at IS NULL
425                   AND superseded_by IS NULL",
426                params![memory_id],
427                |r| {
428                    Ok((
429                        r.get::<_, String>(0)?,
430                        r.get::<_, String>(1)?,
431                        r.get::<_, String>(2)?,
432                        r.get::<_, f32>(3)?,
433                        r.get::<_, i64>(4)?,
434                        r.get::<_, f64>(5)?,
435                    ))
436                },
437            )
438            .optional()?;
439        if let Some((scope, kind, text, confidence, use_count, usefulness_score)) = mem_row {
440            flagged.push(RegretFlaggedMemory {
441                memory_id,
442                scope,
443                kind,
444                text_preview: text.chars().take(80).collect(),
445                confidence,
446                regret_count: regret_count as u64,
447                use_count: use_count as u32,
448                usefulness_score: usefulness_score as f32,
449            });
450        }
451    }
452    Ok(flagged)
453}
454
455// ---------------------------------------------------------------------------
456// Story 3.3 — Proposal-queue hygiene
457// ---------------------------------------------------------------------------
458
459/// Options for the proposal GC pass.
460#[derive(Debug, Clone)]
461pub struct ProposalGcOptions {
462    /// Expire pending proposals older than this many days (0 = disabled).
463    pub expiry_days: u32,
464    /// Auto-accept proposals with `proposed_confidence >= this` threshold.
465    /// Set to 1.0 or above to disable (default = disabled = 1.1).
466    pub auto_accept_confidence: f32,
467    /// Dry-run: report what would happen without writing.
468    pub dry_run: bool,
469}
470
471impl Default for ProposalGcOptions {
472    fn default() -> Self {
473        Self {
474            expiry_days: 30,
475            auto_accept_confidence: 1.1, // disabled by default
476            dry_run: false,
477        }
478    }
479}
480
481/// Summary of a proposal GC pass.
482#[derive(Debug, Clone, Default, Serialize)]
483pub struct ProposalGcSummary {
484    pub expired: u32,
485    pub auto_accepted: u32,
486    pub failed: u32,
487    pub dry_run: bool,
488}
489
490/// Run the proposal-queue hygiene pass.
491///
492/// 1. Expires pending proposals older than `opts.expiry_days` via
493///    `reject_proposal` with reason `"expired"`.
494/// 2. Optionally auto-accepts proposals whose `proposed_confidence` is
495///    above `opts.auto_accept_confidence`.
496///
497/// All mutations go through the existing event-sourced
498/// `reject_proposal` / `accept_proposal` paths — rebuild-safe.
499pub fn gc_proposals(start: &Path, opts: ProposalGcOptions) -> KimetsuResult<ProposalGcSummary> {
500    let mut summary = ProposalGcSummary {
501        dry_run: opts.dry_run,
502        ..Default::default()
503    };
504
505    if opts.expiry_days == 0 && opts.auto_accept_confidence >= 1.0 {
506        return Ok(summary); // nothing to do
507    }
508
509    // Load pending proposals.
510    let pending = {
511        let filter = crate::project::ProposalFilter {
512            status: Some("pending".to_string()),
513            limit: 1000,
514            ..Default::default()
515        };
516        crate::project::list_proposals(start, filter)?
517    };
518
519    let now = OffsetDateTime::now_utc();
520
521    for proposal in &pending {
522        // ---- Expiry check ----
523        if opts.expiry_days > 0 {
524            // proposals table doesn't store created_at directly; derive from the
525            // memory.proposed event timestamp via the events table rowid ordering.
526            // Fallback: if we can't parse a timestamp, skip expiry for this row.
527            let proposal_ts = proposal_created_at(start, &proposal.proposal_id);
528            if let Some(created_at) = proposal_ts {
529                let age_days =
530                    (now.unix_timestamp() - created_at.unix_timestamp()) as f64 / 86_400.0;
531                if age_days >= opts.expiry_days as f64 {
532                    if !opts.dry_run {
533                        match reject_proposal(start, &proposal.proposal_id, Some("expired")) {
534                            Ok(()) => summary.expired += 1,
535                            Err(_) => summary.failed += 1,
536                        }
537                    } else {
538                        summary.expired += 1;
539                    }
540                    continue; // don't also auto-accept something we just expired
541                }
542            }
543        }
544
545        // ---- Auto-accept check ----
546        if opts.auto_accept_confidence < 1.0
547            && proposal.proposed_confidence >= opts.auto_accept_confidence
548        {
549            if !opts.dry_run {
550                match crate::project::accept_proposal(
551                    start,
552                    &proposal.proposal_id,
553                    AcceptOverrides::default(),
554                ) {
555                    Ok(_) => summary.auto_accepted += 1,
556                    Err(_) => summary.failed += 1,
557                }
558            } else {
559                summary.auto_accepted += 1;
560            }
561        }
562    }
563
564    Ok(summary)
565}
566
567/// Look up the wall-clock timestamp of the `memory.proposed` event for a
568/// given `proposal_id`. Returns `None` when the proposal cannot be found or
569/// the timestamp cannot be parsed.
570fn proposal_created_at(start: &Path, proposal_id: &str) -> Option<OffsetDateTime> {
571    let conn = crate::project::load_project(start)
572        .ok()
573        .map(|(_, _, c)| c)?;
574
575    let ts_str: Option<String> = conn
576        .query_row(
577            "SELECT ts FROM events
578             WHERE kind = 'memory.proposed'
579               AND json_extract(payload_json, '$.proposal_id') = ?1
580             ORDER BY rowid ASC
581             LIMIT 1",
582            params![proposal_id],
583            |r| r.get(0),
584        )
585        .optional()
586        .ok()
587        .flatten();
588
589    ts_str
590        .as_deref()
591        .and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok())
592}
593
594// ---------------------------------------------------------------------------
595// Story 3.4 — Analytics: invalidations by reason
596// ---------------------------------------------------------------------------
597
598/// Count of invalidations grouped by structured reason.
599#[derive(Debug, Clone, Serialize)]
600pub struct InvalidationByReason {
601    /// The canonical reason string (matches `InvalidationReason::as_str()`).
602    pub reason: String,
603    pub count: u64,
604}
605
606/// Return a summary of all invalidated memories grouped by their structured
607/// reason (normalised via `InvalidationReason::from_db`).
608pub fn invalidations_by_reason(conn: &Connection) -> KimetsuResult<Vec<InvalidationByReason>> {
609    let mut stmt = conn.prepare(
610        "SELECT COALESCE(invalidated_reason, 'manual') AS reason, COUNT(*) AS cnt
611         FROM memories
612         WHERE invalidated_at IS NOT NULL
613         GROUP BY reason
614         ORDER BY cnt DESC",
615    )?;
616
617    let rows = stmt.query_map([], |row| {
618        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
619    })?;
620
621    let mut grouped: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
622    for row in rows {
623        let (raw_reason, count) = row?;
624        let canonical = InvalidationReason::from_db(&raw_reason)
625            .as_str()
626            .to_string();
627        *grouped.entry(canonical).or_insert(0) += count as u64;
628    }
629
630    let mut result: Vec<InvalidationByReason> = grouped
631        .into_iter()
632        .map(|(reason, count)| InvalidationByReason { reason, count })
633        .collect();
634    result.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.reason.cmp(&b.reason)));
635    Ok(result)
636}
637
638// ---------------------------------------------------------------------------
639// Tests
640// ---------------------------------------------------------------------------
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::project::invalidate_memory;
646    use crate::{
647        project::{add_memory, init_project, propose_memory},
648        projector,
649        user_brain::with_user_brain_disabled,
650    };
651    use kimetsu_core::{
652        event::Event,
653        ids::RunId,
654        memory::{MemoryKind, MemoryScope},
655    };
656    use ulid::Ulid;
657
658    fn test_root() -> std::path::PathBuf {
659        let root = std::env::temp_dir().join(format!("kimetsu-lc-test-{}", Ulid::new()));
660        kimetsu_core::paths::git_init_boundary(&root);
661        root
662    }
663
664    #[test]
665    fn hardening_archive_scan_revalidates_correction_and_recent_use() {
666        let c = Connection::open_in_memory().unwrap();
667        crate::schema::initialize(&c).unwrap();
668        for id in ["corrected", "useful", "old"] {
669            let mut e = Event::new(
670                RunId::new(),
671                "memory.accepted",
672                serde_json::json!({"memory_id":id,"scope":"project","kind":"fact","text":id}),
673            );
674            e.ts = OffsetDateTime::parse("2020-01-01T00:00:00Z", &Rfc3339).unwrap();
675            projector::apply_events(&c, &[e]).unwrap();
676        }
677        let cutoff = "2025-01-01T00:00:00Z";
678        let opts = ForgetOptions {
679            usefulness_floor: 0.0,
680            ..Default::default()
681        };
682        let candidates = query_forget_candidates(&c, 0.0, cutoff, 10).unwrap();
683        assert_eq!(candidates.len(), 3);
684        projector::apply_events(
685            &c,
686            &[Event::new(
687                RunId::new(),
688                "memory.corrected",
689                serde_json::json!({"memory_id":"corrected","text":"new claim"}),
690            )],
691        )
692        .unwrap();
693        c.execute(
694            "UPDATE memories SET last_useful_at='2026-01-01T00:00:00Z' WHERE memory_id='useful'",
695            [],
696        )
697        .unwrap();
698        projector::with_write_txn(&c, |c| {
699            assert_eq!(archive_candidates_locked(c, &opts, cutoff, &candidates)?, 1);
700            Ok(())
701        })
702        .unwrap();
703        assert_eq!(
704            c.query_row(
705                "SELECT memory_id FROM memories WHERE invalidated_at IS NOT NULL",
706                [],
707                |r| r.get::<_, String>(0)
708            )
709            .unwrap(),
710            "old"
711        );
712    }
713
714    // -------------------------------------------------------------------------
715    // Story 3.4: InvalidationReason round-trips
716    // -------------------------------------------------------------------------
717
718    #[test]
719    fn invalidation_reason_as_str_round_trips() {
720        let reasons = [
721            InvalidationReason::Obsolete,
722            InvalidationReason::Superseded,
723            InvalidationReason::Conflicted,
724            InvalidationReason::Incorrect,
725            InvalidationReason::Duplicate,
726            InvalidationReason::Forgotten,
727            InvalidationReason::Manual,
728        ];
729        for r in &reasons {
730            let s = r.as_str();
731            let parsed = InvalidationReason::from_db(s);
732            assert_eq!(&parsed, r, "from_db(as_str()) must round-trip for {:?}", r);
733        }
734    }
735
736    #[test]
737    fn invalidation_reason_legacy_strings_parse_correctly() {
738        assert_eq!(
739            InvalidationReason::from_db("forgotten/archived"),
740            InvalidationReason::Forgotten
741        );
742        assert_eq!(
743            InvalidationReason::from_db("some unknown old reason"),
744            InvalidationReason::Manual
745        );
746        assert_eq!(
747            InvalidationReason::from_db("invalidated_by_cli"),
748            InvalidationReason::Manual
749        );
750    }
751
752    // -------------------------------------------------------------------------
753    // Story 3.4: invalidations_by_reason groups correctly
754    // -------------------------------------------------------------------------
755
756    #[test]
757    fn invalidations_by_reason_groups_structured_reasons() {
758        with_user_brain_disabled(|| {
759            let root = test_root();
760            init_project(&root, false).expect("init");
761
762            let m1 =
763                add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact one").expect("m1");
764            let m2 =
765                add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact two").expect("m2");
766            let m3 = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact three")
767                .expect("m3");
768
769            invalidate_memory(&root, &m1, Some("forgotten")).expect("inv m1");
770            invalidate_memory(&root, &m2, Some("forgotten")).expect("inv m2");
771            invalidate_memory(&root, &m3, Some("obsolete")).expect("inv m3");
772
773            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
774            let by_reason = invalidations_by_reason(&conn).expect("by_reason");
775
776            let forgotten_count = by_reason
777                .iter()
778                .find(|r| r.reason == "forgotten")
779                .map(|r| r.count)
780                .unwrap_or(0);
781            assert_eq!(forgotten_count, 2, "expected 2 forgotten");
782
783            let obsolete_count = by_reason
784                .iter()
785                .find(|r| r.reason == "obsolete")
786                .map(|r| r.count)
787                .unwrap_or(0);
788            assert_eq!(obsolete_count, 1, "expected 1 obsolete");
789
790            std::fs::remove_dir_all(&root).ok();
791        });
792    }
793
794    // -------------------------------------------------------------------------
795    // Story 3.1: forget_brain dry-run identifies noise, not signal
796    // -------------------------------------------------------------------------
797
798    /// Seed an aged memory and its usefulness (the fixtures represent memories
799    /// already existing at their last-useful timestamp, not created today).
800    fn set_memory_usefulness(
801        conn: &rusqlite::Connection,
802        memory_id: &str,
803        use_count: i64,
804        usefulness_score: f64,
805        last_useful_at: Option<&str>,
806    ) {
807        conn.execute(
808            "UPDATE memories SET use_count=?2, usefulness_score=?3, last_useful_at=?4,
809                 created_at=COALESCE(?4,created_at) WHERE memory_id=?1",
810            rusqlite::params![memory_id, use_count, usefulness_score, last_useful_at],
811        )
812        .expect("set_memory_usefulness");
813    }
814
815    #[test]
816    fn forget_brain_dry_run_identifies_noise_keeps_signal() {
817        with_user_brain_disabled(|| {
818            let root = test_root();
819            init_project(&root, false).expect("init");
820
821            // Noise: low usefulness, old, low use_count
822            let noise = add_memory(
823                &root,
824                MemoryScope::Project,
825                MemoryKind::Fact,
826                "noise memory stale unused",
827            )
828            .expect("noise");
829
830            // Signal: high use_count → evergreen → protected
831            let signal = add_memory(
832                &root,
833                MemoryScope::Project,
834                MemoryKind::FailurePattern,
835                "evergreen failure pattern cited many times",
836            )
837            .expect("signal");
838
839            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
840
841            // Noise: usefulness=-0.5, use_count=2, last_useful 200 days ago
842            let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(200 * 86_400))
843                .format(&Rfc3339)
844                .unwrap();
845            set_memory_usefulness(&conn, &noise, 2, -0.5, Some(&old_ts));
846
847            // Signal: use_count=15 (protected), good usefulness
848            let recent_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(5 * 86_400))
849                .format(&Rfc3339)
850                .unwrap();
851            set_memory_usefulness(&conn, &signal, 15, 5.0, Some(&recent_ts));
852            drop(conn);
853
854            let opts = ForgetOptions {
855                dry_run: true,
856                usefulness_floor: -0.1,
857                min_age_days: 90,
858                protect_use_count: 10,
859            };
860            let summary = forget_brain(&root, opts).expect("forget_brain dry-run");
861
862            assert!(summary.dry_run, "must be a dry-run");
863            assert_eq!(summary.archived, 0, "dry-run must archive nothing");
864            let ids: Vec<&str> = summary
865                .candidates
866                .iter()
867                .map(|c| c.memory_id.as_str())
868                .collect();
869            assert!(ids.contains(&noise.as_str()), "noise must be a candidate");
870            assert!(
871                !ids.contains(&signal.as_str()),
872                "signal (use_count=15) must be protected"
873            );
874
875            std::fs::remove_dir_all(&root).ok();
876        });
877    }
878
879    #[test]
880    fn forgetting_uses_meaningful_recency_and_not_harmful_popularity() {
881        let conn = rusqlite::Connection::open_in_memory().unwrap();
882        crate::schema::initialize(&conn).unwrap();
883        for (id, kind, created, used, useful, count, score) in [
884            (
885                "harmful-popular",
886                "fact",
887                "2020-01-01T00:00:00Z",
888                "2026-01-01T00:00:00Z",
889                "2020-01-01T00:00:00Z",
890                20,
891                -10.0,
892            ),
893            (
894                "recently-useful",
895                "fact",
896                "2020-01-01T00:00:00Z",
897                "2020-01-01T00:00:00Z",
898                "2026-01-01T00:00:00Z",
899                1,
900                -0.5,
901            ),
902            (
903                "recently-created",
904                "fact",
905                "2026-01-01T00:00:00Z",
906                "2020-01-01T00:00:00Z",
907                "2020-01-01T00:00:00Z",
908                1,
909                -0.5,
910            ),
911            (
912                "durable-preference",
913                "preference",
914                "2020-01-01T00:00:00Z",
915                "2020-01-01T00:00:00Z",
916                "2020-01-01T00:00:00Z",
917                1,
918                -0.5,
919            ),
920        ] {
921            conn.execute("INSERT INTO memories (memory_id,scope,kind,text,normalized_text,confidence,
922                provenance_snapshot_json,created_at,last_used_at,last_useful_at,use_count,usefulness_score)
923                VALUES (?1,'project',?2,?1,?1,0.5,'{}',?3,?4,?5,?6,?7)",
924                params![id,kind,created,used,useful,count,score]).unwrap();
925        }
926        let candidates = query_forget_candidates(&conn, -0.1, "2025-01-01T00:00:00Z", 10).unwrap();
927        let ids: Vec<_> = candidates.iter().map(|c| c.memory_id.as_str()).collect();
928        assert_eq!(ids, vec!["harmful-popular"]);
929    }
930
931    // v2.6 recall-preservation fix: a memory that was RETRIEVED recently
932    // (`last_used_at` set, e.g. injected into a recent run) is in active use and
933    // must NOT be forgotten just because it has low usefulness and was never
934    // explicitly cited — even when its `created_at` is old.
935    #[test]
936    fn forget_protects_recently_retrieved_via_last_used_at() {
937        with_user_brain_disabled(|| {
938            let root = test_root();
939            init_project(&root, false).expect("init");
940
941            let retrieved = add_memory(
942                &root,
943                MemoryScope::Project,
944                MemoryKind::Fact,
945                "old low-usefulness memory that is still being retrieved",
946            )
947            .expect("retrieved");
948            let stale = add_memory(
949                &root,
950                MemoryScope::Project,
951                MemoryKind::Fact,
952                "old low-usefulness memory never retrieved again",
953            )
954            .expect("stale");
955
956            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
957            let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(200 * 86_400))
958                .format(&Rfc3339)
959                .unwrap();
960            let recent_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(2 * 86_400))
961                .format(&Rfc3339)
962                .unwrap();
963            // Both: old created_at, low usefulness, use_count 0, never cited.
964            // `retrieved` was surfaced 2 days ago (last_used_at recent).
965            conn.execute(
966                "UPDATE memories SET created_at = ?2, usefulness_score = 0.0, use_count = 0,
967                     last_useful_at = NULL, last_used_at = ?3 WHERE memory_id = ?1",
968                rusqlite::params![retrieved, old_ts, recent_ts],
969            )
970            .unwrap();
971            conn.execute(
972                "UPDATE memories SET created_at = ?2, usefulness_score = 0.0, use_count = 0,
973                     last_useful_at = NULL, last_used_at = NULL WHERE memory_id = ?1",
974                rusqlite::params![stale, old_ts],
975            )
976            .unwrap();
977            drop(conn);
978
979            let opts = ForgetOptions {
980                dry_run: true,
981                usefulness_floor: 0.1,
982                min_age_days: 90,
983                protect_use_count: 10,
984            };
985            let summary = forget_brain(&root, opts).expect("forget dry-run");
986            let ids: Vec<&str> = summary
987                .candidates
988                .iter()
989                .map(|c| c.memory_id.as_str())
990                .collect();
991            assert!(
992                ids.contains(&stale.as_str()),
993                "a stale, never-retrieved memory must be a forget candidate"
994            );
995            assert!(
996                !ids.contains(&retrieved.as_str()),
997                "a recently-retrieved (in-use) memory must be protected from forgetting"
998            );
999
1000            std::fs::remove_dir_all(&root).ok();
1001        });
1002    }
1003
1004    #[test]
1005    fn forget_brain_apply_invalidates_noise_keeps_signal() {
1006        with_user_brain_disabled(|| {
1007            let root = test_root();
1008            init_project(&root, false).expect("init");
1009
1010            let noise = add_memory(
1011                &root,
1012                MemoryScope::Project,
1013                MemoryKind::Fact,
1014                "forget me noise",
1015            )
1016            .expect("noise");
1017            let signal = add_memory(
1018                &root,
1019                MemoryScope::Project,
1020                MemoryKind::Convention,
1021                "keep me evergreen",
1022            )
1023            .expect("signal");
1024
1025            {
1026                let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1027                let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(200 * 86_400))
1028                    .format(&Rfc3339)
1029                    .unwrap();
1030                set_memory_usefulness(&conn, &noise, 2, -0.5, Some(&old_ts));
1031                let recent_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(5 * 86_400))
1032                    .format(&Rfc3339)
1033                    .unwrap();
1034                set_memory_usefulness(&conn, &signal, 15, 5.0, Some(&recent_ts));
1035            }
1036
1037            let opts = ForgetOptions {
1038                dry_run: false,
1039                usefulness_floor: -0.1,
1040                min_age_days: 90,
1041                protect_use_count: 10,
1042            };
1043            let summary = forget_brain(&root, opts).expect("forget_brain apply");
1044            assert_eq!(summary.failed, 0, "no failures");
1045            assert!(summary.archived >= 1, "must archive at least noise");
1046
1047            // Verify noise is now invalidated in DB.
1048            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1049            let noise_inv: Option<String> = conn
1050                .query_row(
1051                    "SELECT invalidated_reason FROM memories WHERE memory_id=?1",
1052                    rusqlite::params![noise],
1053                    |r| r.get(0),
1054                )
1055                .optional()
1056                .expect("query")
1057                .flatten();
1058            assert_eq!(
1059                noise_inv.as_deref(),
1060                Some("forgotten"),
1061                "noise must be invalidated with reason=forgotten"
1062            );
1063
1064            // Verify signal is still active.
1065            let signal_inv: Option<String> = conn
1066                .query_row(
1067                    "SELECT invalidated_at FROM memories WHERE memory_id=?1",
1068                    rusqlite::params![signal],
1069                    |r| r.get(0),
1070                )
1071                .optional()
1072                .expect("query")
1073                .flatten();
1074            assert!(signal_inv.is_none(), "signal must NOT be invalidated");
1075
1076            std::fs::remove_dir_all(&root).ok();
1077        });
1078    }
1079
1080    // -------------------------------------------------------------------------
1081    // Story 3.2: regret_flagged_memories
1082    // -------------------------------------------------------------------------
1083
1084    fn seed_regret(conn: &rusqlite::Connection, memory_id: &str, n: usize) {
1085        let run_id = RunId::new();
1086        for _ in 0..n {
1087            let ev = Event::new(
1088                run_id,
1089                "retrieval.regret",
1090                serde_json::json!({
1091                    "memory_id": memory_id,
1092                    "dropped_at": 1000,
1093                    "cited_at": 2000,
1094                    "score": 0.1
1095                }),
1096            );
1097            projector::apply_events(conn, &[ev]).expect("seed regret");
1098        }
1099    }
1100
1101    #[test]
1102    fn regret_flagged_memories_flags_above_threshold() {
1103        with_user_brain_disabled(|| {
1104            let root = test_root();
1105            init_project(&root, false).expect("init");
1106
1107            let m1 = add_memory(
1108                &root,
1109                MemoryScope::Project,
1110                MemoryKind::Fact,
1111                "regret flagged memory",
1112            )
1113            .expect("m1");
1114            let m2 = add_memory(
1115                &root,
1116                MemoryScope::Project,
1117                MemoryKind::Fact,
1118                "not enough regrets",
1119            )
1120            .expect("m2");
1121
1122            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1123            seed_regret(&conn, &m1, 5);
1124            seed_regret(&conn, &m2, 1);
1125
1126            let flagged = regret_flagged_memories(&conn, 3).expect("regret_flagged");
1127            let ids: Vec<&str> = flagged.iter().map(|f| f.memory_id.as_str()).collect();
1128            assert!(ids.contains(&m1.as_str()), "m1 must be flagged (5 regrets)");
1129            assert!(
1130                !ids.contains(&m2.as_str()),
1131                "m2 must NOT be flagged (1 regret < threshold=3)"
1132            );
1133
1134            std::fs::remove_dir_all(&root).ok();
1135        });
1136    }
1137
1138    // -------------------------------------------------------------------------
1139    // Story 3.3: gc_proposals expiry
1140    // -------------------------------------------------------------------------
1141
1142    #[test]
1143    fn gc_proposals_expires_old_pending_keeps_fresh() {
1144        with_user_brain_disabled(|| {
1145            let root = test_root();
1146            init_project(&root, false).expect("init");
1147
1148            // Create two proposals.
1149            let _old_prop = propose_memory(
1150                &root,
1151                MemoryScope::Project,
1152                MemoryKind::Fact,
1153                "old proposal",
1154                0.5,
1155                "old rationale",
1156            )
1157            .expect("old prop");
1158            let _fresh_prop = propose_memory(
1159                &root,
1160                MemoryScope::Project,
1161                MemoryKind::Fact,
1162                "fresh proposal",
1163                0.5,
1164                "fresh rationale",
1165            )
1166            .expect("fresh prop");
1167
1168            // Artificially age the old proposal's event by back-dating it.
1169            {
1170                let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1171                let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(60 * 86_400))
1172                    .format(&Rfc3339)
1173                    .unwrap();
1174                conn.execute(
1175                    "UPDATE events SET ts=?1 WHERE kind='memory.proposed'
1176                     AND json_extract(payload_json,'$.proposal_id')=?2",
1177                    rusqlite::params![old_ts, _old_prop],
1178                )
1179                .expect("back-date event");
1180            }
1181
1182            let opts = ProposalGcOptions {
1183                expiry_days: 30,
1184                auto_accept_confidence: 1.1,
1185                dry_run: false,
1186            };
1187            let summary = gc_proposals(&root, opts).expect("gc_proposals");
1188
1189            assert_eq!(summary.expired, 1, "one old proposal must be expired");
1190
1191            // Verify old proposal is rejected in DB.
1192            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1193            let old_status: String = conn
1194                .query_row(
1195                    "SELECT status FROM memory_proposals WHERE proposal_id=?1",
1196                    rusqlite::params![_old_prop],
1197                    |r| r.get(0),
1198                )
1199                .expect("old status");
1200            assert_eq!(old_status, "rejected", "old proposal must be rejected");
1201
1202            let fresh_status: String = conn
1203                .query_row(
1204                    "SELECT status FROM memory_proposals WHERE proposal_id=?1",
1205                    rusqlite::params![_fresh_prop],
1206                    |r| r.get(0),
1207                )
1208                .expect("fresh status");
1209            assert_eq!(fresh_status, "pending", "fresh proposal must stay pending");
1210
1211            std::fs::remove_dir_all(&root).ok();
1212        });
1213    }
1214}