Skip to main content

zeph_memory/semantic/
tree_consolidation.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `TiMem` temporal-hierarchical memory tree consolidation (#2262).
5//!
6//! Background loop that clusters unconsolidated leaf nodes by cosine similarity and merges
7//! each cluster into a parent node via LLM summarization.
8//!
9//! # Transaction safety (critic S2)
10//!
11//! Each cluster merge runs in its own transaction via `mark_nodes_consolidated`.
12//! The full sweep never holds a write lock across multiple clusters.
13
14use std::collections::HashSet;
15use std::sync::Arc;
16use std::time::Duration;
17
18use tokio_util::sync::CancellationToken;
19use zeph_llm::any::AnyProvider;
20use zeph_llm::provider::{LlmProvider as _, Message, Role};
21
22use crate::error::MemoryError;
23use crate::store::SqliteStore;
24use crate::store::memory_tree::MemoryTreeRow;
25use zeph_common::math::cosine_similarity;
26
27const MERGE_SYSTEM_PROMPT: &str = "\
28You are a memory consolidation assistant. Given several related memory nodes, produce a single \
29concise summary that captures the essential information from all of them. \
30Keep it to 2-4 sentences. Do not repeat details already captured in a single sentence. \
31Return only the summary text — no JSON, no preamble.";
32
33/// Configuration for the tree consolidation loop.
34#[derive(Clone)]
35pub struct TreeConsolidationConfig {
36    /// Enable or disable the tree consolidation background loop.
37    pub enabled: bool,
38    /// Interval between consolidation sweeps, in seconds.
39    pub sweep_interval_secs: u64,
40    /// Maximum number of leaf nodes processed per sweep.
41    pub batch_size: usize,
42    /// Cosine similarity threshold for clustering nodes (0.0–1.0). Nodes with similarity
43    /// above this value are merged into a parent node.
44    pub similarity_threshold: f32,
45    /// Maximum depth of the memory tree (levels above leaf nodes).
46    pub max_level: u32,
47    /// Minimum cluster size required to trigger LLM consolidation.
48    pub min_cluster_size: usize,
49    /// Per-call timeout for every `embed()` invocation, in seconds. Default: `5`.
50    pub embed_timeout_secs: u64,
51}
52
53/// Result of one consolidation sweep.
54#[derive(Debug, Default)]
55pub struct TreeConsolidationResult {
56    pub clusters_merged: u32,
57    pub nodes_created: u32,
58}
59
60/// Start the background tree consolidation loop.
61///
62/// The loop exits immediately when `config.enabled = false` or `cancel` fires.
63pub async fn start_tree_consolidation_loop(
64    store: Arc<SqliteStore>,
65    provider: AnyProvider,
66    config: TreeConsolidationConfig,
67    cancel: CancellationToken,
68) {
69    if !config.enabled {
70        tracing::debug!("tree consolidation disabled (tree.enabled = false)");
71        return;
72    }
73
74    let mut ticker = tokio::time::interval(Duration::from_secs(config.sweep_interval_secs));
75    // Skip the first immediate tick to avoid running at startup.
76    ticker.tick().await;
77
78    loop {
79        tokio::select! {
80            () = cancel.cancelled() => {
81                tracing::debug!("tree consolidation loop shutting down");
82                return;
83            }
84            _ = ticker.tick() => {}
85        }
86
87        tracing::debug!("tree consolidation: starting sweep");
88        let start = std::time::Instant::now();
89
90        let result = run_tree_consolidation_sweep(&store, &provider, &config).await;
91        let elapsed_ms = start.elapsed().as_millis();
92
93        match result {
94            Ok(r) => tracing::info!(
95                clusters_merged = r.clusters_merged,
96                nodes_created = r.nodes_created,
97                elapsed_ms,
98                "tree consolidation: sweep complete"
99            ),
100            Err(e) => tracing::warn!(
101                error = %e,
102                elapsed_ms,
103                "tree consolidation: sweep failed, will retry"
104            ),
105        }
106    }
107}
108
109/// Execute one full consolidation sweep: leaves → level 1, then level 1 → level 2, etc.
110///
111/// Each cluster runs inside its own transaction (critic S2).
112///
113/// # Errors
114///
115/// Returns an error if a database query fails.
116pub async fn run_tree_consolidation_sweep(
117    store: &SqliteStore,
118    provider: &AnyProvider,
119    config: &TreeConsolidationConfig,
120) -> Result<TreeConsolidationResult, MemoryError> {
121    let mut result = TreeConsolidationResult::default();
122
123    for level in 0..config.max_level {
124        let candidates = if level == 0 {
125            store
126                .load_tree_leaves_unconsolidated(config.batch_size)
127                .await?
128        } else {
129            store
130                .load_tree_level(i64::from(level), config.batch_size)
131                .await?
132        };
133
134        if candidates.len() < config.min_cluster_size {
135            continue;
136        }
137
138        if !provider.supports_embeddings() {
139            tracing::debug!(
140                "tree consolidation: provider has no embedding support, skipping level {level}"
141            );
142            continue;
143        }
144
145        let candidate_ids: Vec<i64> = candidates.iter().map(|row| row.id).collect();
146
147        let embedded = embed_candidates(
148            provider,
149            &candidates,
150            Duration::from_secs(config.embed_timeout_secs),
151        )
152        .await;
153        if embedded.len() < config.min_cluster_size {
154            // Bumps every loaded candidate, including ones that embedded fine but were left
155            // without enough surviving peers to reach `min_cluster_size` — a peer's transient
156            // embed failure costs them one attempt too. Harmless one-step bias in practice
157            // (`min_cluster_size` is small, so this only fires when nearly all embeds fail).
158            bump_stuck_attempts(store, &candidate_ids, level).await;
159            continue;
160        }
161
162        let clusters = cluster_by_similarity(
163            &embedded,
164            config.similarity_threshold,
165            config.min_cluster_size,
166        );
167
168        let consolidated_ids =
169            merge_clusters(store, provider, clusters, level, config, &mut result).await;
170
171        // Nodes that end up consolidated this sweep drop out of the load query naturally
172        // (`parent_id` gets set); everything else keeps failing to cluster and must have its
173        // attempt count bumped so the next sweep's load query deprioritizes it (#6393).
174        let stuck_ids: Vec<i64> = candidate_ids
175            .into_iter()
176            .filter(|id| !consolidated_ids.contains(id))
177            .collect();
178        bump_stuck_attempts(store, &stuck_ids, level).await;
179    }
180
181    if result.nodes_created > 0 {
182        let _ = store.increment_tree_consolidation_count().await;
183    }
184
185    Ok(result)
186}
187
188/// Merge every cluster of at least `config.min_cluster_size` nodes into a parent node via LLM
189/// summarization, updating `result` in place. Returns the set of child node ids that were
190/// actually consolidated (persisted successfully) — everything else in the level's candidate
191/// set is left for the caller to mark as a failed attempt (#6393).
192async fn merge_clusters(
193    store: &SqliteStore,
194    provider: &AnyProvider,
195    clusters: Vec<Vec<(i64, String, Vec<f32>)>>,
196    level: u32,
197    config: &TreeConsolidationConfig,
198    result: &mut TreeConsolidationResult,
199) -> HashSet<i64> {
200    let mut consolidated_ids: HashSet<i64> = HashSet::new();
201
202    for cluster in clusters {
203        if cluster.len() < config.min_cluster_size {
204            continue;
205        }
206
207        let child_ids: Vec<i64> = cluster.iter().map(|(id, _, _)| *id).collect();
208        let contents: Vec<&str> = cluster
209            .iter()
210            .map(|(_, content, _)| content.as_str())
211            .collect();
212
213        let summary = match merge_via_llm(provider, &contents).await {
214            Ok(s) => s,
215            Err(e) => {
216                tracing::warn!(
217                    error = %e,
218                    level,
219                    child_count = cluster.len(),
220                    "tree consolidation: LLM merge failed, skipping cluster"
221                );
222                continue;
223            }
224        };
225
226        if summary.is_empty() {
227            continue;
228        }
229
230        let token_count = i64::try_from(summary.split_whitespace().count()).unwrap_or(i64::MAX);
231        let source_ids_json =
232            serde_json::to_string(&child_ids).unwrap_or_else(|_| "[]".to_string());
233
234        // Atomic cluster consolidation: INSERT parent + UPDATE children in one transaction.
235        match store
236            .consolidate_cluster(
237                i64::from(level + 1),
238                &summary,
239                &source_ids_json,
240                token_count,
241                &child_ids,
242            )
243            .await
244        {
245            Ok(_) => {
246                consolidated_ids.extend(&child_ids);
247            }
248            Err(e) => {
249                tracing::warn!(
250                    error = %e,
251                    level,
252                    child_count = cluster.len(),
253                    "tree consolidation: cluster persist failed, skipping"
254                );
255                continue;
256            }
257        }
258
259        result.clusters_merged += 1;
260        result.nodes_created += 1;
261    }
262
263    consolidated_ids
264}
265
266/// Bump the consolidation-attempt counter for nodes that were loaded this sweep but did not
267/// end up consolidated, so the next sweep's load query deprioritizes them (#6393). Best-effort:
268/// a failure here does not fail the sweep, it only means those nodes stay at their current
269/// attempt count and may be reloaded sooner than intended on the next sweep.
270async fn bump_stuck_attempts(store: &SqliteStore, node_ids: &[i64], level: u32) {
271    if node_ids.is_empty() {
272        return;
273    }
274    if let Err(e) = store.bump_consolidation_attempts(node_ids).await {
275        tracing::warn!(
276            error = %e,
277            level,
278            count = node_ids.len(),
279            "tree consolidation: failed to bump consolidation attempts"
280        );
281    }
282}
283
284/// Concurrency cap for embed calls — matches `embed_concurrency` default (#2677).
285const EMBED_CONCURRENCY: usize = 8;
286
287async fn embed_candidates(
288    provider: &AnyProvider,
289    candidates: &[MemoryTreeRow],
290    embed_timeout: Duration,
291) -> Vec<(i64, String, Vec<f32>)> {
292    let mut embedded = Vec::with_capacity(candidates.len());
293
294    // Process in bounded batches to avoid saturating the embed provider (#2677).
295    for chunk in candidates.chunks(EMBED_CONCURRENCY) {
296        let futures: Vec<_> = chunk
297            .iter()
298            .map(|row| {
299                let id = row.id;
300                let content = row.content.clone();
301                async move {
302                    let result =
303                        tokio::time::timeout(embed_timeout, provider.embed(&content)).await;
304                    let result = match result {
305                        Ok(r) => r,
306                        Err(_elapsed) => {
307                            tracing::warn!(
308                                node_id = id,
309                                "tree consolidation: embed() timed out, skipping node"
310                            );
311                            return (id, content, Err(zeph_llm::error::LlmError::Timeout));
312                        }
313                    };
314                    (id, content, result)
315                }
316            })
317            .collect();
318
319        let results = futures::future::join_all(futures).await;
320        for (id, content, result) in results {
321            match result {
322                Ok(vec) => embedded.push((id, content, vec)),
323                Err(e) => tracing::warn!(
324                    node_id = id,
325                    error = %e,
326                    "tree consolidation: failed to embed node, skipping"
327                ),
328            }
329        }
330    }
331    embedded
332}
333
334// INVARIANT: `embedded` must be ordered by `created_at ASC` (as returned by
335// `load_tree_leaves_unconsolidated` / `load_tree_level`).  The greedy leader-based algorithm
336// is deterministic only when the input order is stable across sweeps.  Do not sort or shuffle
337// the slice before calling this function.
338fn cluster_by_similarity(
339    embedded: &[(i64, String, Vec<f32>)],
340    threshold: f32,
341    min_cluster_size: usize,
342) -> Vec<Vec<(i64, String, Vec<f32>)>> {
343    let n = embedded.len();
344    let mut assigned = vec![false; n];
345    let mut clusters: Vec<Vec<(i64, String, Vec<f32>)>> = Vec::new();
346
347    for i in 0..n {
348        if assigned[i] {
349            continue;
350        }
351        let mut cluster = vec![embedded[i].clone()];
352        assigned[i] = true;
353
354        for j in (i + 1)..n {
355            if assigned[j] {
356                continue;
357            }
358            let sim = cosine_similarity(&embedded[i].2, &embedded[j].2);
359            if sim >= threshold {
360                cluster.push(embedded[j].clone());
361                assigned[j] = true;
362            }
363        }
364
365        if cluster.len() >= min_cluster_size {
366            clusters.push(cluster);
367        }
368    }
369
370    clusters
371}
372
373async fn merge_via_llm(provider: &AnyProvider, contents: &[&str]) -> Result<String, MemoryError> {
374    let mut user_prompt = String::from("Memory nodes to consolidate:\n");
375    for (i, content) in contents.iter().enumerate() {
376        use std::fmt::Write as _;
377        let _ = writeln!(user_prompt, "[{}] {}", i + 1, content);
378    }
379    user_prompt.push_str("\nProduce a concise summary.");
380
381    let llm_messages = [
382        Message::from_legacy(Role::System, MERGE_SYSTEM_PROMPT),
383        Message::from_legacy(Role::User, user_prompt),
384    ];
385
386    let response = provider
387        .chat(&llm_messages)
388        .await
389        .map_err(MemoryError::Llm)?;
390
391    Ok(response.trim().to_string())
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    /// `embed()` timeout in `embed_candidates` → timed-out nodes are dropped, function returns
399    /// only successfully embedded entries (fail-open: the sweep can still proceed with fewer nodes).
400    #[tokio::test]
401    async fn embed_candidates_timeout_drops_timed_out_nodes() {
402        let slow = zeph_llm::any::AnyProvider::Mock(
403            zeph_llm::mock::MockProvider::default().with_embed_delay(10_000),
404        );
405
406        let candidates = vec![
407            MemoryTreeRow {
408                id: 1,
409                level: 0,
410                parent_id: None,
411                content: "Alice prefers Rust".to_owned(),
412                source_ids: "[]".to_owned(),
413                token_count: 3,
414                consolidated_at: None,
415                created_at: "2026-01-01T00:00:00".to_owned(),
416            },
417            MemoryTreeRow {
418                id: 2,
419                level: 0,
420                parent_id: None,
421                content: "Alice likes async code".to_owned(),
422                source_ids: "[]".to_owned(),
423                token_count: 4,
424                consolidated_at: None,
425                created_at: "2026-01-01T00:00:01".to_owned(),
426            },
427        ];
428
429        tokio::time::pause();
430
431        let fut = embed_candidates(&slow, &candidates, Duration::from_secs(5));
432        let (result, ()) = tokio::join!(fut, async {
433            tokio::time::advance(std::time::Duration::from_secs(6)).await;
434        });
435
436        assert!(
437            result.is_empty(),
438            "all nodes must be dropped on embed timeout, got {} entries",
439            result.len()
440        );
441    }
442
443    #[test]
444    fn cluster_by_similarity_groups_identical_vectors() {
445        let v1 = vec![1.0f32, 0.0, 0.0];
446        let v2 = vec![1.0f32, 0.0, 0.0];
447        let v3 = vec![0.0f32, 1.0, 0.0]; // orthogonal
448
449        let embedded = vec![
450            (1i64, "a".to_string(), v1),
451            (2i64, "b".to_string(), v2),
452            (3i64, "c".to_string(), v3),
453        ];
454
455        let clusters = cluster_by_similarity(&embedded, 0.9, 2);
456        assert_eq!(
457            clusters.len(),
458            1,
459            "identical vectors should form one cluster"
460        );
461        assert_eq!(clusters[0].len(), 2);
462    }
463
464    #[test]
465    fn cluster_by_similarity_min_cluster_size_gate() {
466        let v1 = vec![1.0f32, 0.0];
467        let v2 = vec![1.0f32, 0.0];
468
469        let embedded = vec![(1i64, "a".to_string(), v1), (2i64, "b".to_string(), v2)];
470
471        // Require 3 — no cluster should form.
472        let clusters = cluster_by_similarity(&embedded, 0.9, 3);
473        assert!(clusters.is_empty());
474    }
475
476    #[test]
477    fn cluster_by_similarity_no_duplicates_across_clusters() {
478        let v = vec![1.0f32, 0.0];
479        let embedded = vec![
480            (1i64, "a".to_string(), v.clone()),
481            (2i64, "b".to_string(), v.clone()),
482            (3i64, "c".to_string(), v.clone()),
483        ];
484
485        let clusters = cluster_by_similarity(&embedded, 0.9, 2);
486        let total_items: usize = clusters.iter().map(Vec::len).sum();
487        // All items across all clusters are unique (no double-assignment).
488        assert_eq!(total_items, 3);
489    }
490
491    async fn make_store() -> SqliteStore {
492        SqliteStore::with_pool_size(":memory:", 1)
493            .await
494            .expect("in-memory store")
495    }
496
497    fn test_config() -> TreeConsolidationConfig {
498        TreeConsolidationConfig {
499            enabled: true,
500            sweep_interval_secs: 3600,
501            batch_size: 2,
502            similarity_threshold: 0.9,
503            max_level: 1,
504            min_cluster_size: 2,
505            embed_timeout_secs: 5,
506        }
507    }
508
509    /// Backdates `last_attempted_at` for `ids` to an exact, controlled value — used to build
510    /// deterministic multi-sweep timelines in tests without depending on real wall-clock gaps.
511    async fn backdate_last_attempted(store: &SqliteStore, ids: &[i64], timestamp: &str) {
512        for &id in ids {
513            zeph_db::query(zeph_db::sql!(
514                "UPDATE memory_tree SET last_attempted_at = ? WHERE id = ?"
515            ))
516            .bind(timestamp)
517            .bind(id)
518            .execute(store.pool())
519            .await
520            .expect("backdate last_attempted_at");
521        }
522    }
523
524    /// Backdates `created_at` for `ids` — see [`backdate_last_attempted`].
525    async fn backdate_created_at(store: &SqliteStore, ids: &[i64], timestamp: &str) {
526        for &id in ids {
527            zeph_db::query(zeph_db::sql!(
528                "UPDATE memory_tree SET created_at = ? WHERE id = ?"
529            ))
530            .bind(timestamp)
531            .bind(id)
532            .execute(store.pool())
533            .await
534            .expect("backdate created_at");
535        }
536    }
537
538    /// #6393 regression, hardened per code review (2026-07-17T18-50-48-review.md Critical #1):
539    /// verifies `run_tree_consolidation_sweep` (the real production code path) wires
540    /// `bump_stuck_attempts` correctly when a cluster stays below `min_cluster_size`, and that
541    /// the resulting deprioritization is not permanent. The original version of this test
542    /// asserted a fresh leaf wins the very next load immediately after one bump — false under
543    /// real timing (a just-bumped leaf's touch is the freshest in the table and correctly keeps
544    /// winning until it is left behind by a later sweep); this version backdates the sweep's
545    /// own bump explicitly, so the assertion holds regardless of how fast the test executes.
546    #[tokio::test]
547    async fn sweep_bumps_attempts_and_stale_stuck_batch_does_not_starve_new_leaf() {
548        let store = make_store().await;
549        let config = test_config();
550
551        let leaf1 = store.insert_tree_leaf("stuck one", 5).await.expect("l1");
552        let leaf2 = store.insert_tree_leaf("stuck two", 5).await.expect("l2");
553
554        // One of the two embed calls fails, so `embedded.len() == 1 < min_cluster_size == 2`:
555        // both loaded candidates end up unconsolidated ("stuck") this sweep.
556        let provider = AnyProvider::Mock(
557            zeph_llm::mock::MockProvider::default()
558                .with_embedding(vec![1.0, 0.0])
559                .with_errors(vec![zeph_llm::error::LlmError::Timeout]),
560        );
561
562        run_tree_consolidation_sweep(&store, &provider, &config)
563            .await
564            .expect("sweep");
565
566        // Both stuck leaves must still be present and unconsolidated (no cluster formed) —
567        // proves the sweep ran the expected embed-failure path and called `bump_stuck_attempts`.
568        let stuck_ids: std::collections::HashSet<i64> = store
569            .load_tree_leaves_unconsolidated(10)
570            .await
571            .expect("load")
572            .into_iter()
573            .map(|r| r.id)
574            .collect();
575        assert!(stuck_ids.contains(&leaf1));
576        assert!(stuck_ids.contains(&leaf2));
577
578        // The sweep's own `bump_stuck_attempts` call (the production wiring verified above)
579        // already set `last_attempted_at` to real "now". Pin it to a controlled, known value
580        // (T=1) so the rest of this test is fully deterministic regardless of how fast it runs.
581        backdate_last_attempted(&store, &[leaf1, leaf2], "2000-01-01 00:00:01").await;
582
583        // A new leaf created strictly *after* that touch (T=2) does not win the very next
584        // load — a just-touched leaf's timestamp is still older (smaller) than anything created
585        // right after it, so leaf1/leaf2 correctly keep winning for now. This is the real,
586        // "not immediate" property: a bump does not instantly lose to a leaf created around the
587        // same moment (code review #6393 Critical #1 — the original version of this test
588        // asserted the opposite and only passed via a same-second timestamp tie).
589        let fresh = store.insert_tree_leaf("fresh leaf", 5).await.expect("l3");
590        backdate_created_at(&store, &[fresh], "2000-01-01 00:00:02").await;
591
592        let immediate: std::collections::HashSet<i64> = store
593            .load_tree_leaves_unconsolidated(1)
594            .await
595            .expect("load immediate")
596            .into_iter()
597            .map(|r| r.id)
598            .collect();
599        assert!(
600            immediate == std::collections::HashSet::from([leaf1])
601                || immediate == std::collections::HashSet::from([leaf2]),
602            "a just-touched stuck leaf must still outrank a leaf created immediately \
603             afterward, got {immediate:?}"
604        );
605
606        // A second sweep-equivalent touch of leaf1/leaf2 (T=3), still without `fresh` ever
607        // being touched, pushes their timestamp past `fresh`'s frozen T=2 — `fresh` must then
608        // win: the bounded, real multi-sweep starvation guard, not a false single-bump
609        // immediacy claim.
610        backdate_last_attempted(&store, &[leaf1, leaf2], "2000-01-01 00:00:03").await;
611        let next_batch = store
612            .load_tree_leaves_unconsolidated(1)
613            .await
614            .expect("load next batch");
615        assert_eq!(
616            next_batch.len(),
617            1,
618            "batch_size limit of 1 must be respected"
619        );
620        assert_eq!(
621            next_batch[0].id, fresh,
622            "a leaf that predates the stuck batch's most recent touch must win once it is \
623             touched again without the fresh leaf ever being touched itself"
624        );
625    }
626
627    /// #6393: normal clustering (leaves that DO cluster successfully) must behave exactly as
628    /// before — the new attempts tracking must not interfere when a cluster actually forms.
629    #[tokio::test]
630    async fn sweep_still_merges_a_successfully_clustered_batch() {
631        let store = make_store().await;
632        let config = test_config();
633
634        store.insert_tree_leaf("alpha", 5).await.expect("l1");
635        store.insert_tree_leaf("beta", 5).await.expect("l2");
636
637        // Both leaves embed to the same vector, so they merge into one cluster of size 2.
638        let provider = AnyProvider::Mock(
639            zeph_llm::mock::MockProvider::default().with_embedding(vec![1.0, 0.0]),
640        );
641
642        let result = run_tree_consolidation_sweep(&store, &provider, &config)
643            .await
644            .expect("sweep");
645
646        assert_eq!(result.clusters_merged, 1);
647        assert_eq!(result.nodes_created, 1);
648
649        let leaves = store
650            .load_tree_leaves_unconsolidated(10)
651            .await
652            .expect("load");
653        assert!(
654            leaves.is_empty(),
655            "successfully clustered leaves must be consolidated, not left as candidates"
656        );
657    }
658}