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