1use std::collections::HashSet;
8
9use chrono::{DateTime, Utc};
10use surrealdb::Surreal;
11
12use super::confidence::{self, Provenance};
13use super::crud;
14use super::error::GraphError;
15use super::store::Db;
16use super::types::{Entity, Relationship};
17
18pub const DEFAULT_EPISODE_MAX_AGE_DAYS: u64 = 180;
23
24#[derive(Debug, Clone)]
26pub struct GcConfig {
27 pub stale_days: u64,
29 pub stale_confidence: f64,
31 pub dead_confidence: f64,
33 pub dead_min_age_days: u64,
35 pub collect_episodes: bool,
38 pub episode_max_age_days: u64,
40 pub dry_run: bool,
42 pub protect_pipeline: bool,
44}
45
46impl Default for GcConfig {
47 fn default() -> Self {
48 Self {
49 stale_days: 30,
50 stale_confidence: 0.5,
51 dead_confidence: 0.2,
52 dead_min_age_days: 14,
53 collect_episodes: false,
54 episode_max_age_days: DEFAULT_EPISODE_MAX_AGE_DAYS,
55 dry_run: true,
56 protect_pipeline: true,
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct GcAction {
64 pub target_id: String,
65 pub target_name: String,
66 pub kind: GcActionKind,
67 pub reason: String,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub enum GcActionKind {
73 StaleRelationship,
74 DeadRelationship,
75 OrphanedEntity,
76 SpentEpisode,
77}
78
79impl std::fmt::Display for GcActionKind {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Self::StaleRelationship => write!(f, "stale_relationship"),
83 Self::DeadRelationship => write!(f, "dead_relationship"),
84 Self::OrphanedEntity => write!(f, "orphaned_entity"),
85 Self::SpentEpisode => write!(f, "spent_episode"),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default)]
92pub struct GcReport {
93 pub entities_scanned: u64,
94 pub relationships_scanned: u64,
95 pub episodes_scanned: u64,
96 pub stale_relationships: u64,
97 pub dead_relationships: u64,
98 pub orphaned_entities: u64,
99 pub spent_episodes: u64,
100 pub total_removed: u64,
101 pub dry_run: bool,
102 pub actions: Vec<GcAction>,
103 pub errors: Vec<String>,
104}
105
106pub async fn run_gc(db: &Surreal<Db>, config: &GcConfig) -> Result<GcReport, GraphError> {
108 let now = Utc::now();
109 let mut report = GcReport {
110 dry_run: config.dry_run,
111 ..Default::default()
112 };
113
114 let all_rels = crud::list_all_relationships(db).await?;
116 let all_entities = crud::list_entities(db, None).await?;
117 report.relationships_scanned = all_rels.len() as u64;
118 report.entities_scanned = all_entities.len() as u64;
119
120 let stale_ids = phase_stale_relationships(&all_rels, config, &now, &mut report);
122
123 let dead_ids = phase_dead_relationships(&all_rels, config, &now, &stale_ids, &mut report);
125
126 let mut rel_ids_to_delete: Vec<String> = Vec::new();
128 rel_ids_to_delete.extend(stale_ids);
129 rel_ids_to_delete.extend(dead_ids);
130
131 let orphan_ids =
133 phase_orphaned_entities(db, &all_entities, config, &rel_ids_to_delete, &mut report).await?;
134
135 let pending = Pending {
138 relationships: &rel_ids_to_delete,
139 entities: &orphan_ids,
140 };
141 let episode_ids = phase_spent_episodes(db, config, &now, &pending, &mut report).await?;
142
143 if !config.dry_run {
145 for rel_id in &rel_ids_to_delete {
146 if let Err(e) = crud::delete_relationship(db, rel_id).await {
147 report
148 .errors
149 .push(format!("Failed to delete relationship {rel_id}: {e}"));
150 } else {
151 report.total_removed += 1;
152 }
153 }
154
155 for entity_id in &orphan_ids {
156 if let Err(e) = crud::delete_entity(db, entity_id).await {
157 report
158 .errors
159 .push(format!("Failed to delete entity {entity_id}: {e}"));
160 } else {
161 report.total_removed += 1;
162 }
163 }
164
165 for episode_id in &episode_ids {
166 if let Err(e) = crud::delete_episode(db, episode_id).await {
167 report
168 .errors
169 .push(format!("Failed to delete episode {episode_id}: {e}"));
170 } else {
171 report.total_removed += 1;
172 }
173 }
174 } else {
175 report.total_removed =
176 (rel_ids_to_delete.len() + orphan_ids.len() + episode_ids.len()) as u64;
177 }
178
179 Ok(report)
180}
181
182struct Pending<'a> {
185 relationships: &'a [String],
186 entities: &'a [String],
187}
188
189fn phase_stale_relationships(
193 rels: &[Relationship],
194 config: &GcConfig,
195 now: &DateTime<Utc>,
196 report: &mut GcReport,
197) -> Vec<String> {
198 let mut stale_ids = Vec::new();
199
200 for rel in rels {
201 if rel.valid_until.is_some() {
203 continue;
204 }
205
206 let effective = confidence::effective_confidence(
208 rel.confidence,
209 rel.last_reinforced.as_ref(),
210 &rel.valid_from,
211 now,
212 );
213
214 if effective >= config.stale_confidence {
216 continue;
217 }
218
219 let age_days = match parse_datetime(&rel.valid_from) {
221 Some(dt) => (*now - dt).num_days(),
222 None => continue,
223 };
224
225 if age_days < config.stale_days as i64 {
226 continue;
227 }
228
229 let id = rel.id_string();
230 let description = rel.description.as_deref().unwrap_or("(no description)");
231 report.actions.push(GcAction {
232 target_id: id.clone(),
233 target_name: format!(
234 "{} --[{}]--> {}",
235 value_to_short_id(&rel.from_id),
236 rel.rel_type,
237 value_to_short_id(&rel.to_id)
238 ),
239 kind: GcActionKind::StaleRelationship,
240 reason: format!(
241 "effective_confidence {:.2} (stored {:.2}) < {:.2}, age {} days > {}, desc: {}",
242 effective,
243 rel.confidence,
244 config.stale_confidence,
245 age_days,
246 config.stale_days,
247 description
248 ),
249 });
250 stale_ids.push(id);
251 report.stale_relationships += 1;
252 }
253
254 stale_ids
255}
256
257fn phase_dead_relationships(
260 rels: &[Relationship],
261 config: &GcConfig,
262 now: &DateTime<Utc>,
263 already_caught: &[String],
264 report: &mut GcReport,
265) -> Vec<String> {
266 let mut dead_ids = Vec::new();
267
268 for rel in rels {
269 let id = rel.id_string();
270
271 if already_caught.contains(&id) {
273 continue;
274 }
275
276 let effective = confidence::effective_confidence(
278 rel.confidence,
279 rel.last_reinforced.as_ref(),
280 &rel.valid_from,
281 now,
282 );
283
284 if effective >= config.dead_confidence {
286 continue;
287 }
288
289 let age_days = match parse_datetime(&rel.valid_from) {
291 Some(dt) => (*now - dt).num_days(),
292 None => continue,
293 };
294
295 if age_days < config.dead_min_age_days as i64 {
296 continue;
297 }
298
299 let description = rel.description.as_deref().unwrap_or("(no description)");
300 report.actions.push(GcAction {
301 target_id: id.clone(),
302 target_name: format!(
303 "{} --[{}]--> {}",
304 value_to_short_id(&rel.from_id),
305 rel.rel_type,
306 value_to_short_id(&rel.to_id)
307 ),
308 kind: GcActionKind::DeadRelationship,
309 reason: format!(
310 "effective_confidence {:.2} (stored {:.2}) < {:.2}, age {} days > {}, desc: {}",
311 effective,
312 rel.confidence,
313 config.dead_confidence,
314 age_days,
315 config.dead_min_age_days,
316 description
317 ),
318 });
319 dead_ids.push(id);
320 report.dead_relationships += 1;
321 }
322
323 dead_ids
324}
325
326async fn phase_orphaned_entities(
329 db: &Surreal<Db>,
330 entities: &[Entity],
331 config: &GcConfig,
332 pending_rel_deletions: &[String],
333 report: &mut GcReport,
334) -> Result<Vec<String>, GraphError> {
335 let mut orphan_ids = Vec::new();
336
337 for entity in entities {
338 if entity.access_count > 0 {
340 continue;
341 }
342
343 if config.protect_pipeline && is_pipeline_entity(entity) {
345 continue;
346 }
347
348 let entity_id = entity.id_string();
350 let current_rels = crud::count_relationships(db, &entity_id).await?;
351
352 let rels_being_deleted =
355 count_pending_deletions_for_entity(db, &entity_id, pending_rel_deletions).await?;
356
357 let remaining = current_rels.saturating_sub(rels_being_deleted);
358
359 if remaining > 0 {
360 continue;
361 }
362
363 report.actions.push(GcAction {
364 target_id: entity_id.clone(),
365 target_name: format!("{} ({})", entity.name, entity.entity_type),
366 kind: GcActionKind::OrphanedEntity,
367 reason: format!(
368 "zero relationships after pruning, access_count={}",
369 entity.access_count
370 ),
371 });
372 orphan_ids.push(entity_id);
373 report.orphaned_entities += 1;
374 }
375
376 Ok(orphan_ids)
377}
378
379async fn count_pending_deletions_for_entity(
381 db: &Surreal<Db>,
382 entity_id: &str,
383 pending_deletions: &[String],
384) -> Result<u64, GraphError> {
385 if pending_deletions.is_empty() {
386 return Ok(0);
387 }
388
389 let mut response = db
391 .query(
392 r#"SELECT id FROM relates_to
393 WHERE in = type::record($id) OR out = type::record($id)"#,
394 )
395 .bind(("id", entity_id.to_string()))
396 .await?;
397
398 #[derive(serde::Deserialize)]
399 struct IdRow {
400 id: serde_json::Value,
401 }
402
403 let rows: Vec<IdRow> = super::deserialize_take(&mut response, 0)?;
404 let count = rows
405 .iter()
406 .filter(|r| {
407 let id_str = match &r.id {
408 serde_json::Value::String(s) => s.clone(),
409 other => other.to_string(),
410 };
411 pending_deletions.contains(&id_str)
412 })
413 .count();
414
415 Ok(count as u64)
416}
417
418#[derive(Debug, Clone, serde::Deserialize)]
431struct EpisodeRow {
432 id: serde_json::Value,
433 session_id: String,
434 timestamp: serde_json::Value,
435 #[serde(default)]
436 log_number: Option<i64>,
437 #[serde(default)]
438 provenance: Option<String>,
439 #[serde(default, deserialize_with = "super::util::count_or_zero")]
440 access_count: i64,
441}
442
443impl EpisodeRow {
444 fn id_string(&self) -> String {
445 value_to_record_id(&self.id)
446 }
447
448 fn provenance(&self) -> Provenance {
450 Provenance::from_stored(self.provenance.as_deref())
451 }
452
453 fn label(&self) -> String {
455 match self.log_number {
456 Some(n) => format!("{} (log {n:03})", self.session_id),
457 None => self.session_id.clone(),
458 }
459 }
460}
461
462async fn phase_spent_episodes(
464 db: &Surreal<Db>,
465 config: &GcConfig,
466 now: &DateTime<Utc>,
467 pending: &Pending<'_>,
468 report: &mut GcReport,
469) -> Result<Vec<String>, GraphError> {
470 if !config.collect_episodes {
471 return Ok(Vec::new());
472 }
473
474 let episodes = load_episode_rows(db).await?;
475 report.episodes_scanned = episodes.len() as u64;
476
477 let cited = cited_session_ids(db, pending).await?;
478 let mut spent_ids = Vec::new();
479
480 for episode in &episodes {
481 let Some(reason) = episode_prune_reason(episode, &cited, config, now) else {
482 continue;
483 };
484
485 report.actions.push(GcAction {
486 target_id: episode.id_string(),
487 target_name: episode.label(),
488 kind: GcActionKind::SpentEpisode,
489 reason,
490 });
491 spent_ids.push(episode.id_string());
492 report.spent_episodes += 1;
493 }
494
495 Ok(spent_ids)
496}
497
498fn episode_prune_reason(
504 episode: &EpisodeRow,
505 cited_sessions: &HashSet<String>,
506 config: &GcConfig,
507 now: &DateTime<Utc>,
508) -> Option<String> {
509 if episode.access_count > 0 {
510 return None;
511 }
512 if episode.provenance() != Provenance::SelfGenerated {
513 return None;
514 }
515 if cited_sessions.contains(&episode.session_id) {
516 return None;
517 }
518
519 let age_days = (*now - parse_datetime(&episode.timestamp)?).num_days();
520 if age_days < config.episode_max_age_days as i64 {
521 return None;
522 }
523
524 Some(format!(
525 "age {age_days} days > {}, never retrieved, provenance self, session {} cited by nothing",
526 config.episode_max_age_days, episode.session_id
527 ))
528}
529
530async fn load_episode_rows(db: &Surreal<Db>) -> Result<Vec<EpisodeRow>, GraphError> {
532 let mut response = db
533 .query(
534 "SELECT id, session_id, timestamp, log_number, provenance, access_count FROM episode",
535 )
536 .await?;
537
538 super::deserialize_take(&mut response, 0)
539}
540
541async fn cited_session_ids(
543 db: &Surreal<Db>,
544 pending: &Pending<'_>,
545) -> Result<HashSet<String>, GraphError> {
546 #[derive(serde::Deserialize)]
547 struct SourceRow {
548 id: serde_json::Value,
549 source: Option<String>,
550 }
551
552 let mut cited = HashSet::new();
553
554 for (table, doomed) in [
555 ("relates_to", pending.relationships),
556 ("entity", pending.entities),
557 ] {
558 let query = format!("SELECT id, source FROM {table} WHERE source IS NOT NONE");
559 let mut response = db.query(&query).await?;
560 let rows: Vec<SourceRow> = super::deserialize_take(&mut response, 0)?;
561
562 for row in rows {
563 if doomed.contains(&value_to_record_id(&row.id)) {
564 continue;
565 }
566 if let Some(source) = row.source {
567 cited.insert(source);
568 }
569 }
570 }
571
572 Ok(cited)
573}
574
575fn is_pipeline_entity(entity: &Entity) -> bool {
577 if let Some(ref source) = entity.source {
579 if source.starts_with("pipeline:") {
580 return true;
581 }
582 }
583
584 if let Some(ref attrs) = entity.attributes {
586 if attrs.get("pipeline_stage").is_some() {
587 return true;
588 }
589 }
590
591 false
592}
593
594use super::util::parse_datetime;
595
596fn value_to_short_id(val: &serde_json::Value) -> String {
598 match val {
599 serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
600 other => other.to_string(),
601 }
602}
603
604fn value_to_record_id(val: &serde_json::Value) -> String {
606 match val {
607 serde_json::Value::String(s) => s.clone(),
608 other => other.to_string(),
609 }
610}
611
612pub async fn stats_only(db: &Surreal<Db>) -> Result<GcStatsReport, GraphError> {
615 let now = Utc::now();
616 let all_rels = crud::list_all_relationships(db).await?;
617 let all_entities = crud::list_entities(db, None).await?;
618
619 let pipeline_entities = all_entities
620 .iter()
621 .filter(|e| is_pipeline_entity(e))
622 .count();
623
624 let zero_access_entities = all_entities.iter().filter(|e| e.access_count == 0).count();
625
626 let low_confidence_rels = all_rels
627 .iter()
628 .filter(|r| {
629 confidence::effective_confidence(
630 r.confidence,
631 r.last_reinforced.as_ref(),
632 &r.valid_from,
633 &now,
634 ) < 0.5
635 })
636 .count();
637
638 let very_low_confidence_rels = all_rels
639 .iter()
640 .filter(|r| {
641 confidence::effective_confidence(
642 r.confidence,
643 r.last_reinforced.as_ref(),
644 &r.valid_from,
645 &now,
646 ) < 0.2
647 })
648 .count();
649
650 let superseded_rels = all_rels.iter().filter(|r| r.valid_until.is_some()).count();
651
652 Ok(GcStatsReport {
653 total_entities: all_entities.len() as u64,
654 total_relationships: all_rels.len() as u64,
655 pipeline_entities: pipeline_entities as u64,
656 zero_access_entities: zero_access_entities as u64,
657 low_confidence_rels: low_confidence_rels as u64,
658 very_low_confidence_rels: very_low_confidence_rels as u64,
659 superseded_rels: superseded_rels as u64,
660 })
661}
662
663#[derive(Debug, Clone)]
665pub struct GcStatsReport {
666 pub total_entities: u64,
667 pub total_relationships: u64,
668 pub pipeline_entities: u64,
669 pub zero_access_entities: u64,
670 pub low_confidence_rels: u64,
672 pub very_low_confidence_rels: u64,
674 pub superseded_rels: u64,
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn test_gc_config_defaults() {
683 let config = GcConfig::default();
684 assert_eq!(config.stale_days, 30);
685 assert_eq!(config.stale_confidence, 0.5);
686 assert_eq!(config.dead_confidence, 0.2);
687 assert_eq!(config.dead_min_age_days, 14);
688 assert!(config.dry_run);
689 assert!(config.protect_pipeline);
690 }
691
692 #[test]
693 fn test_parse_datetime_iso() {
694 let val = serde_json::Value::String("2024-01-15T10:30:00Z".to_string());
695 let dt = parse_datetime(&val);
696 assert!(dt.is_some());
697 }
698
699 #[test]
700 fn test_parse_datetime_invalid() {
701 let val = serde_json::Value::String("not-a-date".to_string());
702 let dt = parse_datetime(&val);
703 assert!(dt.is_none());
704 }
705
706 #[test]
707 fn test_parse_datetime_non_string() {
708 let val = serde_json::Value::Number(serde_json::Number::from(12345));
709 let dt = parse_datetime(&val);
710 assert!(dt.is_none());
711 }
712
713 #[test]
714 fn test_value_to_short_id() {
715 let val = serde_json::Value::String("entity:abc123".to_string());
716 assert_eq!(value_to_short_id(&val), "abc123");
717 }
718
719 #[test]
720 fn test_value_to_short_id_no_colon() {
721 let val = serde_json::Value::String("abc123".to_string());
722 assert_eq!(value_to_short_id(&val), "abc123");
723 }
724
725 #[test]
726 fn test_is_pipeline_entity_by_source() {
727 let entity = Entity {
728 id: serde_json::Value::String("entity:test".to_string()),
729 name: "Test".to_string(),
730 entity_type: super::super::types::EntityType::Thread,
731 abstract_text: "test".to_string(),
732 overview: "test".to_string(),
733 content: None,
734 attributes: None,
735 embedding: None,
736 mutable: true,
737 access_count: 0,
738 utility_score: 0.5,
739 utility_updates: 0,
740 created_at: serde_json::Value::String("2024-01-01T00:00:00Z".to_string()),
741 updated_at: serde_json::Value::String("2024-01-01T00:00:00Z".to_string()),
742 source: Some("pipeline:learning".to_string()),
743 };
744 assert!(is_pipeline_entity(&entity));
745 }
746
747 #[test]
748 fn test_is_pipeline_entity_by_attributes() {
749 let entity = Entity {
750 id: serde_json::Value::String("entity:test".to_string()),
751 name: "Test".to_string(),
752 entity_type: super::super::types::EntityType::Concept,
753 abstract_text: "test".to_string(),
754 overview: "test".to_string(),
755 content: None,
756 attributes: Some(serde_json::json!({"pipeline_stage": "thoughts"})),
757 embedding: None,
758 mutable: true,
759 access_count: 0,
760 utility_score: 0.5,
761 utility_updates: 0,
762 created_at: serde_json::Value::String("2024-01-01T00:00:00Z".to_string()),
763 updated_at: serde_json::Value::String("2024-01-01T00:00:00Z".to_string()),
764 source: None,
765 };
766 assert!(is_pipeline_entity(&entity));
767 }
768
769 #[test]
770 fn test_is_not_pipeline_entity() {
771 let entity = Entity {
772 id: serde_json::Value::String("entity:test".to_string()),
773 name: "Test".to_string(),
774 entity_type: super::super::types::EntityType::Tool,
775 abstract_text: "test".to_string(),
776 overview: "test".to_string(),
777 content: None,
778 attributes: None,
779 embedding: None,
780 mutable: true,
781 access_count: 0,
782 utility_score: 0.5,
783 utility_updates: 0,
784 created_at: serde_json::Value::String("2024-01-01T00:00:00Z".to_string()),
785 updated_at: serde_json::Value::String("2024-01-01T00:00:00Z".to_string()),
786 source: Some("llm:ingest".to_string()),
787 };
788 assert!(!is_pipeline_entity(&entity));
789 }
790
791 #[test]
792 fn test_phase_stale_relationships() {
793 let now = Utc::now();
794 let old_date = (now - chrono::Duration::days(45)).to_rfc3339();
795
796 let rels = vec![Relationship {
797 id: serde_json::Value::String("relates_to:abc".to_string()),
798 from_id: serde_json::Value::String("entity:a".to_string()),
799 to_id: serde_json::Value::String("entity:b".to_string()),
800 rel_type: "CONNECTED_TO".to_string(),
801 description: Some("test rel".to_string()),
802 valid_from: serde_json::Value::String(old_date),
803 valid_until: None,
804 confidence: 0.3,
805 alpha: Some(3.0),
806 beta: Some(7.0),
807 self_reinforcements: Some(0),
808 last_reinforced: None,
809 source: Some("ingest".to_string()),
810 }];
811
812 let config = GcConfig::default();
813 let mut report = GcReport::default();
814 let stale = phase_stale_relationships(&rels, &config, &now, &mut report);
815
816 assert_eq!(stale.len(), 1);
817 assert_eq!(report.stale_relationships, 1);
818 }
819
820 #[test]
821 fn test_phase_stale_skips_high_confidence() {
822 let now = Utc::now();
823 let old_date = (now - chrono::Duration::days(45)).to_rfc3339();
824
825 let rels = vec![Relationship {
826 id: serde_json::Value::String("relates_to:abc".to_string()),
827 from_id: serde_json::Value::String("entity:a".to_string()),
828 to_id: serde_json::Value::String("entity:b".to_string()),
829 rel_type: "CONNECTED_TO".to_string(),
830 description: None,
831 valid_from: serde_json::Value::String(old_date),
832 valid_until: None,
833 confidence: 0.8,
834 alpha: Some(8.0),
835 beta: Some(2.0),
836 self_reinforcements: Some(0),
837 last_reinforced: None,
838 source: None,
839 }];
840
841 let config = GcConfig::default();
842 let mut report = GcReport::default();
843 let stale = phase_stale_relationships(&rels, &config, &now, &mut report);
844
845 assert!(stale.is_empty());
846 }
847
848 #[test]
849 fn test_phase_stale_skips_young() {
850 let now = Utc::now();
851 let recent_date = (now - chrono::Duration::days(5)).to_rfc3339();
852
853 let rels = vec![Relationship {
854 id: serde_json::Value::String("relates_to:abc".to_string()),
855 from_id: serde_json::Value::String("entity:a".to_string()),
856 to_id: serde_json::Value::String("entity:b".to_string()),
857 rel_type: "CONNECTED_TO".to_string(),
858 description: None,
859 valid_from: serde_json::Value::String(recent_date),
860 valid_until: None,
861 confidence: 0.3,
862 alpha: Some(3.0),
863 beta: Some(7.0),
864 self_reinforcements: Some(0),
865 last_reinforced: None,
866 source: None,
867 }];
868
869 let config = GcConfig::default();
870 let mut report = GcReport::default();
871 let stale = phase_stale_relationships(&rels, &config, &now, &mut report);
872
873 assert!(stale.is_empty());
874 }
875
876 #[test]
877 fn test_phase_stale_skips_superseded() {
878 let now = Utc::now();
879 let old_date = (now - chrono::Duration::days(45)).to_rfc3339();
880
881 let rels = vec![Relationship {
882 id: serde_json::Value::String("relates_to:abc".to_string()),
883 from_id: serde_json::Value::String("entity:a".to_string()),
884 to_id: serde_json::Value::String("entity:b".to_string()),
885 rel_type: "CONNECTED_TO".to_string(),
886 description: None,
887 valid_from: serde_json::Value::String(old_date.clone()),
888 valid_until: Some(serde_json::Value::String(old_date)),
889 confidence: 0.3,
890 alpha: Some(3.0),
891 beta: Some(7.0),
892 self_reinforcements: Some(0),
893 last_reinforced: None,
894 source: None,
895 }];
896
897 let config = GcConfig::default();
898 let mut report = GcReport::default();
899 let stale = phase_stale_relationships(&rels, &config, &now, &mut report);
900
901 assert!(stale.is_empty());
902 }
903
904 #[test]
905 fn test_phase_dead_relationships() {
906 let now = Utc::now();
907 let old_date = (now - chrono::Duration::days(20)).to_rfc3339();
908
909 let rels = vec![Relationship {
910 id: serde_json::Value::String("relates_to:dead1".to_string()),
911 from_id: serde_json::Value::String("entity:a".to_string()),
912 to_id: serde_json::Value::String("entity:b".to_string()),
913 rel_type: "CONNECTED_TO".to_string(),
914 description: None,
915 valid_from: serde_json::Value::String(old_date),
916 valid_until: None,
917 confidence: 0.1,
918 alpha: Some(1.0),
919 beta: Some(9.0),
920 self_reinforcements: Some(0),
921 last_reinforced: None,
922 source: None,
923 }];
924
925 let config = GcConfig::default();
926 let mut report = GcReport::default();
927 let already_caught = vec![];
928 let dead = phase_dead_relationships(&rels, &config, &now, &already_caught, &mut report);
929
930 assert_eq!(dead.len(), 1);
931 assert_eq!(report.dead_relationships, 1);
932 }
933
934 #[test]
935 fn test_phase_dead_skips_already_caught() {
936 let now = Utc::now();
937 let old_date = (now - chrono::Duration::days(20)).to_rfc3339();
938
939 let rels = vec![Relationship {
940 id: serde_json::Value::String("relates_to:dead1".to_string()),
941 from_id: serde_json::Value::String("entity:a".to_string()),
942 to_id: serde_json::Value::String("entity:b".to_string()),
943 rel_type: "CONNECTED_TO".to_string(),
944 description: None,
945 valid_from: serde_json::Value::String(old_date),
946 valid_until: None,
947 confidence: 0.1,
948 alpha: Some(1.0),
949 beta: Some(9.0),
950 self_reinforcements: Some(0),
951 last_reinforced: None,
952 source: None,
953 }];
954
955 let config = GcConfig::default();
956 let mut report = GcReport::default();
957 let already_caught = vec!["relates_to:dead1".to_string()];
958 let dead = phase_dead_relationships(&rels, &config, &now, &already_caught, &mut report);
959
960 assert!(dead.is_empty());
961 }
962
963 #[test]
964 fn test_gc_action_kind_display() {
965 assert_eq!(
966 GcActionKind::StaleRelationship.to_string(),
967 "stale_relationship"
968 );
969 assert_eq!(
970 GcActionKind::DeadRelationship.to_string(),
971 "dead_relationship"
972 );
973 assert_eq!(GcActionKind::OrphanedEntity.to_string(), "orphaned_entity");
974 assert_eq!(GcActionKind::SpentEpisode.to_string(), "spent_episode");
975 }
976
977 fn spent_episode(age_days: i64) -> EpisodeRow {
982 EpisodeRow {
983 id: serde_json::Value::String("episode:old".to_string()),
984 session_id: "session-1".to_string(),
985 timestamp: serde_json::Value::String(
986 (Utc::now() - chrono::Duration::days(age_days)).to_rfc3339(),
987 ),
988 log_number: Some(7),
989 provenance: Some("self".to_string()),
990 access_count: 0,
991 }
992 }
993
994 fn cited(sessions: &[&str]) -> HashSet<String> {
995 sessions.iter().map(|s| (*s).to_string()).collect()
996 }
997
998 #[test]
999 fn old_unread_self_authored_episode_is_collectable() {
1000 let reason = episode_prune_reason(
1001 &spent_episode(200),
1002 &HashSet::new(),
1003 &GcConfig::default(),
1004 &Utc::now(),
1005 );
1006 let reason = reason.expect("200-day-old orphan episode should be a candidate");
1007 assert!(reason.contains("never retrieved"), "{reason}");
1008 assert!(reason.contains("session session-1"), "{reason}");
1009 }
1010
1011 #[test]
1012 fn young_episode_survives() {
1013 assert!(episode_prune_reason(
1014 &spent_episode(179),
1015 &HashSet::new(),
1016 &GcConfig::default(),
1017 &Utc::now(),
1018 )
1019 .is_none());
1020 }
1021
1022 #[test]
1023 fn retrieved_episode_survives_any_age() {
1024 let mut episode = spent_episode(3650);
1025 episode.access_count = 1;
1026
1027 assert!(
1028 episode_prune_reason(&episode, &HashSet::new(), &GcConfig::default(), &Utc::now())
1029 .is_none(),
1030 "an episode retrieval has returned is not spent"
1031 );
1032 }
1033
1034 #[test]
1035 fn non_self_authored_episodes_survive_any_age() {
1036 for class in ["user", "external"] {
1040 let mut episode = spent_episode(3650);
1041 episode.provenance = Some(class.to_string());
1042
1043 assert!(
1044 episode_prune_reason(&episode, &HashSet::new(), &GcConfig::default(), &Utc::now())
1045 .is_none(),
1046 "{class}-authored episodes must survive"
1047 );
1048 }
1049 }
1050
1051 #[test]
1052 fn legacy_episodes_without_provenance_are_collectable() {
1053 let mut episode = spent_episode(200);
1056 episode.provenance = None;
1057
1058 assert!(
1059 episode_prune_reason(&episode, &HashSet::new(), &GcConfig::default(), &Utc::now())
1060 .is_some()
1061 );
1062 }
1063
1064 #[test]
1065 fn episode_cited_as_evidence_survives_any_age() {
1066 let episode = spent_episode(3650);
1067
1068 assert!(
1069 episode_prune_reason(
1070 &episode,
1071 &cited(&["session-1"]),
1072 &GcConfig::default(),
1073 &Utc::now()
1074 )
1075 .is_none(),
1076 "an episode a surviving record cites is evidence, not garbage"
1077 );
1078 assert!(
1079 episode_prune_reason(
1080 &episode,
1081 &cited(&["some-other-session"]),
1082 &GcConfig::default(),
1083 &Utc::now()
1084 )
1085 .is_some(),
1086 "another session's citation protects nothing here"
1087 );
1088 }
1089
1090 #[test]
1091 fn unparseable_timestamp_keeps_the_episode() {
1092 let mut episode = spent_episode(200);
1093 episode.timestamp = serde_json::Value::String("not-a-date".to_string());
1094
1095 assert!(
1096 episode_prune_reason(&episode, &HashSet::new(), &GcConfig::default(), &Utc::now())
1097 .is_none(),
1098 "an episode whose age cannot be established is never collected"
1099 );
1100 }
1101
1102 #[test]
1103 fn episode_age_threshold_is_configurable() {
1104 let config = GcConfig {
1105 episode_max_age_days: 30,
1106 ..Default::default()
1107 };
1108
1109 assert!(
1110 episode_prune_reason(&spent_episode(31), &HashSet::new(), &config, &Utc::now())
1111 .is_some()
1112 );
1113 assert!(
1114 episode_prune_reason(&spent_episode(29), &HashSet::new(), &config, &Utc::now())
1115 .is_none()
1116 );
1117 }
1118
1119 #[test]
1120 fn episode_labels_carry_the_log_number_when_there_is_one() {
1121 assert_eq!(spent_episode(1).label(), "session-1 (log 007)");
1122
1123 let mut unnumbered = spent_episode(1);
1124 unnumbered.log_number = None;
1125 assert_eq!(unnumbered.label(), "session-1");
1126 }
1127
1128 #[test]
1129 fn episode_collection_is_off_by_default() {
1130 let config = GcConfig::default();
1131 assert!(!config.collect_episodes, "episodes are opt-in");
1132 assert_eq!(config.episode_max_age_days, DEFAULT_EPISODE_MAX_AGE_DAYS);
1133 assert!(
1134 config.dry_run,
1135 "and the sweep still only reports by default"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_phase_stale_decay_makes_high_stored_confidence_stale() {
1141 let now = Utc::now();
1144 let old_date = (now - chrono::Duration::days(180)).to_rfc3339();
1145
1146 let rels = vec![Relationship {
1147 id: serde_json::Value::String("relates_to:decayed".to_string()),
1148 from_id: serde_json::Value::String("entity:a".to_string()),
1149 to_id: serde_json::Value::String("entity:b".to_string()),
1150 rel_type: "CONNECTED_TO".to_string(),
1151 description: Some("decayed rel".to_string()),
1152 valid_from: serde_json::Value::String(old_date),
1153 valid_until: None,
1154 confidence: 0.6, alpha: Some(6.0),
1156 beta: Some(4.0),
1157 self_reinforcements: Some(0),
1158 last_reinforced: None, source: None,
1160 }];
1161
1162 let config = GcConfig::default();
1163 let mut report = GcReport::default();
1164 let stale = phase_stale_relationships(&rels, &config, &now, &mut report);
1165
1166 assert_eq!(
1169 stale.len(),
1170 1,
1171 "decayed relationship should be caught as stale"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_phase_stale_reinforced_prevents_decay() {
1177 let now = Utc::now();
1179 let old_date = (now - chrono::Duration::days(180)).to_rfc3339();
1180 let recent_reinforce = (now - chrono::Duration::days(5)).to_rfc3339();
1181
1182 let rels = vec![Relationship {
1183 id: serde_json::Value::String("relates_to:reinforced".to_string()),
1184 from_id: serde_json::Value::String("entity:a".to_string()),
1185 to_id: serde_json::Value::String("entity:b".to_string()),
1186 rel_type: "CONNECTED_TO".to_string(),
1187 description: None,
1188 valid_from: serde_json::Value::String(old_date),
1189 valid_until: None,
1190 confidence: 0.6,
1191 alpha: Some(6.0),
1192 beta: Some(4.0),
1193 self_reinforcements: Some(0),
1194 last_reinforced: Some(serde_json::Value::String(recent_reinforce)),
1195 source: None,
1196 }];
1197
1198 let config = GcConfig::default();
1199 let mut report = GcReport::default();
1200 let stale = phase_stale_relationships(&rels, &config, &now, &mut report);
1201
1202 assert!(
1204 stale.is_empty(),
1205 "recently reinforced relationship should NOT be stale"
1206 );
1207 }
1208}