1use crate::format::{self, Format};
28use crate::loader::load_session;
29use crate::timeline::{SessionTotals, Step, compute_session_totals, compute_tool_stats};
30use anyhow::{Result, anyhow};
31use rayon::prelude::*;
32use serde::Serialize;
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Filter {
40 Model(String),
42 Tool(String),
44 Errored,
47 Annotated,
50}
51
52impl Filter {
53 pub fn parse(s: &str) -> Result<Self> {
56 let s = s.trim();
57 if s.eq_ignore_ascii_case("errored") {
58 return Ok(Filter::Errored);
59 }
60 if s.eq_ignore_ascii_case("annotated") {
61 return Ok(Filter::Annotated);
62 }
63 let (key, value) = s.split_once('=').ok_or_else(|| {
64 anyhow!("--filter expects `key=value`, `errored`, or `annotated`, got `{s}`")
65 })?;
66 match key.trim() {
67 "model" => Ok(Filter::Model(value.trim().to_string())),
68 "tool" => Ok(Filter::Tool(value.trim().to_string())),
69 other => Err(anyhow!(
70 "unknown --filter key `{other}` (expected `model`, `tool`, `errored`, or `annotated`)"
71 )),
72 }
73 }
74
75 fn matches(&self, parsed: &ParsedSession) -> bool {
76 match self {
77 Filter::Model(m) => parsed.totals.unique_models.iter().any(|s| s == m),
78 Filter::Tool(t) => parsed
79 .tool_stats
80 .iter()
81 .any(|s| s.name.eq_ignore_ascii_case(t)),
82 Filter::Errored => parsed.tool_stats.iter().any(|s| s.error_count > 0),
83 Filter::Annotated => parsed.annotation_count > 0,
84 }
85 }
86}
87
88#[derive(Debug, Serialize)]
97pub struct ParsedSession {
98 pub path: PathBuf,
99 pub format: Format,
100 pub totals: SessionTotals,
101 pub tool_stats: Vec<crate::timeline::ToolStats>,
102 pub step_count: usize,
103 pub mtime_secs: Option<u64>,
107 pub annotation_count: usize,
111 pub fork_root_count: usize,
116}
117
118#[derive(Debug)]
119pub struct ParseError {
120 pub path: PathBuf,
121 pub error: anyhow::Error,
122}
123
124pub fn discover_files(root: &Path, max_depth: usize) -> Vec<PathBuf> {
129 let mut out = Vec::new();
130 walk(root, max_depth, &mut out);
131 out
132}
133
134fn walk(root: &Path, max_depth: usize, out: &mut Vec<PathBuf>) {
135 if max_depth == 0 {
136 return;
137 }
138 let Ok(entries) = std::fs::read_dir(root) else {
139 return;
140 };
141 for entry in entries.flatten() {
142 let path = entry.path();
143 if path.is_dir() {
144 walk(&path, max_depth - 1, out);
145 } else if path.is_file() {
146 out.push(path);
147 }
148 }
149}
150
151enum LoadOutcome {
162 Ok(Format, Vec<Step>),
163 Skip,
167 Err(anyhow::Error),
168}
169
170type RawLoad = (PathBuf, LoadOutcome);
173
174pub fn load_parallel(paths: &[PathBuf]) -> (Vec<ParsedSession>, Vec<ParseError>) {
175 let raw: Vec<RawLoad> = if std::env::var_os("AGX_CORPUS_SERIAL").is_some() {
176 paths.iter().map(|p| (p.clone(), load_one(p))).collect()
177 } else {
178 paths.par_iter().map(|p| (p.clone(), load_one(p))).collect()
179 };
180
181 let mut parsed = Vec::new();
182 let mut errors = Vec::new();
183 for (path, result) in raw {
184 match result {
185 LoadOutcome::Ok(fmt, steps) => {
186 let totals = compute_session_totals(&steps);
187 let tool_stats = compute_tool_stats(&steps);
188 let mtime_secs = std::fs::metadata(&path)
189 .and_then(|m| m.modified())
190 .ok()
191 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
192 .map(|d| d.as_secs());
193 let annotation_count = crate::annotations::Annotations::load_for(&path).notes.len();
199 let fork_root_count = crate::timeline::fork_root_count(&steps);
204 parsed.push(ParsedSession {
205 path,
206 format: fmt,
207 totals,
208 tool_stats,
209 step_count: steps.len(),
210 mtime_secs,
211 annotation_count,
212 fork_root_count,
213 });
214 }
215 LoadOutcome::Skip => {}
216 LoadOutcome::Err(error) => errors.push(ParseError { path, error }),
217 }
218 }
219 (parsed, errors)
220}
221
222fn load_one(path: &Path) -> LoadOutcome {
223 let fmt = match format::detect(path) {
226 Ok(f) => f,
227 Err(_) => return LoadOutcome::Skip,
228 };
229 match load_session(path) {
230 Ok(steps) => LoadOutcome::Ok(fmt, steps),
231 Err(e) => {
232 if fmt == Format::OtelProto && !cfg!(feature = "otel-proto") {
242 return LoadOutcome::Skip;
243 }
244 LoadOutcome::Err(e)
245 }
246 }
247}
248
249#[derive(Debug, Default, Serialize)]
251pub struct CorpusStats {
252 pub file_count: usize,
253 pub parse_success_count: usize,
254 pub parse_error_count: usize,
255 pub filtered_out_count: usize,
256 pub total_steps: usize,
257 pub total_tokens_in: u64,
258 pub total_tokens_out: u64,
259 pub total_cache_read: u64,
260 pub total_cache_create: u64,
261 pub total_cost_usd: Option<f64>,
262 pub per_model: Vec<ModelBucket>,
263 pub per_tool: Vec<ToolBucket>,
264 pub per_format: Vec<FormatBucket>,
265}
266
267#[derive(Debug, Default, Serialize)]
268pub struct ModelBucket {
269 pub model: String,
270 pub session_count: usize,
271 pub tokens_in: u64,
272 pub tokens_out: u64,
273 pub cost_usd: Option<f64>,
274}
275
276#[derive(Debug, Default, Serialize)]
277pub struct ToolBucket {
278 pub tool: String,
279 pub use_count: usize,
280 pub error_count: usize,
281 pub session_count: usize,
282}
283
284#[derive(Debug, Default, Serialize)]
285pub struct FormatBucket {
286 pub format: String,
287 pub session_count: usize,
288}
289
290pub fn aggregate(
292 parsed: &[ParsedSession],
293 errors: &[ParseError],
294 file_count: usize,
295 filtered_out: usize,
296) -> CorpusStats {
297 let mut stats = CorpusStats {
298 file_count,
299 parse_success_count: parsed.len(),
300 parse_error_count: errors.len(),
301 filtered_out_count: filtered_out,
302 ..CorpusStats::default()
303 };
304
305 let mut model_map: HashMap<String, ModelBucket> = HashMap::new();
306 let mut tool_map: HashMap<String, ToolBucket> = HashMap::new();
307 let mut format_map: HashMap<String, usize> = HashMap::new();
308 let mut any_cost: Option<f64> = None;
309
310 for session in parsed {
311 stats.total_steps += session.step_count;
312 stats.total_tokens_in += session.totals.tokens_in;
313 stats.total_tokens_out += session.totals.tokens_out;
314 stats.total_cache_read += session.totals.cache_read;
315 stats.total_cache_create += session.totals.cache_create;
316 if let Some(c) = session.totals.cost_usd {
317 any_cost = Some(any_cost.unwrap_or(0.0) + c);
318 }
319
320 *format_map.entry(session.format.to_string()).or_insert(0) += 1;
321
322 for model in &session.totals.unique_models {
328 let bucket = model_map
329 .entry(model.clone())
330 .or_insert_with(|| ModelBucket {
331 model: model.clone(),
332 ..ModelBucket::default()
333 });
334 bucket.session_count += 1;
335 bucket.tokens_in += session.totals.tokens_in;
336 bucket.tokens_out += session.totals.tokens_out;
337 if let Some(c) = session.totals.cost_usd {
338 bucket.cost_usd = Some(bucket.cost_usd.unwrap_or(0.0) + c);
339 }
340 }
341
342 for tool in &session.tool_stats {
343 let bucket = tool_map
344 .entry(tool.name.clone())
345 .or_insert_with(|| ToolBucket {
346 tool: tool.name.clone(),
347 ..ToolBucket::default()
348 });
349 bucket.use_count += tool.use_count;
350 bucket.error_count += tool.error_count;
351 bucket.session_count += 1;
352 }
353 }
354
355 stats.total_cost_usd = any_cost;
356
357 let mut models: Vec<ModelBucket> = model_map.into_values().collect();
358 models.sort_by(|a, b| {
359 b.session_count
360 .cmp(&a.session_count)
361 .then_with(|| a.model.cmp(&b.model))
362 });
363 stats.per_model = models;
364
365 let mut tools: Vec<ToolBucket> = tool_map.into_values().collect();
366 tools.sort_by(|a, b| {
367 b.use_count
368 .cmp(&a.use_count)
369 .then_with(|| a.tool.cmp(&b.tool))
370 });
371 stats.per_tool = tools;
372
373 let mut formats: Vec<FormatBucket> = format_map
374 .into_iter()
375 .map(|(format, session_count)| FormatBucket {
376 format,
377 session_count,
378 })
379 .collect();
380 formats.sort_by(|a, b| {
381 b.session_count
382 .cmp(&a.session_count)
383 .then_with(|| a.format.cmp(&b.format))
384 });
385 stats.per_format = formats;
386
387 stats
388}
389
390#[derive(Debug, Default, Clone, Serialize)]
395pub struct Distribution {
396 pub min: u64,
397 pub p50: u64,
398 pub p90: u64,
399 pub p99: u64,
400 pub max: u64,
401 pub mean: f64,
402 pub total: u64,
403}
404
405impl Distribution {
406 fn from_values(values: &[u64]) -> Self {
411 if values.is_empty() {
412 return Self::default();
413 }
414 let mut v = values.to_vec();
415 v.sort_unstable();
416 let n = v.len();
417 let pick = |p: f64| -> u64 {
418 let idx = ((n as f64) * p).ceil() as usize;
422 let idx = idx.saturating_sub(1).min(n - 1);
423 v[idx]
424 };
425 let total: u64 = v.iter().sum();
426 #[allow(clippy::cast_precision_loss)]
427 let mean = total as f64 / n as f64;
428 Self {
429 min: v[0],
430 p50: pick(0.50),
431 p90: pick(0.90),
432 p99: pick(0.99),
433 max: v[n - 1],
434 mean,
435 total,
436 }
437 }
438}
439
440#[derive(Debug, Default, Clone, Serialize)]
444pub struct TrajectoryStats {
445 pub session_count: usize,
446 pub steps_per_session: Distribution,
447 pub tool_calls_per_session: Distribution,
448 pub tokens_in_per_session: Distribution,
449 pub tokens_out_per_session: Distribution,
450 pub branched_rate: f64,
454 pub annotated_rate: f64,
456 pub errored_rate: f64,
459}
460
461#[allow(clippy::cast_precision_loss)]
470fn rate(n: usize, total: usize) -> f64 {
471 n as f64 / total as f64
472}
473
474pub fn compute_trajectory_stats(parsed: &[ParsedSession]) -> TrajectoryStats {
475 let session_count = parsed.len();
476 if session_count == 0 {
477 return TrajectoryStats::default();
478 }
479 let steps: Vec<u64> = parsed.iter().map(|p| p.step_count as u64).collect();
480 let tool_calls: Vec<u64> = parsed
481 .iter()
482 .map(|p| p.tool_stats.iter().map(|t| t.use_count as u64).sum())
483 .collect();
484 let tokens_in: Vec<u64> = parsed.iter().map(|p| p.totals.tokens_in).collect();
485 let tokens_out: Vec<u64> = parsed.iter().map(|p| p.totals.tokens_out).collect();
486 let branched = rate(
487 parsed.iter().filter(|p| p.fork_root_count > 0).count(),
488 session_count,
489 );
490 let annotated = rate(
491 parsed.iter().filter(|p| p.annotation_count > 0).count(),
492 session_count,
493 );
494 let errored = rate(
495 parsed
496 .iter()
497 .filter(|p| p.tool_stats.iter().any(|t| t.error_count > 0))
498 .count(),
499 session_count,
500 );
501 TrajectoryStats {
502 session_count,
503 steps_per_session: Distribution::from_values(&steps),
504 tool_calls_per_session: Distribution::from_values(&tool_calls),
505 tokens_in_per_session: Distribution::from_values(&tokens_in),
506 tokens_out_per_session: Distribution::from_values(&tokens_out),
507 branched_rate: branched,
508 annotated_rate: annotated,
509 errored_rate: errored,
510 }
511}
512
513fn print_trajectory_stats_text(stats: &TrajectoryStats) {
514 println!("Trajectory stats — {} sessions", stats.session_count);
515 if stats.session_count == 0 {
516 println!(" (no sessions after filter / sample)");
517 return;
518 }
519 let row = |label: &str, d: &Distribution| {
520 println!(
521 " {label:<22} min={:>8} p50={:>8} p90={:>8} p99={:>8} max={:>8} mean={:>10.1} total={:>12}",
522 d.min, d.p50, d.p90, d.p99, d.max, d.mean, d.total
523 );
524 };
525 row("steps/session", &stats.steps_per_session);
526 row("tool_calls/session", &stats.tool_calls_per_session);
527 row("tokens_in/session", &stats.tokens_in_per_session);
528 row("tokens_out/session", &stats.tokens_out_per_session);
529 println!();
530 println!(
531 " branched: {:>5.1}% annotated: {:>5.1}% errored: {:>5.1}%",
532 stats.branched_rate * 100.0,
533 stats.annotated_rate * 100.0,
534 stats.errored_rate * 100.0
535 );
536}
537
538#[derive(Debug)]
540pub struct CorpusArgs {
541 pub dir: PathBuf,
542 pub filters: Vec<Filter>,
543 pub json: bool,
544 pub no_cost: bool,
545 pub max_depth: usize,
546 pub bench: bool,
549 pub tui: bool,
554 pub jsonl: bool,
559 pub fail_on_errored: bool,
563 pub trajectory_stats: bool,
570 pub sample: Option<usize>,
575}
576
577pub type TuiLauncher<'a> = dyn Fn(Vec<ParsedSession>, &CorpusStats, bool) -> Result<()> + 'a;
584
585pub fn no_tui(_parsed: Vec<ParsedSession>, _stats: &CorpusStats, _no_cost: bool) -> Result<()> {
589 anyhow::bail!("this agx-core caller does not provide a corpus TUI implementation");
590}
591
592pub fn run(args: &CorpusArgs, tui_launcher: &TuiLauncher<'_>) -> Result<()> {
598 use std::time::Instant;
599 let t_walk = Instant::now();
600 let files = discover_files(&args.dir, args.max_depth);
601 let file_count = files.len();
602 let walk_ms = t_walk.elapsed().as_secs_f64() * 1000.0;
603
604 let t_load = Instant::now();
605 let (mut parsed, errors) = load_parallel(&files);
606 let load_ms = t_load.elapsed().as_secs_f64() * 1000.0;
607
608 let t_agg = Instant::now();
609 let before_filter = parsed.len();
610 if !args.filters.is_empty() {
611 parsed.retain(|p| args.filters.iter().all(|f| f.matches(p)));
612 }
613 let filtered_out = before_filter - parsed.len();
614 if let Some(n) = args.sample
622 && parsed.len() > n
623 {
624 parsed.sort_by_key(|p| std::cmp::Reverse(p.mtime_secs));
625 parsed.truncate(n);
626 }
627 let stats = aggregate(&parsed, &errors, file_count, filtered_out);
628 let agg_ms = t_agg.elapsed().as_secs_f64() * 1000.0;
629
630 let parse_error_count = errors.len();
638 let tool_error_count: usize = parsed
639 .iter()
640 .flat_map(|p| p.tool_stats.iter())
641 .map(|t| t.error_count)
642 .sum();
643 let fail_on_errored = args.fail_on_errored && (parse_error_count > 0 || tool_error_count > 0);
644
645 if args.trajectory_stats {
646 let tstats = compute_trajectory_stats(&parsed);
651 if args.jsonl {
652 print_jsonl(&parsed, &errors);
655 eprintln!("{}", serde_json::to_string_pretty(&tstats)?);
656 } else if args.json {
657 println!("{}", serde_json::to_string_pretty(&tstats)?);
658 } else {
659 print_trajectory_stats_text(&tstats);
660 }
661 } else if args.tui {
662 tui_launcher(parsed, &stats, args.no_cost)?;
668 } else if args.jsonl {
669 print_jsonl(&parsed, &errors);
670 } else if args.json {
671 println!("{}", serde_json::to_string_pretty(&stats)?);
672 } else {
673 print_text_summary(&stats, &args.dir, args.no_cost, &errors);
674 }
675
676 if fail_on_errored {
677 anyhow::bail!(
678 "--fail-on-errored: {parse_error_count} parse error(s), \
679 {tool_error_count} tool-error result(s) detected",
680 );
681 }
682
683 if args.bench {
684 eprintln!(
685 "[bench] walk: {:.2}ms ({} files) load: {:.2}ms ({} parsed, {} errored) aggregate: {:.2}ms total: {:.2}ms",
686 walk_ms,
687 file_count,
688 load_ms,
689 stats.parse_success_count,
690 stats.parse_error_count,
691 agg_ms,
692 walk_ms + load_ms + agg_ms,
693 );
694 }
695 Ok(())
696}
697
698fn print_text_summary(stats: &CorpusStats, dir: &Path, no_cost: bool, errors: &[ParseError]) {
699 println!("agx corpus {}", dir.display());
700 println!(
701 " {} files scanned; {} parsed; {} errored; {} filtered out",
702 stats.file_count,
703 stats.parse_success_count,
704 stats.parse_error_count,
705 stats.filtered_out_count,
706 );
707 if stats.parse_success_count == 0 {
708 println!(" (no sessions to aggregate)");
709 return;
710 }
711 println!(
712 " Total: {} steps, {} input tokens, {} output, {} cache_read, {} cache_create",
713 stats.total_steps,
714 stats.total_tokens_in,
715 stats.total_tokens_out,
716 stats.total_cache_read,
717 stats.total_cache_create,
718 );
719 if !no_cost {
720 match stats.total_cost_usd {
721 Some(c) => println!(" Estimated cost: ${c:.4} USD"),
722 None if stats.total_tokens_in > 0 || stats.total_tokens_out > 0 => {
723 println!(" Estimated cost: (no priced models detected)");
724 }
725 None => {}
726 }
727 }
728
729 if !stats.per_format.is_empty() {
730 println!("\nBy format:");
731 for f in &stats.per_format {
732 println!(" {:<32} {}", f.format, f.session_count);
733 }
734 }
735
736 if !stats.per_model.is_empty() {
737 println!("\nTop models:");
738 for m in stats.per_model.iter().take(10) {
739 let cost = match m.cost_usd {
740 Some(c) if !no_cost => format!(" ${c:.4}"),
741 _ => String::new(),
742 };
743 println!(
744 " {:<28} {:>4} sess {:>10} in {:>10} out{}",
745 m.model, m.session_count, m.tokens_in, m.tokens_out, cost,
746 );
747 }
748 }
749
750 if !stats.per_tool.is_empty() {
751 println!("\nTop tools:");
752 for t in stats.per_tool.iter().take(10) {
753 let err_pct = if t.use_count > 0 {
754 #[allow(clippy::cast_precision_loss)]
755 let r = t.error_count as f64 / t.use_count as f64;
756 format!("({:.1}% err)", r * 100.0)
757 } else {
758 String::new()
759 };
760 println!(
761 " {:<28} {:>5} uses {:>4} errors {}",
762 t.tool, t.use_count, t.error_count, err_pct,
763 );
764 }
765 }
766
767 if !errors.is_empty() {
768 println!("\nParse errors (first {}):", errors.len().min(5));
769 for err in errors.iter().take(5) {
770 println!(" {}: {}", err.path.display(), err.error);
771 }
772 if errors.len() > 5 {
773 println!(" ... ({} more)", errors.len() - 5);
774 }
775 }
776}
777
778#[derive(serde::Serialize)]
782struct SessionLine {
783 path: String,
784 format: String,
785 step_count: usize,
786 tokens_in: u64,
787 tokens_out: u64,
788 cache_read: u64,
789 cache_create: u64,
790 cost_usd: Option<f64>,
791 models: Vec<String>,
792 tool_counts: Vec<ToolLine>,
793 error_count: usize,
794 annotation_count: usize,
795 fork_root_count: usize,
796 mtime_secs: Option<u64>,
797}
798
799#[derive(serde::Serialize)]
800struct ToolLine {
801 name: String,
802 use_count: usize,
803 error_count: usize,
804}
805
806fn session_to_line(s: &ParsedSession) -> SessionLine {
807 SessionLine {
808 path: s.path.display().to_string(),
809 format: s.format.to_string(),
810 step_count: s.step_count,
811 tokens_in: s.totals.tokens_in,
812 tokens_out: s.totals.tokens_out,
813 cache_read: s.totals.cache_read,
814 cache_create: s.totals.cache_create,
815 cost_usd: s.totals.cost_usd,
816 models: s.totals.unique_models.clone(),
817 tool_counts: s
818 .tool_stats
819 .iter()
820 .map(|t| ToolLine {
821 name: t.name.clone(),
822 use_count: t.use_count,
823 error_count: t.error_count,
824 })
825 .collect(),
826 error_count: s.tool_stats.iter().map(|t| t.error_count).sum(),
827 annotation_count: s.annotation_count,
828 fork_root_count: s.fork_root_count,
829 mtime_secs: s.mtime_secs,
830 }
831}
832
833fn print_jsonl(parsed: &[ParsedSession], errors: &[ParseError]) {
834 for session in parsed {
837 let line = session_to_line(session);
838 match serde_json::to_string(&line) {
839 Ok(s) => println!("{s}"),
840 Err(e) => eprintln!("agx: failed to serialize session line: {e}"),
841 }
842 }
843 for err in errors {
846 eprintln!("agx: parse error: {}: {}", err.path.display(), err.error);
847 }
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853 use crate::timeline::{
854 ToolStats, assistant_text_step, tool_result_step, tool_use_step, user_text_step,
855 };
856
857 fn mk_session(path: &str, fmt: Format, steps: Vec<Step>) -> ParsedSession {
858 let totals = compute_session_totals(&steps);
859 let tool_stats = compute_tool_stats(&steps);
860 ParsedSession {
861 path: PathBuf::from(path),
862 format: fmt,
863 step_count: steps.len(),
864 totals,
865 tool_stats,
866 mtime_secs: None,
867 annotation_count: 0,
868 fork_root_count: 0,
869 }
870 }
871
872 fn priced_session(model: &str) -> Vec<Step> {
873 let mut a = assistant_text_step("hi");
874 a.model = Some(model.into());
875 a.tokens_in = Some(100);
876 a.tokens_out = Some(50);
877 vec![user_text_step("q"), a]
878 }
879
880 #[test]
881 fn filter_parse_accepts_all_forms() {
882 assert_eq!(
883 Filter::parse("model=claude-opus-4-6").unwrap(),
884 Filter::Model("claude-opus-4-6".into())
885 );
886 assert_eq!(
887 Filter::parse("tool=Bash").unwrap(),
888 Filter::Tool("Bash".into())
889 );
890 assert_eq!(Filter::parse("errored").unwrap(), Filter::Errored);
891 assert_eq!(Filter::parse(" errored ").unwrap(), Filter::Errored);
892 assert_eq!(
893 Filter::parse(" model = gpt-5 ").unwrap(),
894 Filter::Model("gpt-5".into())
895 );
896 }
897
898 #[test]
899 fn filter_parse_rejects_unknown_key() {
900 assert!(Filter::parse("foo=bar").is_err());
901 }
902
903 #[test]
904 fn filter_parse_rejects_bare_word() {
905 assert!(Filter::parse("not-a-thing").is_err());
906 }
907
908 #[test]
909 fn filter_model_matches_session_with_that_model() {
910 let s = mk_session("a", Format::ClaudeCode, priced_session("claude-opus-4-6"));
911 assert!(Filter::Model("claude-opus-4-6".into()).matches(&s));
912 assert!(!Filter::Model("gpt-5".into()).matches(&s));
913 }
914
915 #[test]
916 fn filter_tool_matches_case_insensitive() {
917 let steps = vec![
918 user_text_step("q"),
919 tool_use_step("t1", "Bash", "{}"),
920 tool_result_step("t1", "ok", Some("Bash"), Some("{}")),
921 ];
922 let s = mk_session("a", Format::ClaudeCode, steps);
923 assert!(Filter::Tool("Bash".into()).matches(&s));
924 assert!(Filter::Tool("bash".into()).matches(&s));
925 assert!(!Filter::Tool("Write".into()).matches(&s));
926 }
927
928 #[test]
929 fn filter_errored_matches_session_with_error_result() {
930 let steps = vec![
931 tool_use_step("t1", "Bash", "{}"),
932 tool_result_step("t1", "error: command failed", Some("Bash"), Some("{}")),
933 ];
934 let s = mk_session("a", Format::ClaudeCode, steps);
935 assert!(Filter::Errored.matches(&s));
936 }
937
938 #[test]
939 fn filter_errored_does_not_match_clean_session() {
940 let steps = vec![
941 tool_use_step("t1", "Bash", "{}"),
942 tool_result_step("t1", "success", Some("Bash"), Some("{}")),
943 ];
944 let s = mk_session("a", Format::ClaudeCode, steps);
945 assert!(!Filter::Errored.matches(&s));
946 }
947
948 #[test]
949 fn aggregate_sums_tokens_across_sessions() {
950 let sessions = vec![
951 mk_session("a", Format::ClaudeCode, priced_session("claude-opus-4-6")),
952 mk_session("b", Format::Codex, priced_session("gpt-5")),
953 ];
954 let stats = aggregate(&sessions, &[], 2, 0);
955 assert_eq!(stats.parse_success_count, 2);
956 assert_eq!(stats.total_tokens_in, 200);
957 assert_eq!(stats.total_tokens_out, 100);
958 assert!(stats.total_cost_usd.is_some());
959 assert_eq!(stats.per_format.len(), 2);
961 assert_eq!(stats.per_model.len(), 2);
962 assert!(stats.per_tool.is_empty());
963 }
964
965 #[test]
966 fn aggregate_per_model_sorts_by_session_count_desc() {
967 let sessions = vec![
968 mk_session("a", Format::ClaudeCode, priced_session("gpt-5")),
969 mk_session("b", Format::ClaudeCode, priced_session("gpt-5")),
970 mk_session("c", Format::ClaudeCode, priced_session("claude-opus-4-6")),
971 ];
972 let stats = aggregate(&sessions, &[], 3, 0);
973 assert_eq!(stats.per_model[0].model, "gpt-5");
974 assert_eq!(stats.per_model[0].session_count, 2);
975 assert_eq!(stats.per_model[1].model, "claude-opus-4-6");
976 }
977
978 #[test]
979 fn aggregate_per_tool_sums_use_and_error_counts() {
980 let s1 = mk_session(
981 "a",
982 Format::ClaudeCode,
983 vec![
984 tool_use_step("t1", "Bash", "{}"),
985 tool_result_step("t1", "ok", Some("Bash"), Some("{}")),
986 ],
987 );
988 let s2 = mk_session(
989 "b",
990 Format::ClaudeCode,
991 vec![
992 tool_use_step("t2", "Bash", "{}"),
993 tool_result_step("t2", "error: failed", Some("Bash"), Some("{}")),
994 ],
995 );
996 let stats = aggregate(&[s1, s2], &[], 2, 0);
997 assert_eq!(stats.per_tool.len(), 1);
998 assert_eq!(stats.per_tool[0].tool, "Bash");
999 assert_eq!(stats.per_tool[0].use_count, 2);
1000 assert_eq!(stats.per_tool[0].error_count, 1);
1001 }
1002
1003 #[test]
1004 fn aggregate_empty_input_returns_zeros() {
1005 let stats = aggregate(&[], &[], 0, 0);
1006 assert_eq!(stats.parse_success_count, 0);
1007 assert_eq!(stats.total_tokens_in, 0);
1008 assert_eq!(stats.total_cost_usd, None);
1009 assert!(stats.per_model.is_empty());
1010 assert!(stats.per_tool.is_empty());
1011 }
1012
1013 #[test]
1014 fn aggregate_counts_filtered_and_errored() {
1015 let sessions = vec![mk_session("a", Format::ClaudeCode, priced_session("gpt-5"))];
1016 let errors = vec![ParseError {
1017 path: PathBuf::from("bad.jsonl"),
1018 error: anyhow!("format drift"),
1019 }];
1020 let stats = aggregate(&sessions, &errors, 5, 3);
1021 assert_eq!(stats.file_count, 5);
1022 assert_eq!(stats.parse_success_count, 1);
1023 assert_eq!(stats.parse_error_count, 1);
1024 assert_eq!(stats.filtered_out_count, 3);
1025 }
1026
1027 #[test]
1028 fn tool_bucket_ordering_is_stable_on_ties() {
1029 let sessions = vec![
1031 mk_session(
1032 "a",
1033 Format::ClaudeCode,
1034 vec![tool_use_step("t1", "Zebra", "{}")],
1035 ),
1036 mk_session(
1037 "b",
1038 Format::ClaudeCode,
1039 vec![tool_use_step("t2", "Apple", "{}")],
1040 ),
1041 ];
1042 let stats = aggregate(&sessions, &[], 2, 0);
1043 assert_eq!(stats.per_tool[0].tool, "Apple");
1044 assert_eq!(stats.per_tool[1].tool, "Zebra");
1045 }
1046
1047 #[test]
1048 fn unused_tool_stats_type_reference() {
1049 let _ = ToolStats {
1052 name: "x".into(),
1053 use_count: 0,
1054 result_count: 0,
1055 error_count: 0,
1056 };
1057 }
1058
1059 #[test]
1062 fn distribution_empty_slice_is_all_zero() {
1063 let d = Distribution::from_values(&[]);
1064 assert_eq!(d.min, 0);
1065 assert_eq!(d.max, 0);
1066 assert_eq!(d.mean, 0.0);
1067 assert_eq!(d.total, 0);
1068 }
1069
1070 #[test]
1071 fn distribution_single_value() {
1072 let d = Distribution::from_values(&[42]);
1073 assert_eq!(d.min, 42);
1074 assert_eq!(d.p50, 42);
1075 assert_eq!(d.p90, 42);
1076 assert_eq!(d.p99, 42);
1077 assert_eq!(d.max, 42);
1078 assert!((d.mean - 42.0).abs() < 1e-6);
1079 assert_eq!(d.total, 42);
1080 }
1081
1082 #[test]
1083 fn distribution_percentiles_on_ordered_integers() {
1084 let values: Vec<u64> = (1..=100).collect();
1086 let d = Distribution::from_values(&values);
1087 assert_eq!(d.min, 1);
1088 assert_eq!(d.max, 100);
1089 assert_eq!(d.p50, 50);
1090 assert_eq!(d.p90, 90);
1091 assert_eq!(d.p99, 99);
1092 assert_eq!(d.total, 5050);
1093 assert!((d.mean - 50.5).abs() < 1e-6);
1094 }
1095
1096 #[test]
1097 fn distribution_handles_unsorted_input() {
1098 let a = Distribution::from_values(&[5, 1, 3, 2, 4]);
1100 let b = Distribution::from_values(&[1, 2, 3, 4, 5]);
1101 assert_eq!(a.min, b.min);
1102 assert_eq!(a.max, b.max);
1103 assert_eq!(a.p50, b.p50);
1104 assert_eq!(a.total, b.total);
1105 }
1106
1107 #[test]
1108 fn trajectory_stats_empty_corpus() {
1109 let stats = compute_trajectory_stats(&[]);
1110 assert_eq!(stats.session_count, 0);
1111 assert_eq!(stats.branched_rate, 0.0);
1112 assert_eq!(stats.annotated_rate, 0.0);
1113 assert_eq!(stats.errored_rate, 0.0);
1114 }
1115
1116 #[test]
1117 fn trajectory_stats_branched_rate_counts_fork_roots() {
1118 let a = {
1119 let mut s = mk_session("a.jsonl", Format::ClaudeCode, Vec::new());
1120 s.fork_root_count = 2;
1121 s
1122 };
1123 let b = mk_session("b.jsonl", Format::ClaudeCode, Vec::new());
1124 let c = {
1125 let mut s = mk_session("c.jsonl", Format::Gemini, Vec::new());
1126 s.fork_root_count = 1;
1127 s
1128 };
1129 let stats = compute_trajectory_stats(&[a, b, c]);
1130 assert_eq!(stats.session_count, 3);
1131 assert!((stats.branched_rate - 2.0 / 3.0).abs() < 1e-6);
1133 }
1134
1135 #[test]
1136 fn trajectory_stats_annotated_rate_counts_annotation_count() {
1137 let mut a = mk_session("a", Format::ClaudeCode, Vec::new());
1138 a.annotation_count = 3;
1139 let b = mk_session("b", Format::ClaudeCode, Vec::new());
1140 let stats = compute_trajectory_stats(&[a, b]);
1141 assert!((stats.annotated_rate - 0.5).abs() < 1e-6);
1142 }
1143
1144 #[test]
1145 fn trajectory_stats_errored_rate_counts_sessions_not_errors() {
1146 let a = mk_session(
1149 "a",
1150 Format::ClaudeCode,
1151 vec![crate::timeline::tool_result_step(
1152 "t1",
1153 "error: bad",
1154 Some("X"),
1155 None,
1156 )],
1157 );
1158 let b = mk_session("b", Format::ClaudeCode, Vec::new());
1160 let stats = compute_trajectory_stats(&[a, b]);
1161 assert!((stats.errored_rate - 0.5).abs() < 1e-6);
1162 }
1163
1164 #[test]
1165 fn trajectory_stats_steps_distribution_reflects_step_counts() {
1166 let a = mk_session(
1167 "a",
1168 Format::ClaudeCode,
1169 vec![
1170 crate::timeline::user_text_step("one"),
1171 crate::timeline::assistant_text_step("two"),
1172 ],
1173 );
1174 let b = mk_session(
1175 "b",
1176 Format::ClaudeCode,
1177 vec![crate::timeline::user_text_step("solo")],
1178 );
1179 let stats = compute_trajectory_stats(&[a, b]);
1180 assert_eq!(stats.steps_per_session.min, 1);
1181 assert_eq!(stats.steps_per_session.max, 2);
1182 assert_eq!(stats.steps_per_session.total, 3);
1183 }
1184}