Skip to main content

mnemo_core/eval/
memfail.rs

1//! MemFail-style per-operation fault-isolation harness.
2//!
3//! # Anchor
4//!
5//! MemFail frames a long-running agent's recall pipeline as a chain of
6//! distinct operations — *summarize* → *store* → *retrieve* — and
7//! makes the per-operation behaviour the testable unit. A failure
8//! observed at the recall surface is decomposed into the single stage
9//! responsible for it.
10//!
11//! mnemo's operation seams are the three primitives exposed on
12//! [`MnemoEngine`](crate::query::MnemoEngine):
13//!
14//! - **store** = [`MnemoEngine::remember`] — write a [`MemoryRecord`]
15//!   into the [`StorageBackend`](crate::storage::StorageBackend)
16//!   *and* the vector + full-text indices, emit a `MemoryWrite`
17//!   event, link the hash chain.
18//! - **summarize** = [`MnemoEngine::run_consolidation`] — cluster
19//!   episodic records by tag overlap and replace each cluster with a
20//!   structured `[Consolidated from N memories] …` semantic bundle
21//!   (`SourceType::Consolidation`) plus `consolidated_from` relations.
22//!   Closest mnemo analogue to MemFail's "summarize" stage.
23//! - **retrieve** = [`MnemoEngine::recall`] — score the active bank
24//!   under the active retrieval mode (hybrid RRF by default), return
25//!   the top-k.
26//!
27//! # What this harness is
28//!
29//! A set of *adversarial probes* — one per operation — each
30//! engineered so a failed assertion is **attributable to exactly one
31//! stage**. The probes are run in order (store → summarize →
32//! retrieve); a downstream probe trusts its upstream peers because
33//! their probes already passed in the same run.
34//!
35//! The canonical
36//! [`run_stale_context_fixture`] case demonstrates the attribution
37//! shape: write the same fact twice (older write at high importance,
38//! newer write at low importance), then recall. The default hybrid
39//! ranker returns the older / stale record on top. Store + summarize
40//! probes succeed (both records are in storage with correct content
41//! hashes; no consolidation has run), so the harness attributes the
42//! stale recall to the retrieve stage — exactly the MemFail
43//! "isolate the operation" output.
44//!
45//! # What this harness is NOT
46//!
47//! - **Not a recall-quality benchmark.** The probes target seams, not
48//!   retrieval-mode quality. Use [`bench/locomo`] for quality numbers.
49//! - **Not a faithful MemFail reproduction.** The arXiv reference is
50//!   prior-art-only; mnemo's harness exercises the three primitives
51//!   that the public MCP surface actually exposes.
52//! - **Not a write-side guard.** A failed probe surfaces an
53//!   attribution; gating production writes on it is the caller's
54//!   choice.
55//!
56//! [`bench/locomo`]: https://github.com/sattyamjjain/mnemo/tree/main/bench/locomo
57
58use 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/// Identifier for the three operation stages MemFail decomposes a
69/// recall into. The harness reports findings keyed on this enum so
70/// callers can pivot dashboards by stage.
71#[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/// Outcome of a single adversarial probe.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ProbeOutcome {
92    pub name: String,
93    pub passed: bool,
94    /// Operator-readable diagnostic. Empty string on pass.
95    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/// Result of running every probe in a single stage.
117#[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/// Output of [`run_stale_context_fixture`]: the canonical MemFail
134/// case. `attributed_stage` is the single stage the harness blames
135/// for the observed failure based on which upstream probes still
136/// pass.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct AttributionReport {
139    /// Human-readable description of the failure the fixture
140    /// observed at the recall surface.
141    pub observed_failure: String,
142    /// `true` when every upstream probe (store, summarize) passed so
143    /// the harness was able to isolate one stage.
144    pub isolated: bool,
145    /// The stage the harness assigns responsibility to.
146    pub attributed_stage: Stage,
147    /// Per-stage assertions consulted to compute the attribution.
148    pub evidence: Vec<String>,
149    /// Per-stage reports leading up to the attribution.
150    pub store_report: StageReport,
151    pub summarize_report: StageReport,
152}
153
154// ---------------------------------------------------------------------------
155// Store probes
156// ---------------------------------------------------------------------------
157
158/// Run the **store** adversarial probe set against `engine`.
159///
160/// Every probe touches storage directly (no recall ranking, no
161/// consolidation), so a failure is attributable to
162/// [`MnemoEngine::remember`] or the underlying
163/// [`StorageBackend`](crate::storage::StorageBackend).
164pub async fn run_store_probes(engine: &MnemoEngine, agent_id: &str) -> Result<StageReport> {
165    let mut probes = Vec::new();
166
167    // (1) Content + hash round-trip via direct storage fetch.
168    {
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    // (2) Distinct ids + bank-size growth.
197    {
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    // (3) Tag round-trip.
228    {
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
259// ---------------------------------------------------------------------------
260// Summarize probes
261// ---------------------------------------------------------------------------
262
263/// Run the **summarize** adversarial probe set against `engine`.
264///
265/// Each probe inspects post-consolidation state via direct storage
266/// reads (no recall ranking), so a failure is attributable to
267/// [`MnemoEngine::run_consolidation`] / the lifecycle module.
268pub async fn run_summarize_probes(engine: &MnemoEngine, agent_id: &str) -> Result<StageReport> {
269    let mut probes = Vec::new();
270
271    // Build an isolated cluster so we do not collide with whatever
272    // store probes left behind.
273    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    // (1) At least one cluster consolidated.
295    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    // (2) Needle survives the bundle verbatim — the canonical
316    // summarize fault is content loss.
317    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    // (3) Originals are flipped to Consolidated state (audit chain stays alive).
341    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
364// ---------------------------------------------------------------------------
365// Retrieve probes
366// ---------------------------------------------------------------------------
367
368/// Run the **retrieve** adversarial probe set against `engine`.
369///
370/// Each probe assumes [`run_store_probes`] passed: it remembers a
371/// record, then asserts something about the ranked recall result.
372/// Because store has already been verified in the same run, a
373/// failure here points at the recall path.
374pub async fn run_retrieve_probes(engine: &MnemoEngine, agent_id: &str) -> Result<StageReport> {
375    let mut probes = Vec::new();
376
377    // (1) Direct hit: the unique needle text must appear in the
378    // top-k of a query that contains the needle verbatim.
379    {
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    // (2) Tag filter: a recall scoped by tag must return a memory
406    // carrying that tag.
407    {
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
440// ---------------------------------------------------------------------------
441// Stale-context fixture (canonical MemFail case)
442// ---------------------------------------------------------------------------
443
444/// Canonical MemFail attribution fixture.
445///
446/// Writes the same fact twice — an older write at `high` importance
447/// and a newer write at `low` importance — then asks the retrieve
448/// stage for the fact. The default hybrid ranker returns the older
449/// (stale) record on top. Store + summarize probes succeed (both
450/// records are in storage with correct content hashes; no
451/// consolidation has run), so the harness attributes the failure to
452/// retrieve.
453///
454/// The fixture asserts the attribution shape, not the retriever's
455/// quality on this scenario — the v0.4.7 current-fact-resolver
456/// (`fact_key` post-processor on `RecallRequest`) is the documented
457/// opt-in mitigation. The point of the fixture is that the harness
458/// can *isolate the operation responsible* when the failure is
459/// observed.
460pub async fn run_stale_context_fixture(
461    engine: &MnemoEngine,
462    agent_id: &str,
463) -> Result<AttributionReport> {
464    let mut evidence = Vec::new();
465
466    // Older write (stale): high importance so the default ranker
467    // prefers it.
468    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    // Yield so the second write lands at a strictly later timestamp.
476    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
477
478    // Newer write (current): low importance, deliberately
479    // de-prioritised by the default ranker.
480    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    // ---- Store stage: directly verify both records exist verbatim.
488    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    // ---- Summarize stage: no consolidation should have fired in
516    // this fixture, so the active bank still contains both records
517    // as raw episodic writes (consolidation_state ∈ {Raw, Active}).
518    // Any Consolidation-source bundle covering either id would shift
519    // the blame upstream.
520    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(&current_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    // ---- Retrieve stage: ask the recall surface.
561    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
613// ---------------------------------------------------------------------------
614// Helpers
615// ---------------------------------------------------------------------------
616
617async 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}