1use serde::{Deserialize, Serialize};
59use uuid::Uuid;
60
61use crate::error::{Error, Result};
62use crate::model::memory::{ConsolidationState, MemoryType, SourceType};
63use crate::query::MnemoEngine;
64use crate::query::recall::RecallRequest;
65use crate::query::remember::RememberRequest;
66use crate::storage::MemoryFilter;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum Stage {
74 Store,
75 Summarize,
76 Retrieve,
77}
78
79impl Stage {
80 pub fn as_str(self) -> &'static str {
81 match self {
82 Stage::Store => "store",
83 Stage::Summarize => "summarize",
84 Stage::Retrieve => "retrieve",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ProbeOutcome {
92 pub name: String,
93 pub passed: bool,
94 pub detail: String,
96}
97
98impl ProbeOutcome {
99 fn pass(name: impl Into<String>) -> Self {
100 Self {
101 name: name.into(),
102 passed: true,
103 detail: String::new(),
104 }
105 }
106
107 fn fail(name: impl Into<String>, detail: impl Into<String>) -> Self {
108 Self {
109 name: name.into(),
110 passed: false,
111 detail: detail.into(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct StageReport {
119 pub stage: Stage,
120 pub probes: Vec<ProbeOutcome>,
121}
122
123impl StageReport {
124 pub fn passed(&self) -> bool {
125 self.probes.iter().all(|p| p.passed)
126 }
127
128 pub fn failing_probes(&self) -> Vec<&ProbeOutcome> {
129 self.probes.iter().filter(|p| !p.passed).collect()
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct AttributionReport {
139 pub observed_failure: String,
142 pub isolated: bool,
145 pub attributed_stage: Stage,
147 pub evidence: Vec<String>,
149 pub store_report: StageReport,
151 pub summarize_report: StageReport,
152}
153
154pub async fn run_store_probes(engine: &MnemoEngine, agent_id: &str) -> Result<StageReport> {
165 let mut probes = Vec::new();
166
167 {
169 let needle = format!("STORE-NEEDLE-{}", Uuid::now_v7());
170 let mut req = RememberRequest::new(needle.clone());
171 req.agent_id = Some(agent_id.to_string());
172 let resp = engine.remember(req).await?;
173 match engine.storage.get_memory(resp.id).await? {
174 Some(record) => {
175 if record.content != needle {
176 probes.push(ProbeOutcome::fail(
177 "store.content_roundtrip",
178 format!("stored content '{}' != input '{}'", record.content, needle),
179 ));
180 } else if record.content_hash.is_empty() {
181 probes.push(ProbeOutcome::fail(
182 "store.content_roundtrip",
183 "stored record carries empty content_hash",
184 ));
185 } else {
186 probes.push(ProbeOutcome::pass("store.content_roundtrip"));
187 }
188 }
189 None => probes.push(ProbeOutcome::fail(
190 "store.content_roundtrip",
191 format!("get_memory({}) returned None after remember", resp.id),
192 )),
193 }
194 }
195
196 {
198 let pre = list_active(engine, agent_id).await?.len();
199 let n = 5;
200 for i in 0..n {
201 let mut req = RememberRequest::new(format!("STORE-ATOM-{}-{i}", Uuid::now_v7()));
202 req.agent_id = Some(agent_id.to_string());
203 engine.remember(req).await?;
204 }
205 let post = list_active(engine, agent_id).await?;
206 let added = post.len().saturating_sub(pre);
207 if added != n {
208 probes.push(ProbeOutcome::fail(
209 "store.bank_size_growth",
210 format!("expected +{n} active records, got +{added}"),
211 ));
212 } else {
213 let mut ids: Vec<Uuid> = post.iter().map(|r| r.id).collect();
214 ids.sort();
215 ids.dedup();
216 if ids.len() != post.len() {
217 probes.push(ProbeOutcome::fail(
218 "store.bank_size_growth",
219 "duplicate ids after batch remember",
220 ));
221 } else {
222 probes.push(ProbeOutcome::pass("store.bank_size_growth"));
223 }
224 }
225 }
226
227 {
229 let mut req = RememberRequest::new(format!("STORE-TAGGED-{}", Uuid::now_v7()));
230 req.agent_id = Some(agent_id.to_string());
231 req.tags = Some(vec!["memfail.alpha".into(), "memfail.beta".into()]);
232 let resp = engine.remember(req).await?;
233 let rec = engine
234 .storage
235 .get_memory(resp.id)
236 .await?
237 .ok_or_else(|| Error::Validation("tagged record missing".into()))?;
238 let has_alpha = rec.tags.iter().any(|t| t == "memfail.alpha");
239 let has_beta = rec.tags.iter().any(|t| t == "memfail.beta");
240 if has_alpha && has_beta {
241 probes.push(ProbeOutcome::pass("store.tag_roundtrip"));
242 } else {
243 probes.push(ProbeOutcome::fail(
244 "store.tag_roundtrip",
245 format!(
246 "tags lost on round-trip: alpha={has_alpha}, beta={has_beta}, observed={:?}",
247 rec.tags
248 ),
249 ));
250 }
251 }
252
253 Ok(StageReport {
254 stage: Stage::Store,
255 probes,
256 })
257}
258
259pub async fn run_summarize_probes(engine: &MnemoEngine, agent_id: &str) -> Result<StageReport> {
269 let mut probes = Vec::new();
270
271 let topic = format!("memfail-cluster-{}", Uuid::now_v7());
274 let needle = format!("SUMMARIZE-NEEDLE-{}", Uuid::now_v7());
275
276 let mut needle_req = RememberRequest::new(needle.clone());
277 needle_req.agent_id = Some(agent_id.to_string());
278 needle_req.tags = Some(vec![topic.clone()]);
279 let needle_resp = engine.remember(needle_req).await?;
280
281 let mut companion_ids = Vec::with_capacity(2);
282 for i in 0..2 {
283 let mut req = RememberRequest::new(format!("companion-{i}-{}", Uuid::now_v7()));
284 req.agent_id = Some(agent_id.to_string());
285 req.tags = Some(vec![topic.clone()]);
286 let resp = engine.remember(req).await?;
287 companion_ids.push(resp.id);
288 }
289
290 let result = engine
291 .run_consolidation(Some(agent_id.to_string()), 3)
292 .await?;
293
294 if result.clusters_found == 0 || result.new_memories_created == 0 {
296 probes.push(ProbeOutcome::fail(
297 "summarize.cluster_emitted",
298 format!(
299 "run_consolidation reported clusters_found={} new={}",
300 result.clusters_found, result.new_memories_created
301 ),
302 ));
303 } else {
304 probes.push(ProbeOutcome::pass("summarize.cluster_emitted"));
305 }
306
307 let active = list_active(engine, agent_id).await?;
308 let bundles: Vec<_> = active
309 .iter()
310 .filter(|r| {
311 r.source_type == SourceType::Consolidation && r.memory_type == MemoryType::Semantic
312 })
313 .collect();
314
315 let bundle_with_needle = bundles.iter().find(|b| b.content.contains(&needle));
318 match bundle_with_needle {
319 Some(b) => {
320 if b.tags.iter().any(|t| t == &topic) {
321 probes.push(ProbeOutcome::pass("summarize.needle_preservation"));
322 } else {
323 probes.push(ProbeOutcome::fail(
324 "summarize.needle_preservation",
325 format!("bundle missing cluster topic tag: {:?}", b.tags),
326 ));
327 }
328 }
329 None => {
330 probes.push(ProbeOutcome::fail(
331 "summarize.needle_preservation",
332 format!(
333 "needle string '{needle}' not found in any of {} Consolidation bundle(s)",
334 bundles.len()
335 ),
336 ));
337 }
338 }
339
340 let needle_after = engine.storage.get_memory(needle_resp.id).await?;
342 let state = needle_after
343 .as_ref()
344 .map(|r| r.consolidation_state)
345 .unwrap_or(ConsolidationState::Raw);
346 if state == ConsolidationState::Consolidated {
347 probes.push(ProbeOutcome::pass("summarize.original_marked_consolidated"));
348 } else {
349 probes.push(ProbeOutcome::fail(
350 "summarize.original_marked_consolidated",
351 format!(
352 "expected needle original ({}) in state Consolidated, observed {:?}",
353 needle_resp.id, state
354 ),
355 ));
356 }
357
358 Ok(StageReport {
359 stage: Stage::Summarize,
360 probes,
361 })
362}
363
364pub async fn run_retrieve_probes(engine: &MnemoEngine, agent_id: &str) -> Result<StageReport> {
375 let mut probes = Vec::new();
376
377 {
380 let needle = format!("RETRIEVE-NEEDLE-{}", Uuid::now_v7());
381 let mut req = RememberRequest::new(needle.clone());
382 req.agent_id = Some(agent_id.to_string());
383 req.tags = Some(vec!["memfail.retrieve.direct".into()]);
384 engine.remember(req).await?;
385
386 let mut rec = RecallRequest::new(needle.clone());
387 rec.agent_id = Some(agent_id.to_string());
388 rec.limit = Some(10);
389 rec.strategy = Some("auto".into());
390 let resp = engine.recall(rec).await?;
391 if resp.memories.iter().any(|m| m.content.contains(&needle)) {
392 probes.push(ProbeOutcome::pass("retrieve.direct_hit"));
393 } else {
394 probes.push(ProbeOutcome::fail(
395 "retrieve.direct_hit",
396 format!(
397 "needle '{needle}' missing from top-{} recall (got {} hits)",
398 10,
399 resp.memories.len()
400 ),
401 ));
402 }
403 }
404
405 {
408 let tag = format!("memfail.retrieve.tag.{}", Uuid::now_v7());
409 let mut req = RememberRequest::new(format!("retrieve-by-tag-{}", Uuid::now_v7()));
410 req.agent_id = Some(agent_id.to_string());
411 req.tags = Some(vec![tag.clone()]);
412 engine.remember(req).await?;
413
414 let mut rec = RecallRequest::new("retrieve-by-tag".into());
415 rec.agent_id = Some(agent_id.to_string());
416 rec.tags = Some(vec![tag.clone()]);
417 rec.limit = Some(10);
418 rec.strategy = Some("auto".into());
419 let resp = engine.recall(rec).await?;
420 let any_tagged = resp.memories.iter().any(|m| m.tags.contains(&tag));
421 if any_tagged {
422 probes.push(ProbeOutcome::pass("retrieve.tag_filter"));
423 } else {
424 probes.push(ProbeOutcome::fail(
425 "retrieve.tag_filter",
426 format!(
427 "no recall result carried tag '{tag}' ({} hits)",
428 resp.memories.len()
429 ),
430 ));
431 }
432 }
433
434 Ok(StageReport {
435 stage: Stage::Retrieve,
436 probes,
437 })
438}
439
440pub async fn run_stale_context_fixture(
461 engine: &MnemoEngine,
462 agent_id: &str,
463) -> Result<AttributionReport> {
464 let mut evidence = Vec::new();
465
466 let stale_content = format!("USER-COLOR-{}: blue (older write)", Uuid::now_v7());
469 let mut stale_req = RememberRequest::new(stale_content.clone());
470 stale_req.agent_id = Some(agent_id.to_string());
471 stale_req.tags = Some(vec!["memfail.stale.user-color".into()]);
472 stale_req.importance = Some(0.95);
473 let stale_resp = engine.remember(stale_req).await?;
474
475 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
477
478 let current_content = format!("USER-COLOR-{}: red (current write)", Uuid::now_v7());
481 let mut current_req = RememberRequest::new(current_content.clone());
482 current_req.agent_id = Some(agent_id.to_string());
483 current_req.tags = Some(vec!["memfail.stale.user-color".into()]);
484 current_req.importance = Some(0.05);
485 let current_resp = engine.remember(current_req).await?;
486
487 let mut store_probes = Vec::new();
489 let stale_after = engine.storage.get_memory(stale_resp.id).await?;
490 let current_after = engine.storage.get_memory(current_resp.id).await?;
491 let stale_ok = stale_after.as_ref().map(|r| r.content.as_str()) == Some(stale_content.as_str());
492 let current_ok =
493 current_after.as_ref().map(|r| r.content.as_str()) == Some(current_content.as_str());
494 store_probes.push(if stale_ok {
495 ProbeOutcome::pass("stale.store.older_write")
496 } else {
497 ProbeOutcome::fail(
498 "stale.store.older_write",
499 format!("older record content drifted: {stale_after:?}"),
500 )
501 });
502 store_probes.push(if current_ok {
503 ProbeOutcome::pass("stale.store.newer_write")
504 } else {
505 ProbeOutcome::fail(
506 "stale.store.newer_write",
507 format!("newer record content drifted: {current_after:?}"),
508 )
509 });
510 let store_report = StageReport {
511 stage: Stage::Store,
512 probes: store_probes,
513 };
514
515 let mut summarize_probes = Vec::new();
521 let active = list_active(engine, agent_id).await?;
522 let consolidation_bundles_touching_fact = active
523 .iter()
524 .filter(|r| r.source_type == SourceType::Consolidation)
525 .filter(|r| r.content.contains(&stale_content) || r.content.contains(¤t_content))
526 .count();
527 let stale_unconsolidated = stale_after
528 .as_ref()
529 .map(|r| r.consolidation_state != ConsolidationState::Consolidated)
530 .unwrap_or(false);
531 let current_unconsolidated = current_after
532 .as_ref()
533 .map(|r| r.consolidation_state != ConsolidationState::Consolidated)
534 .unwrap_or(false);
535 summarize_probes.push(if consolidation_bundles_touching_fact == 0 {
536 ProbeOutcome::pass("stale.summarize.no_bundle_touches_fact")
537 } else {
538 ProbeOutcome::fail(
539 "stale.summarize.no_bundle_touches_fact",
540 format!("{consolidation_bundles_touching_fact} Consolidation bundle(s) cover the fact"),
541 )
542 });
543 summarize_probes.push(if stale_unconsolidated && current_unconsolidated {
544 ProbeOutcome::pass("stale.summarize.both_records_unconsolidated")
545 } else {
546 ProbeOutcome::fail(
547 "stale.summarize.both_records_unconsolidated",
548 format!(
549 "older.state={:?} newer.state={:?}",
550 stale_after.as_ref().map(|r| r.consolidation_state),
551 current_after.as_ref().map(|r| r.consolidation_state),
552 ),
553 )
554 });
555 let summarize_report = StageReport {
556 stage: Stage::Summarize,
557 probes: summarize_probes,
558 };
559
560 let mut rec = RecallRequest::new("USER-COLOR".to_string());
562 rec.agent_id = Some(agent_id.to_string());
563 rec.tags = Some(vec!["memfail.stale.user-color".into()]);
564 rec.limit = Some(5);
565 rec.strategy = Some("auto".into());
566 let resp = engine.recall(rec).await?;
567
568 let top_id = resp.memories.first().map(|m| m.id);
569 let returned_stale_on_top = top_id == Some(stale_resp.id);
570 let observed_failure = if returned_stale_on_top {
571 format!(
572 "default ranker returned older write ({}) above newer write ({}) for the same fact",
573 stale_resp.id, current_resp.id
574 )
575 } else {
576 format!(
577 "recall surfaced current record first ({:?}); the fixture's stale-bias setup did not reproduce",
578 top_id
579 )
580 };
581 evidence.push(format!("recall.top_id = {top_id:?}"));
582 evidence.push(format!(
583 "store.older_write_intact = {}, store.newer_write_intact = {}",
584 stale_ok, current_ok
585 ));
586 evidence.push(format!(
587 "summarize.bundles_touching_fact = {consolidation_bundles_touching_fact}"
588 ));
589 evidence.push(format!(
590 "summarize.both_records_unconsolidated = {}",
591 stale_unconsolidated && current_unconsolidated
592 ));
593
594 let upstream_ok = store_report.passed() && summarize_report.passed();
595 let attributed_stage = if !store_report.passed() {
596 Stage::Store
597 } else if !summarize_report.passed() {
598 Stage::Summarize
599 } else {
600 Stage::Retrieve
601 };
602
603 Ok(AttributionReport {
604 observed_failure,
605 isolated: upstream_ok,
606 attributed_stage,
607 evidence,
608 store_report,
609 summarize_report,
610 })
611}
612
613async fn list_active(
618 engine: &MnemoEngine,
619 agent_id: &str,
620) -> Result<Vec<crate::model::memory::MemoryRecord>> {
621 let filter = MemoryFilter {
622 agent_id: Some(agent_id.to_string()),
623 include_deleted: false,
624 ..Default::default()
625 };
626 engine
627 .storage
628 .list_memories(&filter, super::super::query::MAX_BATCH_QUERY_LIMIT, 0)
629 .await
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::embedding::NoopEmbedding;
636 use crate::index::usearch::UsearchIndex;
637 use crate::search::tantivy_index::TantivyFullTextIndex;
638 use crate::storage::duckdb::DuckDbStorage;
639 use std::sync::Arc;
640
641 fn build_engine() -> MnemoEngine {
642 let storage = Arc::new(DuckDbStorage::open_in_memory().unwrap());
643 let index = Arc::new(UsearchIndex::new(3).unwrap());
644 let embedding = Arc::new(NoopEmbedding::new(3));
645 let ft = Arc::new(TantivyFullTextIndex::open_in_memory().unwrap());
646 MnemoEngine::new(storage, index, embedding, "memfail-agent".into(), None).with_full_text(ft)
647 }
648
649 #[tokio::test]
650 async fn store_probes_pass_on_a_well_formed_engine() {
651 let engine = build_engine();
652 let report = run_store_probes(&engine, "memfail-agent").await.unwrap();
653 assert!(
654 report.passed(),
655 "store probes must pass on default engine: {:?}",
656 report.failing_probes()
657 );
658 assert_eq!(report.stage, Stage::Store);
659 assert_eq!(report.probes.len(), 3);
660 }
661
662 #[tokio::test]
663 async fn summarize_probes_pass_on_a_well_formed_engine() {
664 let engine = build_engine();
665 let report = run_summarize_probes(&engine, "memfail-agent")
666 .await
667 .unwrap();
668 assert!(
669 report.passed(),
670 "summarize probes must pass on default engine: {:?}",
671 report.failing_probes()
672 );
673 assert_eq!(report.stage, Stage::Summarize);
674 assert_eq!(report.probes.len(), 3);
675 }
676
677 #[tokio::test]
678 async fn retrieve_probes_pass_on_a_well_formed_engine() {
679 let engine = build_engine();
680 let report = run_retrieve_probes(&engine, "memfail-agent").await.unwrap();
681 assert!(
682 report.passed(),
683 "retrieve probes must pass on default engine: {:?}",
684 report.failing_probes()
685 );
686 assert_eq!(report.stage, Stage::Retrieve);
687 assert_eq!(report.probes.len(), 2);
688 }
689}