Skip to main content

zeph_experiments/
engine.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Experiment engine — core async loop for autonomous parameter tuning.
5//!
6//! [`ExperimentEngine`] orchestrates baseline evaluation, variation generation,
7//! candidate scoring, acceptance decisions, and optional `SQLite` persistence.
8//! Cancellation is supported via [`tokio_util::sync::CancellationToken`].
9//!
10//! # Loop Summary
11//!
12//! 1. Evaluate the baseline configuration once to establish `initial_baseline_score`.
13//! 2. Ask the [`VariationGenerator`] for the next untested variation.
14//! 3. Clone the subject provider with generation overrides from the candidate snapshot.
15//! 4. Evaluate the candidate; accept if `delta >= config.min_improvement`.
16//! 5. On acceptance, update the progressive baseline (greedy hill-climbing).
17//! 6. Optionally persist the result to `SQLite`.
18//! 7. Repeat until: max experiments, wall-time limit, search exhaustion, or cancellation.
19//!
20//! [`VariationGenerator`]: crate::VariationGenerator
21
22use std::collections::HashSet;
23use std::sync::Arc;
24use std::time::Instant;
25
26use serde::{Deserialize, Serialize};
27use tokio_util::sync::CancellationToken;
28use zeph_common::SessionId;
29use zeph_common::timestamp;
30use zeph_llm::any::AnyProvider;
31use zeph_memory::semantic::SemanticMemory;
32use zeph_memory::store::experiments::NewExperimentResult;
33
34use super::error::EvalError;
35use super::evaluator::Evaluator;
36use super::generator::VariationGenerator;
37use super::snapshot::ConfigSnapshot;
38use super::types::{ExperimentResult, ExperimentSource, Variation};
39use zeph_config::ExperimentConfig;
40
41/// Final report produced by [`ExperimentEngine::run`].
42///
43/// `total_improvement` can be negative if no variation improved the baseline,
44/// or `NaN` if the baseline evaluation itself returned `NaN` (which causes
45/// an early [`EvalError`] rather than a report).
46///
47/// [`EvalError`]: crate::EvalError
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ExperimentSessionReport {
50    /// Session ID generated at [`ExperimentEngine`] construction time.
51    pub session_id: SessionId,
52    /// All variation results recorded in this session (both accepted and rejected).
53    pub results: Vec<ExperimentResult>,
54    /// The best-known config snapshot at session end (may equal the initial baseline).
55    pub best_config: ConfigSnapshot,
56    /// Baseline mean score captured before the variation loop started.
57    ///
58    /// `NaN` when the session was cancelled before the baseline evaluation completed.
59    pub baseline_score: f64,
60    /// Mean score of the best-found configuration at session end.
61    ///
62    /// `NaN` when the session was cancelled before the baseline evaluation completed.
63    pub final_score: f64,
64    /// `final_score - baseline_score` (positive means improvement over baseline).
65    pub total_improvement: f64,
66    /// Total wall-clock time for the session in milliseconds.
67    pub wall_time_ms: u64,
68    /// `true` when the session was stopped via [`ExperimentEngine::stop`] or a
69    /// [`CancellationToken`] before the variation loop completed naturally.
70    ///
71    /// [`CancellationToken`]: tokio_util::sync::CancellationToken
72    pub cancelled: bool,
73}
74
75/// Autonomous parameter-tuning engine.
76///
77/// The engine evaluates a baseline configuration, then generates and tests
78/// parameter variations one at a time. Accepted variations update the progressive
79/// baseline (greedy hill-climbing). The loop terminates on budget exhaustion,
80/// search-space exhaustion, wall-time limit, or cancellation.
81///
82/// # Storage
83///
84/// When `memory` is `Some`, each result is persisted to `SQLite` via
85/// [`SemanticMemory::sqlite`]. When `None`, results are kept only in the
86/// in-memory `results` vec of the final report.
87///
88/// # Budget ownership
89///
90/// The `Evaluator` is passed pre-built by the caller. The caller is responsible
91/// for constructing it with the desired `budget_tokens` (typically
92/// `config.eval_budget_tokens`). The `eval_budget_tokens` field in
93/// [`ExperimentConfig`] is a hint for the caller — the engine itself does not
94/// construct the evaluator.
95pub struct ExperimentEngine {
96    evaluator: Evaluator,
97    generator: Box<dyn VariationGenerator>,
98    subject: Arc<AnyProvider>,
99    baseline: ConfigSnapshot,
100    config: ExperimentConfig,
101    memory: Option<Arc<SemanticMemory>>,
102    session_id: SessionId,
103    cancel: CancellationToken,
104    source: ExperimentSource,
105}
106
107/// Maximum number of consecutive NaN-scored evaluations before the loop breaks.
108/// Prevents unbounded spinning when the evaluator consistently returns degenerate reports.
109const MAX_CONSECUTIVE_NAN: u32 = 3;
110
111impl ExperimentEngine {
112    /// Create a new `ExperimentEngine`.
113    ///
114    /// A fresh UUID session ID is generated at construction time.
115    /// The `evaluator` should already be configured with the desired token budget
116    /// (typically `config.eval_budget_tokens`).
117    ///
118    /// # Contract
119    ///
120    /// The caller must ensure `config` is valid before constructing the engine.
121    /// Call [`ExperimentConfig::validate`] during bootstrap — passing invalid config
122    /// (e.g., `max_experiments=0`, `max_wall_time_secs=0`) results in unspecified
123    /// loop behaviour (immediate exit or no effective budget enforcement).
124    pub fn new(
125        evaluator: Evaluator,
126        generator: Box<dyn VariationGenerator>,
127        subject: Arc<AnyProvider>,
128        baseline: ConfigSnapshot,
129        config: ExperimentConfig,
130        memory: Option<Arc<SemanticMemory>>,
131    ) -> Self {
132        Self {
133            evaluator,
134            generator,
135            subject,
136            baseline,
137            config,
138            memory,
139            session_id: SessionId::generate(),
140            cancel: CancellationToken::new(),
141            source: ExperimentSource::Manual,
142        }
143    }
144
145    /// Set the [`ExperimentSource`] for this session.
146    ///
147    /// Defaults to [`ExperimentSource::Manual`]. Use [`ExperimentSource::Scheduled`]
148    /// for runs triggered by the scheduler.
149    #[must_use]
150    pub fn with_source(mut self, source: ExperimentSource) -> Self {
151        self.source = source;
152        self
153    }
154
155    /// Return a clone of the internal [`CancellationToken`].
156    ///
157    /// External callers (CLI, TUI, scheduler) can hold a token handle and call
158    /// `.cancel()` to trigger graceful shutdown. See also [`Self::stop`].
159    #[must_use]
160    pub fn cancel_token(&self) -> CancellationToken {
161        self.cancel.clone()
162    }
163
164    /// Stop the engine by cancelling the internal [`CancellationToken`].
165    ///
166    /// The current evaluation call will complete; the loop exits after it returns.
167    pub fn stop(&self) {
168        self.cancel.cancel();
169    }
170
171    /// Run the experiment loop and return a session report.
172    ///
173    /// The loop:
174    /// 1. Evaluates the baseline once to obtain `initial_baseline_score`.
175    /// 2. Generates variations via the [`VariationGenerator`].
176    /// 3. Evaluates each variation with a clone of `subject` patched with generation overrides
177    ///    derived from the candidate `ConfigSnapshot` via `AnyProvider::with_generation_overrides`.
178    /// 4. Accepts the variation if `delta >= config.min_improvement`.
179    /// 5. On acceptance, updates the progressive baseline (greedy hill-climbing).
180    ///    **Known limitation (S1):** single-sample acceptance has no statistical
181    ///    confidence check. Noise in the evaluator can cause gradual score drift.
182    ///    Phase 5 should add repeated trials or a confidence margin derived from
183    ///    per-case variance before promoting a variation.
184    /// 6. Optionally persists results to `SQLite` when `memory` is `Some`.
185    /// 7. Breaks on: max experiments, wall-time, search exhaustion, or cancellation.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`EvalError`] if the baseline evaluation or any subject LLM call fails.
190    /// `SQLite` persistence failures are returned as [`EvalError::Storage`].
191    #[tracing::instrument(
192        name = "experiments.engine.run",
193        skip(self),
194        fields(session_id = %self.session_id, source = %self.source)
195    )]
196    pub async fn run(&mut self) -> Result<ExperimentSessionReport, EvalError> {
197        let start = Instant::now();
198        let best_snapshot = self.baseline.clone();
199
200        // Step 0: evaluate baseline once, with cancellation support.
201        // Issue #4: wrapped in select! so a cancel during a slow baseline evaluation is honoured.
202        let baseline_report = tokio::select! {
203            biased;
204            () = self.cancel.cancelled() => {
205                tracing::info!(session_id = %self.session_id, "cancelled before baseline");
206                #[allow(clippy::cast_possible_truncation)]
207                return Ok(ExperimentSessionReport {
208                    session_id: self.session_id.clone(),
209                    results: vec![],
210                    best_config: best_snapshot,
211                    baseline_score: f64::NAN,
212                    final_score: f64::NAN,
213                    total_improvement: 0.0,
214                    wall_time_ms: start.elapsed().as_millis() as u64,
215                    cancelled: true,
216                });
217            }
218            report = self.evaluator.evaluate(&self.subject) => report?,
219        };
220
221        // Bug #3: if baseline produces NaN, there is no meaningful anchor — fail fast.
222        let initial_baseline_score = baseline_report.mean_score;
223        if initial_baseline_score.is_nan() {
224            return Err(EvalError::Storage(
225                "baseline evaluation produced NaN mean score; \
226                 check evaluator budget and judge responses"
227                    .into(),
228            ));
229        }
230        tracing::info!(
231            session_id = %self.session_id,
232            baseline_score = initial_baseline_score,
233            "experiment session started"
234        );
235        self.run_loop(start, initial_baseline_score, best_snapshot)
236            .await
237    }
238
239    /// Inner experiment loop — runs after a successful baseline evaluation.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`EvalError`] if any LLM call or `SQLite` persist fails.
244    #[allow(clippy::too_many_lines)] // experiment loop with inherent complexity: variation→evaluate→compare
245    #[tracing::instrument(
246        name = "experiments.engine.run_loop",
247        skip(self, start, best_snapshot),
248        fields(session_id = %self.session_id, source = %self.source)
249    )]
250    async fn run_loop(
251        &mut self,
252        start: Instant,
253        initial_baseline_score: f64,
254        mut best_snapshot: ConfigSnapshot,
255    ) -> Result<ExperimentSessionReport, EvalError> {
256        let wall_limit = std::time::Duration::from_secs(self.config.max_wall_time_secs);
257        let mut results: Vec<ExperimentResult> = Vec::new();
258        let mut visited: HashSet<Variation> = HashSet::new();
259        let (mut best_score, mut consecutive_nan) = (initial_baseline_score, 0u32);
260
261        'main: loop {
262            if results.len() >= self.config.max_experiments as usize {
263                tracing::info!(session_id = %self.session_id, "budget exhausted");
264                break;
265            }
266            if start.elapsed() >= wall_limit {
267                tracing::info!(session_id = %self.session_id, "wall-time limit reached");
268                break;
269            }
270            let Some(variation) = self.generator.next(&best_snapshot, &visited) else {
271                tracing::info!(session_id = %self.session_id, "search space exhausted");
272                break;
273            };
274            visited.insert(variation.clone());
275            let candidate_snapshot = best_snapshot.apply(&variation);
276            let patched = (*self.subject)
277                .clone()
278                .with_generation_overrides(candidate_snapshot.to_generation_overrides());
279            let candidate_report = tokio::select! {
280                biased;
281                () = self.cancel.cancelled() => {
282                    tracing::info!(session_id = %self.session_id, "experiment cancelled");
283                    break 'main;
284                }
285                report = self.evaluator.evaluate(&patched) => report?,
286            };
287            if candidate_report.mean_score.is_nan() {
288                consecutive_nan += 1;
289                tracing::warn!(
290                    session_id = %self.session_id, param = %variation.parameter,
291                    is_partial = candidate_report.is_partial, consecutive_nan,
292                    "NaN mean score — skipping variation"
293                );
294                if consecutive_nan >= MAX_CONSECUTIVE_NAN {
295                    tracing::warn!(session_id = %self.session_id, "consecutive NaN cap reached");
296                    break;
297                }
298                continue;
299            }
300            consecutive_nan = 0;
301            let candidate_score = candidate_report.mean_score;
302            let delta = candidate_score - best_score;
303            let accepted = delta >= self.config.min_improvement;
304            let result_id = self
305                .persist_result(
306                    &variation,
307                    best_score,
308                    candidate_score,
309                    delta,
310                    accepted,
311                    candidate_report.p50_latency_ms,
312                    candidate_report.total_tokens,
313                )
314                .await?;
315            let pre_accept_baseline = best_score;
316            self.log_outcome(&variation, delta, accepted, best_score);
317            if accepted {
318                best_snapshot = candidate_snapshot;
319                best_score = candidate_score;
320            }
321            results.push(ExperimentResult {
322                id: result_id,
323                session_id: self.session_id.clone(),
324                variation,
325                baseline_score: pre_accept_baseline,
326                candidate_score,
327                delta,
328                latency_ms: candidate_report.p50_latency_ms,
329                tokens_used: candidate_report.total_tokens,
330                accepted,
331                source: self.source.clone(),
332                created_at: timestamp::utc_now_rfc3339(),
333            });
334        }
335
336        #[allow(clippy::cast_possible_truncation)]
337        let wall_time_ms = start.elapsed().as_millis() as u64;
338        let total_improvement = best_score - initial_baseline_score;
339        tracing::info!(
340            session_id = %self.session_id, total = results.len(),
341            baseline_score = initial_baseline_score, final_score = best_score,
342            total_improvement, wall_time_ms, cancelled = self.cancel.is_cancelled(),
343            "experiment session complete"
344        );
345        Ok(ExperimentSessionReport {
346            session_id: self.session_id.clone(),
347            results,
348            best_config: best_snapshot,
349            baseline_score: initial_baseline_score,
350            final_score: best_score,
351            total_improvement,
352            wall_time_ms,
353            cancelled: self.cancel.is_cancelled(),
354        })
355    }
356
357    /// Persist a single experiment result to `SQLite` when memory is configured.
358    ///
359    /// Returns `Some(row_id)` from `SQLite`, or `None` when persistence is disabled
360    /// (`memory` is `None`).
361    ///
362    /// # Errors
363    ///
364    /// Returns [`EvalError::Storage`] if the `SQLite` insert fails.
365    #[tracing::instrument(name = "experiments.engine.persist_result", skip_all)]
366    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
367    async fn persist_result(
368        &self,
369        variation: &Variation,
370        baseline_score: f64,
371        candidate_score: f64,
372        delta: f64,
373        accepted: bool,
374        p50_latency_ms: u64,
375        total_tokens: u64,
376    ) -> Result<Option<i64>, EvalError> {
377        let Some(mem) = &self.memory else {
378            return Ok(None);
379        };
380        let value_json = serde_json::to_string(&variation.value)
381            .map_err(|e| EvalError::Storage(e.to_string()))?;
382        #[allow(clippy::cast_possible_wrap)]
383        let new_result = NewExperimentResult {
384            session_id: self.session_id.as_str(),
385            parameter: variation.parameter.as_str(),
386            value_json: &value_json,
387            baseline_score,
388            candidate_score,
389            delta,
390            latency_ms: p50_latency_ms as i64,
391            tokens_used: total_tokens as i64,
392            accepted,
393            source: self.source.as_str(),
394        };
395        mem.sqlite()
396            .insert_experiment_result(&new_result)
397            .await
398            .map(Some)
399            .map_err(|e: zeph_memory::error::MemoryError| EvalError::Storage(e.to_string()))
400    }
401
402    fn log_outcome(&self, variation: &Variation, delta: f64, accepted: bool, new_score: f64) {
403        if accepted {
404            tracing::info!(
405                session_id = %self.session_id,
406                param = %variation.parameter,
407                value = %variation.value,
408                delta,
409                new_best_score = new_score,
410                "variation accepted — new baseline"
411            );
412        } else {
413            tracing::info!(
414                session_id = %self.session_id,
415                param = %variation.parameter,
416                value = %variation.value,
417                delta,
418                "variation rejected"
419            );
420        }
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    #![allow(clippy::doc_markdown)]
427
428    use super::*;
429    use crate::benchmark::{BenchmarkCase, BenchmarkSet};
430    use crate::evaluator::Evaluator;
431    use crate::generator::VariationGenerator;
432    use crate::snapshot::ConfigSnapshot;
433    use crate::types::{ParameterKind, Variation, VariationValue};
434    use ordered_float::OrderedFloat;
435    use std::sync::Arc;
436    use zeph_config::ExperimentConfig;
437
438    fn make_benchmark() -> BenchmarkSet {
439        BenchmarkSet {
440            cases: vec![BenchmarkCase {
441                prompt: "What is 2+2?".into(),
442                context: None,
443                reference: None,
444                tags: None,
445            }],
446        }
447    }
448
449    fn default_config() -> ExperimentConfig {
450        ExperimentConfig {
451            max_experiments: 10,
452            max_wall_time_secs: 3600,
453            min_improvement: 0.0,
454            ..Default::default()
455        }
456    }
457
458    /// Generates exactly N variations and then exhausts.
459    struct NVariationGenerator {
460        variations: Vec<Variation>,
461        pos: usize,
462    }
463
464    impl NVariationGenerator {
465        fn new(n: usize) -> Self {
466            let variations = (0..n)
467                .map(|i| Variation {
468                    parameter: ParameterKind::Temperature,
469                    #[allow(clippy::cast_precision_loss)]
470                    value: VariationValue::Float(OrderedFloat(0.5 + i as f64 * 0.1)),
471                })
472                .collect();
473            Self { variations, pos: 0 }
474        }
475    }
476
477    impl VariationGenerator for NVariationGenerator {
478        fn next(
479            &mut self,
480            _baseline: &ConfigSnapshot,
481            visited: &HashSet<Variation>,
482        ) -> Option<Variation> {
483            while self.pos < self.variations.len() {
484                let v = self.variations[self.pos].clone();
485                self.pos += 1;
486                if !visited.contains(&v) {
487                    return Some(v);
488                }
489            }
490            None
491        }
492
493        fn name(&self) -> &'static str {
494            "n_variation"
495        }
496    }
497
498    #[cfg(test)]
499    fn make_subject_mock(n_responses: usize) -> zeph_llm::any::AnyProvider {
500        use zeph_llm::any::AnyProvider;
501        use zeph_llm::mock::MockProvider;
502
503        // Each evaluate() call runs 1 subject call + 1 judge call per benchmark case.
504        // With 1 case: 1 subject + 1 judge response per evaluate() invocation.
505        // We need n_responses pairs (subject + judge) for n variations + 1 baseline.
506        let responses: Vec<String> = (0..n_responses).map(|_| "Four".to_string()).collect();
507        AnyProvider::Mock(MockProvider::with_responses(responses))
508    }
509
510    #[cfg(test)]
511    fn make_judge_mock(n_responses: usize) -> zeph_llm::any::AnyProvider {
512        use zeph_llm::any::AnyProvider;
513        use zeph_llm::mock::MockProvider;
514
515        let responses: Vec<String> = (0..n_responses)
516            .map(|_| r#"{"score": 8.0, "reason": "correct"}"#.to_string())
517            .collect();
518        AnyProvider::Mock(MockProvider::with_responses(responses))
519    }
520
521    #[cfg(test)]
522    #[tokio::test]
523    async fn engine_completes_with_no_accepted_variations() {
524        // min_improvement very high so nothing is accepted.
525        let config = ExperimentConfig {
526            max_experiments: 10,
527            max_wall_time_secs: 3600,
528            min_improvement: 100.0,
529            ..Default::default()
530        };
531        // 1 variation + 1 baseline = 2 evaluate() calls (2 subject + 2 judge responses).
532        let subject = make_subject_mock(2);
533        let judge = make_judge_mock(2);
534        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
535
536        let mut engine = ExperimentEngine::new(
537            evaluator,
538            Box::new(NVariationGenerator::new(1)),
539            Arc::new(subject),
540            ConfigSnapshot::default(),
541            config,
542            None,
543        );
544
545        let report = engine.run().await.unwrap();
546        assert_eq!(report.results.len(), 1);
547        assert!(!report.results[0].accepted);
548        assert!(!report.session_id.is_empty());
549        assert!(!report.cancelled);
550    }
551
552    #[cfg(test)]
553    #[tokio::test]
554    async fn engine_respects_max_experiments() {
555        let config = ExperimentConfig {
556            max_experiments: 3,
557            max_wall_time_secs: 3600,
558            min_improvement: 0.0,
559            ..Default::default()
560        };
561        // 5 variations available but max_experiments=3.
562        // 1 baseline + 3 candidate evaluate() calls = 4 calls, each needing 1 subject + 1 judge.
563        let subject = make_subject_mock(4);
564        let judge = make_judge_mock(4);
565        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
566
567        let mut engine = ExperimentEngine::new(
568            evaluator,
569            Box::new(NVariationGenerator::new(5)),
570            Arc::new(subject),
571            ConfigSnapshot::default(),
572            config,
573            None,
574        );
575
576        let report = engine.run().await.unwrap();
577        assert_eq!(report.results.len(), 3);
578        assert!(!report.cancelled);
579    }
580
581    #[cfg(test)]
582    #[tokio::test]
583    async fn engine_cancellation_before_baseline() {
584        // Pre-cancel: cancel token fires during baseline evaluation select!.
585        let config = ExperimentConfig {
586            max_experiments: 100,
587            max_wall_time_secs: 3600,
588            min_improvement: 0.0,
589            ..Default::default()
590        };
591        let subject = make_subject_mock(2);
592        let judge = make_judge_mock(2);
593        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
594
595        let mut engine = ExperimentEngine::new(
596            evaluator,
597            Box::new(NVariationGenerator::new(100)),
598            Arc::new(subject),
599            ConfigSnapshot::default(),
600            config,
601            None,
602        );
603        engine.stop(); // cancel before any evaluation
604        let report = engine.run().await.unwrap();
605        assert!(report.cancelled);
606        assert!(report.results.is_empty());
607    }
608
609    #[cfg(test)]
610    #[tokio::test]
611    async fn engine_cancellation_stops_loop() {
612        // Verify the loop-level select! path: cancel token pre-fired, baseline completes
613        // (because biased baseline select! checks cancel FIRST — fires immediately), then
614        // run() returns early. Since MockProvider is instantaneous, we test the cancel
615        // token semantics via stop() called between construction and run().
616        //
617        // NOTE: engine_cancellation_before_baseline covers the biased baseline path.
618        // This test verifies that cancelling after construction but before run() sets
619        // cancelled=true in the report regardless of results count.
620        let config = ExperimentConfig {
621            max_experiments: 10,
622            max_wall_time_secs: 3600,
623            min_improvement: 0.0,
624            ..Default::default()
625        };
626        let subject = make_subject_mock(2);
627        let judge = make_judge_mock(2);
628        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
629
630        let mut engine = ExperimentEngine::new(
631            evaluator,
632            Box::new(NVariationGenerator::new(10)),
633            Arc::new(subject),
634            ConfigSnapshot::default(),
635            config,
636            None,
637        );
638
639        // Verify cancel_token() gives an independent handle that controls the same token.
640        let external_token = engine.cancel_token();
641        assert!(!external_token.is_cancelled());
642        engine.stop();
643        assert!(
644            external_token.is_cancelled(),
645            "cancel_token() must share the same token"
646        );
647
648        let report = engine.run().await.unwrap();
649        assert!(report.cancelled);
650    }
651
652    #[cfg(test)]
653    #[tokio::test]
654    async fn engine_progressive_baseline_updates() {
655        // One variation applied via NVariationGenerator generates temperature=0.5.
656        // min_improvement=0.0 so it is accepted, updating best_config.
657        let config = ExperimentConfig {
658            max_experiments: 1,
659            max_wall_time_secs: 3600,
660            min_improvement: 0.0,
661            ..Default::default()
662        };
663        // 1 baseline + 1 candidate = 2 evaluate() calls.
664        let subject = make_subject_mock(2);
665        let judge = make_judge_mock(2);
666        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
667
668        let initial_baseline = ConfigSnapshot::default();
669        let mut engine = ExperimentEngine::new(
670            evaluator,
671            Box::new(NVariationGenerator::new(1)),
672            Arc::new(subject),
673            initial_baseline.clone(),
674            config,
675            None,
676        );
677
678        let report = engine.run().await.unwrap();
679        assert_eq!(report.results.len(), 1);
680        assert!(report.results[0].accepted, "variation should be accepted");
681        // best_config should differ from the initial baseline (temperature changed to 0.5).
682        assert!(
683            (report.best_config.temperature - initial_baseline.temperature).abs() > 1e-9,
684            "best_config.temperature should have changed after accepted variation"
685        );
686        assert!(!report.baseline_score.is_nan());
687        assert!(!report.final_score.is_nan());
688        // Bug #1 regression: baseline_score in result must be the PRE-acceptance score.
689        assert!(
690            (report.results[0].baseline_score - report.baseline_score).abs() < 1e-9,
691            "result.baseline_score must equal initial baseline_score (pre-acceptance)"
692        );
693    }
694
695    #[cfg(test)]
696    #[tokio::test]
697    async fn engine_handles_search_space_exhaustion() {
698        let config = default_config();
699        // Generator returns None immediately (0 variations).
700        // Only the baseline evaluate() call is needed.
701        let subject = make_subject_mock(1);
702        let judge = make_judge_mock(1);
703        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
704
705        let mut engine = ExperimentEngine::new(
706            evaluator,
707            Box::new(NVariationGenerator::new(0)),
708            Arc::new(subject),
709            ConfigSnapshot::default(),
710            config,
711            None,
712        );
713
714        let report = engine.run().await.unwrap();
715        assert!(report.results.is_empty());
716        assert!(!report.cancelled);
717    }
718
719    #[cfg(test)]
720    #[tokio::test]
721    async fn engine_skips_nan_scores() {
722        use zeph_llm::any::AnyProvider;
723        use zeph_llm::mock::MockProvider;
724
725        // Candidate evaluations produce NaN (empty judge responses → error → 0 scored → NaN mean).
726        // Baseline uses a sufficient budget to succeed; candidate budget is tiny.
727        // We use two separate Evaluator instances: one for baseline (high budget),
728        // one for candidates (zero budget). Since ExperimentEngine uses a single Evaluator,
729        // we use a judge mock with 1 valid response (baseline) and no more (candidates fail).
730        let config = ExperimentConfig {
731            max_experiments: 5,
732            max_wall_time_secs: 3600,
733            min_improvement: 0.0,
734            ..Default::default()
735        };
736        // Subject: baseline + 3 candidate subject calls (3 NaN iterations before cap).
737        let subject = AnyProvider::Mock(MockProvider::with_responses(vec![
738            "A".into(),
739            "A".into(),
740            "A".into(),
741            "A".into(),
742        ]));
743        // Judge: 1 valid response for baseline, then errors for candidates (mock exhausted).
744        let judge = AnyProvider::Mock(MockProvider::with_responses(vec![
745            r#"{"score": 8.0, "reason": "ok"}"#.into(),
746        ]));
747        // Use large budget — judge errors (not budget) produce NaN via 0 cases scored.
748        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
749
750        let mut engine = ExperimentEngine::new(
751            evaluator,
752            Box::new(NVariationGenerator::new(5)),
753            Arc::new(subject),
754            ConfigSnapshot::default(),
755            config,
756            None,
757        );
758
759        // Should not panic — NaN scores are skipped; loop breaks after MAX_CONSECUTIVE_NAN.
760        let report = engine.run().await.unwrap();
761        // No variations accepted (all NaN); loop stopped at consecutive NaN limit.
762        assert!(
763            report.results.is_empty(),
764            "all NaN iterations should be skipped"
765        );
766        assert!(!report.cancelled);
767    }
768
769    #[cfg(test)]
770    #[tokio::test]
771    async fn engine_nan_baseline_returns_error() {
772        use zeph_llm::any::AnyProvider;
773        use zeph_llm::mock::MockProvider;
774
775        // Budget=0 and no judge responses → baseline evaluation returns NaN mean → engine errors.
776        let config = ExperimentConfig {
777            max_experiments: 5,
778            max_wall_time_secs: 3600,
779            min_improvement: 0.0,
780            ..Default::default()
781        };
782        // Subject responds for baseline subject call.
783        let subject = AnyProvider::Mock(MockProvider::with_responses(vec!["A".into()]));
784        // Judge has no responses — all judge calls error, 0 cases scored, NaN mean.
785        let judge = AnyProvider::Mock(MockProvider::with_responses(vec![]));
786        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
787
788        let mut engine = ExperimentEngine::new(
789            evaluator,
790            Box::new(NVariationGenerator::new(5)),
791            Arc::new(subject),
792            ConfigSnapshot::default(),
793            config,
794            None,
795        );
796
797        let result = engine.run().await;
798        assert!(result.is_err(), "NaN baseline should return an error");
799        let err = result.unwrap_err();
800        assert!(
801            matches!(err, EvalError::Storage(_)),
802            "expected EvalError::Storage, got: {err:?}"
803        );
804    }
805
806    #[cfg(test)]
807    #[tokio::test]
808    // Under a postgres-only build (`--no-default-features --features postgres`),
809    // `mock_semantic_memory` needs a live Postgres reachable via
810    // `ZEPH_TEST_POSTGRES_URL` (see crates/zeph-memory/src/testing.rs) — there is no
811    // in-process equivalent to SQLite's `:memory:`. Mirrors the `#[ignore = "requires
812    // Docker"]` convention in crates/zeph-db/tests/postgres_integration.rs: skipped by
813    // default, run explicitly with `--ignored` once a Postgres instance is available.
814    #[cfg_attr(
815        all(feature = "postgres", not(feature = "sqlite")),
816        ignore = "requires ZEPH_TEST_POSTGRES_URL"
817    )]
818    async fn engine_persists_results_to_sqlite() {
819        use zeph_memory::testing::mock_semantic_memory;
820
821        let memory = mock_semantic_memory().await.unwrap();
822        let config = ExperimentConfig {
823            max_experiments: 1,
824            max_wall_time_secs: 3600,
825            min_improvement: 0.0,
826            ..Default::default()
827        };
828        // 1 baseline + 1 candidate = 2 evaluate() calls.
829        let subject = make_subject_mock(2);
830        let judge = make_judge_mock(2);
831        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
832
833        let session_id = {
834            let mut engine = ExperimentEngine::new(
835                evaluator,
836                Box::new(NVariationGenerator::new(1)),
837                Arc::new(subject),
838                ConfigSnapshot::default(),
839                config,
840                Some(Arc::clone(&memory)),
841            );
842            engine.run().await.unwrap();
843            engine.session_id.clone()
844        };
845
846        let rows = memory
847            .sqlite()
848            .list_experiment_results(Some(&session_id), 10)
849            .await
850            .unwrap();
851        assert_eq!(rows.len(), 1, "expected one persisted result");
852        assert_eq!(rows[0].session_id, session_id.as_str());
853    }
854
855    #[test]
856    fn session_report_serde_roundtrip() {
857        let report = ExperimentSessionReport {
858            session_id: SessionId::new("test-session"),
859            results: vec![],
860            best_config: ConfigSnapshot::default(),
861            baseline_score: 7.5,
862            final_score: 8.0,
863            total_improvement: 0.5,
864            wall_time_ms: 1_234,
865            cancelled: false,
866        };
867        let json = serde_json::to_string(&report).expect("serialize");
868        let report2: ExperimentSessionReport = serde_json::from_str(&json).expect("deserialize");
869        assert_eq!(report2.session_id, report.session_id);
870        assert!((report2.baseline_score - report.baseline_score).abs() < f64::EPSILON);
871        assert!((report2.final_score - report.final_score).abs() < f64::EPSILON);
872        assert_eq!(report2.wall_time_ms, report.wall_time_ms);
873        assert!(!report2.cancelled);
874    }
875
876    #[test]
877    fn utc_now_rfc3339_format() {
878        let s = timestamp::utc_now_rfc3339();
879        assert_eq!(s.len(), 20, "timestamp must be 20 chars (RFC 3339): {s}");
880        assert_eq!(&s[4..5], "-");
881        assert_eq!(&s[7..8], "-");
882        assert_eq!(&s[10..11], "T");
883        assert_eq!(&s[13..14], ":");
884        assert_eq!(&s[16..17], ":");
885        assert!(s.ends_with('Z'));
886    }
887
888    /// Verify that the shared timestamp module returns a non-empty RFC 3339 string.
889    #[test]
890    fn utc_now_rfc3339_is_non_empty() {
891        let ts = timestamp::utc_now_rfc3339();
892        assert!(!ts.is_empty());
893        assert_eq!(ts.len(), 20);
894    }
895
896    #[tokio::test]
897    async fn experiment_result_created_at_is_rfc3339() {
898        let config = ExperimentConfig {
899            max_experiments: 1,
900            max_wall_time_secs: 3600,
901            min_improvement: 0.0,
902            ..Default::default()
903        };
904        let subject = make_subject_mock(2);
905        let judge = make_judge_mock(2);
906        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
907
908        let mut engine = ExperimentEngine::new(
909            evaluator,
910            Box::new(NVariationGenerator::new(1)),
911            Arc::new(subject),
912            ConfigSnapshot::default(),
913            config,
914            None,
915        );
916
917        let report = engine.run().await.unwrap();
918        assert_eq!(report.results.len(), 1);
919        let created_at = &report.results[0].created_at;
920        assert!(!created_at.is_empty(), "created_at must not be empty");
921        assert_eq!(
922            created_at.len(),
923            20,
924            "RFC 3339 timestamp must be 20 chars: {created_at}"
925        );
926        assert!(
927            created_at.contains('T'),
928            "RFC 3339 timestamp must contain 'T': {created_at}"
929        );
930        assert!(
931            created_at.ends_with('Z'),
932            "RFC 3339 timestamp must end with 'Z': {created_at}"
933        );
934    }
935
936    /// `ExperimentEngine` must be Send to be used with `tokio::spawn`.
937    #[test]
938    fn experiment_engine_is_send() {
939        fn assert_send<T: Send>() {}
940        // This is a compile-time check — if ExperimentEngine is not Send, this fails to compile.
941        // We cannot instantiate the engine here without providers, so we use a fn pointer trick.
942        let _ = assert_send::<ExperimentEngine>;
943    }
944
945    #[tokio::test]
946    async fn engine_with_source_scheduled_propagates_to_results() {
947        let config = ExperimentConfig {
948            max_experiments: 1,
949            max_wall_time_secs: 3600,
950            min_improvement: 0.0,
951            ..Default::default()
952        };
953        let subject = make_subject_mock(2);
954        let judge = make_judge_mock(2);
955        let evaluator = Evaluator::new(Arc::new(judge), make_benchmark(), 1_000_000).unwrap();
956
957        let mut engine = ExperimentEngine::new(
958            evaluator,
959            Box::new(NVariationGenerator::new(1)),
960            Arc::new(subject),
961            ConfigSnapshot::default(),
962            config,
963            None,
964        )
965        .with_source(ExperimentSource::Scheduled);
966
967        let report = engine.run().await.unwrap();
968        assert_eq!(report.results.len(), 1);
969        assert_eq!(
970            report.results[0].source,
971            ExperimentSource::Scheduled,
972            "with_source(Scheduled) must propagate to ExperimentResult"
973        );
974    }
975}