Skip to main content

veclayer/
aging.rs

1//! Agent-configurable aging rules for automatic visibility degradation.
2//!
3//! Aging now considers salience: high-salience entries are protected
4//! from degradation even when they haven't been accessed recently.
5
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use crate::chunk::now_epoch_secs;
11use crate::salience::{self, SalienceWeights};
12use crate::{Result, VectorStore};
13
14const AGING_CONFIG_FILE: &str = "aging_config.json";
15
16/// Aging configuration: rules for automatic visibility degradation.
17///
18/// The agent sets these rules via the `configure_aging` MCP tool.
19/// `apply_aging` then executes the rules, degrading chunks that match.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AgingConfig {
22    /// Number of days without access before a chunk is degraded.
23    /// Default: 30
24    pub degrade_after_days: u32,
25    /// Visibility to assign to degraded chunks.
26    /// Default: "deep_only"
27    pub degrade_to: String,
28    /// Only degrade chunks with these visibilities.
29    /// Default: ["normal"]
30    pub degrade_from: Vec<String>,
31    /// Minimum salience score to protect an entry from degradation.
32    /// Entries with salience >= this threshold are kept even when stale.
33    /// Default: 0.15
34    #[serde(default = "default_salience_protection")]
35    pub salience_protection: f32,
36}
37
38fn default_salience_protection() -> f32 {
39    0.15
40}
41
42impl Default for AgingConfig {
43    fn default() -> Self {
44        Self {
45            degrade_after_days: 30,
46            degrade_to: "deep_only".to_string(),
47            degrade_from: vec!["normal".to_string()],
48            salience_protection: default_salience_protection(),
49        }
50    }
51}
52
53impl AgingConfig {
54    /// Load from the data directory. Returns default if no config exists.
55    pub fn load(data_dir: &Path) -> Self {
56        let path = data_dir.join(AGING_CONFIG_FILE);
57        if path.exists() {
58            std::fs::read_to_string(&path)
59                .ok()
60                .and_then(|s| serde_json::from_str(&s).ok())
61                .unwrap_or_default()
62        } else {
63            Self::default()
64        }
65    }
66
67    /// Save to the data directory.
68    pub fn save(&self, data_dir: &Path) -> Result<()> {
69        let path = data_dir.join(AGING_CONFIG_FILE);
70        let json = serde_json::to_string_pretty(self).map_err(|e| {
71            crate::Error::config(format!("Failed to serialize aging config: {}", e))
72        })?;
73        std::fs::write(&path, json)?;
74        Ok(())
75    }
76
77    /// Threshold in seconds.
78    pub fn stale_seconds(&self) -> i64 {
79        self.degrade_after_days as i64 * 86_400
80    }
81}
82
83/// Result of applying aging rules.
84#[derive(Debug, Clone, Serialize)]
85pub struct AgingResult {
86    /// Number of chunks that were degraded.
87    pub degraded_count: usize,
88    /// IDs of chunks that were degraded.
89    pub degraded_ids: Vec<String>,
90}
91
92/// Apply aging rules: find stale chunks and degrade their visibility.
93///
94/// Salience protection: entries with composite salience >= `salience_protection`
95/// are skipped even when stale, preserving high-value knowledge.
96pub async fn apply_aging<S: VectorStore>(store: &S, config: &AgingConfig) -> Result<AgingResult> {
97    let now = now_epoch_secs();
98    let cutoff_secs = config.stale_seconds();
99    let weights = SalienceWeights::default();
100
101    let stale = store.get_stale_chunks(cutoff_secs, 500).await?;
102
103    let mut degraded_ids = Vec::new();
104
105    for chunk in &stale {
106        // Only degrade chunks whose current visibility is in the degrade_from list
107        if !config.degrade_from.contains(&chunk.visibility) {
108            continue;
109        }
110
111        // Check that the chunk is truly stale (no recent activity)
112        let total_recent = chunk.access_profile.hour as u32
113            + chunk.access_profile.day as u32
114            + chunk.access_profile.week as u32
115            + chunk.access_profile.month as u32;
116
117        let age_since_roll = now - chunk.access_profile.last_rolled;
118
119        if total_recent > 0 || age_since_roll < cutoff_secs {
120            continue;
121        }
122
123        // Salience protection: high-salience entries survive aging
124        let score = salience::compute(chunk, &weights);
125        if score.composite >= config.salience_protection {
126            continue;
127        }
128
129        store
130            .update_visibility(&chunk.id, &config.degrade_to)
131            .await
132            .map_err(|e| {
133                crate::Error::store(format!("Failed to degrade chunk {}: {}", chunk.id, e))
134            })?;
135        degraded_ids.push(chunk.id.clone());
136    }
137
138    Ok(AgingResult {
139        degraded_count: degraded_ids.len(),
140        degraded_ids,
141    })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use tempfile::TempDir;
148
149    #[test]
150    fn test_aging_config_default() {
151        let config = AgingConfig::default();
152        assert_eq!(config.degrade_after_days, 30);
153        assert_eq!(config.degrade_to, "deep_only");
154        assert_eq!(config.degrade_from, vec!["normal"]);
155        assert_eq!(config.stale_seconds(), 30 * 86_400);
156        assert!((config.salience_protection - 0.15).abs() < 0.001);
157    }
158
159    #[test]
160    fn test_aging_config_save_load() {
161        let temp_dir = TempDir::new().unwrap();
162        let config = AgingConfig {
163            degrade_after_days: 14,
164            degrade_to: "archived".to_string(),
165            degrade_from: vec!["normal".to_string(), "seasonal".to_string()],
166            salience_protection: 0.2,
167        };
168
169        config.save(temp_dir.path()).unwrap();
170        let loaded = AgingConfig::load(temp_dir.path());
171
172        assert_eq!(loaded.degrade_after_days, 14);
173        assert_eq!(loaded.degrade_to, "archived");
174        assert_eq!(loaded.degrade_from.len(), 2);
175    }
176
177    #[test]
178    fn test_aging_config_load_missing() {
179        let temp_dir = TempDir::new().unwrap();
180        let loaded = AgingConfig::load(temp_dir.path());
181        assert_eq!(loaded.degrade_after_days, 30); // default
182    }
183}