1use 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#[derive(Clone)]
35pub struct TreeConsolidationConfig {
36 pub enabled: bool,
38 pub sweep_interval_secs: u64,
40 pub batch_size: usize,
42 pub similarity_threshold: f32,
45 pub max_level: u32,
47 pub min_cluster_size: usize,
49 pub embed_timeout_secs: u64,
51}
52
53#[derive(Debug, Default)]
55pub struct TreeConsolidationResult {
56 pub clusters_merged: u32,
57 pub nodes_created: u32,
58}
59
60pub 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 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
109pub 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 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 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
188async 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 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
266async 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
284const 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 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
334fn 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 #[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]; 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 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 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 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 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 #[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 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 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 backdate_last_attempted(&store, &[leaf1, leaf2], "2000-01-01 00:00:01").await;
582
583 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 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 #[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 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}