use async_trait::async_trait;
use sha2::{Digest, Sha256};
use crate::memory::clock::Clock;
use crate::memory::config::MemoryConfig;
use crate::memory::context::truncate_to_tokens;
use crate::memory::embedding::EmbeddingProvider;
use crate::memory::error::MemoryError;
use crate::memory::index::cosine;
use crate::memory::store::{Memory, VectorStore};
use crate::memory::tokens::{budget_after_margin, estimate_tokens};
use crate::memory::MemoryKind;
#[async_trait]
pub trait DistillJudge: Send + Sync {
async fn summarize_preferences(&self, episodic: &[Memory]) -> Result<Vec<String>, MemoryError>;
async fn contradicts(&self, a: &str, b: &str) -> Result<bool, MemoryError>;
}
pub async fn distill(
store: &dyn VectorStore,
judge: &dyn DistillJudge,
embedder: &dyn EmbeddingProvider,
clock: &dyn Clock,
cfg: &MemoryConfig,
scope: &str,
) -> Result<(), MemoryError> {
if !cfg.distill_enabled {
return Ok(());
}
let all_active = store.active(scope).await?;
let undistilled: Vec<Memory> = {
let mut v: Vec<Memory> = all_active
.into_iter()
.filter(|m| m.kind == MemoryKind::Episodic && m.distilled_at.is_none())
.collect();
v.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
v
};
if undistilled.is_empty() {
return Ok(());
}
let batch_budget =
budget_after_margin(cfg.distill_max_batch_tokens, 0, cfg.safety_margin_ratio).max(1);
let mut truncated_id: Option<String> = None;
let batch: Vec<Memory> = {
let mut acc = 0usize;
let mut v = Vec::new();
for m in undistilled {
let t = estimate_tokens(&m.text, cfg.chars_per_token);
if acc + t > batch_budget {
if v.is_empty() {
let mut truncated = m.clone();
truncated.text = crate::memory::context::truncate_to_tokens(
&m.text,
batch_budget,
cfg.chars_per_token,
);
truncated_id = Some(m.id.clone());
v.push(truncated);
}
break;
}
acc += t;
v.push(m);
}
v
};
let batch_ids: Vec<String> = batch.iter().map(|m| m.id.clone()).collect();
let now = clock.now();
let prefs = match judge.summarize_preferences(&batch).await {
Ok(p) => p,
Err(e) => {
eprintln!("[distill] summarize_preferences error (retrying next pass): {e}");
return Ok(());
}
};
for pref in &prefs {
if let Err(e) = promote_to_profile(store, clock, cfg, scope, pref).await {
eprintln!("[distill] promote_to_profile error (skipping): {e}");
}
}
let current_model = embedder.model_id();
let current_dim = embedder.dim();
if current_dim > 0 {
let embedded: Vec<&Memory> = batch
.iter()
.filter(|m| {
m.model_id == current_model
&& m.dim == current_dim
&& truncated_id.as_deref() != Some(m.id.as_str())
})
.collect();
let mut judge_calls = 0usize;
'outer: for i in 0..embedded.len() {
for j in (i + 1)..embedded.len() {
if judge_calls >= cfg.supersede_max_candidate_pairs {
break 'outer;
}
let ma = embedded[i];
let mb = embedded[j];
let sim = cosine(&ma.embedding, &mb.embedding);
if (sim as f64) < cfg.supersede_similarity_threshold {
continue;
}
let (older, newer) = if (ma.created_at, &ma.id) < (mb.created_at, &mb.id) {
(ma, mb)
} else {
(mb, ma)
};
let is_contradiction = if older.kind == MemoryKind::Preference
|| newer.kind == MemoryKind::Preference
{
true
} else {
let per_text_cap = (cfg.distill_max_batch_tokens / 2).max(1);
let older_text =
truncate_to_tokens(&older.text, per_text_cap, cfg.chars_per_token);
let newer_text =
truncate_to_tokens(&newer.text, per_text_cap, cfg.chars_per_token);
judge_calls += 1;
match judge.contradicts(&older_text, &newer_text).await {
Ok(v) => v,
Err(e) => {
eprintln!("[distill] contradicts error (skipping pair): {e}");
continue;
}
}
};
if is_contradiction {
if let Err(e) = store.set_superseded(&older.id, &newer.id).await {
eprintln!("[distill] set_superseded error (skipping): {e}");
}
}
}
}
}
store.set_distilled(&batch_ids, now).await?;
Ok(())
}
pub async fn render_profile(
store: &dyn VectorStore,
cfg: &MemoryConfig,
scope: &str,
) -> Result<String, MemoryError> {
let active = store.active(scope).await?;
let mut prefs: Vec<Memory> = active
.into_iter()
.filter(|m| m.kind == MemoryKind::Preference)
.collect();
if prefs.is_empty() {
return Ok(String::new());
}
prefs.sort_by(|a, b| {
b.salience
.partial_cmp(&a.salience)
.unwrap_or(std::cmp::Ordering::Equal)
.then(b.created_at.cmp(&a.created_at))
.then(a.id.cmp(&b.id))
});
let mut out = String::new();
let budget = cfg.profile_max_tokens;
let mut used = 0usize;
for m in &prefs {
let line = format!("- {}\n", m.text);
let cost = estimate_tokens(&line, cfg.chars_per_token);
if used + cost > budget {
if used == 0 {
let overhead = estimate_tokens("- \n", cfg.chars_per_token);
if overhead < budget {
let text_budget = budget.saturating_sub(overhead);
let truncated = truncate_to_tokens(&m.text, text_budget, cfg.chars_per_token);
if !truncated.is_empty() {
out.push_str(&format!("- {truncated}\n"));
}
}
}
break;
}
used += cost;
out.push_str(&line);
}
Ok(out)
}
async fn promote_to_profile(
store: &dyn VectorStore,
clock: &dyn Clock,
cfg: &MemoryConfig,
scope: &str,
pref: &str,
) -> Result<(), MemoryError> {
let normalized = pref.to_lowercase();
let normalized = normalized.trim();
if normalized.is_empty() {
return Ok(());
}
let id = format!("pref:{:x}", Sha256::digest(normalized.as_bytes()));
let now = clock.now();
let salience = cfg.preference_salience.clamp(0.0, 1.0);
let memory = Memory {
id,
session_id: "profile".to_string(),
kind: MemoryKind::Preference,
text: pref.trim().to_string(),
embedding: vec![],
model_id: String::new(),
dim: 0,
created_at: now,
salience,
access_count: 0,
last_accessed_at: now,
superseded_by: None,
evicted_at: None,
scope: scope.to_string(),
distilled_at: None,
};
store.upsert(&memory).await
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use super::*;
use crate::memory::clock::FixedClock;
use crate::memory::config::MemoryConfig;
use crate::memory::error::EmbeddingError;
use crate::memory::store::{Memory, SqliteVectorStore};
use crate::memory::tokens::{budget_after_margin, estimate_tokens};
use crate::memory::MemoryKind;
use crate::system::database::EncryptedSqliteMemory;
fn bow(text: &str, dim: usize) -> Vec<f32> {
let mut v = vec![0f32; dim];
for w in text.to_lowercase().split_whitespace() {
let h = w
.bytes()
.fold(0usize, |a, b| a.wrapping_mul(31).wrapping_add(b as usize))
% dim;
v[h] += 1.0;
}
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if n > 0.0 {
for x in &mut v {
*x /= n;
}
}
v
}
struct FakeEmbedder {
dim: usize,
model: String,
}
#[async_trait]
impl EmbeddingProvider for FakeEmbedder {
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Ok(texts.iter().map(|t| bow(t, self.dim)).collect())
}
fn model_id(&self) -> &str {
&self.model
}
fn dim(&self) -> usize {
self.dim
}
fn query_prefix(&self) -> &str {
""
}
fn document_prefix(&self) -> &str {
""
}
}
pub(super) struct SpyJudge {
prefs: Vec<String>,
fail_summarize: bool,
contradicts_val: bool,
fail_contradicts: bool,
summarize_calls: Arc<Mutex<usize>>,
contradicts_calls: Arc<Mutex<usize>>,
}
#[async_trait]
impl DistillJudge for SpyJudge {
async fn summarize_preferences(
&self,
_episodic: &[Memory],
) -> Result<Vec<String>, MemoryError> {
*self.summarize_calls.lock().unwrap() += 1;
if self.fail_summarize {
Err(MemoryError::Storage(
"spy: summarize_preferences forced error".into(),
))
} else {
Ok(self.prefs.clone())
}
}
async fn contradicts(&self, _a: &str, _b: &str) -> Result<bool, MemoryError> {
*self.contradicts_calls.lock().unwrap() += 1;
if self.fail_contradicts {
Err(MemoryError::Storage("spy: contradicts forced error".into()))
} else {
Ok(self.contradicts_val)
}
}
}
fn make_test_store() -> (tempfile::NamedTempFile, SqliteVectorStore) {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mem = EncryptedSqliteMemory::new(tmp.path().to_path_buf(), "pw".into()).unwrap();
let store = SqliteVectorStore::new(mem.shared_conn(), mem.data_key()).unwrap();
(tmp, store)
}
async fn insert_episodic(
store: &SqliteVectorStore,
id: &str,
text: &str,
embedding: Vec<f32>,
model_id: &str,
dim: usize,
created_at: i64,
) {
let m = Memory {
id: id.into(),
session_id: "s".into(),
kind: MemoryKind::Episodic,
text: text.into(),
embedding,
model_id: model_id.into(),
dim,
created_at,
salience: 0.3,
access_count: 0,
last_accessed_at: created_at,
superseded_by: None,
evicted_at: None,
scope: "root".into(),
distilled_at: None,
};
store.insert(&m).await.unwrap();
}
fn make_spy(
prefs: Vec<String>,
fail_summarize: bool,
contradicts_val: bool,
fail_contradicts: bool,
) -> (SpyJudge, Arc<Mutex<usize>>, Arc<Mutex<usize>>) {
let summarize_calls = Arc::new(Mutex::new(0usize));
let contradicts_calls = Arc::new(Mutex::new(0usize));
let spy = SpyJudge {
prefs,
fail_summarize,
contradicts_val,
fail_contradicts,
summarize_calls: Arc::clone(&summarize_calls),
contradicts_calls: Arc::clone(&contradicts_calls),
};
(spy, summarize_calls, contradicts_calls)
}
#[tokio::test]
async fn test_distill_creates_one_deduped_profile_entry() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
distill_enabled: true,
..MemoryConfig::default()
};
insert_episodic(&store, "e1", "some text", vec![], "", 0, 1_000).await;
let (spy, _, _) = make_spy(
vec!["Use Rust".into(), "Use Rust".into()],
false,
false,
false,
);
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let profile = render_profile(&store, &cfg, "root").await.unwrap();
let occurrences = profile.matches("Use Rust").count();
assert_eq!(
occurrences, 1,
"SC-20: 'Use Rust' should appear exactly once in the profile, got:\n{profile}"
);
}
#[tokio::test]
async fn test_latest_wins_on_conflicting_preferences() {
let (_tmp, store) = make_test_store();
let clock1 = FixedClock::new(1_000);
let clock2 = FixedClock::new(2_000);
let cfg = MemoryConfig::default();
promote_to_profile(&store, &clock1, &cfg, "root", "Use Rust")
.await
.unwrap();
promote_to_profile(&store, &clock2, &cfg, "root", "use rust ")
.await
.unwrap();
let profile = render_profile(&store, &cfg, "root").await.unwrap();
let line_count = profile.lines().count();
assert_eq!(
line_count, 1,
"SC-21: should be exactly one profile entry (dedup), got:\n{profile}"
);
assert!(
profile.contains("use rust"),
"SC-21: profile should contain the latest preference, got:\n{profile}"
);
}
#[tokio::test]
async fn test_boost_lifts_preference_to_protected_tier() {
let (_tmp, store) = make_test_store();
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig::default();
promote_to_profile(&store, &clock, &cfg, "root", "always use dark mode")
.await
.unwrap();
let id = format!(
"pref:{:x}",
Sha256::digest("always use dark mode".as_bytes())
);
let mem = store
.get(&id)
.await
.unwrap()
.expect("promoted preference must exist in store");
assert_eq!(
mem.kind,
MemoryKind::Preference,
"SC-38: kind must be Preference"
);
assert!(
mem.salience >= cfg.protect_salience_threshold,
"SC-38: salience ({}) must be >= protect_salience_threshold ({}) (D-11)",
mem.salience,
cfg.protect_salience_threshold
);
}
#[tokio::test]
async fn test_hard_supersession_marks_older_superseded_by_newer() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 32,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
supersede_similarity_threshold: 0.5, supersede_max_candidate_pairs: 50,
distill_enabled: true,
..MemoryConfig::default()
};
let f1_text = "user always prefers to use dark mode on all screens";
let f2_text = "user always prefers to use light mode on all screens";
insert_episodic(&store, "f1", f1_text, bow(f1_text, 32), "fake", 32, 1_000).await;
insert_episodic(&store, "f2", f2_text, bow(f2_text, 32), "fake", 32, 2_000).await;
let (spy, _, _) = make_spy(vec![], false, true , false);
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let f1 = store.get("f1").await.unwrap().expect("f1 must still exist");
assert_eq!(
f1.superseded_by,
Some("f2".to_string()),
"SC-39: f1 (older) must be superseded_by f2 (newer)"
);
let active_ids: Vec<String> = store
.active("root")
.await
.unwrap()
.into_iter()
.map(|m| m.id)
.collect();
assert!(
!active_ids.contains(&"f1".to_string()),
"SC-39: superseded f1 must not appear in active memories"
);
}
#[tokio::test]
async fn test_candidate_pairs_are_capped() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cap = 2usize;
let cfg = MemoryConfig {
supersede_similarity_threshold: 0.01, supersede_max_candidate_pairs: cap,
distill_enabled: true,
..MemoryConfig::default()
};
let text = "context budget policy";
for i in 0..4i64 {
insert_episodic(
&store,
&format!("m{i}"),
text,
bow(text, 8),
"fake",
8,
1_000 + i,
)
.await;
}
let (spy, _, contradicts_calls) = make_spy(vec![], false, false, false);
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let calls = *contradicts_calls.lock().unwrap();
assert!(
calls <= cap,
"CP2-U: contradicts must be called at most {cap} times, got {calls}"
);
assert_eq!(
calls, cap,
"CP2-U: with 4 identical-text memories (6 pairs) and cap={cap}, \
exactly {cap} contradicts calls expected"
);
}
#[tokio::test]
async fn test_judge_failure_is_non_fatal_and_retried() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
distill_enabled: true,
..MemoryConfig::default()
};
insert_episodic(&store, "e1", "some text", vec![], "", 0, 1_000).await;
let (spy, _, _) = make_spy(vec![], true , false, false);
let result = distill(&store, &spy, &emb, &clock, &cfg, "root").await;
assert!(
result.is_ok(),
"CP2-Z: distill must return Ok(()) on judge failure, got: {result:?}"
);
let e1 = store.get("e1").await.unwrap().expect("e1 must still exist");
assert!(
e1.distilled_at.is_none(),
"CP2-Z: memory must remain undistilled (distilled_at IS NULL) when judge fails"
);
}
#[tokio::test]
async fn test_disabled_distiller_makes_no_judge_calls() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
distill_enabled: false, ..MemoryConfig::default()
};
insert_episodic(&store, "e1", "some text", vec![], "", 0, 1_000).await;
let (spy, summarize_calls, contradicts_calls) = make_spy(vec![], false, false, false);
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
assert_eq!(
*summarize_calls.lock().unwrap(),
0,
"CP2-AB: summarize_preferences must not be called when distill_enabled=false"
);
assert_eq!(
*contradicts_calls.lock().unwrap(),
0,
"CP2-AB: contradicts must not be called when distill_enabled=false"
);
}
#[tokio::test]
async fn test_distill_processes_only_undistilled_in_fifo() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
distill_enabled: true,
..MemoryConfig::default()
};
insert_episodic(&store, "e1", "some text", vec![], "", 0, 1_000).await;
let (spy, summarize_calls, _) = make_spy(vec![], false, false, false);
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let e1 = store.get("e1").await.unwrap().expect("e1 must exist");
assert!(
e1.distilled_at.is_some(),
"SC-42: first distill must set distilled_at"
);
let calls_after_first = *summarize_calls.lock().unwrap();
assert_eq!(
calls_after_first, 1,
"SC-42: summarize must be called exactly once for the first pass"
);
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let calls_after_second = *summarize_calls.lock().unwrap();
assert_eq!(
calls_after_second, calls_after_first,
"SC-42: second distill must not call summarize_preferences again \
(no undistilled memories)"
);
}
#[tokio::test]
async fn test_judge_call_cap_limits_contradicts_not_preference_short_circuit() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
supersede_max_candidate_pairs: 1,
supersede_similarity_threshold: 0.0, distill_enabled: true,
distill_every_n_turns: 0,
..MemoryConfig::default()
};
for i in 0..3i64 {
insert_episodic(
&store,
&format!("ep_e1_{i}"),
"some overlapping text for rust",
vec![0.5f32; 8],
"fake",
8,
1_000 + i * 10,
)
.await;
}
let (spy, _sum, contradicts) = make_spy(vec![], false, false, false);
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let actual = *contradicts.lock().unwrap();
assert!(
actual <= 1,
"E1: judge_calls cap must limit contradicts() calls to at most 1; got {actual}"
);
}
struct CapturingJudge {
captured: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl DistillJudge for CapturingJudge {
async fn summarize_preferences(
&self,
episodic: &[Memory],
) -> Result<Vec<String>, MemoryError> {
let mut guard = self.captured.lock().unwrap();
for m in episodic {
guard.push(m.text.clone());
}
Ok(vec![])
}
async fn contradicts(&self, _a: &str, _b: &str) -> Result<bool, MemoryError> {
Ok(false)
}
}
#[tokio::test]
async fn test_single_oversized_memory_truncated_before_judge() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
distill_enabled: true,
distill_max_batch_tokens: 5,
safety_margin_ratio: 0.0,
chars_per_token: 1.0,
..MemoryConfig::default()
};
let long_text: String = "x".repeat(100);
insert_episodic(&store, "oversized", &long_text, vec![], "", 0, 1_000).await;
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let judge = CapturingJudge {
captured: Arc::clone(&captured),
};
distill(&store, &judge, &emb, &clock, &cfg, "root")
.await
.unwrap();
let batch_budget =
budget_after_margin(cfg.distill_max_batch_tokens, 0, cfg.safety_margin_ratio).max(1);
{
let texts = captured.lock().unwrap();
assert!(
!texts.is_empty(),
"M1: judge must be called even when the single memory exceeds the batch budget"
);
for text in texts.iter() {
let t = estimate_tokens(text, cfg.chars_per_token);
assert!(
t <= batch_budget,
"M1: text handed to judge ({} tokens) must be <= batch_budget={} (R-02/CP2-L)",
t,
batch_budget
);
}
}
let stored = store
.get("oversized")
.await
.unwrap()
.expect("oversized memory must still exist in the store");
assert_eq!(
stored.text, long_text,
"M1: stored memory text must be unchanged after distillation"
);
}
#[tokio::test]
async fn test_promote_to_profile_trims_whitespace() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
distill_enabled: true,
distill_every_n_turns: 0,
supersede_similarity_threshold: 1.1, ..MemoryConfig::default()
};
let (spy, _sum, _con) = make_spy(vec![" prefer dark mode ".into()], false, false, false);
insert_episodic(
&store,
"ep_e2",
"user prefers dark mode",
vec![0.5f32; 8],
"fake",
8,
1_000,
)
.await;
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let all_mems = store.active("root").await.unwrap();
let prefs: Vec<_> = all_mems
.iter()
.filter(|m| m.kind == MemoryKind::Preference)
.collect();
assert!(
!prefs.is_empty(),
"E2: distill must have promoted the preference to profile"
);
for p in &prefs {
assert_eq!(
p.text,
p.text.trim(),
"E2: profile entry must not have leading/trailing whitespace; \
got: {:?}",
p.text
);
}
}
struct LenCapturingJudge {
calls: Arc<Mutex<Vec<(usize, usize)>>>,
}
#[async_trait]
impl DistillJudge for LenCapturingJudge {
async fn summarize_preferences(
&self,
_episodic: &[Memory],
) -> Result<Vec<String>, MemoryError> {
Ok(vec![])
}
async fn contradicts(&self, a: &str, b: &str) -> Result<bool, MemoryError> {
self.calls.lock().unwrap().push((a.len(), b.len()));
Ok(false)
}
}
#[tokio::test]
async fn test_render_profile_truncates_first_entry_exceeding_budget() {
let (_tmp, store) = make_test_store();
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
profile_max_tokens: 5,
chars_per_token: 1.0,
safety_margin_ratio: 0.0,
..MemoryConfig::default()
};
let long_pref = "a".repeat(100);
promote_to_profile(&store, &clock, &cfg, "root", &long_pref)
.await
.unwrap();
let profile = render_profile(&store, &cfg, "root").await.unwrap();
let token_count = estimate_tokens(&profile, cfg.chars_per_token);
assert!(
token_count <= cfg.profile_max_tokens,
"G3: render_profile output ({token_count} tokens) must not exceed \
profile_max_tokens={} even when the first entry alone is oversized; \
got: {profile:?}",
cfg.profile_max_tokens
);
assert!(
!profile.is_empty(),
"G3: render_profile must not return empty output when truncation can fit content"
);
}
#[tokio::test]
async fn test_distill_truncates_long_texts_before_contradicts_judge() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let cfg = MemoryConfig {
distill_enabled: true,
distill_max_batch_tokens: 30,
chars_per_token: 1.0,
safety_margin_ratio: 0.0,
supersede_similarity_threshold: 0.0, supersede_max_candidate_pairs: 50,
..MemoryConfig::default()
};
insert_episodic(
&store,
"mem_a",
&"x".repeat(8),
vec![0.5f32; 8],
"fake",
8,
1_000,
)
.await;
insert_episodic(
&store,
"mem_b",
&"y".repeat(20),
vec![0.5f32; 8],
"fake",
8,
2_000,
)
.await;
let calls: Arc<Mutex<Vec<(usize, usize)>>> = Arc::new(Mutex::new(vec![]));
let judge = LenCapturingJudge {
calls: Arc::clone(&calls),
};
distill(&store, &judge, &emb, &clock, &cfg, "root")
.await
.unwrap();
let recorded = calls.lock().unwrap();
assert!(
!recorded.is_empty(),
"G4: contradicts must have been called for the similar pair"
);
let per_text_cap = (cfg.distill_max_batch_tokens / 2).max(1);
let max_chars = (per_text_cap as f64 * cfg.chars_per_token).floor() as usize;
for &(a_len, b_len) in recorded.iter() {
assert!(
a_len <= max_chars,
"G4: text 'a' passed to contradicts ({a_len} chars) must be ≤ {max_chars}"
);
assert!(
b_len <= max_chars,
"G4: text 'b' passed to contradicts ({b_len} chars) must be ≤ {max_chars}"
);
}
}
#[tokio::test]
async fn test_truncated_m1_memory_excluded_from_supersession_candidates() {
let (_tmp, store) = make_test_store();
let emb = FakeEmbedder {
dim: 8,
model: "fake".into(),
};
let clock = FixedClock::new(1_000);
let (spy, _summarize_calls, contradicts_calls) = make_spy(vec![], false, false, false);
let cfg = MemoryConfig {
distill_enabled: true,
distill_max_batch_tokens: 3,
distill_every_n_turns: 0,
chars_per_token: 1.0,
safety_margin_ratio: 0.0,
supersede_similarity_threshold: 0.0, supersede_max_candidate_pairs: 50,
..MemoryConfig::default()
};
insert_episodic(
&store,
"oversized",
"abcdefgh",
bow("abcdefgh", 8),
"fake",
8,
1_000,
)
.await;
distill(&store, &spy, &emb, &clock, &cfg, "root")
.await
.unwrap();
let cc = *contradicts_calls.lock().unwrap();
assert_eq!(
cc, 0,
"F3: truncated M1 memory must not be offered as a supersession \
candidate — contradicts must not be called; got {cc} call(s)"
);
let all = store.active("root").await.unwrap();
assert!(
all.iter().all(|m| m.distilled_at.is_some()),
"F3: M1 memory must be marked distilled after the pass"
);
}
}