//! Aggregation module for benchmark results (v2.4.0 output schema). //! //! Groups [`BenchmarkResult`] records by framework-and-mode, output format, file type, and //! OCR usage (yes/no), then computes percentile-based statistics for each //! group. The output schema (`schema_version: "2.4.0"`) surfaces TF1 and SF1 separately //! with per-fixture rows preserved and split rankings by output format. //! //! # Percentile methodology //! //! All percentiles use the **R-7 interpolation** method (the default in R and //! NumPy) via [`crate::stats::percentile_r7`]. Three percentiles are reported //! per metric: **p50** (median), **p95**, and **p99**. Values that are `NaN` //! or `Inf` after interpolation are sanitized to `0.0` by //! [`crate::stats::sanitize_f64`] so that downstream JSON consumers never //! encounter non-finite floats. //! //! Failed results (non-zero `error_kind`) are excluded from percentile //! calculations but still counted in `total_sample_count` to preserve the //! `success_rate_percent` metric. //! //! # Output format support //! //! Plaintext-only frameworks must NEVER appear in SF1 rankings or quality metrics //! that require layout information. Markdown frameworks appear in all rankings. //! //! # Aggregate key format //! //! Keys in `by_framework_mode` differ by framework family: //! //! - **kreuzberg** (`kreuzberg-*`): `{framework_name}:{mode}` — the output format is already //! encoded in the framework name (e.g. `kreuzberg-markdown-baseline`), so repeating it in //! the key would be redundant. //! - **competitors** (all other frameworks): `{framework}:{output_format}:{mode}` — format is //! not encoded in the name, so the key must carry it explicitly. use crate::stats::{percentile_r7, sanitize_f64}; use crate::types::{BenchmarkResult, DiskSizeInfo, ErrorKind, OutputFormat}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Schema version for the aggregated output format. pub const SCHEMA_VERSION: &str = "2.4.0"; /// Consolidated results using new aggregation format (v2.4.0) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewConsolidatedResults { /// Schema version for this output format pub schema_version: String, /// Aggregated results grouped by framework:output_format:mode combination pub by_framework_mode: HashMap, /// Disk sizes for each framework pub disk_sizes: HashMap, /// Cross-framework comparison rankings pub comparison: ComparisonData, /// Per-fixture results (one row per framework:output_format:execution_mode:fixture_id:ocr) pub per_fixture_results: Vec, /// Metadata about the consolidation pub metadata: ConsolidationMetadata, } /// Per-fixture benchmark result row #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerFixtureRow { /// Framework name pub framework: String, /// Output format (markdown or plaintext) pub output_format: OutputFormat, /// Execution mode (single, batch, etc.) pub execution_mode: String, /// Whether OCR was used pub ocr: bool, /// Fixture ID (e.g., from file path) pub fixture_id: String, /// File type/extension pub file_type: String, /// Total duration in milliseconds pub duration_ms: f64, /// Peak memory usage in MB pub peak_memory_mb: f64, /// Text F1 score (optional) pub f1_text: Option, /// Layout F1 score (optional, only for markdown mode) pub f1_layout: Option, /// Numeric F1 score (optional) pub f1_numeric: Option, /// Overall quality score (optional) pub quality_score: Option, /// Whether extraction was correct (optional) pub correct: Option, /// Whether extraction succeeded pub success: bool, /// Error kind if failed (optional) pub error_kind: Option, } /// Cross-framework comparison rankings and deltas #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComparisonData { /// Frameworks ranked by median duration (fastest first) pub performance_ranking: Vec, /// Frameworks ranked by median throughput (highest first) pub throughput_ranking: Vec, /// Frameworks ranked by median memory usage (lowest first) pub memory_ranking: Vec, /// Frameworks ranked by median CPU usage (lowest first = most efficient) #[serde(default)] pub cpu_ranking: Vec, /// Frameworks ranked by quality score (highest first) pub quality_ranking: Vec, /// PDF-only: frameworks ranked by overall quality score (highest first) #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pdf_quality_ranking: Vec, /// PDF-only: frameworks ranked by text F1 / TF1 (highest first) — markdown only #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pdf_tf1_ranking_markdown: Vec, /// PDF-only: frameworks ranked by text F1 / TF1 (highest first) — plaintext only #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pdf_tf1_ranking_plaintext: Vec, /// PDF-only: frameworks ranked by structural F1 / SF1 (highest first) — markdown only #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pdf_sf1_ranking_markdown: Vec, /// Performance deltas relative to the fastest framework pub deltas_vs_baseline: HashMap, } /// A framework entry in a ranking #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RankedFramework { /// Framework:mode key (e.g., "kreuzberg-markdown-baseline:single" or "docling:markdown:single") pub framework_mode: String, /// Rank (1-based) pub rank: usize, /// The metric value used for ranking pub value: f64, /// Ratio relative to the best in this ranking (1.0 = best) pub relative: f64, } /// Performance deltas relative to baseline (fastest framework) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeltaMetrics { /// Duration delta in ms (positive = slower) pub duration_delta_ms: f64, /// Duration delta as percentage pub duration_delta_percent: f64, /// Throughput delta in MB/s (negative = slower) pub throughput_delta_mbs: f64, /// Throughput delta as percentage pub throughput_delta_percent: f64, /// Memory delta in MB (positive = more) pub memory_delta_mb: f64, /// Memory delta as percentage pub memory_delta_percent: f64, /// CPU delta in percentage points (positive = higher CPU usage) #[serde(default)] pub cpu_delta_pp: f64, /// CPU delta as percentage relative to baseline #[serde(default)] pub cpu_delta_percent: f64, } /// Metadata about the consolidation process #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsolidationMetadata { /// Number of benchmark results included pub total_results: usize, /// Number of unique frameworks pub framework_count: usize, /// Number of unique file types pub file_type_count: usize, /// Timestamp of consolidation pub timestamp: String, } /// Aggregated results for a specific framework, output format, and mode combination #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FrameworkModeAggregation { /// Framework name (base name without mode suffix) pub framework: String, /// Output format (markdown or plaintext) pub output_format: OutputFormat, /// Mode: "single", "batch", "sync", "async" pub mode: String, /// Cold start duration statistics (if available) pub cold_start: Option, /// Results grouped by file type pub by_file_type: HashMap, } /// Aggregated results for a specific file type #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileTypeAggregation { /// File type (extension) pub file_type: String, /// Results without OCR pub no_ocr: Option, /// Results with OCR pub with_ocr: Option, } /// Performance percentiles for a group of results #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerformancePercentiles { /// Number of successful samples used for percentile calculations pub successful_sample_count: usize, /// Total number of samples in this group (including failed) pub total_sample_count: usize, /// Number of framework-side extraction errors (not our fault) pub framework_errors: usize, /// Number of harness-side errors (potentially our fault) pub harness_errors: usize, /// Number of extractions that timed out pub timeouts: usize, /// Number of extractions that returned empty content pub empty_content: usize, /// Unique error messages with occurrence counts #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub error_details: HashMap, /// Throughput percentiles (p50, p95, p99) in MB/s pub throughput: Percentiles, /// Memory percentiles (p50, p95, p99) in MB pub memory: Percentiles, /// Duration percentiles (p50, p95, p99) in ms pub duration: Percentiles, /// CPU usage percentiles (p50, p95, p99) as percentage (0-100, normalized across cores) #[serde(skip_serializing_if = "Option::is_none")] pub cpu: Option, /// Success rate as percentage (0-100) pub success_rate_percent: f64, /// Extraction duration percentiles (p50, p95, p99) in ms #[serde(skip_serializing_if = "Option::is_none")] pub extraction_duration: Option, /// Quality score percentiles (p50, p95, p99) — 0.0 to 1.0 #[serde(skip_serializing_if = "Option::is_none")] pub quality: Option, } /// Quality percentile values (p50, p95, p99) for all F1 metrics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QualityPercentiles { /// Text F1 50th percentile (TF1 median) pub f1_text_p50: f64, /// Text F1 95th percentile pub f1_text_p95: f64, /// Text F1 99th percentile pub f1_text_p99: f64, /// Numeric F1 50th percentile pub f1_numeric_p50: f64, /// Numeric F1 95th percentile pub f1_numeric_p95: f64, /// Numeric F1 99th percentile pub f1_numeric_p99: f64, /// Layout/structural F1 50th percentile (SF1 median) — None for plaintext-only frameworks pub f1_layout_p50: Option, /// Layout/structural F1 95th percentile — None for plaintext-only frameworks pub f1_layout_p95: Option, /// Layout/structural F1 99th percentile — None for plaintext-only frameworks pub f1_layout_p99: Option, /// Overall quality score 50th percentile pub quality_score_p50: f64, /// Overall quality score 95th percentile pub quality_score_p95: f64, /// Overall quality score 99th percentile pub quality_score_p99: f64, } /// Percentile values for a metric #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Percentiles { /// 50th percentile (median) pub p50: f64, /// 95th percentile pub p95: f64, /// 99th percentile pub p99: f64, } /// Duration percentiles in milliseconds #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DurationPercentiles { /// Number of samples with cold start data pub sample_count: usize, /// 50th percentile (median) in ms pub p50_ms: f64, /// 95th percentile in ms pub p95_ms: f64, /// 99th percentile in ms pub p99_ms: f64, } /// Main aggregation function for new format /// /// Groups results by: /// 1. Framework and mode (extracted from framework name) /// 2. File type (extension) /// 3. OCR usage (yes/no) /// /// Calculates p50/p95/p99 percentiles for each group. pub fn aggregate_new_format(results: &[BenchmarkResult]) -> NewConsolidatedResults { // Validate input if results.is_empty() { return NewConsolidatedResults { schema_version: SCHEMA_VERSION.to_string(), by_framework_mode: HashMap::new(), disk_sizes: HashMap::new(), comparison: ComparisonData { performance_ranking: Vec::new(), throughput_ranking: Vec::new(), memory_ranking: Vec::new(), cpu_ranking: Vec::new(), quality_ranking: Vec::new(), pdf_quality_ranking: Vec::new(), pdf_tf1_ranking_markdown: Vec::new(), pdf_tf1_ranking_plaintext: Vec::new(), pdf_sf1_ranking_markdown: Vec::new(), deltas_vs_baseline: HashMap::new(), }, per_fixture_results: Vec::new(), metadata: ConsolidationMetadata { total_results: 0, framework_count: 0, file_type_count: 0, timestamp: chrono::Utc::now().to_rfc3339(), }, }; } // Group by aggregate key (see make_aggregate_key) and file type let mut by_framework_mode_format: HashMap>> = HashMap::new(); let mut disk_sizes: HashMap = HashMap::new(); let mut file_types = std::collections::HashSet::new(); // Group results by their aggregate key and file type. // // Key format differs by family (see module-level doc): // kreuzberg-* → "{framework_name}:{mode}" // competitors → "{framework}:{output_format}:{mode}" for result in results { let (framework, mode) = extract_framework_and_mode(&result.framework); let key = make_aggregate_key(framework, result.output_format, mode); by_framework_mode_format .entry(key) .or_default() .entry(result.file_extension.clone()) .or_default() .push(result); file_types.insert(result.file_extension.clone()); // Collect disk sizes if let Some(disk_size) = &result.framework_capabilities.installation_size { disk_sizes.insert(framework.to_string(), disk_size.clone()); } } // Aggregate each key combination. // // Key shapes (see make_aggregate_key): // kreuzberg-* → "framework_name:mode" (2 colon-separated parts) // competitors → "framework:output_format:mode" (3 colon-separated parts) let mut aggregated_by_framework_mode = HashMap::new(); for (framework_mode_format_key, file_type_results) in by_framework_mode_format { // Retrieve output_format from the first result in this group; it is the same for all // entries in the group because the key was built from result.output_format. let output_format = file_type_results .values() .flatten() .next() .map(|r| r.output_format) .unwrap_or(OutputFormat::Markdown); let (framework, mode) = parse_aggregate_key(&framework_mode_format_key); // Collect all results for this aggregate key group for cold start calculation let all_results: Vec<&BenchmarkResult> = file_type_results.values().flat_map(|v| v.iter().copied()).collect(); let cold_start = aggregate_cold_starts(&all_results); // Aggregate by file type let mut by_file_type = HashMap::new(); for (file_type, results_for_type) in file_type_results { let aggregation = aggregate_by_ocr_status(&results_for_type); by_file_type.insert( file_type.clone(), FileTypeAggregation { file_type: file_type.clone(), no_ocr: aggregation.0, with_ocr: aggregation.1, }, ); } aggregated_by_framework_mode.insert( framework_mode_format_key.clone(), FrameworkModeAggregation { framework: framework.to_string(), output_format, mode: mode.to_string(), cold_start, by_file_type, }, ); } // Build per-fixture results let per_fixture_results = build_per_fixture_results(results); let metadata = ConsolidationMetadata { total_results: results.len(), framework_count: aggregated_by_framework_mode.len(), file_type_count: file_types.len(), timestamp: chrono::Utc::now().to_rfc3339(), }; let comparison = build_comparison(&aggregated_by_framework_mode); NewConsolidatedResults { schema_version: SCHEMA_VERSION.to_string(), by_framework_mode: aggregated_by_framework_mode, disk_sizes, comparison, per_fixture_results, metadata, } } /// Build per-fixture result rows from raw benchmark results /// /// Extracts one row per (framework, output_format, execution_mode, fixture_id, ocr) group. /// Fixture ID is derived from the file path (filename without extension). fn build_per_fixture_results(results: &[BenchmarkResult]) -> Vec { let mut fixture_rows = Vec::new(); for result in results { let (framework, mode) = extract_framework_and_mode(&result.framework); let fixture_id = result .file_path .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or("unknown") .to_string(); let ocr = matches!(result.ocr_status, crate::types::OcrStatus::Used); let error_kind = if !result.success { Some(format!("{:?}", result.error_kind)) } else { None }; let (f1_text, f1_layout, f1_numeric, quality_score, correct) = if let Some(q) = &result.quality { ( Some(q.f1_score_text), q.f1_score_layout, Some(q.f1_score_numeric), Some(q.quality_score), Some(q.correct), ) } else { (None, None, None, None, None) }; fixture_rows.push(PerFixtureRow { framework: framework.to_string(), output_format: result.output_format, execution_mode: mode.to_string(), ocr, fixture_id, file_type: result.file_extension.clone(), duration_ms: result.duration.as_secs_f64() * 1000.0, peak_memory_mb: result.metrics.peak_memory_bytes as f64 / 1_000_000.0, f1_text, f1_layout, f1_numeric, quality_score, correct, success: result.success, error_kind, }); } fixture_rows } /// Aggregate results by OCR status /// /// Returns (no_ocr, with_ocr) tuple of PerformancePercentiles fn aggregate_by_ocr_status( results: &[&BenchmarkResult], ) -> (Option, Option) { use crate::types::OcrStatus; // OCR status grouping: // - OcrStatus::Used → "with_ocr" group // - OcrStatus::NotUsed → "no_ocr" group // - OcrStatus::Unknown → infer from file type: image formats → "with_ocr", others → "no_ocr" let is_ocr_result = |r: &&BenchmarkResult| -> bool { match r.ocr_status { OcrStatus::Used => true, OcrStatus::NotUsed => false, OcrStatus::Unknown => matches!( r.file_extension.to_lowercase().as_str(), "jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "tif" | "webp" | "jp2" | "jpx" | "jpm" | "mj2" ), } }; let no_ocr: Vec<&BenchmarkResult> = results.iter().filter(|r| !is_ocr_result(r)).copied().collect(); let with_ocr: Vec<&BenchmarkResult> = results.iter().filter(|r| is_ocr_result(r)).copied().collect(); let no_ocr_stats = if !no_ocr.is_empty() { Some(calculate_percentiles(&no_ocr)) } else { None }; let with_ocr_stats = if !with_ocr.is_empty() { Some(calculate_percentiles(&with_ocr)) } else { None }; (no_ocr_stats, with_ocr_stats) } /// Calculate percentiles for a group of results /// /// Only uses successful results for metric calculations. /// Success rate is calculated from all results. fn calculate_percentiles(results: &[&BenchmarkResult]) -> PerformancePercentiles { let successful: Vec<&BenchmarkResult> = results.iter().filter(|r| r.success).copied().collect(); // Extract values for percentile calculation with NaN filtering - HIGH PRIORITY FIX let mut durations: Vec = successful .iter() .map(|r| r.duration.as_secs_f64() * 1000.0) .filter(|&v| !v.is_nan() && v.is_finite()) .collect(); let mut throughputs: Vec = successful .iter() .map(|r| r.metrics.throughput_bytes_per_sec / 1_000_000.0) // Convert to MB/s .filter(|&v| v > 0.0 && v.is_finite()) // Filter zero values (invalid measurements) .collect(); let mut memories: Vec = successful .iter() .map(|r| r.metrics.peak_memory_bytes as f64 / 1_000_000.0) // Convert to MB .filter(|&v| !v.is_nan() && v.is_finite()) .collect(); let mut extraction_durations: Vec = successful .iter() .filter_map(|r| r.extraction_duration.map(|d| d.as_secs_f64() * 1000.0)) .filter(|&v| !v.is_nan() && v.is_finite()) .collect(); let mut cpus: Vec = successful .iter() .map(|r| r.metrics.avg_cpu_percent) .filter(|&v| v > 0.0 && v.is_finite()) .collect(); // Sort for percentile calculation (NaN-safe) durations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); throughputs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); memories.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); extraction_durations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); cpus.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); // Build percentiles with NaN/Inf validation let duration = Percentiles { p50: sanitize_f64(percentile_r7(&durations, 0.50)), p95: sanitize_f64(percentile_r7(&durations, 0.95)), p99: sanitize_f64(percentile_r7(&durations, 0.99)), }; let throughput = Percentiles { p50: sanitize_f64(percentile_r7(&throughputs, 0.50)), p95: sanitize_f64(percentile_r7(&throughputs, 0.95)), p99: sanitize_f64(percentile_r7(&throughputs, 0.99)), }; let memory = Percentiles { p50: sanitize_f64(percentile_r7(&memories, 0.50)), p95: sanitize_f64(percentile_r7(&memories, 0.95)), p99: sanitize_f64(percentile_r7(&memories, 0.99)), }; let extraction_duration = if !extraction_durations.is_empty() { Some(Percentiles { p50: sanitize_f64(percentile_r7(&extraction_durations, 0.50)), p95: sanitize_f64(percentile_r7(&extraction_durations, 0.95)), p99: sanitize_f64(percentile_r7(&extraction_durations, 0.99)), }) } else { None }; let cpu = if !cpus.is_empty() { Some(Percentiles { p50: sanitize_f64(percentile_r7(&cpus, 0.50)), p95: sanitize_f64(percentile_r7(&cpus, 0.95)), p99: sanitize_f64(percentile_r7(&cpus, 0.99)), }) } else { None }; let success_rate_percent = if !results.is_empty() { (successful.len() as f64 / results.len() as f64) * 100.0 } else { 0.0 }; let framework_errors = results .iter() .filter(|r| r.error_kind == ErrorKind::FrameworkError) .count(); let harness_errors = results .iter() .filter(|r| r.error_kind == ErrorKind::HarnessError) .count(); let timeouts = results.iter().filter(|r| r.error_kind == ErrorKind::Timeout).count(); let empty_content = results .iter() .filter(|r| r.error_kind == ErrorKind::EmptyContent) .count(); let mut error_details: HashMap = HashMap::new(); for result in results.iter().filter(|r| !r.success) { if let Some(msg) = &result.error_message { *error_details.entry(msg.clone()).or_insert(0) += 1; } } // Quality percentiles let quality = { let mut f1_texts: Vec = successful .iter() .filter_map(|r| r.quality.as_ref().map(|q| q.f1_score_text)) .filter(|v| !v.is_nan() && v.is_finite()) .collect(); let mut f1_numerics: Vec = successful .iter() .filter_map(|r| r.quality.as_ref().map(|q| q.f1_score_numeric)) .filter(|v| !v.is_nan() && v.is_finite()) .collect(); let mut f1_layouts: Vec = successful .iter() .filter_map(|r| r.quality.as_ref().and_then(|q| q.f1_score_layout)) .filter(|v| !v.is_nan() && v.is_finite()) .collect(); let mut quality_scores: Vec = successful .iter() .filter_map(|r| r.quality.as_ref().map(|q| q.quality_score)) .filter(|v| !v.is_nan() && v.is_finite()) .collect(); if !quality_scores.is_empty() { f1_texts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); f1_numerics.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); f1_layouts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); quality_scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); // f1_layout is None if all results have f1_score_layout = None (plaintext mode) let f1_layout_p50 = if !f1_layouts.is_empty() { Some(sanitize_f64(percentile_r7(&f1_layouts, 0.50))) } else { None }; let f1_layout_p95 = if !f1_layouts.is_empty() { Some(sanitize_f64(percentile_r7(&f1_layouts, 0.95))) } else { None }; let f1_layout_p99 = if !f1_layouts.is_empty() { Some(sanitize_f64(percentile_r7(&f1_layouts, 0.99))) } else { None }; Some(QualityPercentiles { f1_text_p50: sanitize_f64(percentile_r7(&f1_texts, 0.50)), f1_text_p95: sanitize_f64(percentile_r7(&f1_texts, 0.95)), f1_text_p99: sanitize_f64(percentile_r7(&f1_texts, 0.99)), f1_numeric_p50: sanitize_f64(percentile_r7(&f1_numerics, 0.50)), f1_numeric_p95: sanitize_f64(percentile_r7(&f1_numerics, 0.95)), f1_numeric_p99: sanitize_f64(percentile_r7(&f1_numerics, 0.99)), f1_layout_p50, f1_layout_p95, f1_layout_p99, quality_score_p50: sanitize_f64(percentile_r7(&quality_scores, 0.50)), quality_score_p95: sanitize_f64(percentile_r7(&quality_scores, 0.95)), quality_score_p99: sanitize_f64(percentile_r7(&quality_scores, 0.99)), }) } else { None } }; PerformancePercentiles { successful_sample_count: successful.len(), total_sample_count: results.len(), framework_errors, harness_errors, timeouts, empty_content, error_details, throughput, memory, duration, cpu, success_rate_percent, extraction_duration, quality, } } /// Aggregate cold start durations /// /// Returns percentiles of cold start durations if any results have cold start data. fn aggregate_cold_starts(results: &[&BenchmarkResult]) -> Option { let cold_starts: Vec = results .iter() .filter_map(|r| r.cold_start_duration.map(|d| d.as_secs_f64() * 1000.0)) .filter(|&v| !v.is_nan() && v.is_finite()) // HIGH PRIORITY FIX: NaN filtering .collect(); if cold_starts.is_empty() { return None; } let mut sorted = cold_starts.clone(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); Some(DurationPercentiles { sample_count: cold_starts.len(), p50_ms: sanitize_f64(percentile_r7(&sorted, 0.50)), p95_ms: sanitize_f64(percentile_r7(&sorted, 0.95)), p99_ms: sanitize_f64(percentile_r7(&sorted, 0.99)), }) } /// Extract framework name and mode from a raw framework string. /// /// Modes: `-batch` suffix → `"batch"`, anything else → `"single"`. /// Legacy `-sync`/`-async` suffixes (no longer emitted by current adapters, but present in /// historical result files) are stripped from the base name to preserve backward compatibility. /// /// Returns `(framework_name, mode)` where `mode` is `"batch"` or `"single"`. fn extract_framework_and_mode(framework_name: &str) -> (&str, &str) { if let Some(base) = framework_name.strip_suffix("-batch") { // Strip legacy -sync/-async suffixes from the base if present let normalized = base .strip_suffix("-sync") .or_else(|| base.strip_suffix("-async")) .unwrap_or(base); (normalized, "batch") } else { let normalized = framework_name .strip_suffix("-sync") .or_else(|| framework_name.strip_suffix("-async")) .unwrap_or(framework_name); (normalized, "single") } } /// Build the `by_framework_mode` map key for a result. /// /// - `kreuzberg-*` frameworks already encode the output format in their name, so the key is /// `"{framework}:{mode}"` — no redundant format component. /// - All other (competitor) frameworks use `"{framework}:{output_format}:{mode}"`. fn make_aggregate_key(framework: &str, output_format: OutputFormat, mode: &str) -> String { if framework.starts_with("kreuzberg-") { format!("{framework}:{mode}") } else { format!("{framework}:{output_format}:{mode}") } } /// Parse an aggregate key back into `(framework, mode)`. /// /// Handles both key shapes produced by [`make_aggregate_key`]: /// - `"framework:mode"` (kreuzberg family, 2 parts) /// - `"framework:output_format:mode"` (competitors, 3 parts) fn parse_aggregate_key(key: &str) -> (&str, &str) { let mut parts = key.rsplitn(2, ':'); let mode = parts.next().unwrap_or("single"); // For kreuzberg keys the remainder is just the framework name. // For competitor keys the remainder is "framework:output_format" — we want only the // framework portion, which is everything before the first colon. let remainder = parts.next().unwrap_or(key); let framework = remainder.split(':').next().unwrap_or(remainder); (framework, mode) } /// Build cross-framework comparison rankings from aggregated data /// /// Metrics are weighted by successful_sample_count so that file types with more /// samples (e.g., 93 PDFs) dominate the ranking over file types with fewer samples /// (e.g., 1 BMP). This prevents frameworks that handle more file types or do OCR /// from being unfairly penalized in the overall ranking. fn build_comparison(by_framework_mode: &HashMap) -> ComparisonData { // Collect weighted median metrics per framework:mode // (key, duration_p50, throughput_p50, memory_p50, quality_p50, cpu_p50) let mut metrics: Vec<(String, f64, f64, f64, f64, f64)> = Vec::new(); for (key, agg) in by_framework_mode { // (value, weight) pairs for weighted averaging let mut durations: Vec<(f64, usize)> = Vec::new(); let mut throughputs: Vec<(f64, usize)> = Vec::new(); let mut memories: Vec<(f64, usize)> = Vec::new(); let mut qualities: Vec<(f64, usize)> = Vec::new(); let mut cpus: Vec<(f64, usize)> = Vec::new(); for ft in agg.by_file_type.values() { for perf in [&ft.no_ocr, &ft.with_ocr].into_iter().flatten() { // Skip groups where all samples failed — their 0.0 values would // pollute rankings (e.g., docling showing 0.0ms when libGL is missing). if perf.successful_sample_count == 0 { continue; } let weight = perf.successful_sample_count; durations.push((perf.duration.p50, weight)); throughputs.push((perf.throughput.p50, weight)); memories.push((perf.memory.p50, weight)); if let Some(q) = &perf.quality { qualities.push((q.quality_score_p50, weight)); } if let Some(c) = &perf.cpu { cpus.push((c.p50, weight)); } } } if durations.is_empty() { continue; } let weighted_avg = |items: &[(f64, usize)]| -> f64 { let finite: Vec<(f64, usize)> = items.iter().copied().filter(|(v, _)| v.is_finite()).collect(); let total_weight: usize = finite.iter().map(|(_, w)| w).sum(); if total_weight == 0 { f64::NAN } else { finite.iter().map(|(v, w)| v * (*w as f64)).sum::() / total_weight as f64 } }; metrics.push(( key.clone(), weighted_avg(&durations), weighted_avg(&throughputs), weighted_avg(&memories), weighted_avg(&qualities), weighted_avg(&cpus), )); } // Performance ranking (lower duration = better, rank 1) let mut perf = metrics.clone(); perf.retain(|m| m.1.is_finite()); perf.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); let baseline_dur = perf.first().map(|r| r.1).unwrap_or(1.0); let performance_ranking: Vec = perf .iter() .enumerate() .map(|(i, (k, v, ..))| RankedFramework { framework_mode: k.clone(), rank: i + 1, value: *v, relative: if baseline_dur > 0.0 { *v / baseline_dur } else { 1.0 }, }) .collect(); // Throughput ranking (higher = better) let mut thr = metrics.clone(); thr.retain(|m| m.2.is_finite()); thr.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); let baseline_thr = thr.first().map(|r| r.2).unwrap_or(1.0); let throughput_ranking: Vec = thr .iter() .enumerate() .map(|(i, (k, _, v, ..))| RankedFramework { framework_mode: k.clone(), rank: i + 1, value: *v, relative: if baseline_thr > 0.0 { *v / baseline_thr } else { 1.0 }, }) .collect(); // Memory ranking (lower = better) let mut mem = metrics.clone(); mem.retain(|m| m.3.is_finite()); mem.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal)); let baseline_mem = mem.first().map(|r| r.3).unwrap_or(1.0); let memory_ranking: Vec = mem .iter() .enumerate() .map(|(i, (k, _, _, v, ..))| RankedFramework { framework_mode: k.clone(), rank: i + 1, value: *v, relative: if baseline_mem > 0.0 { *v / baseline_mem } else { 1.0 }, }) .collect(); // CPU ranking (lower = more efficient, rank 1) let mut cpu = metrics.clone(); cpu.retain(|m| m.5.is_finite()); cpu.sort_by(|a, b| a.5.partial_cmp(&b.5).unwrap_or(std::cmp::Ordering::Equal)); let baseline_cpu = cpu.first().map(|r| r.5).unwrap_or(1.0); let cpu_ranking: Vec = cpu .iter() .enumerate() .map(|(i, (k, _, _, _, _, v))| RankedFramework { framework_mode: k.clone(), rank: i + 1, value: *v, relative: if baseline_cpu > 0.0 { *v / baseline_cpu } else { 1.0 }, }) .collect(); // Quality ranking (higher = better) let mut qual = metrics.clone(); qual.retain(|m| m.4.is_finite()); qual.sort_by(|a, b| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal)); let baseline_qual = qual.first().map(|r| r.4).unwrap_or(1.0); let quality_ranking: Vec = qual .iter() .enumerate() .map(|(i, (k, _, _, _, v, _))| RankedFramework { framework_mode: k.clone(), rank: i + 1, value: *v, relative: if baseline_qual > 0.0 { *v / baseline_qual } else { 1.0 }, }) .collect(); // Deltas vs baseline (fastest framework) let mut deltas_vs_baseline = HashMap::new(); if let Some(baseline) = metrics .iter() .filter(|(_, dur, _, _, _, _)| dur.is_finite()) .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) { for (k, dur, thr, mem_val, _, cpu_val) in &metrics { if k != &baseline.0 { deltas_vs_baseline.insert( k.clone(), DeltaMetrics { duration_delta_ms: dur - baseline.1, duration_delta_percent: if baseline.1 > 0.0 { ((dur - baseline.1) / baseline.1) * 100.0 } else { 0.0 }, throughput_delta_mbs: thr - baseline.2, throughput_delta_percent: if baseline.2 > 0.0 { ((thr - baseline.2) / baseline.2) * 100.0 } else { 0.0 }, memory_delta_mb: mem_val - baseline.3, memory_delta_percent: if baseline.3 > 0.0 { ((mem_val - baseline.3) / baseline.3) * 100.0 } else { 0.0 }, cpu_delta_pp: cpu_val - baseline.5, cpu_delta_percent: if baseline.5 > 0.0 { ((cpu_val - baseline.5) / baseline.5) * 100.0 } else { 0.0 }, }, ); } } } // PDF-specific quality rankings (quality, TF1, SF1) // Collect PDF quality metrics per framework:output_format:mode // (key, quality, tf1, sf1, output_format) let mut pdf_metrics: Vec<(String, f64, f64, f64, OutputFormat)> = Vec::new(); for (key, agg) in by_framework_mode { if let Some(pdf_ft) = agg.by_file_type.get("pdf") { let mut qualities: Vec<(f64, usize)> = Vec::new(); let mut tf1s: Vec<(f64, usize)> = Vec::new(); let mut sf1s: Vec<(f64, usize)> = Vec::new(); for perf in [&pdf_ft.no_ocr, &pdf_ft.with_ocr].into_iter().flatten() { if perf.successful_sample_count == 0 { continue; } if let Some(q) = &perf.quality { let w = perf.successful_sample_count; qualities.push((q.quality_score_p50, w)); tf1s.push((q.f1_text_p50, w)); // Only include f1_layout if present (markdown mode) if let Some(layout) = q.f1_layout_p50 { sf1s.push((layout, w)); } } } let weighted_avg = |items: &[(f64, usize)]| -> f64 { let finite: Vec<(f64, usize)> = items.iter().copied().filter(|(v, _)| v.is_finite()).collect(); let total_weight: usize = finite.iter().map(|(_, w)| w).sum(); if total_weight == 0 { f64::NAN } else { finite.iter().map(|(v, w)| v * (*w as f64)).sum::() / total_weight as f64 } }; let q = weighted_avg(&qualities); let t = weighted_avg(&tf1s); let s = weighted_avg(&sf1s); if q.is_finite() { pdf_metrics.push((key.clone(), q, t, s, agg.output_format)); } } } let build_ranking = |items: &mut Vec<(String, f64)>| -> Vec { items.retain(|(_, v)| v.is_finite()); items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); let best = items.first().map(|r| r.1).unwrap_or(1.0); items .iter() .enumerate() .map(|(i, (k, v))| RankedFramework { framework_mode: k.clone(), rank: i + 1, value: *v, relative: if best > 0.0 { *v / best } else { 1.0 }, }) .collect() }; let mut pdf_qual_items: Vec<(String, f64)> = pdf_metrics.iter().map(|(k, q, _, _, _)| (k.clone(), *q)).collect(); let mut pdf_tf1_markdown: Vec<(String, f64)> = pdf_metrics .iter() .filter(|(_, _, _, _, fmt)| *fmt == OutputFormat::Markdown) .map(|(k, _, t, _, _)| (k.clone(), *t)) .collect(); let mut pdf_tf1_plaintext: Vec<(String, f64)> = pdf_metrics .iter() .filter(|(_, _, _, _, fmt)| *fmt == OutputFormat::Plaintext) .map(|(k, _, t, _, _)| (k.clone(), *t)) .collect(); let mut pdf_sf1_markdown: Vec<(String, f64)> = pdf_metrics .iter() .filter(|(_, _, _, _, fmt)| *fmt == OutputFormat::Markdown) .map(|(k, _, _, s, _)| (k.clone(), *s)) .collect(); let pdf_quality_ranking = build_ranking(&mut pdf_qual_items); let pdf_tf1_ranking_markdown = build_ranking(&mut pdf_tf1_markdown); let pdf_tf1_ranking_plaintext = build_ranking(&mut pdf_tf1_plaintext); let pdf_sf1_ranking_markdown = build_ranking(&mut pdf_sf1_markdown); ComparisonData { performance_ranking, throughput_ranking, memory_ranking, cpu_ranking, quality_ranking, pdf_quality_ranking, pdf_tf1_ranking_markdown, pdf_tf1_ranking_plaintext, pdf_sf1_ranking_markdown, deltas_vs_baseline, } } #[cfg(test)] mod tests { use super::*; use crate::types::{ErrorKind, FrameworkCapabilities, OcrStatus, PerformanceMetrics}; use std::path::PathBuf; use std::time::Duration; fn create_test_result( framework: &str, file_ext: &str, ocr_status: OcrStatus, duration_ms: u64, throughput_bps: f64, memory_bytes: u64, ) -> BenchmarkResult { BenchmarkResult { framework: framework.to_string(), file_path: PathBuf::from(format!("test.{}", file_ext)), file_size: 1024, success: true, error_message: None, error_kind: ErrorKind::None, duration: Duration::from_millis(duration_ms), extraction_duration: None, subprocess_overhead: None, metrics: PerformanceMetrics { peak_memory_bytes: memory_bytes, avg_cpu_percent: 50.0, throughput_bytes_per_sec: throughput_bps, p50_memory_bytes: memory_bytes, p95_memory_bytes: memory_bytes, p99_memory_bytes: memory_bytes, }, quality: None, iterations: vec![], statistics: None, cold_start_duration: Some(Duration::from_millis(500)), file_extension: file_ext.to_string(), framework_capabilities: FrameworkCapabilities::default(), pdf_metadata: None, ocr_status, output_format: OutputFormat::Markdown, extracted_text: None, } } #[test] fn test_extract_framework_and_mode() { // Current kreuzberg pipeline naming (format encoded in name) assert_eq!( extract_framework_and_mode("kreuzberg-markdown-baseline"), ("kreuzberg-markdown-baseline", "single") ); assert_eq!( extract_framework_and_mode("kreuzberg-plaintext-paddle-ocr"), ("kreuzberg-plaintext-paddle-ocr", "single") ); assert_eq!( extract_framework_and_mode("kreuzberg-markdown-baseline-batch"), ("kreuzberg-markdown-baseline", "batch") ); // Legacy -sync/-async suffixes are still stripped for backward compatibility assert_eq!(extract_framework_and_mode("kreuzberg-sync"), ("kreuzberg", "single")); assert_eq!(extract_framework_and_mode("kreuzberg-async"), ("kreuzberg", "single")); // Batch mode is preserved assert_eq!(extract_framework_and_mode("kreuzberg-batch"), ("kreuzberg", "batch")); assert_eq!(extract_framework_and_mode("python-batch"), ("python", "batch")); // No suffix defaults to single mode assert_eq!(extract_framework_and_mode("kreuzberg"), ("kreuzberg", "single")); assert_eq!(extract_framework_and_mode("docling"), ("docling", "single")); } #[test] fn test_make_aggregate_key_kreuzberg_family() { // kreuzberg-* frameworks get slim keys (no redundant format component) assert_eq!( make_aggregate_key("kreuzberg-markdown-baseline", OutputFormat::Markdown, "single"), "kreuzberg-markdown-baseline:single" ); assert_eq!( make_aggregate_key("kreuzberg-plaintext-layout", OutputFormat::Plaintext, "batch"), "kreuzberg-plaintext-layout:batch" ); } #[test] fn test_make_aggregate_key_competitors() { // Competitor frameworks include format in key assert_eq!( make_aggregate_key("docling", OutputFormat::Markdown, "single"), "docling:markdown:single" ); assert_eq!( make_aggregate_key("pdfplumber", OutputFormat::Plaintext, "batch"), "pdfplumber:plaintext:batch" ); } #[test] fn test_aggregate_new_format_kreuzberg_key_shape() { // kreuzberg-markdown-baseline results should produce slim keys let results = vec![ create_test_result( "kreuzberg-markdown-baseline", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000, ), create_test_result( "kreuzberg-markdown-baseline-batch", "pdf", OcrStatus::NotUsed, 80, 1_000_000.0, 10_000_000, ), ]; let aggregated = aggregate_new_format(&results); assert_eq!(aggregated.by_framework_mode.len(), 2); assert!( aggregated .by_framework_mode .contains_key("kreuzberg-markdown-baseline:single") ); assert!( aggregated .by_framework_mode .contains_key("kreuzberg-markdown-baseline:batch") ); let single_agg = &aggregated.by_framework_mode["kreuzberg-markdown-baseline:single"]; assert_eq!(single_agg.framework, "kreuzberg-markdown-baseline"); assert_eq!(single_agg.mode, "single"); } #[test] fn test_percentile_r7() { let values = vec![1.0, 2.0, 3.0, 4.0, 5.0]; assert_eq!(percentile_r7(&values, 0.0), 1.0); assert_eq!(percentile_r7(&values, 0.5), 3.0); assert_eq!(percentile_r7(&values, 1.0), 5.0); assert_eq!(percentile_r7(&[], 0.5), 0.0); } #[test] fn test_aggregate_new_format() { let results = vec![ create_test_result( "kreuzberg-sync", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000, ), create_test_result("kreuzberg-sync", "pdf", OcrStatus::Used, 200, 500_000.0, 20_000_000), create_test_result( "kreuzberg-batch", "docx", OcrStatus::NotUsed, 150, 750_000.0, 15_000_000, ), ]; let aggregated = aggregate_new_format(&results); assert_eq!(aggregated.by_framework_mode.len(), 2); // "kreuzberg-sync" is normalized to "kreuzberg:markdown:single" assert!(aggregated.by_framework_mode.contains_key("kreuzberg:markdown:single")); assert!(aggregated.by_framework_mode.contains_key("kreuzberg:markdown:batch")); let single_agg = &aggregated.by_framework_mode["kreuzberg:markdown:single"]; assert_eq!(single_agg.framework, "kreuzberg"); assert_eq!(single_agg.mode, "single"); assert!(single_agg.cold_start.is_some()); let pdf_agg = &single_agg.by_file_type["pdf"]; assert!(pdf_agg.no_ocr.is_some()); assert!(pdf_agg.with_ocr.is_some()); assert_eq!(pdf_agg.no_ocr.as_ref().unwrap().successful_sample_count, 1); assert_eq!(pdf_agg.with_ocr.as_ref().unwrap().successful_sample_count, 1); } #[test] fn test_calculate_percentiles() { let results = [ create_test_result("kreuzberg", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000), create_test_result("kreuzberg", "pdf", OcrStatus::NotUsed, 200, 2_000_000.0, 20_000_000), create_test_result("kreuzberg", "pdf", OcrStatus::NotUsed, 300, 3_000_000.0, 30_000_000), ]; let refs: Vec<&BenchmarkResult> = results.iter().collect(); let percentiles = calculate_percentiles(&refs); assert_eq!(percentiles.successful_sample_count, 3); assert_eq!(percentiles.total_sample_count, 3); assert_eq!(percentiles.success_rate_percent, 100.0); assert!(percentiles.duration.p50 > 0.0); assert!(percentiles.throughput.p50 > 0.0); assert!(percentiles.memory.p50 > 0.0); } #[test] fn test_aggregate_cold_starts() { let results = [ create_test_result("kreuzberg", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000), create_test_result("kreuzberg", "pdf", OcrStatus::NotUsed, 200, 2_000_000.0, 20_000_000), ]; let refs: Vec<&BenchmarkResult> = results.iter().collect(); let cold_starts = aggregate_cold_starts(&refs); assert!(cold_starts.is_some()); let cold_starts = cold_starts.unwrap(); assert_eq!(cold_starts.sample_count, 2); assert!(cold_starts.p50_ms > 0.0); } #[test] fn test_ocr_unknown_handling() { // Test that Unknown OCR status is handled correctly let results = vec![BenchmarkResult { framework: "test-framework".to_string(), file_path: PathBuf::from("/tmp/test1.pdf"), file_size: 1024, success: true, error_message: None, error_kind: ErrorKind::None, duration: Duration::from_millis(100), extraction_duration: None, subprocess_overhead: None, metrics: PerformanceMetrics { peak_memory_bytes: 10_000_000, avg_cpu_percent: 50.0, throughput_bytes_per_sec: 10_240.0, p50_memory_bytes: 8_000_000, p95_memory_bytes: 9_500_000, p99_memory_bytes: 9_900_000, }, quality: None, iterations: vec![], statistics: None, cold_start_duration: Some(Duration::from_millis(200)), file_extension: "pdf".to_string(), framework_capabilities: Default::default(), pdf_metadata: None, ocr_status: OcrStatus::Unknown, // Unknown status extracted_text: None, output_format: OutputFormat::Markdown, }]; let aggregated = aggregate_new_format(&results); // Unknown should be in no_ocr group let framework_mode = aggregated .by_framework_mode .get("test-framework:markdown:single") .unwrap(); let file_type = framework_mode.by_file_type.get("pdf").unwrap(); assert!(file_type.no_ocr.is_some()); assert_eq!(file_type.no_ocr.as_ref().unwrap().successful_sample_count, 1); } #[test] fn test_failed_results_excluded_from_percentiles() { // Test that failed results don't affect percentile calculations let results = vec![ BenchmarkResult { framework: "test-framework".to_string(), file_path: PathBuf::from("/tmp/test1.pdf"), file_size: 1024, success: true, error_message: None, error_kind: ErrorKind::None, duration: Duration::from_millis(100), extraction_duration: None, subprocess_overhead: None, metrics: PerformanceMetrics { peak_memory_bytes: 10_000_000, avg_cpu_percent: 50.0, throughput_bytes_per_sec: 10_240.0, p50_memory_bytes: 8_000_000, p95_memory_bytes: 9_500_000, p99_memory_bytes: 9_900_000, }, quality: None, iterations: vec![], statistics: None, cold_start_duration: None, file_extension: "pdf".to_string(), framework_capabilities: Default::default(), pdf_metadata: None, ocr_status: OcrStatus::NotUsed, extracted_text: None, output_format: OutputFormat::Markdown, }, BenchmarkResult { framework: "test-framework".to_string(), file_path: PathBuf::from("/tmp/test2.pdf"), file_size: 2048, success: false, // Failed result error_message: Some("Test error".to_string()), error_kind: ErrorKind::HarnessError, duration: Duration::from_secs(0), extraction_duration: None, subprocess_overhead: None, metrics: PerformanceMetrics { peak_memory_bytes: 0, avg_cpu_percent: 0.0, throughput_bytes_per_sec: 0.0, p50_memory_bytes: 0, p95_memory_bytes: 0, p99_memory_bytes: 0, }, quality: None, iterations: vec![], statistics: None, cold_start_duration: None, file_extension: "pdf".to_string(), framework_capabilities: Default::default(), pdf_metadata: None, ocr_status: OcrStatus::NotUsed, extracted_text: None, output_format: OutputFormat::Markdown, }, ]; let aggregated = aggregate_new_format(&results); let framework_mode = aggregated .by_framework_mode .get("test-framework:markdown:single") .unwrap(); let file_type = framework_mode.by_file_type.get("pdf").unwrap(); let no_ocr = file_type.no_ocr.as_ref().unwrap(); // successful_sample_count should only count successful results assert_eq!(no_ocr.successful_sample_count, 1); assert_eq!(no_ocr.total_sample_count, 2); // success_rate_percent should account for all results assert_eq!(no_ocr.success_rate_percent, 50.0); // 1 success / 2 total = 50% // Percentiles based on 1 successful result assert_eq!(no_ocr.duration.p50, 100.0); } #[test] fn test_empty_input() { let results: Vec = vec![]; let aggregated = aggregate_new_format(&results); assert_eq!(aggregated.by_framework_mode.len(), 0); assert_eq!(aggregated.metadata.total_results, 0); } #[test] fn test_percentile_interpolation() { // Test that p95 with [1,2,3,4,5] uses interpolation let sorted = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let p95 = percentile_r7(&sorted, 0.95); // With linear interpolation: index = 0.95 * 4 = 3.8 // Result = values[3] * 0.2 + values[4] * 0.8 = 4.0 * 0.2 + 5.0 * 0.8 = 4.8 assert!((p95 - 4.8).abs() < 0.01); } // ============================================================================ // Tests for extraction_duration aggregation in new format // ============================================================================ #[test] fn test_calculate_percentiles_extraction_duration_all_present() { // Test: All results have extraction_duration -> percentiles populated let mut result1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); result1.extraction_duration = Some(Duration::from_millis(80)); let mut result2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000); result2.extraction_duration = Some(Duration::from_millis(120)); let mut result3 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 200, 1_000_000.0, 10_000_000); result3.extraction_duration = Some(Duration::from_millis(160)); let refs = vec![&result1, &result2, &result3]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.extraction_duration.is_some()); let ext_dur = percentiles.extraction_duration.as_ref().unwrap(); assert!((ext_dur.p50 - 120.0).abs() < 0.1); // median: 120 assert!(ext_dur.p95 > 120.0); // p95 should be between 120 and 160 assert!(ext_dur.p95 <= 160.0); } #[test] fn test_calculate_percentiles_extraction_duration_all_none() { // Test: All results have extraction_duration = None -> extraction_duration None let result1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); let result2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000); let result3 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 200, 1_000_000.0, 10_000_000); let refs = vec![&result1, &result2, &result3]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.extraction_duration.is_none()); } #[test] fn test_calculate_percentiles_extraction_duration_mixed() { // Test: Mixed Some/None extraction_duration -> only Some values used let mut result1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); result1.extraction_duration = Some(Duration::from_millis(80)); let result2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000); // result2.extraction_duration = None let mut result3 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 200, 1_000_000.0, 10_000_000); result3.extraction_duration = Some(Duration::from_millis(160)); let refs = vec![&result1, &result2, &result3]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.extraction_duration.is_some()); let ext_dur = percentiles.extraction_duration.as_ref().unwrap(); // Only 80 and 160 used, median should be 120 assert!((ext_dur.p50 - 120.0).abs() < 0.1); } #[test] fn test_calculate_percentiles_extraction_duration_filters_invalid() { // Test: NaN/infinite extraction durations filtered out // Note: We can't directly create NaN with Duration, so we test the filtering logic // by ensuring valid values are correctly processed let mut result1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); result1.extraction_duration = Some(Duration::from_millis(80)); let mut result2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000); result2.extraction_duration = Some(Duration::from_millis(120)); let mut result3 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 200, 1_000_000.0, 10_000_000); result3.extraction_duration = Some(Duration::from_millis(160)); let refs = vec![&result1, &result2, &result3]; let percentiles = calculate_percentiles(&refs); // All values should be present and valid assert!(percentiles.extraction_duration.is_some()); let ext_dur = percentiles.extraction_duration.as_ref().unwrap(); assert!(ext_dur.p50.is_finite()); assert!(!ext_dur.p50.is_nan()); } #[test] fn test_calculate_percentiles_extraction_duration_with_failed_results() { // Test: Failed results excluded from extraction_duration calculation let mut result1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); result1.extraction_duration = Some(Duration::from_millis(80)); let mut result2_failed = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 0, 0.0, 0); result2_failed.success = false; result2_failed.error_message = Some("Failed".to_string()); result2_failed.extraction_duration = Some(Duration::from_millis(50)); // Should be ignored let mut result3 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 200, 1_000_000.0, 10_000_000); result3.extraction_duration = Some(Duration::from_millis(160)); let refs = vec![&result1, &result2_failed, &result3]; let percentiles = calculate_percentiles(&refs); // Only result1 and result3 should be used (80 and 160) assert!(percentiles.extraction_duration.is_some()); let ext_dur = percentiles.extraction_duration.as_ref().unwrap(); assert_eq!(percentiles.successful_sample_count, 2); // Only 2 successful results assert_eq!(percentiles.total_sample_count, 3); assert!((ext_dur.p50 - 120.0).abs() < 0.1); // median: 120 } #[test] fn test_aggregate_by_ocr_status_extraction_duration() { // Test: Extraction duration aggregated correctly with OCR status split let mut result_no_ocr_1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); result_no_ocr_1.extraction_duration = Some(Duration::from_millis(80)); let mut result_no_ocr_2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000); result_no_ocr_2.extraction_duration = Some(Duration::from_millis(120)); let mut result_with_ocr = create_test_result("framework1", "pdf", OcrStatus::Used, 300, 500_000.0, 20_000_000); result_with_ocr.extraction_duration = Some(Duration::from_millis(250)); let refs = vec![&result_no_ocr_1, &result_no_ocr_2, &result_with_ocr]; let (no_ocr, with_ocr) = aggregate_by_ocr_status(&refs); // No OCR group assert!(no_ocr.is_some()); let no_ocr_perf = no_ocr.unwrap(); assert!(no_ocr_perf.extraction_duration.is_some()); assert_eq!(no_ocr_perf.extraction_duration.as_ref().unwrap().p50, 100.0); // median of [80, 120] // With OCR group assert!(with_ocr.is_some()); let with_ocr_perf = with_ocr.unwrap(); assert!(with_ocr_perf.extraction_duration.is_some()); assert_eq!(with_ocr_perf.extraction_duration.as_ref().unwrap().p50, 250.0); } #[test] fn test_aggregate_new_format_extraction_duration_preserved() { // Test: aggregate_new_format preserves extraction_duration statistics let mut result1 = create_test_result( "kreuzberg-sync", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000, ); result1.extraction_duration = Some(Duration::from_millis(80)); let mut result2 = create_test_result( "kreuzberg-sync", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000, ); result2.extraction_duration = Some(Duration::from_millis(120)); let results = vec![result1, result2]; let aggregated = aggregate_new_format(&results); let framework_mode = aggregated.by_framework_mode.get("kreuzberg:markdown:single").unwrap(); let pdf_stats = framework_mode.by_file_type.get("pdf").unwrap(); let no_ocr = pdf_stats.no_ocr.as_ref().unwrap(); assert!(no_ocr.extraction_duration.is_some()); let ext_dur = no_ocr.extraction_duration.as_ref().unwrap(); assert!((ext_dur.p50 - 100.0).abs() < 0.1); } #[test] fn test_calculate_percentiles_extraction_duration_single_value() { // Test: Single extraction_duration value -> all percentiles return that value let mut result = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); result.extraction_duration = Some(Duration::from_millis(80)); let refs = vec![&result]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.extraction_duration.is_some()); let ext_dur = percentiles.extraction_duration.as_ref().unwrap(); assert_eq!(ext_dur.p50, 80.0); assert_eq!(ext_dur.p95, 80.0); assert_eq!(ext_dur.p99, 80.0); } #[test] fn test_calculate_percentiles_extraction_duration_large_dataset() { // Test: Large dataset with extraction_duration -> percentiles calculated correctly let mut results = vec![]; for i in 1..=100 { let mut result = create_test_result("framework1", "pdf", OcrStatus::NotUsed, i * 10, 1_000_000.0, 10_000_000); result.extraction_duration = Some(Duration::from_millis(i * 8)); results.push(result); } let refs: Vec<&BenchmarkResult> = results.iter().collect(); let percentiles = calculate_percentiles(&refs); assert!(percentiles.extraction_duration.is_some()); let ext_dur = percentiles.extraction_duration.as_ref().unwrap(); // p50 (median) of 1-100 scaled by 8: around 404-408ms assert!(ext_dur.p50 >= 400.0 && ext_dur.p50 <= 410.0); // p95 should be higher than p50 assert!(ext_dur.p95 > ext_dur.p50); // p99 should be higher than p95 assert!(ext_dur.p99 > ext_dur.p95); } #[test] fn test_calculate_percentiles_extraction_duration_no_extraction_some_failed() { // Test: No extraction_duration data, some failures -> extraction_duration None let result1_failed = BenchmarkResult { framework: "test".to_string(), file_path: PathBuf::from("test1.pdf"), file_size: 1024, success: false, error_message: Some("Error".to_string()), error_kind: ErrorKind::HarnessError, duration: Duration::from_millis(0), extraction_duration: None, subprocess_overhead: None, metrics: PerformanceMetrics { peak_memory_bytes: 0, avg_cpu_percent: 0.0, throughput_bytes_per_sec: 0.0, p50_memory_bytes: 0, p95_memory_bytes: 0, p99_memory_bytes: 0, }, quality: None, iterations: vec![], statistics: None, cold_start_duration: None, file_extension: "pdf".to_string(), framework_capabilities: FrameworkCapabilities::default(), pdf_metadata: None, ocr_status: OcrStatus::NotUsed, extracted_text: None, output_format: OutputFormat::Markdown, }; let result2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); let refs = vec![&result1_failed, &result2]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.extraction_duration.is_none()); assert_eq!(percentiles.success_rate_percent, 50.0); } // ============================================================================ // Tests for CPU aggregation // ============================================================================ #[test] fn test_calculate_percentiles_cpu_populated() { // Test: Results with avg_cpu_percent > 0 produce CPU percentiles let mut r1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); r1.metrics.avg_cpu_percent = 25.0; let mut r2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000); r2.metrics.avg_cpu_percent = 75.0; let mut r3 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 200, 1_000_000.0, 10_000_000); r3.metrics.avg_cpu_percent = 50.0; let refs = vec![&r1, &r2, &r3]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.cpu.is_some()); let cpu = percentiles.cpu.as_ref().unwrap(); assert_eq!(cpu.p50, 50.0); // median of [25, 50, 75] assert!(cpu.p95 > cpu.p50); assert!(cpu.p99 >= cpu.p95); } #[test] fn test_calculate_percentiles_cpu_zero_excluded() { // Test: avg_cpu_percent = 0.0 is filtered out (fallback snapshot path) let mut r1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); r1.metrics.avg_cpu_percent = 0.0; let refs = vec![&r1]; let percentiles = calculate_percentiles(&refs); // 0.0 is filtered, so cpu should be None assert!(percentiles.cpu.is_none()); } #[test] fn test_calculate_percentiles_cpu_mixed_zero_and_nonzero() { // Test: Mix of 0.0 and valid CPU values — only valid values used let mut r1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); r1.metrics.avg_cpu_percent = 0.0; // filtered out let mut r2 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 150, 1_000_000.0, 10_000_000); r2.metrics.avg_cpu_percent = 40.0; let mut r3 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 200, 1_000_000.0, 10_000_000); r3.metrics.avg_cpu_percent = 60.0; let refs = vec![&r1, &r2, &r3]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.cpu.is_some()); let cpu = percentiles.cpu.as_ref().unwrap(); // Only 40 and 60 → median = 50 assert_eq!(cpu.p50, 50.0); } #[test] fn test_calculate_percentiles_cpu_failed_results_excluded() { // Test: Failed results' CPU values are excluded let mut r1 = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 100, 1_000_000.0, 10_000_000); r1.metrics.avg_cpu_percent = 30.0; let mut r2_failed = create_test_result("framework1", "pdf", OcrStatus::NotUsed, 0, 0.0, 0); r2_failed.success = false; r2_failed.error_message = Some("Failed".to_string()); r2_failed.metrics.avg_cpu_percent = 90.0; // Should be ignored let refs = vec![&r1, &r2_failed]; let percentiles = calculate_percentiles(&refs); assert!(percentiles.cpu.is_some()); let cpu = percentiles.cpu.as_ref().unwrap(); assert_eq!(cpu.p50, 30.0); // Only successful result's CPU used } #[test] fn test_comparison_cpu_ranking() { // Test: CPU ranking in comparison data — lower CPU = rank 1 let mut r1 = create_test_result("fast-framework", "pdf", OcrStatus::NotUsed, 50, 2_000_000.0, 5_000_000); r1.metrics.avg_cpu_percent = 80.0; // high CPU let mut r2 = create_test_result("slow-framework", "pdf", OcrStatus::NotUsed, 200, 500_000.0, 20_000_000); r2.metrics.avg_cpu_percent = 20.0; // low CPU let results = vec![r1, r2]; let aggregated = aggregate_new_format(&results); assert!(!aggregated.comparison.cpu_ranking.is_empty()); // slow-framework has lower CPU, should be rank 1 assert_eq!( aggregated.comparison.cpu_ranking[0].framework_mode, "slow-framework:markdown:single" ); assert_eq!(aggregated.comparison.cpu_ranking[0].rank, 1); assert_eq!( aggregated.comparison.cpu_ranking[1].framework_mode, "fast-framework:markdown:single" ); assert_eq!(aggregated.comparison.cpu_ranking[1].rank, 2); } #[test] fn test_deltas_include_cpu() { // Test: Deltas vs baseline include CPU delta fields let mut r1 = create_test_result("baseline-fw", "pdf", OcrStatus::NotUsed, 50, 2_000_000.0, 5_000_000); r1.metrics.avg_cpu_percent = 30.0; let mut r2 = create_test_result("other-fw", "pdf", OcrStatus::NotUsed, 200, 500_000.0, 20_000_000); r2.metrics.avg_cpu_percent = 60.0; let results = vec![r1, r2]; let aggregated = aggregate_new_format(&results); // baseline-fw is fastest (50ms), so other-fw has deltas vs it let delta = aggregated .comparison .deltas_vs_baseline .get("other-fw:markdown:single") .unwrap(); assert_eq!(delta.cpu_delta_pp, 30.0); // 60 - 30 = 30 percentage points assert!((delta.cpu_delta_percent - 100.0).abs() < 0.1); // (60-30)/30 * 100 = 100% } }