1use std::sync::Arc;
18use std::time::Duration;
19
20use tokio_util::sync::CancellationToken;
21use tracing::Instrument as _;
22use zeph_llm::any::AnyProvider;
23use zeph_llm::provider::LlmProvider as _;
24
25use crate::error::MemoryError;
26use crate::store::SqliteStore;
27use crate::store::messages::PromotionCandidate;
28use crate::sweep_helpers::{
29 EmbedBatchOutcome, cluster_by_cosine_similarity, embed_batch_with_validation,
30};
31use crate::types::ConversationId;
32use zeph_common::math::cosine_similarity;
33
34const MERGE_VALIDATION_MIN_SIMILARITY: f32 = 0.7;
37
38#[derive(Debug, Clone)]
42pub struct TierPromotionConfig {
43 pub enabled: bool,
45 pub promotion_min_sessions: u32,
48 pub similarity_threshold: f32,
51 pub sweep_interval_secs: u64,
53 pub sweep_batch_size: usize,
55 pub embed_timeout_secs: u64,
57}
58
59pub async fn start_tier_promotion_loop(
72 store: Arc<SqliteStore>,
73 provider: AnyProvider,
74 config: TierPromotionConfig,
75 cancel: CancellationToken,
76) {
77 if !config.enabled {
78 tracing::debug!("tier promotion disabled (tiers.enabled = false)");
79 return;
80 }
81
82 let mut ticker = tokio::time::interval(Duration::from_secs(config.sweep_interval_secs));
83 ticker.tick().await;
85
86 loop {
87 tokio::select! {
88 () = cancel.cancelled() => {
89 tracing::debug!("tier promotion loop shutting down");
90 return;
91 }
92 _ = ticker.tick() => {}
93 }
94
95 tracing::debug!("tier promotion: starting sweep");
96 let start = std::time::Instant::now();
97
98 let result = run_promotion_sweep(&store, &provider, &config).await;
99
100 let elapsed_ms = start.elapsed().as_millis();
101
102 match result {
103 Ok(stats) => {
104 tracing::info!(
105 candidates = stats.candidates_evaluated,
106 clusters = stats.clusters_formed,
107 promoted = stats.promotions_completed,
108 merge_failures = stats.merge_failures,
109 elapsed_ms,
110 "tier promotion: sweep complete"
111 );
112 }
113 Err(e) => {
114 tracing::warn!(error = %e, elapsed_ms, "tier promotion: sweep failed, will retry");
115 }
116 }
117 }
118}
119
120#[derive(Debug, Default)]
122struct SweepStats {
123 candidates_evaluated: usize,
124 clusters_formed: usize,
125 promotions_completed: usize,
126 merge_failures: usize,
127}
128
129#[tracing::instrument(name = "memory.tiers.promotion_sweep", skip_all)]
131async fn run_promotion_sweep(
132 store: &SqliteStore,
133 provider: &AnyProvider,
134 config: &TierPromotionConfig,
135) -> Result<SweepStats, MemoryError> {
136 let candidates = store
137 .find_promotion_candidates(config.promotion_min_sessions, config.sweep_batch_size)
138 .await?;
139
140 if candidates.is_empty() {
141 return Ok(SweepStats::default());
142 }
143
144 let mut stats = SweepStats {
145 candidates_evaluated: candidates.len(),
146 ..SweepStats::default()
147 };
148
149 let embedded: Vec<(PromotionCandidate, Vec<f32>)> = if provider.supports_embeddings() {
151 let texts: Vec<&str> = candidates.iter().map(|c| c.content.as_str()).collect();
152 let span = tracing::info_span!("memory.tiers.embed_batch", count = texts.len());
153 let vecs = embed_batch_with_validation(provider, &texts, "tier promotion")
154 .instrument(span)
155 .await;
156 match vecs {
157 EmbedBatchOutcome::Ok(vecs) => candidates.into_iter().zip(vecs).collect(),
158 EmbedBatchOutcome::Skip => return Ok(stats),
159 }
160 } else {
161 candidates.into_iter().map(|c| (c, Vec::new())).collect()
163 };
164
165 if embedded.is_empty() {
166 return Ok(stats);
167 }
168
169 let threshold = config.similarity_threshold;
173 let clusters = cluster_by_cosine_similarity(embedded, threshold);
174
175 for cluster in clusters {
176 if cluster.len() < 2 {
177 tracing::debug!(
179 cluster_size = cluster.len(),
180 "tier promotion: singleton cluster skipped"
181 );
182 continue;
183 }
184
185 stats.clusters_formed += 1;
186
187 let source_conv_id = cluster[0].0.conversation_id;
188
189 match merge_cluster_and_promote(
190 store,
191 provider,
192 &cluster,
193 source_conv_id,
194 Duration::from_secs(config.embed_timeout_secs),
195 )
196 .await
197 {
198 Ok(()) => stats.promotions_completed += 1,
199 Err(e) => {
200 tracing::warn!(
201 cluster_size = cluster.len(),
202 error = %e,
203 "tier promotion: cluster merge failed, skipping"
204 );
205 stats.merge_failures += 1;
206 }
207 }
208 }
209
210 Ok(stats)
211}
212
213#[tracing::instrument(name = "memory.tiers.merge_cluster_and_promote", skip_all)]
219async fn merge_cluster_and_promote(
220 store: &SqliteStore,
221 provider: &AnyProvider,
222 cluster: &[(PromotionCandidate, Vec<f32>)],
223 conversation_id: ConversationId,
224 embed_timeout: Duration,
225) -> Result<(), MemoryError> {
226 let contents: Vec<&str> = cluster.iter().map(|(c, _)| c.content.as_str()).collect();
227 let original_ids: Vec<crate::types::MessageId> = cluster.iter().map(|(c, _)| c.id).collect();
228
229 let merged = call_merge_llm(provider, &contents).await?;
230
231 let merged = merged.trim().to_owned();
233 if merged.is_empty() {
234 return Err(MemoryError::InvalidInput(
235 "LLM merge returned empty result".into(),
236 ));
237 }
238
239 if provider.supports_embeddings() {
242 let embeddings_available = cluster.iter().any(|(_, emb)| !emb.is_empty());
243 if embeddings_available {
244 match tokio::time::timeout(embed_timeout, provider.embed(&merged)).await {
245 Ok(Ok(merged_vec)) => {
246 let max_sim = cluster
247 .iter()
248 .filter(|(_, emb)| !emb.is_empty())
249 .map(|(_, emb)| cosine_similarity(&merged_vec, emb))
250 .fold(f32::NEG_INFINITY, f32::max);
251
252 if max_sim < MERGE_VALIDATION_MIN_SIMILARITY {
253 return Err(MemoryError::InvalidInput(format!(
254 "LLM merge validation failed: max similarity to originals = {max_sim:.3} < {MERGE_VALIDATION_MIN_SIMILARITY}"
255 )));
256 }
257 }
258 Ok(Err(e)) => {
259 tracing::warn!(
260 error = %e,
261 "tier promotion: failed to embed merged result, skipping similarity validation"
262 );
263 }
264 Err(_) => {
265 tracing::warn!(
266 "tier promotion: embed timed out, skipping similarity validation"
267 );
268 }
269 }
270 }
271 }
272
273 let delays_ms = [50u64, 100, 200];
276 for (attempt, &delay_ms) in delays_ms.iter().enumerate() {
277 match store
278 .promote_to_semantic(conversation_id, &merged, &original_ids)
279 .await
280 {
281 Ok(_) => break,
282 Err(e) => {
283 let is_busy = if let MemoryError::Sqlx(sqlx::Error::Database(ref db_err)) = e {
288 db_err.code().as_deref() == Some("5")
289 } else {
290 e.to_string().contains("database is locked")
291 };
292 if is_busy && attempt < delays_ms.len() - 1 {
293 tracing::warn!(
294 attempt = attempt + 1,
295 delay_ms,
296 "tier promotion: SQLite busy, retrying"
297 );
298 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
299 } else {
300 return Err(e);
301 }
302 }
303 }
304 }
305 tracing::debug!(
306 cluster_size = cluster.len(),
307 merged_len = merged.len(),
308 "tier promotion: cluster promoted to semantic"
309 );
310
311 Ok(())
312}
313
314async fn call_merge_llm(provider: &AnyProvider, contents: &[&str]) -> Result<String, MemoryError> {
316 use zeph_llm::provider::{Message, MessageMetadata, Role};
317
318 let bullet_list: String = contents
319 .iter()
320 .enumerate()
321 .map(|(i, c)| format!("{}. {c}", i + 1))
322 .collect::<Vec<_>>()
323 .join("\n");
324
325 let system_content = "You are a memory consolidation agent. \
326 Merge the following episodic memories into a single concise semantic fact. \
327 Strip timestamps, session context, hedging, and filler. \
328 Output ONLY the distilled fact as a single plain-text sentence or short paragraph. \
329 Do not add prefixes like 'The user...' or 'Fact:'.";
330
331 let user_content =
332 format!("Merge these episodic memories into one semantic fact:\n\n{bullet_list}");
333
334 let messages = vec![
335 Message {
336 role: Role::System,
337 content: system_content.to_owned(),
338 parts: vec![],
339 metadata: MessageMetadata::default(),
340 },
341 Message {
342 role: Role::User,
343 content: user_content,
344 parts: vec![],
345 metadata: MessageMetadata::default(),
346 },
347 ];
348
349 crate::sweep_helpers::llm_call_with_timeout(provider, &messages, "LLM merge").await
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn make_candidate(id: i64) -> PromotionCandidate {
357 PromotionCandidate {
358 id: crate::types::MessageId(id),
359 conversation_id: ConversationId(1),
360 content: format!("content {id}"),
361 session_count: 3,
362 importance_score: 0.5,
363 }
364 }
365
366 #[tokio::test]
372 async fn merge_validation_embed_failure_is_fail_open() {
373 let store = crate::store::SqliteStore::new(":memory:").await.unwrap();
374 let conv_id = store.create_conversation().await.unwrap();
375 let m1 = store
376 .save_message(conv_id, "user", "Alice uses Rust")
377 .await
378 .unwrap();
379 let m2 = store
380 .save_message(conv_id, "user", "Alice loves Rust")
381 .await
382 .unwrap();
383
384 let provider = zeph_llm::any::AnyProvider::Mock(
387 zeph_llm::mock::MockProvider::with_responses(vec!["Alice uses and loves Rust".into()])
388 .with_embed_invalid_input(),
389 );
390
391 let cluster = vec![
392 (make_candidate(m1.0), vec![1.0_f32, 0.0, 0.0]),
393 (make_candidate(m2.0), vec![1.0_f32, 0.0, 0.0]),
394 ];
395
396 let result =
397 merge_cluster_and_promote(&store, &provider, &cluster, conv_id, Duration::from_secs(5))
398 .await;
399 assert!(
400 result.is_ok(),
401 "embed failure during merge validation must be fail-open (Ok), got {result:?}"
402 );
403 }
404}