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, invalidate_memory, 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 memory_id: String,
160 pub scope: String,
161 pub kind: String,
162 pub text_preview: String,
164 pub use_count: u32,
165 pub usefulness_score: f32,
166 pub age_days: f64,
168}
169
170#[derive(Debug, Clone, Default, Serialize)]
172pub struct ForgetSummary {
173 pub candidates: Vec<ForgetCandidate>,
175 pub archived: u32,
177 pub failed: u32,
179 pub dry_run: bool,
181}
182
183pub 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 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 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 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
232fn query_forget_candidates(
234 conn: &Connection,
235 usefulness_floor: f32,
236 cutoff_iso: &str,
237 protect_use_count: u32,
238) -> KimetsuResult<Vec<ForgetCandidate>> {
239 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#[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
324pub fn regret_flagged_memories(
332 conn: &Connection,
333 threshold: u64,
334) -> KimetsuResult<Vec<RegretFlaggedMemory>> {
335 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#[derive(Debug, Clone)]
396pub struct ProposalGcOptions {
397 pub expiry_days: u32,
399 pub auto_accept_confidence: f32,
402 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, dry_run: false,
412 }
413 }
414}
415
416#[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
425pub 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); }
443
444 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 if opts.expiry_days > 0 {
459 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; }
477 }
478 }
479
480 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
502fn 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#[derive(Debug, Clone, Serialize)]
535pub struct InvalidationByReason {
536 pub reason: String,
538 pub count: u64,
539}
540
541pub 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#[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 #[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 #[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 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 let noise = add_memory(
706 &root,
707 MemoryScope::Project,
708 MemoryKind::Fact,
709 "noise memory stale unused",
710 )
711 .expect("noise");
712
713 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 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 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 #[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 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 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 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 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 #[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 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 {
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 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}