use std::time::Duration;
use zeph_llm::any::AnyProvider;
use zeph_llm::provider::{LlmProvider as _, Message};
use crate::error::MemoryError;
use zeph_common::math::cosine_similarity;
const LLM_SWEEP_TIMEOUT_SECS: u64 = 15;
pub(crate) fn cluster_by_cosine_similarity<T>(
items: Vec<(T, Vec<f32>)>,
threshold: f32,
) -> Vec<Vec<(T, Vec<f32>)>> {
let mut clusters: Vec<Vec<(T, Vec<f32>)>> = Vec::new();
'outer: for item in items {
if item.1.is_empty() {
clusters.push(vec![item]);
continue;
}
for cluster in &mut clusters {
let rep = &cluster[0].1;
if rep.is_empty() {
continue;
}
if cosine_similarity(&item.1, rep) >= threshold {
cluster.push(item);
continue 'outer;
}
}
clusters.push(vec![item]);
}
clusters
}
pub(crate) enum EmbedBatchOutcome {
Ok(Vec<Vec<f32>>),
Skip,
}
pub(crate) async fn embed_batch_with_validation(
provider: &AnyProvider,
texts: &[&str],
log_tag: &'static str,
) -> EmbedBatchOutcome {
let vecs = provider.embed_batch(texts).await;
match vecs {
Ok(vecs) => {
if vecs.len() != texts.len() {
tracing::warn!(
expected = texts.len(),
got = vecs.len(),
"{log_tag}: embed_batch length mismatch, skipping sweep"
);
return EmbedBatchOutcome::Skip;
}
EmbedBatchOutcome::Ok(vecs)
}
Err(e) => {
tracing::warn!(error = %e, "{log_tag}: batch embed failed, skipping sweep");
EmbedBatchOutcome::Skip
}
}
}
pub(crate) async fn llm_call_with_timeout(
provider: &AnyProvider,
messages: &[Message],
timeout_label: &str,
) -> Result<String, MemoryError> {
tokio::time::timeout(
Duration::from_secs(LLM_SWEEP_TIMEOUT_SECS),
provider.chat(messages),
)
.await
.map_err(|_| MemoryError::Timeout(format!("{timeout_label} timed out after 15s")))?
.map_err(MemoryError::Llm)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::MessageId;
#[test]
fn cluster_by_cosine_similarity_groups_similar() {
let v1 = vec![1.0f32, 0.0, 0.0];
let v2 = vec![1.0f32, 0.0, 0.0];
let v3 = vec![0.0f32, 1.0, 0.0];
let items = vec![(MessageId(1), v1), (MessageId(2), v2), (MessageId(3), v3)];
let clusters = cluster_by_cosine_similarity(items, 0.80);
assert_eq!(clusters.len(), 2);
assert_eq!(clusters[0].len(), 2);
assert_eq!(clusters[1].len(), 1);
}
#[test]
fn cluster_by_cosine_similarity_groups_identical() {
let v1 = vec![1.0f32, 0.0, 0.0];
let v2 = vec![1.0f32, 0.0, 0.0];
let v3 = vec![0.0f32, 1.0, 0.0];
let items = vec![(MessageId(1), v1), (MessageId(2), v2), (MessageId(3), v3)];
let clusters = cluster_by_cosine_similarity(items, 0.92f32);
assert_eq!(clusters.len(), 2, "should produce 2 clusters");
assert_eq!(clusters[0].len(), 2, "first cluster should have 2 members");
assert_eq!(clusters[1].len(), 1, "second cluster is the orthogonal one");
}
#[test]
fn cluster_by_cosine_similarity_empty_embeddings_become_singletons() {
let items = vec![(MessageId(1), vec![]), (MessageId(2), vec![])];
let clusters = cluster_by_cosine_similarity(items, 0.92);
assert_eq!(clusters.len(), 2);
}
#[tokio::test]
async fn embed_batch_with_validation_success_returns_ok() {
use zeph_llm::any::AnyProvider;
use zeph_llm::mock::MockProvider;
let provider =
AnyProvider::Mock(MockProvider::default().with_embedding(vec![1.0, 0.0, 0.0]));
let texts = ["a", "b"];
match embed_batch_with_validation(&provider, &texts, "test").await {
EmbedBatchOutcome::Ok(vecs) => assert_eq!(vecs.len(), 2),
EmbedBatchOutcome::Skip => panic!("expected Ok, got Skip"),
}
}
#[tokio::test]
async fn embed_batch_with_validation_provider_error_returns_skip() {
use zeph_llm::any::AnyProvider;
use zeph_llm::mock::MockProvider;
let provider = AnyProvider::Mock(
MockProvider::default()
.with_embedding(vec![1.0, 0.0, 0.0])
.with_embed_invalid_input(),
);
let texts = ["a", "b"];
match embed_batch_with_validation(&provider, &texts, "test").await {
EmbedBatchOutcome::Ok(_) => panic!("expected Skip, got Ok"),
EmbedBatchOutcome::Skip => {}
}
}
#[tokio::test(start_paused = true)]
async fn llm_call_with_timeout_times_out() {
use zeph_llm::any::AnyProvider;
use zeph_llm::mock::MockProvider;
use zeph_llm::provider::{MessageMetadata, Role};
let provider = AnyProvider::Mock(
MockProvider::with_responses(vec!["late reply".into()])
.with_delay(LLM_SWEEP_TIMEOUT_SECS * 1_000 + 1_000),
);
let messages = vec![Message {
role: Role::User,
content: "hello".to_owned(),
parts: vec![],
metadata: MessageMetadata::default(),
}];
let result = llm_call_with_timeout(&provider, &messages, "test call").await;
assert!(
matches!(result, Err(MemoryError::Timeout(_))),
"expected Timeout error, got {result:?}"
);
}
}