Skip to main content

recall_echo/graph/
gc.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Garbage collection for the knowledge graph.
6//!
7//! Sweep order: stale relationships → dead relationships → orphaned entities →
8//! spent episodes → delete. Dry-run by default. Pipeline-linked entities are
9//! protected, and so is anything a surviving record cites as its source.
10
11use 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
22/// Default age, in days, before a never-retrieved episode may be collected.
23///
24/// Two confidence half-lives: an episode nothing has read in half a year, and
25/// which no surviving entity or edge cites, is storage rather than memory.
26pub const DEFAULT_EPISODE_MAX_AGE_DAYS: u64 = 180;
27
28/// Configuration for garbage collection thresholds.
29#[derive(Debug, Clone)]
30pub struct GcConfig {
31    /// Days since valid_from before a low-confidence relationship is considered stale.
32    pub stale_days: u64,
33    /// Confidence threshold for stale relationships (below this = candidate).
34    pub stale_confidence: f64,
35    /// Confidence threshold for dead relationships (below this + age check = dead).
36    pub dead_confidence: f64,
37    /// Minimum age in days for dead relationship pruning.
38    pub dead_min_age_days: u64,
39    /// If true, also sweep episodes. Off by default: the relationship sweep
40    /// predates episode collection and must keep behaving as it did.
41    pub collect_episodes: bool,
42    /// Days since an episode's timestamp before it may be collected.
43    pub episode_max_age_days: u64,
44    /// If true, only report — don't delete anything.
45    pub dry_run: bool,
46    /// If true, never GC entities linked to pipeline documents.
47    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/// A single GC action with reason.
66#[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/// What kind of thing is being collected.
75#[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/// Report from a GC run.
95#[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
110/// Run garbage collection on the graph.
111pub 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    // Load all relationships and entities
119    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    // Phase 1: Stale relationship decay
125    let stale_ids = phase_stale_relationships(&all_rels, config, &now, &mut report);
126
127    // Phase 2: Dead relationship pruning
128    let dead_ids = phase_dead_relationships(&all_rels, config, &now, &stale_ids, &mut report);
129
130    // Collect all relationship IDs to delete
131    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    // Phase 3: Orphaned entity removal (must account for relationships being removed)
136    let orphan_ids =
137        phase_orphaned_entities(db, &all_entities, config, &rel_ids_to_delete, &mut report).await?;
138
139    // Phase 4: Spent episodes (must account for everything above being removed:
140    // an episode is evidence only for records that survive the sweep)
141    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    // Phase 5: Execute deletions
148    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
186/// Records this sweep has already decided to remove. Episode collection reads
187/// it so that "cited by a surviving record" means what it says.
188struct Pending<'a> {
189    relationships: &'a [String],
190    entities: &'a [String],
191}
192
193/// Phase 1: Find relationships older than stale_days with effective confidence below stale_confidence.
194/// Uses temporal decay — effective confidence accounts for time since last reinforcement.
195/// Only considers active relationships (valid_until is None).
196fn 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        // Skip already-superseded relationships
206        if rel.valid_until.is_some() {
207            continue;
208        }
209
210        // Compute effective confidence with temporal decay
211        let effective = confidence::effective_confidence(
212            rel.confidence,
213            rel.last_reinforced.as_ref(),
214            &rel.valid_from,
215            now,
216        );
217
218        // Check effective confidence threshold
219        if effective >= config.stale_confidence {
220            continue;
221        }
222
223        // Check age
224        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
261/// Phase 2: Find very low effective confidence relationships older than dead_min_age_days.
262/// Uses temporal decay. Excludes relationships already caught in phase 1.
263fn 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        // Skip if already caught in phase 1
276        if already_caught.contains(&id) {
277            continue;
278        }
279
280        // Compute effective confidence with temporal decay
281        let effective = confidence::effective_confidence(
282            rel.confidence,
283            rel.last_reinforced.as_ref(),
284            &rel.valid_from,
285            now,
286        );
287
288        // Check effective confidence threshold (lower bar than stale)
289        if effective >= config.dead_confidence {
290            continue;
291        }
292
293        // Check minimum age
294        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
330/// Phase 3: Find entities with zero relationships (accounting for pending deletions),
331/// zero access_count, and no pipeline linkage.
332async 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        // Skip entities that have been accessed
343        if entity.access_count > 0 {
344            continue;
345        }
346
347        // Skip pipeline-linked entities if protection is on
348        if config.protect_pipeline && is_pipeline_entity(entity) {
349            continue;
350        }
351
352        // Count current relationships
353        let entity_id = entity.id_string();
354        let current_rels = crud::count_relationships(db, &entity_id).await?;
355
356        // Count how many of those relationships are being deleted
357        // (we need to check the actual relationship IDs touching this entity)
358        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
383/// Count how many of the pending relationship deletions affect a given entity.
384async 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    // Get all relationships for this entity and check overlap with pending deletions
394    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// ── Episodes ─────────────────────────────────────────────────────────
423//
424// Episodes are the raw text a session left behind. The graph does not link
425// them to entities or edges directly; the one linkage the schema has is by
426// session: entities and relationships extracted from a session carry its id
427// in `source`. So "evidence for a surviving record" is exactly "some record
428// that survives this sweep cites my session as its source", and an episode is
429// collectable only when it is old, never retrieved, self-authored, and cited
430// by nothing.
431
432/// The scan projection for an episode: everything the sweep judges on, and
433/// nothing it does not — no content, no embedding.
434#[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    /// Who authored this episode; absent and unrecognised resolve to `self`.
453    fn provenance(&self) -> Provenance {
454        Provenance::from_stored(self.provenance.as_deref())
455    }
456
457    /// How this episode reads in a GC report.
458    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
466/// Phase 4: find episodes nothing needs any more.
467async 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
502/// Why this episode is collectable, or `None` if it is not.
503///
504/// All four conditions must hold, and the order is cheapest-first:
505/// never retrieved, self-authored, cited by no surviving record, older than
506/// the configured age. An unparseable timestamp keeps the episode.
507fn 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
534/// Load every episode's judgeable fields.
535async 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
545/// Session ids cited as `source` by records that survive this sweep.
546async 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
579/// Check if an entity is linked to a pipeline document.
580fn is_pipeline_entity(entity: &Entity) -> bool {
581    // Check source field
582    if let Some(ref source) = entity.source {
583        if source.starts_with("pipeline:") {
584            return true;
585        }
586    }
587
588    // Check attributes for pipeline_stage
589    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
600/// Extract a short ID from a record ID value (e.g. "entity:abc" → "abc").
601fn 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
608/// Render a record ID value whole (e.g. "entity:abc"), as deletion needs it.
609fn 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
616/// Get stats-only report without computing deletion candidates.
617/// Uses effective confidence (with temporal decay) for threshold counts.
618pub 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/// Health stats without running GC.
668#[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    /// Count of relationships with effective (decayed) confidence < 0.5
675    pub low_confidence_rels: u64,
676    /// Count of relationships with effective (decayed) confidence < 0.2
677    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    // ── Episode collection ───────────────────────────────────────────
982
983    /// An episode as old as `age_days`, never retrieved, self-authored — the
984    /// shape that is collectable unless a test says otherwise.
985    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        // The human's words and ingested documents are not the agent's to
1041        // discard: they are the only evidence in the store that is not the
1042        // agent restating itself.
1043        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        // AC7's conservative default cuts the other way here: unlabelled text
1058        // reads as self-authored, and self-authored text is collectable.
1059        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        // A relationship with stored confidence 0.6 (above stale threshold 0.5)
1146        // but last reinforced 180 days ago — decay brings effective to ~0.15
1147        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, // Above stale threshold!
1159            alpha: Some(6.0),
1160            beta: Some(4.0),
1161            self_reinforcements: Some(0),
1162            last_reinforced: None, // Never reinforced, so decays from valid_from
1163            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        // Without decay: 0.6 >= 0.5, would NOT be caught
1171        // With decay: 0.6 * 0.5^(180/90) = 0.6 * 0.25 = 0.15 < 0.5, IS caught
1172        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        // Same stored confidence 0.6, old valid_from, but recently reinforced
1182        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        // Reinforced 5 days ago: effective ≈ 0.6 * 0.5^(5/90) ≈ 0.577 > 0.5
1207        assert!(
1208            stale.is_empty(),
1209            "recently reinforced relationship should NOT be stale"
1210        );
1211    }
1212}