Skip to main content

anno_eval/eval/
history.rs

1//! Evaluation history tracking with optional SQLite index.
2//!
3//! This module provides:
4//! - JSONL storage (primary, git-friendly, human-readable)
5//! - Optional SQLite index for queries (time-series, comparisons, aggregations)
6//!
7//! # Design Philosophy
8//!
9//! - **JSONL is source of truth**: Always append to JSONL first
10//! - **SQLite is queryable index**: Automatically maintained for fast queries
11//! - **Both by default**: SQLite enabled with `eval` feature (no separate flag needed)
12//!
13//! # Usage
14//!
15//! ```rust,ignore
16//! use anno_eval::eval::history::EvalHistory;
17//!
18//! let history = EvalHistory::new("reports/eval-results.jsonl")?;
19//!
20//! // Append result (writes to JSONL, optionally updates SQLite)
21//! history.append_result(&result)?;
22//!
23//! // Query (uses SQLite if available, falls back to JSONL scan)
24//! let recent = history.query_recent("gliner", 10)?;
25//! let trends = history.query_trends("gliner", 30)?;
26//! ```
27
28use crate::eval::task_evaluator::TaskEvalResult;
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::fs::{File, OpenOptions};
32use std::io::{BufRead, BufReader, Write};
33use std::path::{Path, PathBuf};
34use std::sync::OnceLock;
35
36/// Cached git commit hash (avoids spawning `git` on every SQLite insert).
37fn cached_git_commit() -> Option<String> {
38    static COMMIT: OnceLock<Option<String>> = OnceLock::new();
39    COMMIT
40        .get_or_init(|| {
41            std::env::var("ANNO_GIT_COMMIT").ok().or_else(|| {
42                std::process::Command::new("git")
43                    .args(["rev-parse", "--short", "HEAD"])
44                    .output()
45                    .ok()
46                    .and_then(|o| {
47                        if o.status.success() {
48                            String::from_utf8(o.stdout)
49                                .ok()
50                                .map(|s| s.trim().to_string())
51                        } else {
52                            None
53                        }
54                    })
55            })
56        })
57        .clone()
58}
59
60/// Evaluation result entry for history tracking.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EvalHistoryEntry {
63    /// ISO 8601 timestamp
64    pub timestamp: String,
65    /// Backend name
66    pub backend: String,
67    /// Dataset identifier
68    pub dataset: String,
69    /// Task type (NER, Coref, etc.)
70    pub task: String,
71    /// Random seed used
72    pub seed: u64,
73    /// F1 score (0.0-1.0)
74    pub f1: Option<f64>,
75    /// Precision (0.0-1.0)
76    pub precision: Option<f64>,
77    /// Recall (0.0-1.0)
78    pub recall: Option<f64>,
79    /// Number of examples evaluated
80    pub n: usize,
81    /// Duration in milliseconds
82    pub duration_ms: Option<f64>,
83    /// Error message if failed
84    pub error: Option<String>,
85    /// Additional metadata (JSON string for flexibility)
86    pub metadata: Option<String>,
87}
88
89impl From<&TaskEvalResult> for EvalHistoryEntry {
90    fn from(result: &TaskEvalResult) -> Self {
91        let f1 = result.metrics.get("f1").copied();
92        let precision = result.metrics.get("precision").copied();
93        let recall = result.metrics.get("recall").copied();
94
95        Self {
96            timestamp: chrono::Utc::now().to_rfc3339(),
97            backend: result.backend.clone(),
98            dataset: result.dataset.name().to_string(),
99            task: format!("{:?}", result.task),
100            seed: result.seed,
101            f1,
102            precision,
103            recall,
104            n: result.num_examples,
105            duration_ms: result.duration_ms,
106            error: result.error.clone(),
107            metadata: serde_json::to_string(result).ok(),
108        }
109    }
110}
111
112/// Evaluation history manager.
113///
114/// Handles both JSONL storage (primary) and optional SQLite indexing.
115pub struct EvalHistory {
116    jsonl_path: PathBuf,
117    sqlite_path: Option<PathBuf>,
118}
119
120impl EvalHistory {
121    /// Create a new evaluation history manager.
122    ///
123    /// # Arguments
124    ///
125    /// * `jsonl_path` - Path to JSONL file (source of truth)
126    pub fn new(jsonl_path: impl AsRef<Path>) -> std::io::Result<Self> {
127        let jsonl_path = jsonl_path.as_ref().to_path_buf();
128
129        // Ensure parent directory exists
130        if let Some(parent) = jsonl_path.parent() {
131            std::fs::create_dir_all(parent)?;
132        }
133
134        // SQLite index in same directory as JSONL
135        let sqlite_path = jsonl_path
136            .parent()
137            .map(|p| p.join("eval-history.db"))
138            .or_else(|| Some(PathBuf::from("eval-history.db")));
139
140        // Initialize SQLite schema if needed
141        if let Some(ref db_path) = sqlite_path {
142            Self::init_sqlite(db_path)?;
143        }
144
145        Ok(Self {
146            jsonl_path,
147            sqlite_path,
148        })
149    }
150
151    /// Append a result to history.
152    ///
153    /// Writes to JSONL (primary) and optionally updates SQLite index.
154    pub fn append_result(&self, result: &TaskEvalResult) -> std::io::Result<()> {
155        let entry = EvalHistoryEntry::from(result);
156        self.append_entry(&entry)
157    }
158
159    /// Append an entry to history.
160    ///
161    /// Lower-level method that accepts a pre-constructed entry.
162    /// Useful when you need to customize the entry (e.g., set seed from config).
163    pub fn append_entry(&self, entry: &EvalHistoryEntry) -> std::io::Result<()> {
164        // Always write to JSONL first (source of truth)
165        self.append_jsonl(entry)?;
166
167        // Update SQLite index for fast queries
168        if let Some(ref db_path) = self.sqlite_path {
169            self.insert_sqlite(entry, db_path)?;
170        }
171
172        Ok(())
173    }
174
175    /// Append entry to JSONL file.
176    fn append_jsonl(&self, entry: &EvalHistoryEntry) -> std::io::Result<()> {
177        let mut file = OpenOptions::new()
178            .create(true)
179            .append(true)
180            .open(&self.jsonl_path)?;
181
182        let line = serde_json::to_string(entry)
183            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
184        writeln!(file, "{}", line)?;
185        Ok(())
186    }
187
188    /// Load all entries from JSONL file.
189    pub fn load_all(&self) -> std::io::Result<Vec<EvalHistoryEntry>> {
190        if !self.jsonl_path.exists() {
191            return Ok(Vec::new());
192        }
193
194        let file = File::open(&self.jsonl_path)?;
195        let reader = BufReader::new(file);
196        let mut entries = Vec::new();
197
198        for line in reader.lines() {
199            let line = line?;
200            if line.trim().is_empty() {
201                continue;
202            }
203            if let Ok(entry) = serde_json::from_str::<EvalHistoryEntry>(&line) {
204                entries.push(entry);
205            }
206        }
207
208        Ok(entries)
209    }
210
211    /// Get all unique backends in history.
212    pub fn backends(&self) -> std::io::Result<Vec<String>> {
213        let entries = self.load_all()?;
214        let backends: std::collections::HashSet<String> =
215            entries.iter().map(|e| e.backend.clone()).collect();
216        let mut result: Vec<String> = backends.into_iter().collect();
217        result.sort();
218        Ok(result)
219    }
220
221    /// Get all unique datasets in history.
222    pub fn datasets(&self) -> std::io::Result<Vec<String>> {
223        let entries = self.load_all()?;
224        let datasets: std::collections::HashSet<String> =
225            entries.iter().map(|e| e.dataset.clone()).collect();
226        let mut result: Vec<String> = datasets.into_iter().collect();
227        result.sort();
228        Ok(result)
229    }
230
231    /// Get statistics about the history.
232    pub fn stats(&self) -> std::io::Result<HistoryStats> {
233        let entries = self.load_all()?;
234
235        let mut by_backend: HashMap<String, usize> = HashMap::new();
236        let mut by_dataset: HashMap<String, usize> = HashMap::new();
237        let mut total_f1: f64 = 0.0;
238        let mut f1_count: usize = 0;
239
240        for entry in &entries {
241            *by_backend.entry(entry.backend.clone()).or_insert(0) += 1;
242            *by_dataset.entry(entry.dataset.clone()).or_insert(0) += 1;
243            if let Some(f1) = entry.f1 {
244                total_f1 += f1;
245                f1_count += 1;
246            }
247        }
248
249        Ok(HistoryStats {
250            total_entries: entries.len(),
251            by_backend,
252            by_dataset,
253            avg_f1: if f1_count > 0 {
254                Some(total_f1 / f1_count as f64)
255            } else {
256                None
257            },
258        })
259    }
260
261    fn init_sqlite(db_path: &Path) -> std::io::Result<()> {
262        use rusqlite::Connection;
263
264        let conn = Connection::open(db_path)
265            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
266
267        conn.execute(
268            "CREATE TABLE IF NOT EXISTS eval_results (
269                id INTEGER PRIMARY KEY AUTOINCREMENT,
270                timestamp TEXT NOT NULL,
271                backend TEXT NOT NULL,
272                dataset TEXT NOT NULL,
273                task TEXT NOT NULL,
274                seed INTEGER NOT NULL,
275                f1 REAL,
276                precision REAL,
277                recall REAL,
278                n INTEGER NOT NULL,
279                duration_ms REAL,
280                error TEXT,
281                metadata TEXT,
282                created_at TEXT DEFAULT CURRENT_TIMESTAMP
283            )",
284            [],
285        )
286        .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
287
288        // Create indexes for common queries
289        conn.execute(
290            "CREATE INDEX IF NOT EXISTS idx_backend_dataset ON eval_results(backend, dataset)",
291            [],
292        )
293        .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
294        conn.execute(
295            "CREATE INDEX IF NOT EXISTS idx_timestamp ON eval_results(timestamp)",
296            [],
297        )
298        .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
299        conn.execute("CREATE INDEX IF NOT EXISTS idx_f1 ON eval_results(f1)", [])
300            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
301        conn.execute(
302            "CREATE INDEX IF NOT EXISTS idx_backend_timestamp ON eval_results(backend, timestamp)",
303            [],
304        )
305        .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
306
307        // Schema evolution: add git_commit column if not present.
308        // This enables change-point detection tied to specific code versions.
309        let _ = conn.execute("ALTER TABLE eval_results ADD COLUMN git_commit TEXT", []); // Silently ignore if column already exists.
310
311        Ok(())
312    }
313
314    fn insert_sqlite(&self, entry: &EvalHistoryEntry, db_path: &Path) -> std::io::Result<()> {
315        use rusqlite::params;
316
317        let conn = rusqlite::Connection::open(db_path)
318            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
319
320        // Include git commit hash if available (for change-point detection).
321        // Cached to avoid spawning `git` on every insert.
322        let git_commit = cached_git_commit();
323
324        conn.execute(
325            "INSERT INTO eval_results (
326                timestamp, backend, dataset, task, seed,
327                f1, precision, recall, n, duration_ms, error, metadata, git_commit
328            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
329            params![
330                entry.timestamp,
331                entry.backend,
332                entry.dataset,
333                entry.task,
334                entry.seed,
335                entry.f1,
336                entry.precision,
337                entry.recall,
338                entry.n,
339                entry.duration_ms,
340                entry.error,
341                entry.metadata,
342                git_commit,
343            ],
344        )
345        .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
346
347        Ok(())
348    }
349
350    /// Query recent results for a backend.
351    ///
352    /// Returns the N most recent results, ordered by timestamp descending.
353    pub fn query_recent(
354        &self,
355        backend: &str,
356        limit: usize,
357    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
358        if let Some(ref db_path) = self.sqlite_path {
359            return Self::query_recent_sqlite(db_path, backend, limit);
360        }
361
362        // Fallback to JSONL scan
363        let mut entries = self.load_all()?;
364        entries.retain(|e| e.backend == backend);
365        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
366        entries.truncate(limit);
367        Ok(entries)
368    }
369
370    fn query_recent_sqlite(
371        db_path: &Path,
372        backend: &str,
373        limit: usize,
374    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
375        use rusqlite::params;
376
377        let conn = rusqlite::Connection::open(db_path)
378            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
379        let mut stmt = conn
380            .prepare(
381                "SELECT timestamp, backend, dataset, task, seed, f1, precision, recall, n, duration_ms, error, metadata
382             FROM eval_results
383             WHERE backend = ?1
384             ORDER BY timestamp DESC
385             LIMIT ?2",
386            )
387            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
388
389        let rows = stmt
390            .query_map(params![backend, limit as i64], |row| {
391                Ok(EvalHistoryEntry {
392                    timestamp: row.get(0)?,
393                    backend: row.get(1)?,
394                    dataset: row.get(2)?,
395                    task: row.get(3)?,
396                    seed: row.get(4)?,
397                    f1: row.get(5)?,
398                    precision: row.get(6)?,
399                    recall: row.get(7)?,
400                    n: row.get(8)?,
401                    duration_ms: row.get(9)?,
402                    error: row.get(10)?,
403                    metadata: row.get(11)?,
404                })
405            })
406            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
407
408        let mut entries = Vec::new();
409        for row in rows {
410            entries.push(row.map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?);
411        }
412        Ok(entries)
413    }
414
415    /// Query best results for a backend-dataset combination.
416    ///
417    /// Returns results ordered by F1 score descending.
418    pub fn query_best(
419        &self,
420        backend: &str,
421        dataset: Option<&str>,
422        limit: usize,
423    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
424        if let Some(ref db_path) = self.sqlite_path {
425            return Self::query_best_sqlite(db_path, backend, dataset, limit);
426        }
427
428        // Fallback to JSONL scan
429        let mut entries = self.load_all()?;
430        entries.retain(|e| {
431            e.backend == backend
432                && match dataset {
433                    None => true,
434                    Some(d) => e.dataset == d,
435                }
436        });
437        entries.sort_by(|a, b| {
438            let a_f1 = a.f1.unwrap_or(0.0);
439            let b_f1 = b.f1.unwrap_or(0.0);
440            b_f1.partial_cmp(&a_f1).unwrap_or(std::cmp::Ordering::Equal)
441        });
442        entries.truncate(limit);
443        Ok(entries)
444    }
445
446    fn query_best_sqlite(
447        db_path: &Path,
448        backend: &str,
449        dataset: Option<&str>,
450        limit: usize,
451    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
452        use rusqlite::params;
453
454        let conn = rusqlite::Connection::open(db_path)
455            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
456
457        let mut entries = Vec::new();
458
459        if let Some(ds) = dataset {
460            let mut stmt = conn
461                .prepare(
462                    "SELECT timestamp, backend, dataset, task, seed, f1, precision, recall, n, duration_ms, error, metadata
463                     FROM eval_results
464                     WHERE backend = ?1 AND dataset = ?2 AND f1 IS NOT NULL
465                     ORDER BY f1 DESC
466                     LIMIT ?3",
467                )
468                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
469            let rows = stmt
470                .query_map(params![backend, ds, limit as i64], |row| {
471                    Ok(EvalHistoryEntry {
472                        timestamp: row.get(0)?,
473                        backend: row.get(1)?,
474                        dataset: row.get(2)?,
475                        task: row.get(3)?,
476                        seed: row.get(4)?,
477                        f1: row.get(5)?,
478                        precision: row.get(6)?,
479                        recall: row.get(7)?,
480                        n: row.get(8)?,
481                        duration_ms: row.get(9)?,
482                        error: row.get(10)?,
483                        metadata: row.get(11)?,
484                    })
485                })
486                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
487
488            for row in rows {
489                entries
490                    .push(row.map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?);
491            }
492        } else {
493            let mut stmt = conn
494                .prepare(
495                    "SELECT timestamp, backend, dataset, task, seed, f1, precision, recall, n, duration_ms, error, metadata
496                     FROM eval_results
497                     WHERE backend = ?1 AND f1 IS NOT NULL
498                     ORDER BY f1 DESC
499                     LIMIT ?2",
500                )
501                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
502            let rows = stmt
503                .query_map(params![backend, limit as i64], |row| {
504                    Ok(EvalHistoryEntry {
505                        timestamp: row.get(0)?,
506                        backend: row.get(1)?,
507                        dataset: row.get(2)?,
508                        task: row.get(3)?,
509                        seed: row.get(4)?,
510                        f1: row.get(5)?,
511                        precision: row.get(6)?,
512                        recall: row.get(7)?,
513                        n: row.get(8)?,
514                        duration_ms: row.get(9)?,
515                        error: row.get(10)?,
516                        metadata: row.get(11)?,
517                    })
518                })
519                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
520
521            for row in rows {
522                entries
523                    .push(row.map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?);
524            }
525        }
526
527        Ok(entries)
528    }
529
530    /// Query results by date range.
531    ///
532    /// Returns all results between `start_date` and `end_date` (inclusive).
533    /// Dates should be in ISO 8601 format (e.g., "2024-01-01T00:00:00Z").
534    pub fn query_by_date_range(
535        &self,
536        start_date: &str,
537        end_date: &str,
538        backend: Option<&str>,
539    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
540        if let Some(ref db_path) = self.sqlite_path {
541            return Self::query_by_date_range_sqlite(db_path, start_date, end_date, backend);
542        }
543
544        // Fallback to JSONL scan
545        let mut entries = self.load_all()?;
546        entries.retain(|e| {
547            e.timestamp.as_str() >= start_date
548                && e.timestamp.as_str() <= end_date
549                && match backend {
550                    None => true,
551                    Some(b) => e.backend == b,
552                }
553        });
554        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
555        Ok(entries)
556    }
557
558    fn query_by_date_range_sqlite(
559        db_path: &Path,
560        start_date: &str,
561        end_date: &str,
562        backend: Option<&str>,
563    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
564        use rusqlite::params;
565
566        let conn = rusqlite::Connection::open(db_path)
567            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
568
569        let mut entries = Vec::new();
570
571        if let Some(b) = backend {
572            let mut stmt = conn
573                .prepare(
574                    "SELECT timestamp, backend, dataset, task, seed, f1, precision, recall, n, duration_ms, error, metadata
575                     FROM eval_results
576                     WHERE timestamp >= ?1 AND timestamp <= ?2 AND backend = ?3
577                     ORDER BY timestamp DESC",
578                )
579                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
580            let rows = stmt
581                .query_map(params![start_date, end_date, b], |row| {
582                    Ok(EvalHistoryEntry {
583                        timestamp: row.get(0)?,
584                        backend: row.get(1)?,
585                        dataset: row.get(2)?,
586                        task: row.get(3)?,
587                        seed: row.get(4)?,
588                        f1: row.get(5)?,
589                        precision: row.get(6)?,
590                        recall: row.get(7)?,
591                        n: row.get(8)?,
592                        duration_ms: row.get(9)?,
593                        error: row.get(10)?,
594                        metadata: row.get(11)?,
595                    })
596                })
597                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
598
599            for row in rows {
600                entries
601                    .push(row.map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?);
602            }
603        } else {
604            let mut stmt = conn
605                .prepare(
606                    "SELECT timestamp, backend, dataset, task, seed, f1, precision, recall, n, duration_ms, error, metadata
607                     FROM eval_results
608                     WHERE timestamp >= ?1 AND timestamp <= ?2
609                     ORDER BY timestamp DESC",
610                )
611                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
612            let rows = stmt
613                .query_map(params![start_date, end_date], |row| {
614                    Ok(EvalHistoryEntry {
615                        timestamp: row.get(0)?,
616                        backend: row.get(1)?,
617                        dataset: row.get(2)?,
618                        task: row.get(3)?,
619                        seed: row.get(4)?,
620                        f1: row.get(5)?,
621                        precision: row.get(6)?,
622                        recall: row.get(7)?,
623                        n: row.get(8)?,
624                        duration_ms: row.get(9)?,
625                        error: row.get(10)?,
626                        metadata: row.get(11)?,
627                    })
628                })
629                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
630
631            for row in rows {
632                entries
633                    .push(row.map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?);
634            }
635        }
636
637        Ok(entries)
638    }
639
640    /// Compare two backends on the same dataset.
641    ///
642    /// Returns entries for both backends, ordered by timestamp.
643    pub fn compare_backends(
644        &self,
645        backend1: &str,
646        backend2: &str,
647        dataset: Option<&str>,
648    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
649        if let Some(ref db_path) = self.sqlite_path {
650            return Self::compare_backends_sqlite(db_path, backend1, backend2, dataset);
651        }
652
653        // Fallback to JSONL scan
654        let mut entries = self.load_all()?;
655        entries.retain(|e| {
656            (e.backend == backend1 || e.backend == backend2)
657                && match dataset {
658                    None => true,
659                    Some(d) => e.dataset == d,
660                }
661        });
662        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
663        Ok(entries)
664    }
665
666    fn compare_backends_sqlite(
667        db_path: &Path,
668        backend1: &str,
669        backend2: &str,
670        dataset: Option<&str>,
671    ) -> std::io::Result<Vec<EvalHistoryEntry>> {
672        use rusqlite::params;
673
674        let conn = rusqlite::Connection::open(db_path)
675            .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
676
677        let mut entries = Vec::new();
678
679        if let Some(ds) = dataset {
680            let mut stmt = conn
681                .prepare(
682                    "SELECT timestamp, backend, dataset, task, seed, f1, precision, recall, n, duration_ms, error, metadata
683                     FROM eval_results
684                     WHERE (backend = ?1 OR backend = ?2) AND dataset = ?3
685                     ORDER BY timestamp DESC",
686                )
687                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
688            let rows = stmt
689                .query_map(params![backend1, backend2, ds], |row| {
690                    Ok(EvalHistoryEntry {
691                        timestamp: row.get(0)?,
692                        backend: row.get(1)?,
693                        dataset: row.get(2)?,
694                        task: row.get(3)?,
695                        seed: row.get(4)?,
696                        f1: row.get(5)?,
697                        precision: row.get(6)?,
698                        recall: row.get(7)?,
699                        n: row.get(8)?,
700                        duration_ms: row.get(9)?,
701                        error: row.get(10)?,
702                        metadata: row.get(11)?,
703                    })
704                })
705                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
706
707            for row in rows {
708                entries
709                    .push(row.map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?);
710            }
711        } else {
712            let mut stmt = conn
713                .prepare(
714                    "SELECT timestamp, backend, dataset, task, seed, f1, precision, recall, n, duration_ms, error, metadata
715                     FROM eval_results
716                     WHERE backend = ?1 OR backend = ?2
717                     ORDER BY timestamp DESC",
718                )
719                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
720            let rows = stmt
721                .query_map(params![backend1, backend2], |row| {
722                    Ok(EvalHistoryEntry {
723                        timestamp: row.get(0)?,
724                        backend: row.get(1)?,
725                        dataset: row.get(2)?,
726                        task: row.get(3)?,
727                        seed: row.get(4)?,
728                        f1: row.get(5)?,
729                        precision: row.get(6)?,
730                        recall: row.get(7)?,
731                        n: row.get(8)?,
732                        duration_ms: row.get(9)?,
733                        error: row.get(10)?,
734                        metadata: row.get(11)?,
735                    })
736                })
737                .map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?;
738
739            for row in rows {
740                entries
741                    .push(row.map_err(|e| std::io::Error::other(format!("SQLite error: {}", e)))?);
742            }
743        }
744
745        Ok(entries)
746    }
747
748    /// Return observation counts per (backend, dataset) cell from the SQLite index.
749    ///
750    /// This is the quality matrix coverage map: each entry tells you how many times
751    /// a (backend, dataset) pair has been evaluated.  Used by the Estimate strategy
752    /// to prioritize cells with fewest observations.
753    ///
754    /// Falls back to JSONL scan if SQLite is unavailable.
755    pub fn cell_observation_counts(&self) -> std::io::Result<HashMap<(String, String), u64>> {
756        if let Some(ref db_path) = self.sqlite_path {
757            if let Ok(conn) = rusqlite::Connection::open(db_path) {
758                let mut stmt = conn
759                    .prepare(
760                        "SELECT backend, dataset, COUNT(*) FROM eval_results GROUP BY backend, dataset",
761                    )
762                    .map_err(|e| std::io::Error::other(e.to_string()))?;
763                let mut counts = HashMap::new();
764                let rows = stmt
765                    .query_map([], |row| {
766                        let backend: String = row.get(0)?;
767                        let dataset: String = row.get(1)?;
768                        let count: u64 = row.get(2)?;
769                        Ok((backend, dataset, count))
770                    })
771                    .map_err(|e| std::io::Error::other(e.to_string()))?;
772                for (b, d, c) in rows.flatten() {
773                    counts.insert((b, d), c);
774                }
775                return Ok(counts);
776            }
777        }
778        // Fallback: scan JSONL
779        let entries = self.load_all()?;
780        let mut counts = HashMap::new();
781        for e in entries {
782            *counts
783                .entry((e.backend.clone(), e.dataset.clone()))
784                .or_insert(0u64) += 1;
785        }
786        Ok(counts)
787    }
788
789    /// Return total observation counts per dataset across all backends.
790    ///
791    /// Used by the Estimate strategy to find least-observed datasets.
792    pub fn dataset_observation_counts(&self) -> std::io::Result<HashMap<String, u64>> {
793        if let Some(ref db_path) = self.sqlite_path {
794            if let Ok(conn) = rusqlite::Connection::open(db_path) {
795                let mut stmt = conn
796                    .prepare("SELECT dataset, COUNT(*) FROM eval_results GROUP BY dataset")
797                    .map_err(|e| std::io::Error::other(e.to_string()))?;
798                let mut counts = HashMap::new();
799                let rows = stmt
800                    .query_map([], |row| {
801                        let dataset: String = row.get(0)?;
802                        let count: u64 = row.get(1)?;
803                        Ok((dataset, count))
804                    })
805                    .map_err(|e| std::io::Error::other(e.to_string()))?;
806                for (d, c) in rows.flatten() {
807                    counts.insert(d, c);
808                }
809                return Ok(counts);
810            }
811        }
812        let entries = self.load_all()?;
813        let mut counts = HashMap::new();
814        for e in entries {
815            *counts.entry(e.dataset.clone()).or_insert(0u64) += 1;
816        }
817        Ok(counts)
818    }
819
820    /// Return total observation counts per backend across all datasets.
821    pub fn backend_observation_counts(&self) -> std::io::Result<HashMap<String, u64>> {
822        if let Some(ref db_path) = self.sqlite_path {
823            if let Ok(conn) = rusqlite::Connection::open(db_path) {
824                let mut stmt = conn
825                    .prepare("SELECT backend, COUNT(*) FROM eval_results GROUP BY backend")
826                    .map_err(|e| std::io::Error::other(e.to_string()))?;
827                let mut counts = HashMap::new();
828                let rows = stmt
829                    .query_map([], |row| {
830                        let backend: String = row.get(0)?;
831                        let count: u64 = row.get(1)?;
832                        Ok((backend, count))
833                    })
834                    .map_err(|e| std::io::Error::other(e.to_string()))?;
835                for (b, c) in rows.flatten() {
836                    counts.insert(b, c);
837                }
838                return Ok(counts);
839            }
840        }
841        let entries = self.load_all()?;
842        let mut counts = HashMap::new();
843        for e in entries {
844            *counts.entry(e.backend.clone()).or_insert(0u64) += 1;
845        }
846        Ok(counts)
847    }
848
849    /// Detect regressions: cells where recent F1 is significantly lower than historical.
850    ///
851    /// For each (backend, dataset) cell with enough observations, splits the data at
852    /// a time threshold (default: median timestamp), computes the mean F1 for "before"
853    /// and "after", and flags cells where the drop exceeds `min_drop`.
854    ///
855    /// This is the **change point detection** mechanism for the quality matrix:
856    /// code changes that break a backend on a dataset will show up as a drop in
857    /// recent F1 relative to historical F1.
858    ///
859    /// Returns a list of `(backend, dataset, old_mean, new_mean, drop, n_old, n_new)`.
860    pub fn detect_regressions(
861        &self,
862        min_observations: u64,
863        min_drop: f64,
864    ) -> std::io::Result<Vec<RegressionAlert>> {
865        let Some(ref db_path) = self.sqlite_path else {
866            return Ok(Vec::new());
867        };
868        let conn = rusqlite::Connection::open(db_path).map_err(std::io::Error::other)?;
869
870        // For each cell, split observations by the median timestamp and compare means.
871        let mut stmt = conn
872            .prepare(
873                "SELECT backend, dataset, timestamp, f1 FROM eval_results \
874                 WHERE f1 IS NOT NULL AND error IS NULL \
875                 ORDER BY backend, dataset, timestamp",
876            )
877            .map_err(std::io::Error::other)?;
878
879        let mut cells: HashMap<(String, String), Vec<(String, f64)>> = HashMap::new();
880        let rows = stmt
881            .query_map([], |row| {
882                let b: String = row.get(0)?;
883                let d: String = row.get(1)?;
884                let ts: String = row.get(2)?;
885                let f1: f64 = row.get(3)?;
886                Ok((b, d, ts, f1))
887            })
888            .map_err(std::io::Error::other)?;
889        for row in rows.flatten() {
890            let (b, d, ts, f1) = row;
891            cells.entry((b, d)).or_default().push((ts, f1));
892        }
893
894        let mut alerts = Vec::new();
895        for ((backend, dataset), obs) in &cells {
896            let n = obs.len() as u64;
897            if n < min_observations {
898                continue;
899            }
900            // Split at the median index (first half = historical, second half = recent).
901            let mid = obs.len() / 2;
902            let old_vals: Vec<f64> = obs[..mid].iter().map(|(_, f)| *f).collect();
903            let new_vals: Vec<f64> = obs[mid..].iter().map(|(_, f)| *f).collect();
904
905            if old_vals.is_empty() || new_vals.is_empty() {
906                continue;
907            }
908
909            let old_mean = old_vals.iter().sum::<f64>() / old_vals.len() as f64;
910            let new_mean = new_vals.iter().sum::<f64>() / new_vals.len() as f64;
911            let drop = old_mean - new_mean;
912
913            if drop >= min_drop {
914                alerts.push(RegressionAlert {
915                    backend: backend.clone(),
916                    dataset: dataset.clone(),
917                    old_mean,
918                    new_mean,
919                    drop,
920                    n_old: old_vals.len() as u64,
921                    n_new: new_vals.len() as u64,
922                    split_timestamp: obs[mid].0.clone(),
923                });
924            }
925        }
926
927        alerts.sort_by(|a, b| b.drop.total_cmp(&a.drop));
928        Ok(alerts)
929    }
930
931    /// Detect regressions using a recent-window comparison with sample-size normalization.
932    ///
933    /// For each (backend, dataset) cell, compares the last `recent_n` observations to all
934    /// earlier observations.  Only compares observations with similar evaluation size (n)
935    /// to avoid false alarms from the n-dependent F1 variance (small n = high F1 variance).
936    ///
937    /// Uses Cohen's d effect size: d = (old_mean - new_mean) / pooled_sd.
938    /// Flags cells where d > `min_effect_size` (default 0.8 = "large" effect).
939    pub fn detect_regressions_recent(
940        &self,
941        recent_n: usize,
942        min_effect_size: f64,
943        min_total: u64,
944    ) -> std::io::Result<Vec<RegressionAlert>> {
945        let Some(ref db_path) = self.sqlite_path else {
946            return Ok(Vec::new());
947        };
948        let conn = rusqlite::Connection::open(db_path).map_err(std::io::Error::other)?;
949
950        // Include evaluation size (n) so we can match comparable observations.
951        let mut stmt = conn
952            .prepare(
953                "SELECT backend, dataset, timestamp, f1, n FROM eval_results \
954                 WHERE f1 IS NOT NULL AND error IS NULL \
955                 ORDER BY backend, dataset, timestamp",
956            )
957            .map_err(std::io::Error::other)?;
958
959        // (backend, dataset) → [(timestamp, f1, n)]
960        type CellKey = (String, String);
961        type CellRow = (String, f64, i64);
962        let mut cells: HashMap<CellKey, Vec<CellRow>> = HashMap::new();
963        let rows = stmt
964            .query_map([], |row| {
965                Ok((
966                    row.get::<_, String>(0)?,
967                    row.get::<_, String>(1)?,
968                    row.get::<_, String>(2)?,
969                    row.get::<_, f64>(3)?,
970                    row.get::<_, i64>(4)?,
971                ))
972            })
973            .map_err(std::io::Error::other)?;
974        for row in rows.flatten() {
975            cells
976                .entry((row.0, row.1))
977                .or_default()
978                .push((row.2, row.3, row.4));
979        }
980
981        let mut alerts = Vec::new();
982        for ((backend, dataset), obs) in &cells {
983            if (obs.len() as u64) < min_total || obs.len() <= recent_n {
984                continue;
985            }
986            let split = obs.len() - recent_n;
987
988            // Determine the typical evaluation size in the recent window.
989            let recent_n_vals: Vec<i64> = obs[split..].iter().map(|(_, _, n)| *n).collect();
990            let median_recent_n = {
991                let mut sorted = recent_n_vals.clone();
992                sorted.sort();
993                sorted[sorted.len() / 2]
994            };
995
996            // Only compare against historical observations with similar evaluation size
997            // (within 2x) to avoid the n-dependent variance confound.
998            let old: Vec<f64> = obs[..split]
999                .iter()
1000                .filter(|(_, _, n)| *n >= median_recent_n / 2 && *n <= median_recent_n * 2)
1001                .map(|(_, f, _)| *f)
1002                .collect();
1003            let new: Vec<f64> = obs[split..].iter().map(|(_, f, _)| *f).collect();
1004
1005            if old.len() < 3 || new.is_empty() {
1006                continue; // not enough comparable historical observations
1007            }
1008
1009            let old_mean = old.iter().sum::<f64>() / old.len() as f64;
1010            let new_mean = new.iter().sum::<f64>() / new.len() as f64;
1011            let drop = old_mean - new_mean;
1012            if drop <= 0.0 {
1013                continue;
1014            }
1015
1016            let old_var = old.iter().map(|x| (x - old_mean).powi(2)).sum::<f64>()
1017                / (old.len() as f64 - 1.0).max(1.0);
1018            let new_var = new.iter().map(|x| (x - new_mean).powi(2)).sum::<f64>()
1019                / (new.len() as f64 - 1.0).max(1.0);
1020            let pooled_sd = ((old_var + new_var) / 2.0).sqrt();
1021            if pooled_sd < 1e-12 {
1022                continue;
1023            }
1024            let d = drop / pooled_sd;
1025
1026            if d >= min_effect_size {
1027                alerts.push(RegressionAlert {
1028                    backend: backend.clone(),
1029                    dataset: dataset.clone(),
1030                    old_mean,
1031                    new_mean,
1032                    drop,
1033                    n_old: old.len() as u64,
1034                    n_new: new.len() as u64,
1035                    split_timestamp: obs[split].0.clone(),
1036                });
1037            }
1038        }
1039
1040        alerts.sort_by(|a, b| b.drop.total_cmp(&a.drop));
1041        Ok(alerts)
1042    }
1043
1044    /// Detect regressions between two git commits.
1045    ///
1046    /// This is the most precise change-point detection: it directly compares F1 scores
1047    /// from evaluations tagged with `old_commit` to those tagged with `new_commit`.
1048    /// Only works after the git_commit column is populated (evaluations run after this
1049    /// code change).
1050    pub fn detect_regressions_by_commit(
1051        &self,
1052        old_commit: &str,
1053        new_commit: &str,
1054        min_drop: f64,
1055    ) -> std::io::Result<Vec<RegressionAlert>> {
1056        let Some(ref db_path) = self.sqlite_path else {
1057            return Ok(Vec::new());
1058        };
1059        let conn = rusqlite::Connection::open(db_path).map_err(std::io::Error::other)?;
1060
1061        // Check if the git_commit column exists.
1062        let has_column = conn
1063            .prepare("SELECT git_commit FROM eval_results LIMIT 0")
1064            .is_ok();
1065        if !has_column {
1066            return Ok(Vec::new());
1067        }
1068
1069        let mut stmt = conn
1070            .prepare(
1071                "SELECT backend, dataset, git_commit, f1 FROM eval_results \
1072                 WHERE f1 IS NOT NULL AND error IS NULL \
1073                 AND git_commit IN (?1, ?2) \
1074                 ORDER BY backend, dataset",
1075            )
1076            .map_err(std::io::Error::other)?;
1077
1078        let mut old_cells: HashMap<(String, String), Vec<f64>> = HashMap::new();
1079        let mut new_cells: HashMap<(String, String), Vec<f64>> = HashMap::new();
1080        let rows = stmt
1081            .query_map(rusqlite::params![old_commit, new_commit], |row| {
1082                Ok((
1083                    row.get::<_, String>(0)?,
1084                    row.get::<_, String>(1)?,
1085                    row.get::<_, String>(2)?,
1086                    row.get::<_, f64>(3)?,
1087                ))
1088            })
1089            .map_err(std::io::Error::other)?;
1090        for row in rows.flatten() {
1091            let (b, d, commit, f1) = row;
1092            if commit == old_commit {
1093                old_cells.entry((b, d)).or_default().push(f1);
1094            } else {
1095                new_cells.entry((b, d)).or_default().push(f1);
1096            }
1097        }
1098
1099        let mut alerts = Vec::new();
1100        for ((backend, dataset), old_vals) in &old_cells {
1101            let Some(new_vals) = new_cells.get(&(backend.clone(), dataset.clone())) else {
1102                continue;
1103            };
1104            if old_vals.is_empty() || new_vals.is_empty() {
1105                continue;
1106            }
1107            let old_mean = old_vals.iter().sum::<f64>() / old_vals.len() as f64;
1108            let new_mean = new_vals.iter().sum::<f64>() / new_vals.len() as f64;
1109            let drop = old_mean - new_mean;
1110            if drop >= min_drop {
1111                alerts.push(RegressionAlert {
1112                    backend: backend.clone(),
1113                    dataset: dataset.clone(),
1114                    old_mean,
1115                    new_mean,
1116                    drop,
1117                    n_old: old_vals.len() as u64,
1118                    n_new: new_vals.len() as u64,
1119                    split_timestamp: format!("{} -> {}", old_commit, new_commit),
1120                });
1121            }
1122        }
1123
1124        alerts.sort_by(|a, b| b.drop.total_cmp(&a.drop));
1125        Ok(alerts)
1126    }
1127
1128    /// Rebuild SQLite index from JSONL file.
1129    ///
1130    /// Useful if SQLite gets corrupted or out of sync.
1131    pub fn rebuild_index(&self) -> std::io::Result<()> {
1132        if let Some(ref db_path) = self.sqlite_path {
1133            // Delete existing database
1134            if db_path.exists() {
1135                std::fs::remove_file(db_path)?;
1136            }
1137
1138            // Reinitialize schema
1139            Self::init_sqlite(db_path)?;
1140
1141            // Reload all entries and insert
1142            let entries = self.load_all()?;
1143            for entry in &entries {
1144                self.insert_sqlite(entry, db_path)?;
1145            }
1146
1147            eprintln!(
1148                "[history] Rebuilt SQLite index with {} entries",
1149                entries.len()
1150            );
1151        }
1152        Ok(())
1153    }
1154}
1155
1156/// A detected regression in a (backend, dataset) cell.
1157#[derive(Debug, Clone)]
1158pub struct RegressionAlert {
1159    /// Backend name.
1160    pub backend: String,
1161    /// Dataset name.
1162    pub dataset: String,
1163    /// Mean F1 in the historical (older) half.
1164    pub old_mean: f64,
1165    /// Mean F1 in the recent (newer) half.
1166    pub new_mean: f64,
1167    /// Size of the drop (old_mean - new_mean, positive = regression).
1168    pub drop: f64,
1169    /// Number of observations in the historical half.
1170    pub n_old: u64,
1171    /// Number of observations in the recent half.
1172    pub n_new: u64,
1173    /// Timestamp where the split occurs.
1174    pub split_timestamp: String,
1175}
1176
1177/// Statistics about evaluation history.
1178#[derive(Debug, Clone)]
1179pub struct HistoryStats {
1180    /// Total number of entries
1181    pub total_entries: usize,
1182    /// Count per backend
1183    pub by_backend: HashMap<String, usize>,
1184    /// Count per dataset
1185    pub by_dataset: HashMap<String, usize>,
1186    /// Average F1 score across all entries
1187    pub avg_f1: Option<f64>,
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193    use tempfile::TempDir;
1194
1195    #[test]
1196    fn test_append_and_load() {
1197        let temp = TempDir::new().expect("failed to create temp dir");
1198        let jsonl_path = temp.path().join("history.jsonl");
1199
1200        let history = EvalHistory::new(&jsonl_path).expect("failed to create history");
1201
1202        // Create a test result
1203        let result = TaskEvalResult {
1204            task: crate::eval::task_mapping::Task::NER,
1205            dataset: crate::eval::loader::DatasetId::WikiGold,
1206            backend: "test-backend".to_string(),
1207            backend_display: None,
1208            seed: 42,
1209            success: true,
1210            error: None,
1211            metrics: {
1212                let mut m = HashMap::new();
1213                m.insert("f1".to_string(), 0.85);
1214                m.insert("precision".to_string(), 0.90);
1215                m.insert("recall".to_string(), 0.80);
1216                m
1217            },
1218            num_examples: 100,
1219            duration_ms: Some(5000.0),
1220            label_shift: None,
1221            robustness: None,
1222            stratified: None,
1223            confidence_intervals: None,
1224            kb_version: None,
1225        };
1226
1227        // Append result
1228        history.append_result(&result).expect("append failed");
1229
1230        // Load and verify
1231        let entries = history.load_all().expect("load failed");
1232        assert_eq!(entries.len(), 1);
1233        assert_eq!(entries[0].backend, "test-backend");
1234        assert_eq!(entries[0].f1, Some(0.85));
1235    }
1236
1237    #[test]
1238    fn test_stats() {
1239        let temp = TempDir::new().expect("failed to create temp dir");
1240        let jsonl_path = temp.path().join("history.jsonl");
1241
1242        let history = EvalHistory::new(&jsonl_path).expect("failed to create history");
1243
1244        // Add multiple results
1245        for i in 0..5 {
1246            let result = TaskEvalResult {
1247                task: crate::eval::task_mapping::Task::NER,
1248                dataset: crate::eval::loader::DatasetId::WikiGold,
1249                backend: format!("backend-{}", i % 2),
1250                backend_display: None,
1251                seed: 42,
1252                success: true,
1253                error: None,
1254                metrics: {
1255                    let mut m = HashMap::new();
1256                    m.insert("f1".to_string(), 0.8 + (i as f64 * 0.01));
1257                    m
1258                },
1259                num_examples: 100,
1260                duration_ms: Some(5000.0),
1261                label_shift: None,
1262                robustness: None,
1263                stratified: None,
1264                confidence_intervals: None,
1265                kb_version: None,
1266            };
1267            history.append_result(&result).expect("append failed");
1268        }
1269
1270        let stats = history.stats().expect("stats failed");
1271        assert_eq!(stats.total_entries, 5);
1272        assert_eq!(stats.by_backend.len(), 2);
1273        assert!(stats.avg_f1.is_some());
1274    }
1275
1276    #[test]
1277    fn test_query_recent() {
1278        let temp = TempDir::new().expect("failed to create temp dir");
1279        let jsonl_path = temp.path().join("history.jsonl");
1280
1281        let history = EvalHistory::new(&jsonl_path).expect("failed to create history");
1282
1283        // Add results with different backends
1284        for i in 0..10 {
1285            let result = TaskEvalResult {
1286                task: crate::eval::task_mapping::Task::NER,
1287                dataset: crate::eval::loader::DatasetId::WikiGold,
1288                backend: if i % 2 == 0 {
1289                    "backend-a".to_string()
1290                } else {
1291                    "backend-b".to_string()
1292                },
1293                backend_display: None,
1294                seed: 42,
1295                success: true,
1296                error: None,
1297                metrics: {
1298                    let mut m = HashMap::new();
1299                    m.insert("f1".to_string(), 0.8);
1300                    m
1301                },
1302                num_examples: 100,
1303                duration_ms: Some(1000.0),
1304                label_shift: None,
1305                robustness: None,
1306                stratified: None,
1307                confidence_intervals: None,
1308                kb_version: None,
1309            };
1310            history.append_result(&result).expect("append failed");
1311        }
1312
1313        let recent = history.query_recent("backend-a", 3).expect("query failed");
1314        assert_eq!(recent.len(), 3);
1315        assert!(recent.iter().all(|e| e.backend == "backend-a"));
1316    }
1317
1318    #[test]
1319    fn test_query_best() {
1320        let temp = TempDir::new().expect("failed to create temp dir");
1321        let jsonl_path = temp.path().join("history.jsonl");
1322
1323        let history = EvalHistory::new(&jsonl_path).expect("failed to create history");
1324
1325        // Add results with different F1 scores
1326        for i in 0..5 {
1327            let result = TaskEvalResult {
1328                task: crate::eval::task_mapping::Task::NER,
1329                dataset: crate::eval::loader::DatasetId::WikiGold,
1330                backend: "test-backend".to_string(),
1331                backend_display: None,
1332                seed: 42,
1333                success: true,
1334                error: None,
1335                metrics: {
1336                    let mut m = HashMap::new();
1337                    m.insert("f1".to_string(), 0.5 + (i as f64 * 0.1));
1338                    m
1339                },
1340                num_examples: 100,
1341                duration_ms: Some(1000.0),
1342                label_shift: None,
1343                robustness: None,
1344                stratified: None,
1345                confidence_intervals: None,
1346                kb_version: None,
1347            };
1348            history.append_result(&result).expect("append failed");
1349        }
1350
1351        let best = history
1352            .query_best("test-backend", None, 3)
1353            .expect("query failed");
1354        assert_eq!(best.len(), 3);
1355        // Should be sorted by F1 descending
1356        assert!(best[0].f1.unwrap() > best[1].f1.unwrap());
1357        assert!(best[1].f1.unwrap() > best[2].f1.unwrap());
1358    }
1359
1360    #[test]
1361    fn test_backends_and_datasets() {
1362        let temp = TempDir::new().expect("failed to create temp dir");
1363        let jsonl_path = temp.path().join("history.jsonl");
1364
1365        let history = EvalHistory::new(&jsonl_path).expect("failed to create history");
1366
1367        // Add results with different backends and datasets
1368        let backends = ["backend-a", "backend-b", "backend-c"];
1369
1370        for backend in backends.iter() {
1371            let result = TaskEvalResult {
1372                task: crate::eval::task_mapping::Task::NER,
1373                dataset: crate::eval::loader::DatasetId::WikiGold,
1374                backend: backend.to_string(),
1375                backend_display: None,
1376                seed: 42,
1377                success: true,
1378                error: None,
1379                metrics: {
1380                    let mut m = HashMap::new();
1381                    m.insert("f1".to_string(), 0.8);
1382                    m
1383                },
1384                num_examples: 100,
1385                duration_ms: Some(1000.0),
1386                label_shift: None,
1387                robustness: None,
1388                stratified: None,
1389                confidence_intervals: None,
1390                kb_version: None,
1391            };
1392            history.append_result(&result).expect("append failed");
1393        }
1394
1395        let backends_list = history.backends().expect("backends failed");
1396        assert_eq!(backends_list.len(), 3);
1397        assert!(backends_list.contains(&"backend-a".to_string()));
1398
1399        let datasets_list = history.datasets().expect("datasets failed");
1400        assert!(!datasets_list.is_empty());
1401    }
1402
1403    #[test]
1404    fn test_rebuild_index() {
1405        let temp = TempDir::new().expect("failed to create temp dir");
1406        let jsonl_path = temp.path().join("history.jsonl");
1407
1408        let history = EvalHistory::new(&jsonl_path).expect("failed to create history");
1409
1410        // Add a result
1411        let result = TaskEvalResult {
1412            task: crate::eval::task_mapping::Task::NER,
1413            dataset: crate::eval::loader::DatasetId::WikiGold,
1414            backend: "test-backend".to_string(),
1415            backend_display: None,
1416            seed: 42,
1417            success: true,
1418            error: None,
1419            metrics: {
1420                let mut m = HashMap::new();
1421                m.insert("f1".to_string(), 0.85);
1422                m
1423            },
1424            num_examples: 100,
1425            duration_ms: Some(1000.0),
1426            label_shift: None,
1427            robustness: None,
1428            stratified: None,
1429            confidence_intervals: None,
1430            kb_version: None,
1431        };
1432        history.append_result(&result).expect("append failed");
1433
1434        // Rebuild index
1435        history.rebuild_index().expect("rebuild failed");
1436
1437        // Verify data is still accessible
1438        let stats = history.stats().expect("stats failed");
1439        assert_eq!(stats.total_entries, 1);
1440    }
1441}