zeph_bench/runner.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Benchmark runner: drives `Agent<BenchmarkChannel>` over a dataset and collects results.
5//!
6//! [`BenchRunner`] is the execution engine for `zeph bench run`. It is intentionally
7//! minimal — baseline mode only (no tools, no memory, no MCP). Each scenario is run in
8//! isolation through a fresh [`BenchmarkChannel`] and the agent's raw text response is
9//! scored by the supplied [`Evaluator`].
10//!
11//! # Usage
12//!
13//! ```no_run
14//! use std::path::Path;
15//! use zeph_bench::runner::{BenchRunner, RunOptions};
16//! use zeph_bench::loaders::{GaiaLoader, GaiaEvaluator};
17//! use zeph_llm::{any::AnyProvider, mock::MockProvider};
18//!
19//! # async fn example() -> Result<(), zeph_bench::BenchError> {
20//! let provider = AnyProvider::Mock(MockProvider::with_responses(vec!["1945".into()]));
21//! let runner = BenchRunner::new(provider);
22//! let opts = RunOptions::default();
23//! let run = runner.run_dataset(&GaiaLoader::all_levels(), &GaiaEvaluator, Path::new("/data/gaia.jsonl"), opts).await?;
24//! println!("mean score: {:.4}", run.aggregate.mean_score);
25//! # Ok(())
26//! # }
27//! ```
28
29use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32use std::time::Instant;
33
34use tracing::Instrument as _;
35use zeph_common::timestamp;
36use zeph_core::agent::Agent;
37use zeph_core::instructions::InstructionBlock;
38use zeph_llm::any::AnyProvider;
39use zeph_llm::provider::LlmProvider as _;
40use zeph_memory::semantic::SemanticMemory;
41use zeph_skills::registry::SkillRegistry;
42use zeph_tools::executor::{ToolError, ToolExecutor, ToolOutput};
43
44use crate::channel::BenchmarkChannel;
45use crate::error::BenchError;
46use crate::loaders::tau2_bench::{ActionTrace, TauBenchEvaluator};
47use crate::results::{BenchRun, RunStatus, ScenarioResult};
48use crate::scenario::{DatasetLoader, Evaluator, Scenario};
49
50/// Controls how the runner processes the agent's raw text response.
51///
52/// Used by [`BenchRunner::run_one_with_executor`] to select the appropriate
53/// system prompt and post-processing behaviour.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum ResponseMode {
57 /// Inject a "shortest possible answer" system prompt and strip markdown from the response.
58 ///
59 /// Used by all knowledge-retrieval datasets (GAIA, LOCOMO, FRAMES, `LongMemEval`).
60 TerseAnswer,
61 /// Inject a tool-use system prompt; return the raw agent response without post-processing.
62 ///
63 /// Used by tau2-bench where the evaluation is based on the action trace, not text output.
64 ToolUse,
65}
66
67/// Controls whether `SemanticMemory` is wired into the agent during a benchmark run.
68///
69/// # Examples
70///
71/// ```
72/// use zeph_bench::runner::MemoryMode;
73///
74/// assert_eq!(MemoryMode::default(), MemoryMode::Off);
75/// ```
76#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum MemoryMode {
79 /// No `SemanticMemory` — current default behaviour.
80 #[default]
81 Off,
82 /// Wire a `SQLite`-backed `SemanticMemory` into the agent via `Agent::with_memory`.
83 On,
84}
85
86/// Parameters required to construct a per-scenario `SQLite`-backed `SemanticMemory`.
87///
88/// Populated by [`BenchRunner::with_memory_params`] and consumed inside
89/// [`BenchRunner::run_one`] when `opts.memory_mode == MemoryMode::On`.
90///
91/// # Examples
92///
93/// ```
94/// use std::path::PathBuf;
95/// use zeph_bench::runner::BenchMemoryParams;
96///
97/// let params = BenchMemoryParams {
98/// data_dir: PathBuf::from("/tmp/bench"),
99/// embedding_model: "nomic-embed-text".into(),
100/// run_id: "bench-abc".into(),
101/// dataset: "locomo".into(),
102/// };
103/// assert!(params.data_dir.to_string_lossy().contains("bench"));
104/// ```
105#[derive(Debug, Clone)]
106pub struct BenchMemoryParams {
107 /// Directory where per-scenario `SQLite` files live (deleted between scenarios).
108 ///
109 /// The derived path always contains the `bench-` segment (NFR-001).
110 pub data_dir: PathBuf,
111 /// Embedding model name passed to `SemanticMemory`.
112 pub embedding_model: String,
113 /// Run ID used to namespace bench artifacts; matches the outer `BenchRun.run_id`.
114 pub run_id: String,
115 /// Dataset name used to namespace bench artifacts.
116 pub dataset: String,
117}
118
119/// Options that control which scenarios are executed and whether to resume a prior run.
120///
121/// Build via [`RunOptions::default`] and override the fields you need.
122///
123/// # Examples
124///
125/// ```
126/// use zeph_bench::runner::{RunOptions, MemoryMode};
127///
128/// // Run all scenarios.
129/// let opts = RunOptions::default();
130/// assert!(opts.scenario_filter.is_none());
131/// assert!(opts.completed_ids.is_empty());
132/// assert_eq!(opts.memory_mode, MemoryMode::Off);
133/// ```
134#[derive(Debug, Default)]
135pub struct RunOptions {
136 /// When `Some(id)`, only the scenario with this ID is executed.
137 pub scenario_filter: Option<String>,
138 /// Set of scenario IDs already completed in a prior run (used for `--resume`).
139 pub completed_ids: HashSet<String>,
140 /// Whether to wire a `SemanticMemory` backend into the agent for this run.
141 pub memory_mode: MemoryMode,
142}
143
144/// Minimal no-op tool executor for baseline benchmark runs.
145///
146/// Returns an empty tool list and `Ok(None)` on every execute call, ensuring that
147/// the agent loop cannot invoke any tools during a benchmark run.
148struct NoopExecutor;
149
150impl ToolExecutor for NoopExecutor {
151 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
152 Ok(None)
153 }
154}
155
156/// Drives [`Agent<BenchmarkChannel>`] over a dataset and collects scored results.
157///
158/// Each call to [`run_dataset`][BenchRunner::run_dataset] creates a fresh agent per
159/// scenario (baseline mode: no tools, no MCP). Memory is optionally wired via
160/// [`BenchRunner::with_memory_params`] and [`RunOptions::memory_mode`].
161///
162/// # Examples
163///
164/// ```no_run
165/// use zeph_bench::runner::BenchRunner;
166/// use zeph_llm::{any::AnyProvider, mock::MockProvider};
167///
168/// let provider = AnyProvider::Mock(MockProvider::with_responses(vec!["Paris".into()]));
169/// let runner = BenchRunner::new(provider);
170/// ```
171pub struct BenchRunner {
172 provider: AnyProvider,
173 /// Parameters for constructing per-scenario `SQLite`-backed `SemanticMemory`.
174 ///
175 /// Set via [`BenchRunner::with_memory_params`]; required when
176 /// `RunOptions::memory_mode == MemoryMode::On`.
177 memory_params: Option<BenchMemoryParams>,
178}
179
180impl BenchRunner {
181 /// Create a new runner with the given provider.
182 ///
183 /// The `no_deterministic` argument is unused at runtime but kept in the public API
184 /// so the bench command can pass it through for future use (e.g., logging or config).
185 /// Apply deterministic overrides to `provider` before calling this if needed.
186 ///
187 /// # Examples
188 ///
189 /// ```no_run
190 /// use zeph_bench::runner::BenchRunner;
191 /// use zeph_llm::{any::AnyProvider, mock::MockProvider};
192 ///
193 /// let provider = AnyProvider::Mock(MockProvider::with_responses(vec![]));
194 /// let runner = BenchRunner::new(provider);
195 /// ```
196 #[must_use]
197 pub fn new(provider: AnyProvider) -> Self {
198 Self {
199 provider,
200 memory_params: None,
201 }
202 }
203
204 /// Attach `SemanticMemory` parameters for memory-on benchmark runs.
205 ///
206 /// When set, a per-scenario `SQLite`-backed `SemanticMemory` is constructed inside
207 /// [`run_one`][BenchRunner::run_one] whenever `opts.memory_mode == MemoryMode::On`.
208 ///
209 /// # Examples
210 ///
211 /// ```no_run
212 /// use std::path::PathBuf;
213 /// use zeph_bench::runner::{BenchRunner, BenchMemoryParams};
214 /// use zeph_llm::{any::AnyProvider, mock::MockProvider};
215 ///
216 /// let provider = AnyProvider::Mock(MockProvider::with_responses(vec![]));
217 /// let params = BenchMemoryParams {
218 /// data_dir: PathBuf::from("/tmp/bench-data"),
219 /// embedding_model: "nomic-embed-text".into(),
220 /// run_id: "bench-abc".into(),
221 /// dataset: "locomo".into(),
222 /// };
223 /// let runner = BenchRunner::new(provider).with_memory_params(params);
224 /// ```
225 #[must_use]
226 pub fn with_memory_params(mut self, params: BenchMemoryParams) -> Self {
227 self.memory_params = Some(params);
228 self
229 }
230
231 /// Run all matching scenarios from `path` through the agent and return a [`BenchRun`].
232 ///
233 /// For each scenario:
234 /// 1. Builds a fresh `Agent<BenchmarkChannel>` with no tools or memory.
235 /// 2. Feeds the scenario prompt and collects the agent's response.
236 /// 3. Scores the response with `evaluator`.
237 /// 4. Appends a [`ScenarioResult`] and recomputes aggregate statistics.
238 ///
239 /// The returned [`BenchRun`] has `status = Running` until the caller sets it to
240 /// `Completed` or `Interrupted`.
241 ///
242 /// # Errors
243 ///
244 /// Returns [`BenchError`] if the dataset cannot be loaded or a scenario run fails.
245 #[tracing::instrument(skip_all, fields(dataset = loader.name()), name = "bench.run_dataset")]
246 pub async fn run_dataset<L, E>(
247 &self,
248 loader: &L,
249 evaluator: &E,
250 path: &Path,
251 opts: RunOptions,
252 ) -> Result<BenchRun, BenchError>
253 where
254 L: DatasetLoader,
255 E: Evaluator,
256 {
257 let scenarios = loader.load(path)?;
258 let filtered = filter_scenarios(&scenarios, &opts, loader.name())?;
259
260 let model_id = self.provider.model_identifier().to_owned();
261
262 let mut run = BenchRun {
263 dataset: loader.name().to_owned(),
264 model: model_id,
265 run_id: uuid(),
266 started_at: timestamp::utc_now_rfc3339(),
267 finished_at: String::new(),
268 status: RunStatus::Running,
269 results: vec![],
270 aggregate: crate::results::Aggregate::default(),
271 };
272
273 for scenario in filtered {
274 let t0 = Instant::now();
275 let response_text = Box::pin(self.run_one(scenario, opts.memory_mode))
276 .instrument(tracing::info_span!("bench.scenario", id = %scenario.id))
277 .await?;
278 let elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX);
279
280 let eval = evaluator.evaluate(scenario, &response_text);
281 let excerpt = response_text.chars().take(200).collect::<String>();
282
283 run.results.push(ScenarioResult {
284 scenario_id: scenario.id.clone(),
285 score: eval.score,
286 response_excerpt: excerpt,
287 error: None,
288 elapsed_ms,
289 });
290 run.recompute_aggregate();
291 }
292
293 Ok(run)
294 }
295
296 /// Run all scenarios from `path` through a per-scenario env executor and return a [`BenchRun`].
297 ///
298 /// This is the execution path for tool-driven datasets (tau2-bench). For each scenario:
299 /// 1. Calls `env_factory(scenario)` to build a fresh `(ToolExecutor, ActionTrace)`.
300 /// 2. Builds a fresh `TauBenchEvaluator` from the scenario metadata and the trace.
301 /// 3. Runs the agent with the env executor and the tool-use system prompt.
302 /// 4. Scores the response via the evaluator (reads the populated trace).
303 ///
304 /// # Errors
305 ///
306 /// Returns [`BenchError`] if the dataset cannot be loaded, the env factory fails, or
307 /// `TauBenchEvaluator::from_scenario` fails (malformed metadata).
308 #[tracing::instrument(skip_all, fields(dataset = loader.name()), name = "bench.run_dataset_with_env_factory")]
309 pub async fn run_dataset_with_env_factory<L, F, X>(
310 &self,
311 loader: &L,
312 env_factory: F,
313 path: &Path,
314 opts: RunOptions,
315 ) -> Result<BenchRun, BenchError>
316 where
317 L: DatasetLoader,
318 F: Fn(&Scenario) -> Result<(X, ActionTrace), BenchError>,
319 X: ToolExecutor + Send + Sync + 'static,
320 {
321 let scenarios = loader.load(path)?;
322 let filtered = filter_scenarios(&scenarios, &opts, loader.name())?;
323
324 let model_id = self.provider.model_identifier().to_owned();
325
326 let mut run = BenchRun {
327 dataset: loader.name().to_owned(),
328 model: model_id,
329 run_id: uuid(),
330 started_at: timestamp::utc_now_rfc3339(),
331 finished_at: String::new(),
332 status: RunStatus::Running,
333 results: vec![],
334 aggregate: crate::results::Aggregate::default(),
335 };
336
337 for scenario in filtered {
338 let (executor, trace) = env_factory(scenario)?;
339 let evaluator = TauBenchEvaluator::from_scenario(scenario, trace)?;
340
341 let t0 = Instant::now();
342 let response_text = Box::pin(self.run_one_with_executor(
343 scenario,
344 executor,
345 opts.memory_mode,
346 ResponseMode::ToolUse,
347 ))
348 .instrument(tracing::info_span!("bench.scenario", id = %scenario.id))
349 .await?;
350 let elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX);
351
352 let eval = evaluator.evaluate(scenario, &response_text);
353 let excerpt = response_text.chars().take(200).collect::<String>();
354
355 run.results.push(ScenarioResult {
356 scenario_id: scenario.id.clone(),
357 score: eval.score,
358 response_excerpt: excerpt,
359 error: None,
360 elapsed_ms,
361 });
362 run.recompute_aggregate();
363 }
364
365 Ok(run)
366 }
367
368 /// Run a single scenario through a fresh agent and return the last response text.
369 ///
370 /// A concise-answer system prompt is injected via [`InstructionBlock`] so the model
371 /// responds with only the final answer (a number, word, or short phrase) rather than
372 /// full sentences. The raw response is then post-processed to extract the first
373 /// non-empty line and strip markdown formatting, which further reduces noise for
374 /// evaluators that perform exact or near-exact matching.
375 ///
376 /// When `memory_mode == MemoryMode::On`, a per-scenario `SQLite`-backed
377 /// `SemanticMemory` is constructed and wired into the agent. The database file is
378 /// deleted after the scenario completes (best-effort, NFR-001).
379 ///
380 /// # Errors
381 ///
382 /// Returns [`BenchError::InvalidFormat`] when the scenario has no user turn or when
383 /// `SemanticMemory` initialisation fails.
384 async fn run_one(
385 &self,
386 scenario: &Scenario,
387 memory_mode: MemoryMode,
388 ) -> Result<String, BenchError> {
389 Box::pin(self.run_one_with_executor(
390 scenario,
391 NoopExecutor,
392 memory_mode,
393 ResponseMode::TerseAnswer,
394 ))
395 .await
396 }
397
398 /// Core execution: run one scenario with the given executor and response mode.
399 ///
400 /// Called by both [`BenchRunner::run_dataset`] (with `NoopExecutor` + `TerseAnswer`) and
401 /// [`BenchRunner::run_dataset_with_env_factory`] (with the domain env + `ToolUse`).
402 #[allow(clippy::too_many_lines)] // sequential setup steps; splitting adds indirection without clarity
403 #[tracing::instrument(skip_all, fields(scenario_id = %scenario.id, mode = ?mode), name = "bench.run_one")]
404 async fn run_one_with_executor<X: ToolExecutor + Send + Sync + 'static>(
405 &self,
406 scenario: &Scenario,
407 executor: X,
408 memory_mode: MemoryMode,
409 mode: ResponseMode,
410 ) -> Result<String, BenchError> {
411 let channel = BenchmarkChannel::from_turns(scenario.turns.clone());
412 if channel.total() == 0 {
413 return Err(BenchError::InvalidFormat(format!(
414 "scenario '{}' has no user turn",
415 scenario.id
416 )));
417 }
418 let registry = SkillRegistry::empty();
419
420 let system_content = match mode {
421 ResponseMode::TerseAnswer => concat!(
422 "You are an evaluation assistant. ",
423 "Answer every question with the shortest possible response. ",
424 "Give only the final answer — no explanation, no full sentences, ",
425 "no punctuation unless it is part of the answer. ",
426 "If the answer is a single word or number, respond with only that word or number."
427 ),
428 ResponseMode::ToolUse => concat!(
429 "You are a customer-service agent. ",
430 "Use the available tools to help the user. ",
431 "Always call a tool when one applies; do not ask the user to perform actions you can perform yourself. ",
432 "When you have completed the user's request, respond with a brief confirmation."
433 ),
434 };
435
436 let blocks = vec![InstructionBlock {
437 source: PathBuf::from("<bench-system-prompt>"),
438 content: system_content.to_owned(),
439 }];
440
441 let base_agent = Agent::new(self.provider.clone(), channel, registry, None, 1, executor)
442 .with_instruction_blocks(blocks);
443
444 // Optionally wire SemanticMemory when the caller requests memory-on mode.
445 let (mut agent, scenario_db) = if memory_mode == MemoryMode::On
446 && let Some(ref params) = self.memory_params
447 {
448 // One SQLite file per scenario gives strict isolation (NFR-001 choice (a)).
449 // This is more files than a per-run DB, but eliminates any cross-scenario
450 // memory bleed and avoids needing BenchIsolation::reset() between scenarios.
451 let scenario_db = params
452 .data_dir
453 .join(format!("bench-{}-{}.db", params.run_id, scenario.id));
454 debug_assert!(
455 scenario_db.to_string_lossy().contains("bench-"),
456 "NFR-001: bench SQLite path must be namespaced with 'bench-'"
457 );
458
459 tracing::debug!(
460 scenario_id = %scenario.id,
461 path = %scenario_db.display(),
462 "bench: memory init start"
463 );
464 let memory = Arc::new(
465 tokio::time::timeout(
466 std::time::Duration::from_secs(10),
467 SemanticMemory::with_sqlite_backend(
468 scenario_db.to_string_lossy().as_ref(),
469 self.provider.clone(),
470 ¶ms.embedding_model,
471 0.7,
472 0.3,
473 ),
474 )
475 .await
476 .map_err(|_| {
477 BenchError::InvalidFormat(format!(
478 "SemanticMemory init timed out for scenario '{}'",
479 scenario.id
480 ))
481 })?
482 .map_err(|e| BenchError::InvalidFormat(format!("SemanticMemory init: {e}")))?,
483 );
484 tracing::debug!(scenario_id = %scenario.id, "bench: memory init done");
485
486 // Seed the sessions table so persist_message does not fail with FK violation.
487 let conv_id = memory
488 .sqlite()
489 .create_conversation()
490 .await
491 .map_err(|e| BenchError::InvalidFormat(format!("create_conversation: {e}")))?;
492
493 // summarization_threshold = 100_000 deliberately suppresses LLM-driven
494 // compaction during bench runs. Compaction calls another LLM round-trip
495 // with non-deterministic timing/output, which would violate FR-003
496 // (deterministic runs). recall_limit = 20 is generous enough to surface
497 // long-context memory effects without silently capping LongMemEval scores
498 // below their theoretical maximum. history_limit = 200 covers the longest
499 // LongMemEval session without truncation.
500 let wired_agent = base_agent.with_memory(memory, conv_id, 200, 20, 100_000);
501 (wired_agent, Some(scenario_db))
502 } else {
503 (base_agent, None)
504 };
505
506 // Ignore agent errors — a failed LLM call still yields an empty response that
507 // the evaluator scores as 0.0 rather than aborting the entire run.
508 let _ = Box::pin(agent.run()).await;
509 let channel = agent.into_channel();
510 // tool_outputs available for Phase 2 scoring (#4234); log count so future
511 // implementors have a trace even before the evaluator wires them up.
512 tracing::debug!(
513 count = channel.tool_outputs().len(),
514 "bench: tool outputs captured"
515 );
516 let responses = channel.into_responses();
517
518 // Best-effort cleanup: delete per-scenario SQLite file after the run.
519 // Failure is intentionally ignored — NFR-001 is hygiene, not correctness.
520 if let Some(ref db_path) = scenario_db {
521 let _ = std::fs::remove_file(db_path);
522 }
523
524 let raw = responses
525 .into_iter()
526 .last()
527 .map(|r| r.text)
528 .unwrap_or_default();
529
530 Ok(match mode {
531 ResponseMode::TerseAnswer => post_process_response(&raw),
532 // Verified: dropping send_tool_output does NOT affect the agent loop's tool-result
533 // feedback to the LLM. Tool outputs flow via Agent's internal MessagePart::ToolResult,
534 // not via the channel. See crates/zeph-core/src/agent/tool_execution/native.rs.
535 ResponseMode::ToolUse => raw,
536 })
537 }
538}
539
540/// Return the subset of `scenarios` that should run given `opts`.
541///
542/// Validates that when a `scenario_filter` is set, at least one matching scenario exists in
543/// `scenarios`. Then filters out already-completed IDs and non-matching scenarios.
544///
545/// # Errors
546///
547/// Returns [`BenchError::InvalidFormat`] when `opts.scenario_filter` names a scenario that
548/// does not appear in `scenarios`.
549fn filter_scenarios<'a>(
550 scenarios: &'a [Scenario],
551 opts: &RunOptions,
552 loader_name: &str,
553) -> Result<Vec<&'a Scenario>, BenchError> {
554 if let Some(ref filter) = opts.scenario_filter
555 && !scenarios.iter().any(|s| &s.id == filter)
556 {
557 return Err(BenchError::InvalidFormat(format!(
558 "scenario '{filter}' not found in dataset '{loader_name}'"
559 )));
560 }
561
562 Ok(scenarios
563 .iter()
564 .filter(|s| {
565 if opts.completed_ids.contains(&s.id) {
566 return false;
567 }
568 if let Some(ref filter) = opts.scenario_filter {
569 return &s.id == filter;
570 }
571 true
572 })
573 .collect())
574}
575
576/// Post-process the raw agent response to extract a clean, terse answer.
577///
578/// Applies these transformations in order:
579/// 1. Take only the first non-empty line — strips explanations appended after the answer.
580/// 2. Strip markdown formatting (bold `**`, italic `*` and `_`, inline code `` ` ``).
581/// 3. Trim surrounding whitespace.
582///
583/// This is a best-effort cleanup. Evaluators still normalize the result, so minor
584/// leftover punctuation is handled downstream.
585fn post_process_response(raw: &str) -> String {
586 // Take the first non-empty line to discard any trailing explanation.
587 let first_line = raw
588 .lines()
589 .map(str::trim)
590 .find(|l| !l.is_empty())
591 .unwrap_or("");
592
593 // Strip common markdown formatting characters.
594 first_line
595 .trim_matches(|c: char| matches!(c, '*' | '_' | '`' | ' ' | '\t'))
596 .replace("**", "")
597 .replace('`', "")
598 .trim()
599 .to_owned()
600}
601
602/// Generate a short pseudo-UUID-like run ID without the `uuid` crate.
603///
604/// Uses `std::time::SystemTime` for uniqueness. Not cryptographically random but
605/// sufficient for benchmark run identification.
606fn uuid() -> String {
607 use std::time::{SystemTime, UNIX_EPOCH};
608 let d = SystemTime::now()
609 .duration_since(UNIX_EPOCH)
610 .unwrap_or_default();
611 format!("bench-{:x}-{:x}", d.as_secs(), d.subsec_nanos())
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn run_options_default_is_empty() {
620 let opts = RunOptions::default();
621 assert!(opts.scenario_filter.is_none());
622 assert!(opts.completed_ids.is_empty());
623 assert_eq!(opts.memory_mode, MemoryMode::Off);
624 }
625
626 #[test]
627 fn memory_mode_default_is_off() {
628 assert_eq!(MemoryMode::default(), MemoryMode::Off);
629 }
630
631 #[test]
632 fn with_memory_params_sets_isolation() {
633 use zeph_llm::{any::AnyProvider, mock::MockProvider};
634 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![]));
635 let params = BenchMemoryParams {
636 data_dir: std::path::PathBuf::from("/tmp/bench-data"),
637 embedding_model: "nomic-embed-text".into(),
638 run_id: "bench-abc".into(),
639 dataset: "locomo".into(),
640 };
641 let runner = BenchRunner::new(provider).with_memory_params(params.clone());
642 assert!(runner.memory_params.is_some());
643 let stored = runner.memory_params.unwrap();
644 assert_eq!(stored.run_id, "bench-abc");
645 assert_eq!(stored.dataset, "locomo");
646 }
647
648 #[test]
649 fn nfr_001_sqlite_path_namespaced() {
650 let params = BenchMemoryParams {
651 data_dir: std::path::PathBuf::from("/tmp/bench-data"),
652 embedding_model: "nomic-embed-text".into(),
653 run_id: "run-xyz".into(),
654 dataset: "locomo".into(),
655 };
656 let scenario_id = "s1_0";
657 let scenario_db = params
658 .data_dir
659 .join(format!("bench-{}-{}.db", params.run_id, scenario_id));
660 assert!(
661 scenario_db.to_string_lossy().contains("bench-"),
662 "NFR-001: SQLite path must contain bench- prefix"
663 );
664 }
665
666 #[test]
667 fn now_rfc3339_has_correct_format() {
668 let ts = timestamp::utc_now_rfc3339();
669 // e.g. "2026-04-25T10:30:00Z"
670 assert_eq!(ts.len(), 20);
671 assert!(ts.ends_with('Z'));
672 assert!(ts.contains('T'));
673 }
674
675 #[test]
676 fn uuid_generates_non_empty_string() {
677 let id = uuid();
678 assert!(id.starts_with("bench-"));
679 assert!(id.len() > 10);
680 }
681
682 #[test]
683 fn post_process_takes_first_line() {
684 let raw = "1945\n\nWorld War II ended in 1945.";
685 assert_eq!(post_process_response(raw), "1945");
686 }
687
688 #[test]
689 fn post_process_strips_markdown_bold() {
690 assert_eq!(post_process_response("**1945**"), "1945");
691 }
692
693 #[test]
694 fn post_process_strips_backticks() {
695 assert_eq!(post_process_response("`Au`"), "Au");
696 }
697
698 #[test]
699 fn post_process_trims_whitespace() {
700 assert_eq!(post_process_response(" Paris "), "Paris");
701 }
702
703 #[test]
704 fn post_process_empty_input_returns_empty() {
705 assert_eq!(post_process_response(""), "");
706 }
707
708 #[test]
709 fn post_process_skips_empty_leading_lines() {
710 let raw = "\n\n \nParis";
711 assert_eq!(post_process_response(raw), "Paris");
712 }
713}