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, invalidate_memory, 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 memory_id: String,
160    pub scope: String,
161    pub kind: String,
162    /// First ~80 characters of the memory text.
163    pub text_preview: String,
164    pub use_count: u32,
165    pub usefulness_score: f32,
166    /// Age in days (from `last_useful_at` / `created_at`).
167    pub age_days: f64,
168}
169
170/// Result of a `forget_brain` call.
171#[derive(Debug, Clone, Default, Serialize)]
172pub struct ForgetSummary {
173    /// Memories identified as candidates.
174    pub candidates: Vec<ForgetCandidate>,
175    /// Memories that were actually archived (0 on dry_run).
176    pub archived: u32,
177    /// Memories that could not be archived due to errors.
178    pub failed: u32,
179    /// True when this was a dry-run (nothing written).
180    pub dry_run: bool,
181}
182
183/// Run the active-forgetting policy.
184///
185/// Identifies stale low-usefulness memories and (unless `opts.dry_run`)
186/// archives them via `invalidate_memory` with reason `"forgotten"`.
187///
188/// This function is **completely gated**: it early-returns Ok(empty) when
189/// the lifecycle section has `forget_enabled = false`, so callers that
190/// always pass the config option through will never archive anything unless
191/// the user has opted in.
192pub fn forget_brain(start: &Path, opts: ForgetOptions) -> KimetsuResult<ForgetSummary> {
193    let mut summary = ForgetSummary {
194        dry_run: opts.dry_run,
195        ..Default::default()
196    };
197
198    // Compute the age cutoff timestamp.
199    let now = OffsetDateTime::now_utc();
200    let cutoff = now - time::Duration::seconds(opts.min_age_days as i64 * 86_400);
201    let cutoff_iso = cutoff.format(&Rfc3339).unwrap_or_default();
202
203    // Query candidates.
204    let candidates = {
205        let (_paths, _config, conn) = crate::project::load_project(start)?;
206        query_forget_candidates(
207            &conn,
208            opts.usefulness_floor,
209            &cutoff_iso,
210            opts.protect_use_count,
211        )?
212    };
213
214    summary.candidates = candidates.clone();
215
216    if opts.dry_run {
217        return Ok(summary);
218    }
219
220    // Archive each candidate via the event-sourced invalidate path.
221    for candidate in &candidates {
222        let reason = InvalidationReason::Forgotten.as_str();
223        match invalidate_memory(start, &candidate.memory_id, Some(reason)) {
224            Ok(()) => summary.archived += 1,
225            Err(_) => summary.failed += 1,
226        }
227    }
228
229    Ok(summary)
230}
231
232/// Query candidates that meet the forget criteria.
233fn query_forget_candidates(
234    conn: &Connection,
235    usefulness_floor: f32,
236    cutoff_iso: &str,
237    protect_use_count: u32,
238) -> KimetsuResult<Vec<ForgetCandidate>> {
239    // A memory qualifies when:
240    //   - active (not invalidated, not superseded)
241    //   - use_count < protect_use_count
242    //   - usefulness is low: score / max(use_count,1) <= floor
243    //   - stale: it has not been RETRIEVED, proven useful, or created within the
244    //     age window. The staleness reference is the most recent of
245    //     `last_used_at` (bumped on every retrieval), `last_useful_at` (bumped on
246    //     a successful citation), and `created_at`. Including `last_used_at` is
247    //     the v3.0 fix for recall-preservation: a memory that is still being
248    //     surfaced is in active use, so it must not be forgotten just because it
249    //     has a low usefulness score and was never explicitly cited.
250    let mut stmt = conn.prepare(
251        "SELECT memory_id, scope, kind, text, use_count, usefulness_score,
252                COALESCE(last_used_at, last_useful_at, created_at) AS ref_ts
253         FROM memories
254         WHERE invalidated_at IS NULL
255           AND superseded_by IS NULL
256           AND use_count < ?1
257           AND (CAST(usefulness_score AS REAL) / MAX(CAST(use_count AS REAL), 1.0)) <= ?2
258           AND COALESCE(last_used_at, last_useful_at, created_at) <= ?3
259         ORDER BY (CAST(usefulness_score AS REAL) / MAX(CAST(use_count AS REAL), 1.0)) ASC",
260    )?;
261
262    let now = OffsetDateTime::now_utc();
263    let now_secs = now.unix_timestamp() as f64;
264
265    let rows = stmt.query_map(
266        params![
267            protect_use_count as i64,
268            usefulness_floor as f64,
269            cutoff_iso
270        ],
271        |row| {
272            Ok((
273                row.get::<_, String>(0)?,
274                row.get::<_, String>(1)?,
275                row.get::<_, String>(2)?,
276                row.get::<_, String>(3)?,
277                row.get::<_, i64>(4)?,
278                row.get::<_, f64>(5)?,
279                row.get::<_, String>(6)?,
280            ))
281        },
282    )?;
283
284    let mut candidates = Vec::new();
285    for row in rows {
286        let (memory_id, scope, kind, text, use_count, usefulness_score, ref_ts) = row?;
287        let age_days = if let Ok(ref_dt) = OffsetDateTime::parse(&ref_ts, &Rfc3339) {
288            let ref_secs = ref_dt.unix_timestamp() as f64;
289            (now_secs - ref_secs) / 86_400.0
290        } else {
291            0.0
292        };
293        let text_preview: String = text.chars().take(80).collect();
294        candidates.push(ForgetCandidate {
295            memory_id,
296            scope,
297            kind,
298            text_preview,
299            use_count: use_count as u32,
300            usefulness_score: usefulness_score as f32,
301            age_days,
302        });
303    }
304    Ok(candidates)
305}
306
307// ---------------------------------------------------------------------------
308// Story 3.2 — Regret-driven review
309// ---------------------------------------------------------------------------
310
311/// A memory flagged for review due to repeated retrieval regrets.
312#[derive(Debug, Clone, Serialize)]
313pub struct RegretFlaggedMemory {
314    pub memory_id: String,
315    pub scope: String,
316    pub kind: String,
317    pub text_preview: String,
318    pub confidence: f32,
319    pub regret_count: u64,
320    pub use_count: u32,
321    pub usefulness_score: f32,
322}
323
324/// Query memories that have accumulated ≥ `threshold` `retrieval.regret`
325/// events. These are surfaced for review but NOT auto-deleted.
326///
327/// A high-confidence memory that keeps being dropped (low retrieval score)
328/// but cited by the model anyway is a signal that the memory is right but
329/// the retrieval config is mis-calibrated — OR that the memory is
330/// over-confident. Either way it deserves human attention.
331pub fn regret_flagged_memories(
332    conn: &Connection,
333    threshold: u64,
334) -> KimetsuResult<Vec<RegretFlaggedMemory>> {
335    // Count regret events per memory_id from the events table.
336    let mut stmt = conn.prepare(
337        "SELECT json_extract(payload_json, '$.memory_id') AS mid,
338                COUNT(*) AS cnt
339         FROM events
340         WHERE kind = 'retrieval.regret'
341           AND mid IS NOT NULL
342         GROUP BY mid
343         HAVING cnt >= ?1
344         ORDER BY cnt DESC",
345    )?;
346
347    let rows = stmt.query_map(params![threshold as i64], |row| {
348        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
349    })?;
350
351    let mut flagged = Vec::new();
352    for row in rows {
353        let (memory_id, regret_count) = row?;
354        let mem_row: Option<(String, String, String, f32, i64, f64)> = conn
355            .query_row(
356                "SELECT scope, kind, text, confidence, use_count, usefulness_score
357                 FROM memories
358                 WHERE memory_id = ?1
359                   AND invalidated_at IS NULL
360                   AND superseded_by IS NULL",
361                params![memory_id],
362                |r| {
363                    Ok((
364                        r.get::<_, String>(0)?,
365                        r.get::<_, String>(1)?,
366                        r.get::<_, String>(2)?,
367                        r.get::<_, f32>(3)?,
368                        r.get::<_, i64>(4)?,
369                        r.get::<_, f64>(5)?,
370                    ))
371                },
372            )
373            .optional()?;
374        if let Some((scope, kind, text, confidence, use_count, usefulness_score)) = mem_row {
375            flagged.push(RegretFlaggedMemory {
376                memory_id,
377                scope,
378                kind,
379                text_preview: text.chars().take(80).collect(),
380                confidence,
381                regret_count: regret_count as u64,
382                use_count: use_count as u32,
383                usefulness_score: usefulness_score as f32,
384            });
385        }
386    }
387    Ok(flagged)
388}
389
390// ---------------------------------------------------------------------------
391// Story 3.3 — Proposal-queue hygiene
392// ---------------------------------------------------------------------------
393
394/// Options for the proposal GC pass.
395#[derive(Debug, Clone)]
396pub struct ProposalGcOptions {
397    /// Expire pending proposals older than this many days (0 = disabled).
398    pub expiry_days: u32,
399    /// Auto-accept proposals with `proposed_confidence >= this` threshold.
400    /// Set to 1.0 or above to disable (default = disabled = 1.1).
401    pub auto_accept_confidence: f32,
402    /// Dry-run: report what would happen without writing.
403    pub dry_run: bool,
404}
405
406impl Default for ProposalGcOptions {
407    fn default() -> Self {
408        Self {
409            expiry_days: 30,
410            auto_accept_confidence: 1.1, // disabled by default
411            dry_run: false,
412        }
413    }
414}
415
416/// Summary of a proposal GC pass.
417#[derive(Debug, Clone, Default, Serialize)]
418pub struct ProposalGcSummary {
419    pub expired: u32,
420    pub auto_accepted: u32,
421    pub failed: u32,
422    pub dry_run: bool,
423}
424
425/// Run the proposal-queue hygiene pass.
426///
427/// 1. Expires pending proposals older than `opts.expiry_days` via
428///    `reject_proposal` with reason `"expired"`.
429/// 2. Optionally auto-accepts proposals whose `proposed_confidence` is
430///    above `opts.auto_accept_confidence`.
431///
432/// All mutations go through the existing event-sourced
433/// `reject_proposal` / `accept_proposal` paths — rebuild-safe.
434pub fn gc_proposals(start: &Path, opts: ProposalGcOptions) -> KimetsuResult<ProposalGcSummary> {
435    let mut summary = ProposalGcSummary {
436        dry_run: opts.dry_run,
437        ..Default::default()
438    };
439
440    if opts.expiry_days == 0 && opts.auto_accept_confidence >= 1.0 {
441        return Ok(summary); // nothing to do
442    }
443
444    // Load pending proposals.
445    let pending = {
446        let filter = crate::project::ProposalFilter {
447            status: Some("pending".to_string()),
448            limit: 1000,
449            ..Default::default()
450        };
451        crate::project::list_proposals(start, filter)?
452    };
453
454    let now = OffsetDateTime::now_utc();
455
456    for proposal in &pending {
457        // ---- Expiry check ----
458        if opts.expiry_days > 0 {
459            // proposals table doesn't store created_at directly; derive from the
460            // memory.proposed event timestamp via the events table rowid ordering.
461            // Fallback: if we can't parse a timestamp, skip expiry for this row.
462            let proposal_ts = proposal_created_at(start, &proposal.proposal_id);
463            if let Some(created_at) = proposal_ts {
464                let age_days =
465                    (now.unix_timestamp() - created_at.unix_timestamp()) as f64 / 86_400.0;
466                if age_days >= opts.expiry_days as f64 {
467                    if !opts.dry_run {
468                        match reject_proposal(start, &proposal.proposal_id, Some("expired")) {
469                            Ok(()) => summary.expired += 1,
470                            Err(_) => summary.failed += 1,
471                        }
472                    } else {
473                        summary.expired += 1;
474                    }
475                    continue; // don't also auto-accept something we just expired
476                }
477            }
478        }
479
480        // ---- Auto-accept check ----
481        if opts.auto_accept_confidence < 1.0
482            && proposal.proposed_confidence >= opts.auto_accept_confidence
483        {
484            if !opts.dry_run {
485                match crate::project::accept_proposal(
486                    start,
487                    &proposal.proposal_id,
488                    AcceptOverrides::default(),
489                ) {
490                    Ok(_) => summary.auto_accepted += 1,
491                    Err(_) => summary.failed += 1,
492                }
493            } else {
494                summary.auto_accepted += 1;
495            }
496        }
497    }
498
499    Ok(summary)
500}
501
502/// Look up the wall-clock timestamp of the `memory.proposed` event for a
503/// given `proposal_id`. Returns `None` when the proposal cannot be found or
504/// the timestamp cannot be parsed.
505fn proposal_created_at(start: &Path, proposal_id: &str) -> Option<OffsetDateTime> {
506    let conn = crate::project::load_project(start)
507        .ok()
508        .map(|(_, _, c)| c)?;
509
510    let ts_str: Option<String> = conn
511        .query_row(
512            "SELECT ts FROM events
513             WHERE kind = 'memory.proposed'
514               AND json_extract(payload_json, '$.proposal_id') = ?1
515             ORDER BY rowid ASC
516             LIMIT 1",
517            params![proposal_id],
518            |r| r.get(0),
519        )
520        .optional()
521        .ok()
522        .flatten();
523
524    ts_str
525        .as_deref()
526        .and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok())
527}
528
529// ---------------------------------------------------------------------------
530// Story 3.4 — Analytics: invalidations by reason
531// ---------------------------------------------------------------------------
532
533/// Count of invalidations grouped by structured reason.
534#[derive(Debug, Clone, Serialize)]
535pub struct InvalidationByReason {
536    /// The canonical reason string (matches `InvalidationReason::as_str()`).
537    pub reason: String,
538    pub count: u64,
539}
540
541/// Return a summary of all invalidated memories grouped by their structured
542/// reason (normalised via `InvalidationReason::from_db`).
543pub fn invalidations_by_reason(conn: &Connection) -> KimetsuResult<Vec<InvalidationByReason>> {
544    let mut stmt = conn.prepare(
545        "SELECT COALESCE(invalidated_reason, 'manual') AS reason, COUNT(*) AS cnt
546         FROM memories
547         WHERE invalidated_at IS NOT NULL
548         GROUP BY reason
549         ORDER BY cnt DESC",
550    )?;
551
552    let rows = stmt.query_map([], |row| {
553        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
554    })?;
555
556    let mut grouped: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
557    for row in rows {
558        let (raw_reason, count) = row?;
559        let canonical = InvalidationReason::from_db(&raw_reason)
560            .as_str()
561            .to_string();
562        *grouped.entry(canonical).or_insert(0) += count as u64;
563    }
564
565    let mut result: Vec<InvalidationByReason> = grouped
566        .into_iter()
567        .map(|(reason, count)| InvalidationByReason { reason, count })
568        .collect();
569    result.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.reason.cmp(&b.reason)));
570    Ok(result)
571}
572
573// ---------------------------------------------------------------------------
574// Tests
575// ---------------------------------------------------------------------------
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use crate::{
581        project::{add_memory, init_project, propose_memory},
582        projector,
583        user_brain::with_user_brain_disabled,
584    };
585    use kimetsu_core::{
586        event::Event,
587        ids::RunId,
588        memory::{MemoryKind, MemoryScope},
589    };
590    use ulid::Ulid;
591
592    fn test_root() -> std::path::PathBuf {
593        let root = std::env::temp_dir().join(format!("kimetsu-lc-test-{}", Ulid::new()));
594        kimetsu_core::paths::git_init_boundary(&root);
595        root
596    }
597
598    // -------------------------------------------------------------------------
599    // Story 3.4: InvalidationReason round-trips
600    // -------------------------------------------------------------------------
601
602    #[test]
603    fn invalidation_reason_as_str_round_trips() {
604        let reasons = [
605            InvalidationReason::Obsolete,
606            InvalidationReason::Superseded,
607            InvalidationReason::Conflicted,
608            InvalidationReason::Incorrect,
609            InvalidationReason::Duplicate,
610            InvalidationReason::Forgotten,
611            InvalidationReason::Manual,
612        ];
613        for r in &reasons {
614            let s = r.as_str();
615            let parsed = InvalidationReason::from_db(s);
616            assert_eq!(&parsed, r, "from_db(as_str()) must round-trip for {:?}", r);
617        }
618    }
619
620    #[test]
621    fn invalidation_reason_legacy_strings_parse_correctly() {
622        assert_eq!(
623            InvalidationReason::from_db("forgotten/archived"),
624            InvalidationReason::Forgotten
625        );
626        assert_eq!(
627            InvalidationReason::from_db("some unknown old reason"),
628            InvalidationReason::Manual
629        );
630        assert_eq!(
631            InvalidationReason::from_db("invalidated_by_cli"),
632            InvalidationReason::Manual
633        );
634    }
635
636    // -------------------------------------------------------------------------
637    // Story 3.4: invalidations_by_reason groups correctly
638    // -------------------------------------------------------------------------
639
640    #[test]
641    fn invalidations_by_reason_groups_structured_reasons() {
642        with_user_brain_disabled(|| {
643            let root = test_root();
644            init_project(&root, false).expect("init");
645
646            let m1 =
647                add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact one").expect("m1");
648            let m2 =
649                add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact two").expect("m2");
650            let m3 = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "fact three")
651                .expect("m3");
652
653            invalidate_memory(&root, &m1, Some("forgotten")).expect("inv m1");
654            invalidate_memory(&root, &m2, Some("forgotten")).expect("inv m2");
655            invalidate_memory(&root, &m3, Some("obsolete")).expect("inv m3");
656
657            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
658            let by_reason = invalidations_by_reason(&conn).expect("by_reason");
659
660            let forgotten_count = by_reason
661                .iter()
662                .find(|r| r.reason == "forgotten")
663                .map(|r| r.count)
664                .unwrap_or(0);
665            assert_eq!(forgotten_count, 2, "expected 2 forgotten");
666
667            let obsolete_count = by_reason
668                .iter()
669                .find(|r| r.reason == "obsolete")
670                .map(|r| r.count)
671                .unwrap_or(0);
672            assert_eq!(obsolete_count, 1, "expected 1 obsolete");
673
674            std::fs::remove_dir_all(&root).ok();
675        });
676    }
677
678    // -------------------------------------------------------------------------
679    // Story 3.1: forget_brain dry-run identifies noise, not signal
680    // -------------------------------------------------------------------------
681
682    /// Helper to directly set usefulness_score + last_useful_at on a memory
683    /// row (bypasses the event system for test speed).
684    fn set_memory_usefulness(
685        conn: &rusqlite::Connection,
686        memory_id: &str,
687        use_count: i64,
688        usefulness_score: f64,
689        last_useful_at: Option<&str>,
690    ) {
691        conn.execute(
692            "UPDATE memories SET use_count=?2, usefulness_score=?3, last_useful_at=?4 WHERE memory_id=?1",
693            rusqlite::params![memory_id, use_count, usefulness_score, last_useful_at],
694        )
695        .expect("set_memory_usefulness");
696    }
697
698    #[test]
699    fn forget_brain_dry_run_identifies_noise_keeps_signal() {
700        with_user_brain_disabled(|| {
701            let root = test_root();
702            init_project(&root, false).expect("init");
703
704            // Noise: low usefulness, old, low use_count
705            let noise = add_memory(
706                &root,
707                MemoryScope::Project,
708                MemoryKind::Fact,
709                "noise memory stale unused",
710            )
711            .expect("noise");
712
713            // Signal: high use_count → evergreen → protected
714            let signal = add_memory(
715                &root,
716                MemoryScope::Project,
717                MemoryKind::FailurePattern,
718                "evergreen failure pattern cited many times",
719            )
720            .expect("signal");
721
722            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
723
724            // Noise: usefulness=-0.5, use_count=2, last_useful 200 days ago
725            let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(200 * 86_400))
726                .format(&Rfc3339)
727                .unwrap();
728            set_memory_usefulness(&conn, &noise, 2, -0.5, Some(&old_ts));
729
730            // Signal: use_count=15 (protected), good usefulness
731            let recent_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(5 * 86_400))
732                .format(&Rfc3339)
733                .unwrap();
734            set_memory_usefulness(&conn, &signal, 15, 5.0, Some(&recent_ts));
735            drop(conn);
736
737            let opts = ForgetOptions {
738                dry_run: true,
739                usefulness_floor: -0.1,
740                min_age_days: 90,
741                protect_use_count: 10,
742            };
743            let summary = forget_brain(&root, opts).expect("forget_brain dry-run");
744
745            assert!(summary.dry_run, "must be a dry-run");
746            assert_eq!(summary.archived, 0, "dry-run must archive nothing");
747            let ids: Vec<&str> = summary
748                .candidates
749                .iter()
750                .map(|c| c.memory_id.as_str())
751                .collect();
752            assert!(ids.contains(&noise.as_str()), "noise must be a candidate");
753            assert!(
754                !ids.contains(&signal.as_str()),
755                "signal (use_count=15) must be protected"
756            );
757
758            std::fs::remove_dir_all(&root).ok();
759        });
760    }
761
762    // v3.0 recall-preservation fix: a memory that was RETRIEVED recently
763    // (`last_used_at` set, e.g. injected into a recent run) is in active use and
764    // must NOT be forgotten just because it has low usefulness and was never
765    // explicitly cited — even when its `created_at` is old.
766    #[test]
767    fn forget_protects_recently_retrieved_via_last_used_at() {
768        with_user_brain_disabled(|| {
769            let root = test_root();
770            init_project(&root, false).expect("init");
771
772            let retrieved = add_memory(
773                &root,
774                MemoryScope::Project,
775                MemoryKind::Fact,
776                "old low-usefulness memory that is still being retrieved",
777            )
778            .expect("retrieved");
779            let stale = add_memory(
780                &root,
781                MemoryScope::Project,
782                MemoryKind::Fact,
783                "old low-usefulness memory never retrieved again",
784            )
785            .expect("stale");
786
787            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
788            let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(200 * 86_400))
789                .format(&Rfc3339)
790                .unwrap();
791            let recent_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(2 * 86_400))
792                .format(&Rfc3339)
793                .unwrap();
794            // Both: old created_at, low usefulness, use_count 0, never cited.
795            // `retrieved` was surfaced 2 days ago (last_used_at recent).
796            conn.execute(
797                "UPDATE memories SET created_at = ?2, usefulness_score = 0.0, use_count = 0,
798                     last_useful_at = NULL, last_used_at = ?3 WHERE memory_id = ?1",
799                rusqlite::params![retrieved, old_ts, recent_ts],
800            )
801            .unwrap();
802            conn.execute(
803                "UPDATE memories SET created_at = ?2, usefulness_score = 0.0, use_count = 0,
804                     last_useful_at = NULL, last_used_at = NULL WHERE memory_id = ?1",
805                rusqlite::params![stale, old_ts],
806            )
807            .unwrap();
808            drop(conn);
809
810            let opts = ForgetOptions {
811                dry_run: true,
812                usefulness_floor: 0.1,
813                min_age_days: 90,
814                protect_use_count: 10,
815            };
816            let summary = forget_brain(&root, opts).expect("forget dry-run");
817            let ids: Vec<&str> = summary
818                .candidates
819                .iter()
820                .map(|c| c.memory_id.as_str())
821                .collect();
822            assert!(
823                ids.contains(&stale.as_str()),
824                "a stale, never-retrieved memory must be a forget candidate"
825            );
826            assert!(
827                !ids.contains(&retrieved.as_str()),
828                "a recently-retrieved (in-use) memory must be protected from forgetting"
829            );
830
831            std::fs::remove_dir_all(&root).ok();
832        });
833    }
834
835    #[test]
836    fn forget_brain_apply_invalidates_noise_keeps_signal() {
837        with_user_brain_disabled(|| {
838            let root = test_root();
839            init_project(&root, false).expect("init");
840
841            let noise = add_memory(
842                &root,
843                MemoryScope::Project,
844                MemoryKind::Fact,
845                "forget me noise",
846            )
847            .expect("noise");
848            let signal = add_memory(
849                &root,
850                MemoryScope::Project,
851                MemoryKind::Convention,
852                "keep me evergreen",
853            )
854            .expect("signal");
855
856            {
857                let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
858                let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(200 * 86_400))
859                    .format(&Rfc3339)
860                    .unwrap();
861                set_memory_usefulness(&conn, &noise, 2, -0.5, Some(&old_ts));
862                let recent_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(5 * 86_400))
863                    .format(&Rfc3339)
864                    .unwrap();
865                set_memory_usefulness(&conn, &signal, 15, 5.0, Some(&recent_ts));
866            }
867
868            let opts = ForgetOptions {
869                dry_run: false,
870                usefulness_floor: -0.1,
871                min_age_days: 90,
872                protect_use_count: 10,
873            };
874            let summary = forget_brain(&root, opts).expect("forget_brain apply");
875            assert_eq!(summary.failed, 0, "no failures");
876            assert!(summary.archived >= 1, "must archive at least noise");
877
878            // Verify noise is now invalidated in DB.
879            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
880            let noise_inv: Option<String> = conn
881                .query_row(
882                    "SELECT invalidated_reason FROM memories WHERE memory_id=?1",
883                    rusqlite::params![noise],
884                    |r| r.get(0),
885                )
886                .optional()
887                .expect("query")
888                .flatten();
889            assert_eq!(
890                noise_inv.as_deref(),
891                Some("forgotten"),
892                "noise must be invalidated with reason=forgotten"
893            );
894
895            // Verify signal is still active.
896            let signal_inv: Option<String> = conn
897                .query_row(
898                    "SELECT invalidated_at FROM memories WHERE memory_id=?1",
899                    rusqlite::params![signal],
900                    |r| r.get(0),
901                )
902                .optional()
903                .expect("query")
904                .flatten();
905            assert!(signal_inv.is_none(), "signal must NOT be invalidated");
906
907            std::fs::remove_dir_all(&root).ok();
908        });
909    }
910
911    // -------------------------------------------------------------------------
912    // Story 3.2: regret_flagged_memories
913    // -------------------------------------------------------------------------
914
915    fn seed_regret(conn: &rusqlite::Connection, memory_id: &str, n: usize) {
916        let run_id = RunId::new();
917        for _ in 0..n {
918            let ev = Event::new(
919                run_id,
920                "retrieval.regret",
921                serde_json::json!({
922                    "memory_id": memory_id,
923                    "dropped_at": 1000,
924                    "cited_at": 2000,
925                    "score": 0.1
926                }),
927            );
928            projector::apply_events(conn, &[ev]).expect("seed regret");
929        }
930    }
931
932    #[test]
933    fn regret_flagged_memories_flags_above_threshold() {
934        with_user_brain_disabled(|| {
935            let root = test_root();
936            init_project(&root, false).expect("init");
937
938            let m1 = add_memory(
939                &root,
940                MemoryScope::Project,
941                MemoryKind::Fact,
942                "regret flagged memory",
943            )
944            .expect("m1");
945            let m2 = add_memory(
946                &root,
947                MemoryScope::Project,
948                MemoryKind::Fact,
949                "not enough regrets",
950            )
951            .expect("m2");
952
953            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
954            seed_regret(&conn, &m1, 5);
955            seed_regret(&conn, &m2, 1);
956
957            let flagged = regret_flagged_memories(&conn, 3).expect("regret_flagged");
958            let ids: Vec<&str> = flagged.iter().map(|f| f.memory_id.as_str()).collect();
959            assert!(ids.contains(&m1.as_str()), "m1 must be flagged (5 regrets)");
960            assert!(
961                !ids.contains(&m2.as_str()),
962                "m2 must NOT be flagged (1 regret < threshold=3)"
963            );
964
965            std::fs::remove_dir_all(&root).ok();
966        });
967    }
968
969    // -------------------------------------------------------------------------
970    // Story 3.3: gc_proposals expiry
971    // -------------------------------------------------------------------------
972
973    #[test]
974    fn gc_proposals_expires_old_pending_keeps_fresh() {
975        with_user_brain_disabled(|| {
976            let root = test_root();
977            init_project(&root, false).expect("init");
978
979            // Create two proposals.
980            let _old_prop = propose_memory(
981                &root,
982                MemoryScope::Project,
983                MemoryKind::Fact,
984                "old proposal",
985                0.5,
986                "old rationale",
987            )
988            .expect("old prop");
989            let _fresh_prop = propose_memory(
990                &root,
991                MemoryScope::Project,
992                MemoryKind::Fact,
993                "fresh proposal",
994                0.5,
995                "fresh rationale",
996            )
997            .expect("fresh prop");
998
999            // Artificially age the old proposal's event by back-dating it.
1000            {
1001                let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1002                let old_ts = (OffsetDateTime::now_utc() - time::Duration::seconds(60 * 86_400))
1003                    .format(&Rfc3339)
1004                    .unwrap();
1005                conn.execute(
1006                    "UPDATE events SET ts=?1 WHERE kind='memory.proposed'
1007                     AND json_extract(payload_json,'$.proposal_id')=?2",
1008                    rusqlite::params![old_ts, _old_prop],
1009                )
1010                .expect("back-date event");
1011            }
1012
1013            let opts = ProposalGcOptions {
1014                expiry_days: 30,
1015                auto_accept_confidence: 1.1,
1016                dry_run: false,
1017            };
1018            let summary = gc_proposals(&root, opts).expect("gc_proposals");
1019
1020            assert_eq!(summary.expired, 1, "one old proposal must be expired");
1021
1022            // Verify old proposal is rejected in DB.
1023            let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1024            let old_status: String = conn
1025                .query_row(
1026                    "SELECT status FROM memory_proposals WHERE proposal_id=?1",
1027                    rusqlite::params![_old_prop],
1028                    |r| r.get(0),
1029                )
1030                .expect("old status");
1031            assert_eq!(old_status, "rejected", "old proposal must be rejected");
1032
1033            let fresh_status: String = conn
1034                .query_row(
1035                    "SELECT status FROM memory_proposals WHERE proposal_id=?1",
1036                    rusqlite::params![_fresh_prop],
1037                    |r| r.get(0),
1038                )
1039                .expect("fresh status");
1040            assert_eq!(fresh_status, "pending", "fresh proposal must stay pending");
1041
1042            std::fs::remove_dir_all(&root).ok();
1043        });
1044    }
1045}