1use 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
36fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EvalHistoryEntry {
63 pub timestamp: String,
65 pub backend: String,
67 pub dataset: String,
69 pub task: String,
71 pub seed: u64,
73 pub f1: Option<f64>,
75 pub precision: Option<f64>,
77 pub recall: Option<f64>,
79 pub n: usize,
81 pub duration_ms: Option<f64>,
83 pub error: Option<String>,
85 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
112pub struct EvalHistory {
116 jsonl_path: PathBuf,
117 sqlite_path: Option<PathBuf>,
118}
119
120impl EvalHistory {
121 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 if let Some(parent) = jsonl_path.parent() {
131 std::fs::create_dir_all(parent)?;
132 }
133
134 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 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 pub fn append_result(&self, result: &TaskEvalResult) -> std::io::Result<()> {
155 let entry = EvalHistoryEntry::from(result);
156 self.append_entry(&entry)
157 }
158
159 pub fn append_entry(&self, entry: &EvalHistoryEntry) -> std::io::Result<()> {
164 self.append_jsonl(entry)?;
166
167 if let Some(ref db_path) = self.sqlite_path {
169 self.insert_sqlite(entry, db_path)?;
170 }
171
172 Ok(())
173 }
174
175 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 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 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 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 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 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 let _ = conn.execute("ALTER TABLE eval_results ADD COLUMN git_commit TEXT", []); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; }
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 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 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 pub fn rebuild_index(&self) -> std::io::Result<()> {
1132 if let Some(ref db_path) = self.sqlite_path {
1133 if db_path.exists() {
1135 std::fs::remove_file(db_path)?;
1136 }
1137
1138 Self::init_sqlite(db_path)?;
1140
1141 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#[derive(Debug, Clone)]
1158pub struct RegressionAlert {
1159 pub backend: String,
1161 pub dataset: String,
1163 pub old_mean: f64,
1165 pub new_mean: f64,
1167 pub drop: f64,
1169 pub n_old: u64,
1171 pub n_new: u64,
1173 pub split_timestamp: String,
1175}
1176
1177#[derive(Debug, Clone)]
1179pub struct HistoryStats {
1180 pub total_entries: usize,
1182 pub by_backend: HashMap<String, usize>,
1184 pub by_dataset: HashMap<String, usize>,
1186 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 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 history.append_result(&result).expect("append failed");
1229
1230 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 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 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 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 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 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 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 history.rebuild_index().expect("rebuild failed");
1436
1437 let stats = history.stats().expect("stats failed");
1439 assert_eq!(stats.total_entries, 1);
1440 }
1441}