Skip to main content

zeph_memory/
tiers.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! AOI three-layer memory tier promotion.
5//!
6//! Provides a background sweep loop that promotes frequently-accessed episodic messages
7//! to the semantic tier by:
8//! 1. Finding candidates with `session_count >= promotion_min_sessions`.
9//! 2. Grouping near-duplicate candidates by cosine similarity (greedy nearest-neighbor).
10//! 3. For each cluster with >= 2 messages, calling the LLM to distill a merged fact.
11//! 4. Validating the merge output (non-empty, similarity >= 0.7 to at least one original).
12//! 5. Inserting the semantic fact and soft-deleting the originals.
13//!
14//! The sweep respects a `CancellationToken` for graceful shutdown, following the
15//! same pattern as `eviction.rs`.
16
17use 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
34/// Minimum cosine similarity between the merged result and at least one original for the
35/// merge to be accepted. Prevents the LLM from producing semantically unrelated output.
36const MERGE_VALIDATION_MIN_SIMILARITY: f32 = 0.7;
37
38/// Configuration for the tier promotion sweep, passed from `zeph-config::TierPromotionConfig`.
39///
40/// Defined locally to avoid a direct dependency from `zeph-memory` on `zeph-config`.
41#[derive(Debug, Clone)]
42pub struct TierPromotionConfig {
43    /// Enable or disable the tier promotion loop.
44    pub enabled: bool,
45    /// Minimum number of distinct sessions in which a message must appear
46    /// before it becomes a promotion candidate.
47    pub promotion_min_sessions: u32,
48    /// Minimum cosine similarity for two messages to be considered duplicates
49    /// eligible for merging into one semantic fact.
50    pub similarity_threshold: f32,
51    /// How often to run a promotion sweep, in seconds.
52    pub sweep_interval_secs: u64,
53    /// Maximum number of candidates to process per sweep.
54    pub sweep_batch_size: usize,
55    /// Per-call timeout for every `embed()` invocation, in seconds. Default: `5`.
56    pub embed_timeout_secs: u64,
57}
58
59/// Start the background tier promotion loop.
60///
61/// Each sweep cycle:
62/// 1. Fetches episodic candidates with `session_count >= config.promotion_min_sessions`.
63/// 2. Embeds candidates and clusters near-duplicates (cosine similarity >= threshold).
64/// 3. For each cluster, calls the LLM to merge into a single semantic fact.
65/// 4. Validates the merged output; skips the cluster on failure.
66/// 5. Promotes validated clusters to semantic tier.
67///
68/// The loop exits immediately if `config.enabled = false`.
69///
70/// Database and LLM errors are logged but do not stop the loop.
71pub 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    // Skip the first immediate tick so we don't run at startup.
84    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/// Stats collected during a single promotion sweep.
121#[derive(Debug, Default)]
122struct SweepStats {
123    candidates_evaluated: usize,
124    clusters_formed: usize,
125    promotions_completed: usize,
126    merge_failures: usize,
127}
128
129/// Execute one full promotion sweep cycle.
130#[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    // Embed all candidates in a single batch call, then zip back with candidates.
150    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        // No embedding support — push all with empty vecs (will become singletons).
162        candidates.into_iter().map(|c| (c, Vec::new())).collect()
163    };
164
165    if embedded.is_empty() {
166        return Ok(stats);
167    }
168
169    // Cluster candidates by cosine similarity (greedy nearest-neighbor).
170    // Each candidate is assigned to the first existing cluster whose centroid
171    // representative has similarity >= threshold with it, or starts a new cluster.
172    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            // Single-member cluster — no merge needed, skip to avoid unnecessary LLM calls.
178            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/// Call the LLM to merge a cluster and promote the result to semantic tier.
214///
215/// Validates the merged output before promoting. If the output is empty or has
216/// a cosine similarity below `MERGE_VALIDATION_MIN_SIMILARITY` to all originals,
217/// returns an error without promoting.
218#[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    // Validate: non-empty result required.
232    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    // Validate: merged result must be semantically related to at least one original.
240    // Embed the merged result and compare against original embeddings.
241    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    // Retry the DB write up to 3 times with exponential backoff on SQLITE_BUSY.
274    // The LLM merge above is not retried — only the cheap DB write is.
275    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                // Detect SQLITE_BUSY via the sqlx::Error::Database error code ("5") when
284                // available; fall back to string matching. String matching is safe here because
285                // the error originates from SQLite internals, not user input. The fallback
286                // handles wrapping layers where downcasting would add disproportionate complexity.
287                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
314/// Call the LLM to distill a set of episodic messages into a single semantic fact.
315async 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    /// `embed()` failure during merge validation → fail-open: merge proceeds without rejecting.
367    ///
368    /// `merge_cluster_and_promote` must return `Ok(())` when the `embed` call for similarity
369    /// validation errors (covers both timeout and provider error — both are handled fail-open
370    /// by the same `Ok(Err(e))` arm in the production code).
371    #[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        // Provider: instant LLM chat reply + embed always errors (InvalidInput).
385        // Simulates any non-timeout embed failure; the timeout path maps to the same fail-open arm.
386        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}