1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AgingConfig {
22 pub degrade_after_days: u32,
25 pub degrade_to: String,
28 pub degrade_from: Vec<String>,
31 #[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 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 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 pub fn stale_seconds(&self) -> i64 {
79 self.degrade_after_days as i64 * 86_400
80 }
81}
82
83#[derive(Debug, Clone, Serialize)]
85pub struct AgingResult {
86 pub degraded_count: usize,
88 pub degraded_ids: Vec<String>,
90}
91
92pub 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 if !config.degrade_from.contains(&chunk.visibility) {
108 continue;
109 }
110
111 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 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); }
183}