Skip to main content

recall_echo/graph/
gc.rs

1//! Garbage collection for the knowledge graph.
2//!
3//! Sweep order: stale relationships → dead relationships → orphaned entities →
4//! spent episodes → delete. Dry-run by default. Pipeline-linked entities are
5//! protected, and so is anything a surviving record cites as its source.
6
7use 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
18/// Default age, in days, before a never-retrieved episode may be collected.
19///
20/// Two confidence half-lives: an episode nothing has read in half a year, and
21/// which no surviving entity or edge cites, is storage rather than memory.
22pub const DEFAULT_EPISODE_MAX_AGE_DAYS: u64 = 180;
23
24/// Configuration for garbage collection thresholds.
25#[derive(Debug, Clone)]
26pub struct GcConfig {
27    /// Days since valid_from before a low-confidence relationship is considered stale.
28    pub stale_days: u64,
29    /// Confidence threshold for stale relationships (below this = candidate).
30    pub stale_confidence: f64,
31    /// Confidence threshold for dead relationships (below this + age check = dead).
32    pub dead_confidence: f64,
33    /// Minimum age in days for dead relationship pruning.
34    pub dead_min_age_days: u64,
35    /// If true, also sweep episodes. Off by default: the relationship sweep
36    /// predates episode collection and must keep behaving as it did.
37    pub collect_episodes: bool,
38    /// Days since an episode's timestamp before it may be collected.
39    pub episode_max_age_days: u64,
40    /// If true, only report — don't delete anything.
41    pub dry_run: bool,
42    /// If true, never GC entities linked to pipeline documents.
43    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/// A single GC action with reason.
62#[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/// What kind of thing is being collected.
71#[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/// Report from a GC run.
91#[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
106/// Run garbage collection on the graph.
107pub 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    // Load all relationships and entities
115    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    // Phase 1: Stale relationship decay
121    let stale_ids = phase_stale_relationships(&all_rels, config, &now, &mut report);
122
123    // Phase 2: Dead relationship pruning
124    let dead_ids = phase_dead_relationships(&all_rels, config, &now, &stale_ids, &mut report);
125
126    // Collect all relationship IDs to delete
127    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    // Phase 3: Orphaned entity removal (must account for relationships being removed)
132    let orphan_ids =
133        phase_orphaned_entities(db, &all_entities, config, &rel_ids_to_delete, &mut report).await?;
134
135    // Phase 4: Spent episodes (must account for everything above being removed:
136    // an episode is evidence only for records that survive the sweep)
137    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    // Phase 5: Execute deletions
144    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
182/// Records this sweep has already decided to remove. Episode collection reads
183/// it so that "cited by a surviving record" means what it says.
184struct Pending<'a> {
185    relationships: &'a [String],
186    entities: &'a [String],
187}
188
189/// Phase 1: Find relationships older than stale_days with effective confidence below stale_confidence.
190/// Uses temporal decay — effective confidence accounts for time since last reinforcement.
191/// Only considers active relationships (valid_until is None).
192fn 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        // Skip already-superseded relationships
202        if rel.valid_until.is_some() {
203            continue;
204        }
205
206        // Compute effective confidence with temporal decay
207        let effective = confidence::effective_confidence(
208            rel.confidence,
209            rel.last_reinforced.as_ref(),
210            &rel.valid_from,
211            now,
212        );
213
214        // Check effective confidence threshold
215        if effective >= config.stale_confidence {
216            continue;
217        }
218
219        // Check age
220        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
257/// Phase 2: Find very low effective confidence relationships older than dead_min_age_days.
258/// Uses temporal decay. Excludes relationships already caught in phase 1.
259fn 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        // Skip if already caught in phase 1
272        if already_caught.contains(&id) {
273            continue;
274        }
275
276        // Compute effective confidence with temporal decay
277        let effective = confidence::effective_confidence(
278            rel.confidence,
279            rel.last_reinforced.as_ref(),
280            &rel.valid_from,
281            now,
282        );
283
284        // Check effective confidence threshold (lower bar than stale)
285        if effective >= config.dead_confidence {
286            continue;
287        }
288
289        // Check minimum age
290        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
326/// Phase 3: Find entities with zero relationships (accounting for pending deletions),
327/// zero access_count, and no pipeline linkage.
328async 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        // Skip entities that have been accessed
339        if entity.access_count > 0 {
340            continue;
341        }
342
343        // Skip pipeline-linked entities if protection is on
344        if config.protect_pipeline && is_pipeline_entity(entity) {
345            continue;
346        }
347
348        // Count current relationships
349        let entity_id = entity.id_string();
350        let current_rels = crud::count_relationships(db, &entity_id).await?;
351
352        // Count how many of those relationships are being deleted
353        // (we need to check the actual relationship IDs touching this entity)
354        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
379/// Count how many of the pending relationship deletions affect a given entity.
380async 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    // Get all relationships for this entity and check overlap with pending deletions
390    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// ── Episodes ─────────────────────────────────────────────────────────
419//
420// Episodes are the raw text a session left behind. The graph does not link
421// them to entities or edges directly; the one linkage the schema has is by
422// session: entities and relationships extracted from a session carry its id
423// in `source`. So "evidence for a surviving record" is exactly "some record
424// that survives this sweep cites my session as its source", and an episode is
425// collectable only when it is old, never retrieved, self-authored, and cited
426// by nothing.
427
428/// The scan projection for an episode: everything the sweep judges on, and
429/// nothing it does not — no content, no embedding.
430#[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    /// Who authored this episode; absent and unrecognised resolve to `self`.
449    fn provenance(&self) -> Provenance {
450        Provenance::from_stored(self.provenance.as_deref())
451    }
452
453    /// How this episode reads in a GC report.
454    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
462/// Phase 4: find episodes nothing needs any more.
463async 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
498/// Why this episode is collectable, or `None` if it is not.
499///
500/// All four conditions must hold, and the order is cheapest-first:
501/// never retrieved, self-authored, cited by no surviving record, older than
502/// the configured age. An unparseable timestamp keeps the episode.
503fn 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
530/// Load every episode's judgeable fields.
531async 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
541/// Session ids cited as `source` by records that survive this sweep.
542async 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
575/// Check if an entity is linked to a pipeline document.
576fn is_pipeline_entity(entity: &Entity) -> bool {
577    // Check source field
578    if let Some(ref source) = entity.source {
579        if source.starts_with("pipeline:") {
580            return true;
581        }
582    }
583
584    // Check attributes for pipeline_stage
585    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
596/// Extract a short ID from a record ID value (e.g. "entity:abc" → "abc").
597fn 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
604/// Render a record ID value whole (e.g. "entity:abc"), as deletion needs it.
605fn 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
612/// Get stats-only report without computing deletion candidates.
613/// Uses effective confidence (with temporal decay) for threshold counts.
614pub 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/// Health stats without running GC.
664#[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    /// Count of relationships with effective (decayed) confidence < 0.5
671    pub low_confidence_rels: u64,
672    /// Count of relationships with effective (decayed) confidence < 0.2
673    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    // ── Episode collection ───────────────────────────────────────────
978
979    /// An episode as old as `age_days`, never retrieved, self-authored — the
980    /// shape that is collectable unless a test says otherwise.
981    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        // The human's words and ingested documents are not the agent's to
1037        // discard: they are the only evidence in the store that is not the
1038        // agent restating itself.
1039        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        // AC7's conservative default cuts the other way here: unlabelled text
1054        // reads as self-authored, and self-authored text is collectable.
1055        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        // A relationship with stored confidence 0.6 (above stale threshold 0.5)
1142        // but last reinforced 180 days ago — decay brings effective to ~0.15
1143        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, // Above stale threshold!
1155            alpha: Some(6.0),
1156            beta: Some(4.0),
1157            self_reinforcements: Some(0),
1158            last_reinforced: None, // Never reinforced, so decays from valid_from
1159            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        // Without decay: 0.6 >= 0.5, would NOT be caught
1167        // With decay: 0.6 * 0.5^(180/90) = 0.6 * 0.25 = 0.15 < 0.5, IS caught
1168        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        // Same stored confidence 0.6, old valid_from, but recently reinforced
1178        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        // Reinforced 5 days ago: effective ≈ 0.6 * 0.5^(5/90) ≈ 0.577 > 0.5
1203        assert!(
1204            stale.is_empty(),
1205            "recently reinforced relationship should NOT be stale"
1206        );
1207    }
1208}