1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum InvalidationReason {
69 Obsolete,
71 Superseded,
73 Conflicted,
75 Incorrect,
77 Duplicate,
79 Forgotten,
81 Manual,
83}
84
85impl InvalidationReason {
86 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 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#[derive(Debug, Clone)]
130pub struct ForgetOptions {
131 pub dry_run: bool,
133 pub usefulness_floor: f32,
136 pub min_age_days: u32,
140 pub protect_use_count: u32,
143}
144
145impl Default for ForgetOptions {
146 fn default() -> Self {
147 Self {
148 dry_run: true, usefulness_floor: -0.1,
150 min_age_days: 90,
151 protect_use_count: 10,
152 }
153 }
154}
155
156#[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 pub text_preview: String,
165 pub use_count: u32,
166 pub usefulness_score: f32,
167 pub age_days: f64,
169}
170
171#[derive(Debug, Clone, Default, Serialize)]
173pub struct ForgetSummary {
174 pub candidates: Vec<ForgetCandidate>,
176 pub archived: u32,
178 pub failed: u32,
180 pub dry_run: bool,
182}
183
184pub 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 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 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 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
233fn 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
264pub 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
272pub 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
294fn query_forget_candidates(
296 conn: &Connection,
297 usefulness_floor: f32,
298 cutoff_iso: &str,
299 protect_use_count: u32,
300) -> KimetsuResult<Vec<ForgetCandidate>> {
301 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#[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
389pub fn regret_flagged_memories(
397 conn: &Connection,
398 threshold: u64,
399) -> KimetsuResult<Vec<RegretFlaggedMemory>> {
400 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#[derive(Debug, Clone)]
461pub struct ProposalGcOptions {
462 pub expiry_days: u32,
464 pub auto_accept_confidence: f32,
467 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, dry_run: false,
477 }
478 }
479}
480
481#[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
490pub 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); }
508
509 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 if opts.expiry_days > 0 {
524 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; }
542 }
543 }
544
545 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
567fn 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#[derive(Debug, Clone, Serialize)]
600pub struct InvalidationByReason {
601 pub reason: String,
603 pub count: u64,
604}
605
606pub 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#[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 #[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 #[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 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 let noise = add_memory(
823 &root,
824 MemoryScope::Project,
825 MemoryKind::Fact,
826 "noise memory stale unused",
827 )
828 .expect("noise");
829
830 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 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 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 #[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 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 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 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 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 #[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 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 {
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 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}