Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/realizar/src/bench/mod.rs
Line
Count
Source
1
//! Benchmark harness for model runner comparison
2
//!
3
//! Implements the benchmark specification v1.1 with Toyota Way engineering principles:
4
//! - Dynamic CV-based stop-rule (Hoefler & Belli, SC12)
5
//! - Thermal throttling protocol
6
//! - ITL variance measurement (Dean & Barroso, "Tail at Scale")
7
//! - KV-cache fragmentation detection (PagedAttention methodology)
8
//! - KL-Divergence quality validation (LLM.int8())
9
//!
10
//! ## References
11
//!
12
//! - [17] Hoefler & Belli, "Scientific Benchmarking of Parallel Computing Systems", SC'15
13
//! - [11] Dean & Barroso, "The Tail at Scale", CACM 2013
14
//! - [12] Kwon et al., "PagedAttention", SOSP'23
15
//! - [13] Dettmers et al., "LLM.int8()", NeurIPS 2022
16
17
#![allow(clippy::cast_precision_loss)] // Statistical functions need usize->f64
18
19
use std::time::Duration;
20
21
use serde::{Deserialize, Serialize};
22
23
#[cfg(feature = "bench-http")]
24
use crate::http_client::{CompletionRequest, ModelHttpClient, OllamaOptions, OllamaRequest};
25
26
// PMAT-802: Extracted modules
27
mod runtime;
28
mod statistics;
29
mod load_testing;
30
mod matrix;
31
mod gpu_parity;
32
33
// RuntimeType always available (matrix.rs needs it)
34
pub use runtime::RuntimeType;
35
36
// HTTP-dependent runtime backends
37
#[cfg(feature = "bench-http")]
38
pub use runtime::{
39
    InferenceRequest, InferenceResponse, BackendInfo, RuntimeBackend,
40
    MockBackend, BackendRegistry, LlamaCppConfig, VllmConfig, LlamaCppBackend,
41
    VllmBackend, OllamaConfig, OllamaBackend,
42
};
43
44
pub use statistics::{
45
    MeasurementProtocol, LatencyStatistics, detect_outliers, BenchmarkMetrics,
46
    Regression, RegressionReport, RegressionDetector, WelchTTestResult, welch_t_test,
47
};
48
49
pub use load_testing::{LoadTestConfig, LoadTestResult, LoadTestRunner};
50
51
pub use matrix::{
52
    MatrixBenchmarkEntry, BenchmarkMatrix, MatrixBenchmarkConfig,
53
    BackendSummary, MatrixSummary, ComputeBackendType,
54
};
55
56
pub use gpu_parity::{
57
    GpuParityBenchmark, GpuParityResult, GapAnalysis, FalsifiableClaim,
58
    OptimizedGemmConfig, GemmPerformanceResult, OptimizedGemmBenchmark,
59
    FusedOpType, FusedOpSpec, FlashAttentionConfig, Imp900Result, MemoryPoolConfig,
60
};
61
62
// ============================================================================
63
// Dynamic Sampler (Section 2.1)
64
// ============================================================================
65
66
/// Dynamic stop-rule based on Coefficient of Variation (CV)
67
///
68
/// Per Hoefler & Belli [17], fixed iteration counts mask variance characteristics.
69
/// This sampler stops when statistical stability is achieved.
70
#[derive(Debug, Clone)]
71
pub struct DynamicSampler {
72
    /// Minimum number of samples before checking CV
73
    pub min_samples: usize,
74
    /// Maximum samples (failsafe)
75
    pub max_samples: usize,
76
    /// Target CV threshold (default: 0.05 = 5%)
77
    pub cv_threshold: f64,
78
    /// Sliding window size for CV calculation
79
    pub cv_window: usize,
80
    /// Number of consecutive stable windows required
81
    pub stability_count: usize,
82
    /// Current stability streak
83
    stable_streak: usize,
84
}
85
86
impl Default for DynamicSampler {
87
6
    fn default() -> Self {
88
6
        Self {
89
6
            min_samples: 100,
90
6
            max_samples: 10_000,
91
6
            cv_threshold: 0.05,
92
6
            cv_window: 50,
93
6
            stability_count: 3,
94
6
            stable_streak: 0,
95
6
        }
96
6
    }
97
}
98
99
impl DynamicSampler {
100
    /// Create a new sampler with custom parameters
101
    #[must_use]
102
8
    pub fn new(min_samples: usize, max_samples: usize, cv_threshold: f64) -> Self {
103
8
        Self {
104
8
            min_samples,
105
8
            max_samples,
106
8
            cv_threshold,
107
8
            cv_window: 50,
108
8
            stability_count: 3,
109
8
            stable_streak: 0,
110
8
        }
111
8
    }
112
113
    /// Check if sampling should continue
114
    ///
115
    /// Returns `true` if more samples are needed, `false` if stable.
116
    #[must_use]
117
20
    pub fn should_continue(&mut self, samples: &[f64]) -> bool {
118
20
        let n = samples.len();
119
120
        // Always continue until minimum samples
121
20
        if n < self.min_samples {
122
11
            return true;
123
9
        }
124
125
        // Stop at maximum (failsafe)
126
9
        if n >= self.max_samples {
127
2
            return false;
128
7
        }
129
130
        // Compute CV over sliding window
131
7
        let window_start = n.saturating_sub(self.cv_window);
132
7
        let window = &samples[window_start..];
133
7
        let cv = compute_cv(window);
134
135
7
        if cv < self.cv_threshold {
136
7
            self.stable_streak += 1;
137
7
            if self.stable_streak >= self.stability_count {
138
3
                return false; // Stable - stop sampling
139
4
            }
140
0
        } else {
141
0
            self.stable_streak = 0;
142
0
        }
143
144
4
        true // Continue sampling
145
20
    }
146
147
    /// Get the current CV for the last window
148
    #[must_use]
149
7
    pub fn current_cv(&self, samples: &[f64]) -> f64 {
150
7
        if samples.len() < 2 {
151
2
            return f64::INFINITY;
152
5
        }
153
5
        let window_start = samples.len().saturating_sub(self.cv_window);
154
5
        compute_cv(&samples[window_start..])
155
7
    }
156
157
    /// Reset the sampler for a new run
158
1
    pub fn reset(&mut self) {
159
1
        self.stable_streak = 0;
160
1
    }
161
}
162
163
/// Compute Coefficient of Variation (CV = std_dev / mean)
164
55
fn compute_cv(data: &[f64]) -> f64 {
165
55
    if data.len() < 2 {
166
22
        return f64::INFINITY;
167
33
    }
168
169
33
    let n = data.len() as f64;
170
33
    let mean = data.iter().sum::<f64>() / n;
171
172
33
    if mean.abs() < 1e-10 {
173
1
        return f64::INFINITY;
174
32
    }
175
176
510
    let 
variance32
=
data32
.
iter32
().
map32
(|x| (x - mean).powi(2)).
sum32
::<f64>() /
(n - 1.0)32
;
177
32
    let std_dev = variance.sqrt();
178
179
32
    std_dev / mean.abs()
180
55
}
181
182
// ============================================================================
183
// Thermal Guard (Section 2.2)
184
// ============================================================================
185
186
/// Temperature monitoring for benchmark validity
187
///
188
/// Per spec Section 2.2, benchmarks are invalid if temperature variance > 2°C.
189
#[derive(Debug, Clone)]
190
pub struct ThermalGuard {
191
    /// Maximum temperature before cooldown (°C)
192
    pub max_temp_c: f64,
193
    /// Temperature to resume at after cooldown (°C)
194
    pub cooldown_threshold_c: f64,
195
    /// Cooldown sleep duration (ms)
196
    pub cooldown_sleep_ms: u64,
197
    /// Maximum allowed temperature variance (°C)
198
    pub temp_variance_c: f64,
199
}
200
201
impl Default for ThermalGuard {
202
20
    fn default() -> Self {
203
20
        Self {
204
20
            max_temp_c: 80.0,
205
20
            cooldown_threshold_c: 70.0,
206
20
            cooldown_sleep_ms: 10_000,
207
20
            temp_variance_c: 2.0,
208
20
        }
209
20
    }
210
}
211
212
/// Result of thermal validation
213
#[derive(Debug, Clone, PartialEq)]
214
pub enum ThermalValidity {
215
    /// Temperature variance within acceptable range
216
    Valid,
217
    /// Temperature variance too high
218
    Invalid(String),
219
}
220
221
impl ThermalGuard {
222
    /// Create a new ThermalGuard with custom parameters
223
    #[must_use]
224
1
    pub fn new(
225
1
        max_temp_c: f64,
226
1
        cooldown_threshold_c: f64,
227
1
        cooldown_sleep_ms: u64,
228
1
        temp_variance_c: f64,
229
1
    ) -> Self {
230
1
        Self {
231
1
            max_temp_c,
232
1
            cooldown_threshold_c,
233
1
            cooldown_sleep_ms,
234
1
            temp_variance_c,
235
1
        }
236
1
    }
237
238
    /// Check if cooldown is needed (without sleeping)
239
    #[must_use]
240
2
    pub fn needs_cooldown(&self, current_temp: f64) -> bool {
241
2
        current_temp > self.max_temp_c
242
2
    }
243
244
    /// Check if benchmark results are thermally valid
245
    #[must_use]
246
8
    pub fn validate_run(&self, temps: &[f64]) -> ThermalValidity {
247
8
        if temps.is_empty() {
248
2
            return ThermalValidity::Valid;
249
6
        }
250
251
6
        let variance = compute_variance(temps);
252
6
        let std_dev = variance.sqrt();
253
254
6
        if std_dev > self.temp_variance_c {
255
3
            ThermalValidity::Invalid(format!(
256
3
                "Temperature variance {std_dev:.2}°C exceeds threshold {:.2}°C",
257
3
                self.temp_variance_c
258
3
            ))
259
        } else {
260
3
            ThermalValidity::Valid
261
        }
262
8
    }
263
264
    /// Check if cooldown is needed and sleep if so
265
1
    pub fn cooldown_if_needed(&self, current_temp: f64) {
266
1
        if current_temp > self.max_temp_c {
267
0
            std::thread::sleep(Duration::from_millis(self.cooldown_sleep_ms));
268
1
        }
269
1
    }
270
271
    /// Get max temperature from readings
272
    #[must_use]
273
6
    pub fn max_temp(&self, temps: &[f64]) -> f64 {
274
6
        if temps.is_empty() {
275
2
            return 0.0;
276
4
        }
277
4
        temps.iter().copied().fold(f64::NEG_INFINITY, f64::max)
278
6
    }
279
280
    /// Get temperature variance
281
    #[must_use]
282
7
    pub fn temp_variance(&self, temps: &[f64]) -> f64 {
283
7
        compute_variance(temps).sqrt()
284
7
    }
285
}
286
287
/// Compute variance of a dataset
288
22
fn compute_variance(data: &[f64]) -> f64 {
289
22
    if data.len() < 2 {
290
8
        return 0.0;
291
14
    }
292
293
14
    let n = data.len() as f64;
294
14
    let mean = data.iter().sum::<f64>() / n;
295
213
    
data14
.
iter14
().
map14
(|x| (x - mean).powi(2)).
sum14
::<f64>() /
(n - 1.0)14
296
22
}
297
298
// ============================================================================
299
// KV-Cache Metrics (Section 4.3)
300
// ============================================================================
301
302
/// KV-cache fragmentation metrics per PagedAttention [12]
303
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304
pub struct KvCacheMetrics {
305
    /// Total allocated KV-cache memory (bytes)
306
    pub allocated_bytes: u64,
307
    /// Actually used KV-cache memory (bytes)
308
    pub used_bytes: u64,
309
    /// Fragmentation percentage (waste)
310
    pub fragmentation_pct: f64,
311
}
312
313
impl KvCacheMetrics {
314
    /// Create new metrics from allocated and used bytes
315
    #[must_use]
316
5
    pub fn new(allocated_bytes: u64, used_bytes: u64) -> Self {
317
5
        let waste = allocated_bytes.saturating_sub(used_bytes);
318
5
        let fragmentation_pct = if allocated_bytes > 0 {
319
4
            (waste as f64 / allocated_bytes as f64) * 100.0
320
        } else {
321
1
            0.0
322
        };
323
324
5
        Self {
325
5
            allocated_bytes,
326
5
            used_bytes,
327
5
            fragmentation_pct,
328
5
        }
329
5
    }
330
331
    /// Get allocated memory in MB
332
    #[must_use]
333
1
    pub fn allocated_mb(&self) -> f64 {
334
1
        self.allocated_bytes as f64 / (1024.0 * 1024.0)
335
1
    }
336
337
    /// Get used memory in MB
338
    #[must_use]
339
1
    pub fn used_mb(&self) -> f64 {
340
1
        self.used_bytes as f64 / (1024.0 * 1024.0)
341
1
    }
342
343
    /// Check if fragmentation is acceptable (< threshold)
344
    #[must_use]
345
3
    pub fn is_acceptable(&self, threshold_pct: f64) -> bool {
346
3
        self.fragmentation_pct < threshold_pct
347
3
    }
348
}
349
350
// ============================================================================
351
// Energy Metrics (Section 4.4)
352
// ============================================================================
353
354
/// Energy measurement metrics per Garcia-Martin et al. [14]
355
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
356
pub struct EnergyMetrics {
357
    /// Total energy consumed (Joules)
358
    pub total_joules: f64,
359
    /// Idle power consumption (Watts)
360
    pub idle_watts: f64,
361
    /// Average active power consumption (Watts)
362
    pub active_watts_avg: f64,
363
    /// Number of tokens generated
364
    pub tokens_generated: u64,
365
}
366
367
impl EnergyMetrics {
368
    /// Create new energy metrics
369
    #[must_use]
370
5
    pub fn new(total_joules: f64, idle_watts: f64, active_watts_avg: f64, tokens: u64) -> Self {
371
5
        Self {
372
5
            total_joules,
373
5
            idle_watts,
374
5
            active_watts_avg,
375
5
            tokens_generated: tokens,
376
5
        }
377
5
    }
378
379
    /// Calculate energy per token (Joules/token)
380
    #[must_use]
381
2
    pub fn joules_per_token(&self) -> f64 {
382
2
        if self.tokens_generated == 0 {
383
1
            return 0.0;
384
1
        }
385
1
        self.total_joules / self.tokens_generated as f64
386
2
    }
387
388
    /// Calculate energy efficiency ratio (tokens per Joule)
389
    #[must_use]
390
3
    pub fn tokens_per_joule(&self) -> f64 {
391
3
        if self.total_joules < 1e-10 {
392
2
            return 0.0;
393
1
        }
394
1
        self.tokens_generated as f64 / self.total_joules
395
3
    }
396
}
397
398
// ============================================================================
399
// ITL (Inter-Token Latency) Metrics (Section 4.2)
400
// ============================================================================
401
402
/// Inter-Token Latency metrics per "Tail at Scale" [11]
403
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
404
pub struct ItlMetrics {
405
    /// Median ITL (ms)
406
    pub median_ms: f64,
407
    /// Standard deviation (jitter indicator)
408
    pub std_dev_ms: f64,
409
    /// p99 ITL (ms)
410
    pub p99_ms: f64,
411
    /// p99.9 ITL (ms)
412
    pub p999_ms: f64,
413
}
414
415
impl ItlMetrics {
416
    /// Create ITL metrics from raw measurements
417
    #[must_use]
418
10
    pub fn from_measurements(itl_times_ms: &[f64]) -> Self {
419
10
        if itl_times_ms.is_empty() {
420
2
            return Self::default();
421
8
        }
422
423
8
        let mut sorted = itl_times_ms.to_vec();
424
247
        
sorted8
.
sort_by8
(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
425
426
8
        let n = sorted.len();
427
8
        let median_ms = if n.is_multiple_of(2) {
428
6
            f64::midpoint(sorted[n / 2 - 1], sorted[n / 2])
429
        } else {
430
2
            sorted[n / 2]
431
        };
432
433
8
        let mean = itl_times_ms.iter().sum::<f64>() / n as f64;
434
226
        let 
variance8
=
itl_times_ms8
.
iter8
().
map8
(|x| (x - mean).powi(2)).
sum8
::<f64>()
435
8
            / (n as f64 - 1.0).max(1.0);
436
8
        let std_dev_ms = variance.sqrt();
437
438
8
        let percentile_99 = ((n as f64 * 0.99).ceil() as usize)
439
8
            .saturating_sub(1)
440
8
            .min(n - 1);
441
8
        let percentile_999 = ((n as f64 * 0.999).ceil() as usize)
442
8
            .saturating_sub(1)
443
8
            .min(n - 1);
444
445
8
        Self {
446
8
            median_ms,
447
8
            std_dev_ms,
448
8
            p99_ms: sorted[percentile_99],
449
8
            p999_ms: sorted[percentile_999],
450
8
        }
451
10
    }
452
453
    /// Check if jitter is acceptable (std_dev < threshold)
454
    #[must_use]
455
2
    pub fn is_low_jitter(&self, threshold_ms: f64) -> bool {
456
2
        self.std_dev_ms < threshold_ms
457
2
    }
458
}
459
460
// ============================================================================
461
// KL-Divergence Quality Validation (Section 6.1)
462
// ============================================================================
463
464
/// Result of quantization quality validation
465
#[derive(Debug, Clone, PartialEq)]
466
pub enum QualityResult {
467
    /// Quality is acceptable
468
    Pass {
469
        /// Measured KL-divergence (nats)
470
        kl_divergence: f64,
471
    },
472
    /// Quality degradation detected
473
    Fail {
474
        /// Measured KL-divergence (nats)
475
        kl_divergence: f64,
476
        /// Threshold that was exceeded
477
        threshold: f64,
478
        /// Descriptive message
479
        message: &'static str,
480
    },
481
}
482
483
/// Compute softmax of logits
484
18
fn softmax(logits: &[f32]) -> Vec<f64> {
485
18
    let max_logit = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
486
18
    let exp_logits: Vec<f64> = logits
487
18
        .iter()
488
76
        .
map18
(|x| ((*x - max_logit) as f64).exp())
489
18
        .collect();
490
18
    let sum: f64 = exp_logits.iter().sum();
491
76
    
exp_logits.iter()18
.
map18
(|x| x / sum).
collect18
()
492
18
}
493
494
/// Validate quantization quality using KL-Divergence
495
///
496
/// Per LLM.int8() [13], epsilon checks fail on outlier features.
497
/// KL-divergence provides a proper information-theoretic measure.
498
///
499
/// # Arguments
500
///
501
/// * `fp32_logits` - Reference logits from FP32 model
502
/// * `quantized_logits` - Logits from quantized model
503
/// * `threshold` - Maximum acceptable KL-divergence (nats)
504
///
505
/// # Returns
506
///
507
/// `QualityResult::Pass` if KL-divergence < threshold, `Fail` otherwise.
508
#[must_use]
509
8
pub fn validate_quantization_quality(
510
8
    fp32_logits: &[f32],
511
8
    quantized_logits: &[f32],
512
8
    threshold: f64,
513
8
) -> QualityResult {
514
8
    if fp32_logits.len() != quantized_logits.len() {
515
1
        return QualityResult::Fail {
516
1
            kl_divergence: f64::INFINITY,
517
1
            threshold,
518
1
            message: "Logit vector lengths do not match",
519
1
        };
520
7
    }
521
522
7
    if fp32_logits.is_empty() {
523
1
        return QualityResult::Pass { kl_divergence: 0.0 };
524
6
    }
525
526
    // Convert to probability distributions
527
6
    let fp32_probs = softmax(fp32_logits);
528
6
    let quant_probs = softmax(quantized_logits);
529
530
    // Compute KL(P_fp32 || P_quant)
531
6
    let kl_div: f64 = fp32_probs
532
6
        .iter()
533
6
        .zip(&quant_probs)
534
27
        .
map6
(|(p, q)| {
535
27
            if *p > 1e-10 && *q > 1e-10 {
536
27
                p * (p / q).ln()
537
            } else {
538
0
                0.0
539
            }
540
27
        })
541
6
        .sum();
542
543
6
    if kl_div < threshold {
544
4
        QualityResult::Pass {
545
4
            kl_divergence: kl_div,
546
4
        }
547
    } else {
548
2
        QualityResult::Fail {
549
2
            kl_divergence: kl_div,
550
2
            threshold,
551
2
            message: "Quantization quality degradation detected",
552
2
        }
553
    }
554
8
}
555
556
// ============================================================================
557
// Benchmark Result (Section 4.1)
558
// ============================================================================
559
560
/// Configuration for a benchmark run
561
#[derive(Debug, Clone, Serialize, Deserialize)]
562
pub struct BenchmarkConfig {
563
    /// Model identifier
564
    pub model: String,
565
    /// Model format (apr, gguf, safetensors)
566
    pub format: String,
567
    /// Quantization level
568
    pub quantization: String,
569
    /// Runtime name
570
    pub runtime: String,
571
    /// Runtime version
572
    pub runtime_version: String,
573
}
574
575
/// Complete benchmark result per spec Section 4.1
576
#[derive(Debug, Clone, Serialize, Deserialize)]
577
pub struct BenchmarkResult {
578
    /// Configuration used
579
    pub config: BenchmarkConfig,
580
    /// Cold start time (ms)
581
    pub cold_start_ms: f64,
582
    /// Model load time (ms)
583
    pub model_load_ms: f64,
584
    /// Time-to-first-token measurements (ms)
585
    pub ttft_ms: Vec<f64>,
586
    /// Inter-token latency measurements (ms)
587
    pub itl_ms: Vec<f64>,
588
    /// Generation throughput measurements (tok/s)
589
    pub generation_tok_s: Vec<f64>,
590
    /// Peak memory usage (MB)
591
    pub peak_memory_mb: u64,
592
    /// KV-cache fragmentation percentage
593
    pub kv_cache_waste_pct: f64,
594
    /// Total energy consumed (Joules)
595
    pub energy_joules: f64,
596
    /// Total tokens generated
597
    pub tokens_generated: u64,
598
    /// Actual number of iterations (dynamic sampling)
599
    pub actual_iterations: usize,
600
    /// CV at stop point
601
    pub cv_at_stop: f64,
602
    /// Unix timestamp
603
    pub timestamp: u64,
604
}
605
606
/// Summary statistics for benchmark results
607
#[derive(Debug, Clone, Serialize, Deserialize)]
608
pub struct BenchmarkSummary {
609
    // TTFT metrics
610
    /// TTFT p50 (ms)
611
    pub ttft_p50: f64,
612
    /// TTFT p95 (ms)
613
    pub ttft_p95: f64,
614
    /// TTFT p99 (ms)
615
    pub ttft_p99: f64,
616
    /// TTFT p99.9 (ms)
617
    pub ttft_p999: f64,
618
619
    // ITL metrics
620
    /// ITL median (ms)
621
    pub itl_median: f64,
622
    /// ITL standard deviation (jitter)
623
    pub itl_std_dev: f64,
624
625
    // Throughput metrics
626
    /// Throughput median (tok/s)
627
    pub throughput_median: f64,
628
    /// Throughput 95% CI (lower, upper)
629
    pub throughput_ci_95: (f64, f64),
630
631
    // Energy metrics
632
    /// Energy per token (J/tok)
633
    pub token_joules: f64,
634
635
    // Memory metrics
636
    /// KV-cache waste percentage
637
    pub memory_waste_pct: f64,
638
639
    // Statistical validity
640
    /// Number of iterations run
641
    pub iterations: usize,
642
    /// Final CV value
643
    pub cv_final: f64,
644
}
645
646
impl BenchmarkResult {
647
    /// Generate summary statistics from raw measurements
648
    #[must_use]
649
5
    pub fn summary(&self) -> BenchmarkSummary {
650
        BenchmarkSummary {
651
5
            ttft_p50: percentile(&self.ttft_ms, 50.0),
652
5
            ttft_p95: percentile(&self.ttft_ms, 95.0),
653
5
            ttft_p99: percentile(&self.ttft_ms, 99.0),
654
5
            ttft_p999: percentile(&self.ttft_ms, 99.9),
655
656
5
            itl_median: percentile(&self.itl_ms, 50.0),
657
5
            itl_std_dev: compute_std_dev(&self.itl_ms),
658
659
5
            throughput_median: percentile(&self.generation_tok_s, 50.0),
660
5
            throughput_ci_95: bootstrap_ci(&self.generation_tok_s, 0.95, 1000),
661
662
5
            token_joules: if self.tokens_generated > 0 {
663
4
                self.energy_joules / self.tokens_generated as f64
664
            } else {
665
1
                0.0
666
            },
667
668
5
            memory_waste_pct: self.kv_cache_waste_pct,
669
5
            iterations: self.actual_iterations,
670
5
            cv_final: self.cv_at_stop,
671
        }
672
5
    }
673
}
674
675
/// Compute percentile of a dataset
676
162
fn percentile(data: &[f64], p: f64) -> f64 {
677
162
    if data.is_empty() {
678
3
        return 0.0;
679
159
    }
680
681
159
    let mut sorted = data.to_vec();
682
3.81k
    
sorted159
.
sort_by159
(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
683
684
159
    let idx = ((sorted.len() as f64 * p / 100.0).ceil() as usize)
685
159
        .saturating_sub(1)
686
159
        .min(sorted.len() - 1);
687
159
    sorted[idx]
688
162
}
689
690
/// Compute standard deviation
691
7
fn compute_std_dev(data: &[f64]) -> f64 {
692
7
    compute_variance(data).sqrt()
693
7
}
694
695
/// Bootstrap confidence interval
696
10
fn bootstrap_ci(data: &[f64], confidence: f64, n_resamples: usize) -> (f64, f64) {
697
10
    if data.is_empty() {
698
1
        return (0.0, 0.0);
699
9
    }
700
701
9
    let mut bootstrap_means = Vec::with_capacity(n_resamples);
702
9
    let n = data.len();
703
704
8.10k
    for i in 0..
n_resamples9
{
705
        // Simple deterministic pseudo-random for reproducibility
706
        // Uses a basic LCG instead of hash for clippy compliance
707
8.10k
        let seed = (i as u64)
708
8.10k
            .wrapping_mul(6_364_136_223_846_793_005)
709
8.10k
            .wrapping_add(1);
710
711
8.10k
        let mut sum = 0.0;
712
218k
        for j in 0..
n8.10k
{
713
218k
            let idx = ((seed.wrapping_mul(j as u64 + 1)) as usize) % n;
714
218k
            sum += data[idx];
715
218k
        }
716
8.10k
        bootstrap_means.push(sum / n as f64);
717
    }
718
719
25.4k
    
bootstrap_means9
.
sort_by9
(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
720
721
9
    let alpha = 1.0 - confidence;
722
9
    let lower_idx = ((n_resamples as f64 * alpha / 2.0).floor() as usize).min(n_resamples - 1);
723
9
    let upper_idx =
724
9
        ((n_resamples as f64 * (1.0 - alpha / 2.0)).ceil() as usize).min(n_resamples - 1);
725
726
9
    (bootstrap_means[lower_idx], bootstrap_means[upper_idx])
727
10
}
728
729
// ============================================================================
730
// Convoy Test (Section 2.4)
731
// ============================================================================
732
733
/// Workload type for convoy testing
734
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735
pub enum WorkloadType {
736
    /// Short QA: 32 input tokens, 64 output tokens
737
    ShortQa,
738
    /// Long Context: 2048 input tokens, 512 output tokens
739
    LongContext,
740
}
741
742
impl WorkloadType {
743
    /// Get input token count for this workload type
744
    #[must_use]
745
2
    pub const fn input_tokens(&self) -> usize {
746
2
        match self {
747
1
            Self::ShortQa => 32,
748
1
            Self::LongContext => 2048,
749
        }
750
2
    }
751
752
    /// Get output token count for this workload type
753
    #[must_use]
754
2
    pub const fn output_tokens(&self) -> usize {
755
2
        match self {
756
1
            Self::ShortQa => 64,
757
1
            Self::LongContext => 512,
758
        }
759
2
    }
760
}
761
762
/// Configuration for convoy test per spec Section 2.4
763
#[derive(Debug, Clone)]
764
pub struct ConvoyTestConfig {
765
    /// Number of long-context requests (default: 10)
766
    pub long_requests: usize,
767
    /// Number of short-QA requests (default: 100)
768
    pub short_requests: usize,
769
    /// Maximum acceptable p99 latency increase (default: 50%)
770
    pub max_p99_increase_pct: f64,
771
    /// Maximum acceptable head-of-line blocking time (ms)
772
    pub max_hol_blocking_ms: f64,
773
    /// Maximum acceptable KV-cache fragmentation (%)
774
    pub max_kv_fragmentation_pct: f64,
775
}
776
777
impl Default for ConvoyTestConfig {
778
13
    fn default() -> Self {
779
13
        Self {
780
13
            long_requests: 10,
781
13
            short_requests: 100,
782
13
            max_p99_increase_pct: 50.0,
783
13
            max_hol_blocking_ms: 500.0,
784
13
            max_kv_fragmentation_pct: 15.0,
785
13
        }
786
13
    }
787
}
788
789
/// Result of a single request in convoy test
790
#[derive(Debug, Clone, Serialize, Deserialize)]
791
pub struct ConvoyRequestResult {
792
    /// Request type
793
    pub workload_type: String,
794
    /// Time spent waiting (head-of-line blocking)
795
    pub queue_time_ms: f64,
796
    /// Time to first token
797
    pub ttft_ms: f64,
798
    /// Total latency
799
    pub total_latency_ms: f64,
800
}
801
802
/// Overall convoy test result
803
#[derive(Debug, Clone, Serialize, Deserialize)]
804
pub struct ConvoyTestResult {
805
    /// Number of long-context requests in test
806
    pub long_requests: usize,
807
    /// Number of short-QA requests in test
808
    pub short_requests: usize,
809
810
    /// Baseline: Short-QA p99 without convoy
811
    pub baseline_short_p99_ms: f64,
812
    /// Convoy: Short-QA p99 with convoy
813
    pub convoy_short_p99_ms: f64,
814
    /// P99 increase percentage
815
    pub p99_increase_pct: f64,
816
817
    /// Maximum head-of-line blocking observed
818
    pub max_hol_blocking_ms: f64,
819
    /// Average head-of-line blocking
820
    pub avg_hol_blocking_ms: f64,
821
822
    /// KV-cache fragmentation during convoy
823
    pub kv_fragmentation_pct: f64,
824
825
    /// Pass/fail status
826
    pub passed: bool,
827
    /// Failure reasons (if any)
828
    pub failure_reasons: Vec<String>,
829
}
830
831
impl ConvoyTestResult {
832
    /// Create a new convoy test result from measurements
833
    #[must_use]
834
12
    pub fn new(
835
12
        config: &ConvoyTestConfig,
836
12
        baseline_short_latencies: &[f64],
837
12
        convoy_short_latencies: &[f64],
838
12
        hol_blocking_times: &[f64],
839
12
        kv_fragmentation_pct: f64,
840
12
    ) -> Self {
841
12
        let baseline_short_p99 = percentile(baseline_short_latencies, 99.0);
842
12
        let convoy_short_p99 = percentile(convoy_short_latencies, 99.0);
843
844
12
        let p99_increase_pct = if baseline_short_p99 > 0.0 {
845
11
            ((convoy_short_p99 - baseline_short_p99) / baseline_short_p99) * 100.0
846
        } else {
847
1
            0.0
848
        };
849
850
12
        let max_hol_blocking = hol_blocking_times.iter().copied().fold(0.0_f64, f64::max);
851
12
        let avg_hol_blocking = if hol_blocking_times.is_empty() {
852
1
            0.0
853
        } else {
854
11
            hol_blocking_times.iter().sum::<f64>() / hol_blocking_times.len() as f64
855
        };
856
857
12
        let mut failure_reasons = Vec::new();
858
859
12
        if p99_increase_pct > config.max_p99_increase_pct {
860
1
            failure_reasons.push(format!(
861
1
                "P99 increase {p99_increase_pct:.1}% exceeds threshold {:.1}%",
862
1
                config.max_p99_increase_pct
863
1
            ));
864
11
        }
865
866
12
        if max_hol_blocking > config.max_hol_blocking_ms {
867
1
            failure_reasons.push(format!(
868
1
                "Max HOL blocking {max_hol_blocking:.1}ms exceeds threshold {:.1}ms",
869
1
                config.max_hol_blocking_ms
870
1
            ));
871
11
        }
872
873
12
        if kv_fragmentation_pct > config.max_kv_fragmentation_pct {
874
1
            failure_reasons.push(format!(
875
1
                "KV fragmentation {kv_fragmentation_pct:.1}% exceeds threshold {:.1}%",
876
1
                config.max_kv_fragmentation_pct
877
1
            ));
878
11
        }
879
880
12
        Self {
881
12
            long_requests: config.long_requests,
882
12
            short_requests: config.short_requests,
883
12
            baseline_short_p99_ms: baseline_short_p99,
884
12
            convoy_short_p99_ms: convoy_short_p99,
885
12
            p99_increase_pct,
886
12
            max_hol_blocking_ms: max_hol_blocking,
887
12
            avg_hol_blocking_ms: avg_hol_blocking,
888
12
            kv_fragmentation_pct,
889
12
            passed: failure_reasons.is_empty(),
890
12
            failure_reasons,
891
12
        }
892
12
    }
893
}
894
895
// ============================================================================
896
// Saturation Test (Section 2.5)
897
// ============================================================================
898
899
/// Configuration for saturation stress test per spec Section 2.5
900
#[derive(Debug, Clone)]
901
pub struct SaturationTestConfig {
902
    /// CPU load percentage (default: 50%)
903
    pub cpu_load_pct: u8,
904
    /// Maximum acceptable throughput degradation (default: 30%)
905
    pub max_throughput_degradation_pct: f64,
906
    /// Maximum acceptable p99 latency increase (default: 100%)
907
    pub max_p99_increase_pct: f64,
908
}
909
910
impl Default for SaturationTestConfig {
911
12
    fn default() -> Self {
912
12
        Self {
913
12
            cpu_load_pct: 50,
914
12
            max_throughput_degradation_pct: 30.0,
915
12
            max_p99_increase_pct: 100.0,
916
12
        }
917
12
    }
918
}
919
920
/// Saturation test result
921
#[derive(Debug, Clone, Serialize, Deserialize)]
922
pub struct SaturationTestResult {
923
    /// CPU load used
924
    pub cpu_load_pct: u8,
925
926
    /// Baseline throughput (tok/s)
927
    pub baseline_throughput: f64,
928
    /// Stressed throughput (tok/s)
929
    pub stressed_throughput: f64,
930
    /// Throughput degradation percentage
931
    pub throughput_degradation_pct: f64,
932
933
    /// Baseline p99 latency (ms)
934
    pub baseline_p99_ms: f64,
935
    /// Stressed p99 latency (ms)
936
    pub stressed_p99_ms: f64,
937
    /// P99 latency increase percentage
938
    pub p99_increase_pct: f64,
939
940
    /// Pass/fail status
941
    pub passed: bool,
942
    /// Failure reasons (if any)
943
    pub failure_reasons: Vec<String>,
944
}
945
946
impl SaturationTestResult {
947
    /// Create a new saturation test result
948
    #[must_use]
949
11
    pub fn new(
950
11
        config: &SaturationTestConfig,
951
11
        baseline_throughputs: &[f64],
952
11
        stressed_throughputs: &[f64],
953
11
        baseline_latencies: &[f64],
954
11
        stressed_latencies: &[f64],
955
11
    ) -> Self {
956
11
        let baseline_throughput = if baseline_throughputs.is_empty() {
957
1
            0.0
958
        } else {
959
10
            baseline_throughputs.iter().sum::<f64>() / baseline_throughputs.len() as f64
960
        };
961
962
11
        let stressed_throughput = if stressed_throughputs.is_empty() {
963
1
            0.0
964
        } else {
965
10
            stressed_throughputs.iter().sum::<f64>() / stressed_throughputs.len() as f64
966
        };
967
968
11
        let throughput_degradation_pct = if baseline_throughput > 0.0 {
969
9
            ((baseline_throughput - stressed_throughput) / baseline_throughput) * 100.0
970
        } else {
971
2
            0.0
972
        };
973
974
11
        let baseline_p99 = percentile(baseline_latencies, 99.0);
975
11
        let stressed_p99 = percentile(stressed_latencies, 99.0);
976
977
11
        let p99_increase_pct = if baseline_p99 > 0.0 {
978
9
            ((stressed_p99 - baseline_p99) / baseline_p99) * 100.0
979
        } else {
980
2
            0.0
981
        };
982
983
11
        let mut failure_reasons = Vec::new();
984
985
11
        if throughput_degradation_pct > config.max_throughput_degradation_pct {
986
1
            failure_reasons.push(format!(
987
1
                "Throughput degradation {throughput_degradation_pct:.1}% exceeds threshold {:.1}%",
988
1
                config.max_throughput_degradation_pct
989
1
            ));
990
10
        }
991
992
11
        if p99_increase_pct > config.max_p99_increase_pct {
993
1
            failure_reasons.push(format!(
994
1
                "P99 increase {p99_increase_pct:.1}% exceeds threshold {:.1}%",
995
1
                config.max_p99_increase_pct
996
1
            ));
997
10
        }
998
999
11
        Self {
1000
11
            cpu_load_pct: config.cpu_load_pct,
1001
11
            baseline_throughput,
1002
11
            stressed_throughput,
1003
11
            throughput_degradation_pct,
1004
11
            baseline_p99_ms: baseline_p99,
1005
11
            stressed_p99_ms: stressed_p99,
1006
11
            p99_increase_pct,
1007
11
            passed: failure_reasons.is_empty(),
1008
11
            failure_reasons,
1009
11
        }
1010
11
    }
1011
}
1012
1013
// ============================================================================
1014
// Benchmark Runner (Full Harness)
1015
// ============================================================================
1016
1017
/// Hardware specification for reproducibility
1018
#[derive(Debug, Clone, Serialize, Deserialize)]
1019
pub struct HardwareSpec {
1020
    /// CPU model
1021
    pub cpu: String,
1022
    /// GPU model (if any)
1023
    pub gpu: Option<String>,
1024
    /// Total memory in GB
1025
    pub memory_gb: u64,
1026
    /// Storage type
1027
    pub storage: String,
1028
}
1029
1030
impl Default for HardwareSpec {
1031
38
    fn default() -> Self {
1032
38
        Self {
1033
38
            cpu: "Unknown".to_string(),
1034
38
            gpu: None,
1035
38
            memory_gb: 0,
1036
38
            storage: "Unknown".to_string(),
1037
38
        }
1038
38
    }
1039
}
1040
1041
/// Sampling method configuration
1042
#[derive(Debug, Clone, Serialize, Deserialize)]
1043
pub struct SamplingConfig {
1044
    /// Sampling method (e.g., "dynamic_cv")
1045
    pub method: String,
1046
    /// CV threshold for stopping
1047
    pub cv_threshold: f64,
1048
    /// Actual iterations run
1049
    pub actual_iterations: usize,
1050
    /// CV at stop point
1051
    pub cv_at_stop: f64,
1052
    /// Warmup iterations
1053
    pub warmup_iterations: usize,
1054
}
1055
1056
impl Default for SamplingConfig {
1057
19
    fn default() -> Self {
1058
19
        Self {
1059
19
            method: "dynamic_cv".to_string(),
1060
19
            cv_threshold: 0.05,
1061
19
            actual_iterations: 0,
1062
19
            cv_at_stop: 0.0,
1063
19
            warmup_iterations: 100,
1064
19
        }
1065
19
    }
1066
}
1067
1068
/// Thermal validity info
1069
#[derive(Debug, Clone, Serialize, Deserialize)]
1070
pub struct ThermalInfo {
1071
    /// Whether thermal conditions were valid
1072
    pub valid: bool,
1073
    /// Temperature variance (°C)
1074
    pub temp_variance_c: f64,
1075
    /// Maximum temperature observed (°C)
1076
    pub max_temp_c: f64,
1077
}
1078
1079
impl Default for ThermalInfo {
1080
19
    fn default() -> Self {
1081
19
        Self {
1082
19
            valid: true,
1083
19
            temp_variance_c: 0.0,
1084
19
            max_temp_c: 0.0,
1085
19
        }
1086
19
    }
1087
}
1088
1089
/// TTFT results structure
1090
#[derive(Debug, Clone, Serialize, Deserialize)]
1091
pub struct TtftResults {
1092
    /// P50 (median)
1093
    pub p50: f64,
1094
    /// P95
1095
    pub p95: f64,
1096
    /// P99
1097
    pub p99: f64,
1098
    /// P99.9
1099
    pub p999: f64,
1100
}
1101
1102
/// ITL results structure
1103
#[derive(Debug, Clone, Serialize, Deserialize)]
1104
pub struct ItlResults {
1105
    /// Median ITL
1106
    pub median: f64,
1107
    /// Standard deviation (jitter)
1108
    pub std_dev: f64,
1109
    /// P99 ITL
1110
    pub p99: f64,
1111
}
1112
1113
/// Throughput results structure
1114
#[derive(Debug, Clone, Serialize, Deserialize)]
1115
pub struct ThroughputResults {
1116
    /// Median throughput (tok/s)
1117
    pub median: f64,
1118
    /// 95% confidence interval
1119
    pub ci_95: (f64, f64),
1120
}
1121
1122
/// Memory results structure
1123
#[derive(Debug, Clone, Serialize, Deserialize)]
1124
pub struct MemoryResults {
1125
    /// Model size (MB)
1126
    pub model_mb: u64,
1127
    /// Peak RSS (MB)
1128
    pub peak_rss_mb: u64,
1129
    /// KV-cache waste percentage
1130
    pub kv_waste_pct: f64,
1131
}
1132
1133
/// Energy results structure
1134
#[derive(Debug, Clone, Serialize, Deserialize)]
1135
pub struct EnergyResults {
1136
    /// Total energy (Joules)
1137
    pub total_joules: f64,
1138
    /// Energy per token (J/tok)
1139
    pub token_joules: f64,
1140
    /// Idle power (Watts)
1141
    pub idle_watts: f64,
1142
}
1143
1144
/// Cold start results structure
1145
#[derive(Debug, Clone, Serialize, Deserialize)]
1146
pub struct ColdStartResults {
1147
    /// Median cold start time (ms)
1148
    pub median: f64,
1149
    /// P99 cold start time (ms)
1150
    pub p99: f64,
1151
}
1152
1153
/// Quality validation results
1154
#[derive(Debug, Clone, Serialize, Deserialize)]
1155
pub struct QualityValidation {
1156
    /// KL-divergence vs FP32
1157
    pub kl_divergence_vs_fp32: f64,
1158
    /// Perplexity on WikiText-2 (optional)
1159
    pub perplexity_wikitext2: Option<f64>,
1160
}
1161
1162
/// Full benchmark results per JSON schema v1.1 (Appendix B)
1163
#[derive(Debug, Clone, Serialize, Deserialize)]
1164
pub struct FullBenchmarkResult {
1165
    /// Schema version
1166
    pub version: String,
1167
    /// ISO 8601 timestamp
1168
    pub timestamp: String,
1169
    /// Model configuration
1170
    pub config: BenchmarkConfig,
1171
    /// Hardware specification
1172
    pub hardware: HardwareSpec,
1173
    /// Sampling configuration
1174
    pub sampling: SamplingConfig,
1175
    /// Thermal information
1176
    pub thermal: ThermalInfo,
1177
    /// All results
1178
    pub results: BenchmarkResults,
1179
    /// Quality validation
1180
    pub quality: QualityValidation,
1181
}
1182
1183
/// Consolidated benchmark results
1184
#[derive(Debug, Clone, Serialize, Deserialize)]
1185
pub struct BenchmarkResults {
1186
    /// Time-to-first-token metrics
1187
    pub ttft_ms: TtftResults,
1188
    /// Inter-token latency metrics
1189
    pub itl_ms: ItlResults,
1190
    /// Throughput metrics
1191
    pub throughput_tok_s: ThroughputResults,
1192
    /// Memory metrics
1193
    pub memory_mb: MemoryResults,
1194
    /// Energy metrics
1195
    pub energy: EnergyResults,
1196
    /// Cold start metrics
1197
    pub cold_start_ms: ColdStartResults,
1198
}
1199
1200
impl FullBenchmarkResult {
1201
    /// Create from a BenchmarkResult with additional metadata
1202
    #[must_use]
1203
3
    pub fn from_benchmark_result(
1204
3
        result: &BenchmarkResult,
1205
3
        hardware: HardwareSpec,
1206
3
        thermal_temps: &[f64],
1207
3
        kl_divergence: f64,
1208
3
    ) -> Self {
1209
3
        let thermal_guard = ThermalGuard::default();
1210
3
        let thermal_validity = thermal_guard.validate_run(thermal_temps);
1211
1212
3
        let summary = result.summary();
1213
1214
3
        Self {
1215
3
            version: "1.1".to_string(),
1216
3
            timestamp: chrono_timestamp(),
1217
3
            config: result.config.clone(),
1218
3
            hardware,
1219
3
            sampling: SamplingConfig {
1220
3
                method: "dynamic_cv".to_string(),
1221
3
                cv_threshold: 0.05,
1222
3
                actual_iterations: result.actual_iterations,
1223
3
                cv_at_stop: result.cv_at_stop,
1224
3
                warmup_iterations: 100,
1225
3
            },
1226
3
            thermal: ThermalInfo {
1227
3
                valid: thermal_validity == ThermalValidity::Valid,
1228
3
                temp_variance_c: thermal_guard.temp_variance(thermal_temps),
1229
3
                max_temp_c: thermal_guard.max_temp(thermal_temps),
1230
3
            },
1231
3
            results: BenchmarkResults {
1232
3
                ttft_ms: TtftResults {
1233
3
                    p50: summary.ttft_p50,
1234
3
                    p95: summary.ttft_p95,
1235
3
                    p99: summary.ttft_p99,
1236
3
                    p999: summary.ttft_p999,
1237
3
                },
1238
3
                itl_ms: ItlResults {
1239
3
                    median: summary.itl_median,
1240
3
                    std_dev: summary.itl_std_dev,
1241
3
                    p99: percentile(&result.itl_ms, 99.0),
1242
3
                },
1243
3
                throughput_tok_s: ThroughputResults {
1244
3
                    median: summary.throughput_median,
1245
3
                    ci_95: summary.throughput_ci_95,
1246
3
                },
1247
3
                memory_mb: MemoryResults {
1248
3
                    model_mb: result.peak_memory_mb / 2, // Approximate model size
1249
3
                    peak_rss_mb: result.peak_memory_mb,
1250
3
                    kv_waste_pct: result.kv_cache_waste_pct,
1251
3
                },
1252
3
                energy: EnergyResults {
1253
3
                    total_joules: result.energy_joules,
1254
3
                    token_joules: summary.token_joules,
1255
3
                    idle_watts: 0.0, // Would need separate measurement
1256
3
                },
1257
3
                cold_start_ms: ColdStartResults {
1258
3
                    median: result.cold_start_ms,
1259
3
                    p99: result.cold_start_ms * 1.5, // Approximate
1260
3
                },
1261
3
            },
1262
3
            quality: QualityValidation {
1263
3
                kl_divergence_vs_fp32: kl_divergence,
1264
3
                perplexity_wikitext2: None,
1265
3
            },
1266
3
        }
1267
3
    }
1268
1269
    /// Serialize to JSON string
1270
    ///
1271
    /// # Errors
1272
    ///
1273
    /// Returns an error if serialization fails.
1274
1
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
1275
1
        serde_json::to_string_pretty(self)
1276
1
    }
1277
1278
    /// Deserialize from JSON string
1279
    ///
1280
    /// # Errors
1281
    ///
1282
    /// Returns an error if the JSON is invalid or doesn't match the schema.
1283
3
    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
1284
3
        serde_json::from_str(json)
1285
3
    }
1286
}
1287
1288
/// Generate ISO 8601 timestamp
1289
22
fn chrono_timestamp() -> String {
1290
    use std::time::{SystemTime, UNIX_EPOCH};
1291
1292
22
    let duration = SystemTime::now()
1293
22
        .duration_since(UNIX_EPOCH)
1294
22
        .unwrap_or_default();
1295
22
    let secs = duration.as_secs();
1296
1297
    // Simple ISO 8601 format without external dependencies
1298
22
    format!("1970-01-01T00:00:00Z+{secs}s")
1299
22
}
1300
1301
// ============================================================================
1302
// Benchmark Comparison
1303
// ============================================================================
1304
1305
/// Result of comparing two benchmarks
1306
#[derive(Debug, Clone, Serialize, Deserialize)]
1307
pub struct BenchmarkComparison {
1308
    /// Baseline config
1309
    pub baseline_runtime: String,
1310
    /// Current config
1311
    pub current_runtime: String,
1312
1313
    /// TTFT p99 change percentage (negative = improvement)
1314
    pub ttft_p99_change_pct: f64,
1315
    /// Throughput change percentage (positive = improvement)
1316
    pub throughput_change_pct: f64,
1317
    /// Memory change percentage (negative = improvement)
1318
    pub memory_change_pct: f64,
1319
    /// Energy change percentage (negative = improvement)
1320
    pub energy_change_pct: f64,
1321
1322
    /// Overall winner
1323
    pub winner: String,
1324
    /// Significance level (p-value from Mann-Whitney U)
1325
    pub significance: f64,
1326
}
1327
1328
impl BenchmarkComparison {
1329
    /// Compare two benchmark results
1330
    #[must_use]
1331
4
    pub fn compare(baseline: &FullBenchmarkResult, current: &FullBenchmarkResult) -> Self {
1332
4
        let ttft_p99_change = if baseline.results.ttft_ms.p99 > 0.0 {
1333
3
            ((current.results.ttft_ms.p99 - baseline.results.ttft_ms.p99)
1334
3
                / baseline.results.ttft_ms.p99)
1335
3
                * 100.0
1336
        } else {
1337
1
            0.0
1338
        };
1339
1340
4
        let throughput_change = if baseline.results.throughput_tok_s.median > 0.0 {
1341
3
            ((current.results.throughput_tok_s.median - baseline.results.throughput_tok_s.median)
1342
3
                / baseline.results.throughput_tok_s.median)
1343
3
                * 100.0
1344
        } else {
1345
1
            0.0
1346
        };
1347
1348
4
        let memory_change = if baseline.results.memory_mb.peak_rss_mb > 0 {
1349
3
            ((current.results.memory_mb.peak_rss_mb as f64
1350
3
                - baseline.results.memory_mb.peak_rss_mb as f64)
1351
3
                / baseline.results.memory_mb.peak_rss_mb as f64)
1352
3
                * 100.0
1353
        } else {
1354
1
            0.0
1355
        };
1356
1357
4
        let energy_change = if baseline.results.energy.token_joules > 0.0 {
1358
3
            ((current.results.energy.token_joules - baseline.results.energy.token_joules)
1359
3
                / baseline.results.energy.token_joules)
1360
3
                * 100.0
1361
        } else {
1362
1
            0.0
1363
        };
1364
1365
        // Simple winner determination: count improvements
1366
4
        let mut current_wins = 0;
1367
4
        let mut baseline_wins = 0;
1368
1369
4
        if ttft_p99_change < -5.0 {
1370
1
            current_wins += 1;
1371
3
        } else if ttft_p99_change > 5.0 {
1372
1
            baseline_wins += 1;
1373
2
        }
1374
1375
4
        if throughput_change > 5.0 {
1376
1
            current_wins += 1;
1377
3
        } else if throughput_change < -5.0 {
1378
1
            baseline_wins += 1;
1379
2
        }
1380
1381
4
        if memory_change < -5.0 {
1382
1
            current_wins += 1;
1383
3
        } else if memory_change > 5.0 {
1384
1
            baseline_wins += 1;
1385
2
        }
1386
1387
4
        if energy_change < -5.0 {
1388
1
            current_wins += 1;
1389
3
        } else if energy_change > 5.0 {
1390
1
            baseline_wins += 1;
1391
2
        }
1392
1393
4
        let winner = match current_wins.cmp(&baseline_wins) {
1394
1
            std::cmp::Ordering::Greater => current.config.runtime.clone(),
1395
1
            std::cmp::Ordering::Less => baseline.config.runtime.clone(),
1396
2
            std::cmp::Ordering::Equal => "tie".to_string(),
1397
        };
1398
1399
4
        Self {
1400
4
            baseline_runtime: baseline.config.runtime.clone(),
1401
4
            current_runtime: current.config.runtime.clone(),
1402
4
            ttft_p99_change_pct: ttft_p99_change,
1403
4
            throughput_change_pct: throughput_change,
1404
4
            memory_change_pct: memory_change,
1405
4
            energy_change_pct: energy_change,
1406
4
            winner,
1407
4
            significance: 0.001, // Would need actual Mann-Whitney U test
1408
4
        }
1409
4
    }
1410
}
1411
1412
// ============================================================================
1413
// Regression Detection
1414
// ============================================================================
1415
1416
/// Regression detection result
1417
#[derive(Debug, Clone, Serialize, Deserialize)]
1418
pub struct RegressionResult {
1419
    /// Whether a regression was detected
1420
    pub regression_detected: bool,
1421
    /// Metrics that regressed
1422
    pub regressed_metrics: Vec<String>,
1423
    /// Regression threshold used (%)
1424
    pub threshold_pct: f64,
1425
}
1426
1427
impl RegressionResult {
1428
    /// Check for regressions between baseline and current
1429
    #[must_use]
1430
5
    pub fn check(
1431
5
        baseline: &FullBenchmarkResult,
1432
5
        current: &FullBenchmarkResult,
1433
5
        threshold_pct: f64,
1434
5
    ) -> Self {
1435
5
        let mut regressed_metrics = Vec::new();
1436
1437
        // Check TTFT p99 (higher = regression)
1438
5
        if baseline.results.ttft_ms.p99 > 0.0 {
1439
4
            let change = ((current.results.ttft_ms.p99 - baseline.results.ttft_ms.p99)
1440
4
                / baseline.results.ttft_ms.p99)
1441
4
                * 100.0;
1442
4
            if change > threshold_pct {
1443
1
                regressed_metrics.push(format!("ttft_p99 (+{change:.1}%)"));
1444
3
            }
1445
1
        }
1446
1447
        // Check throughput (lower = regression)
1448
5
        if baseline.results.throughput_tok_s.median > 0.0 {
1449
4
            let change = ((baseline.results.throughput_tok_s.median
1450
4
                - current.results.throughput_tok_s.median)
1451
4
                / baseline.results.throughput_tok_s.median)
1452
4
                * 100.0;
1453
4
            if change > threshold_pct {
1454
1
                regressed_metrics.push(format!("throughput (-{change:.1}%)"));
1455
3
            }
1456
1
        }
1457
1458
        // Check memory (higher = regression)
1459
5
        if baseline.results.memory_mb.peak_rss_mb > 0 {
1460
4
            let change = ((current.results.memory_mb.peak_rss_mb as f64
1461
4
                - baseline.results.memory_mb.peak_rss_mb as f64)
1462
4
                / baseline.results.memory_mb.peak_rss_mb as f64)
1463
4
                * 100.0;
1464
4
            if change > threshold_pct {
1465
1
                regressed_metrics.push(format!("memory (+{change:.1}%)"));
1466
3
            }
1467
1
        }
1468
1469
5
        Self {
1470
5
            regression_detected: !regressed_metrics.is_empty(),
1471
5
            regressed_metrics,
1472
5
            threshold_pct,
1473
5
        }
1474
5
    }
1475
}
1476
1477
1478
// Tests extracted to tests.rs (PMAT-802)
1479
#[cfg(test)]
1480
#[path = "tests.rs"]
1481
mod bench_tests;