1use crate::error::{Result, UserError};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::Path;
13
14pub const PERF_SCHEMA_VERSION: u32 = 2;
16
17pub const PERF_EVIDENCE_VERSION: &str = "0.0.22-perf-v1";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
26#[serde(rename_all = "snake_case")]
27pub enum HardwareTier {
28 MacosArm64,
30 LinuxX86_64Gnu,
32 WindowsX86_64Msvc,
34}
35
36impl HardwareTier {
37 pub fn as_str(self) -> &'static str {
38 match self {
39 Self::MacosArm64 => "macos_arm64",
40 Self::LinuxX86_64Gnu => "linux_x86_64_gnu",
41 Self::WindowsX86_64Msvc => "windows_x86_64_msvc",
42 }
43 }
44
45 pub fn all() -> &'static [HardwareTier] {
46 &[
47 Self::MacosArm64,
48 Self::LinuxX86_64Gnu,
49 Self::WindowsX86_64Msvc,
50 ]
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct NamedHardwareProfile {
57 pub profile_id: String,
58 pub tier: HardwareTier,
59 pub cpu_label: String,
61 pub core_count: u32,
62 pub memory_gib: u32,
63 pub os_label: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub power_mode: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct PerfScenario {
76 pub id: String,
77 pub kind: String,
79 pub description: String,
80 pub release_gated: bool,
82 pub min_repetitions: u32,
84 #[serde(default)]
86 pub warmups: u32,
87}
88
89pub fn perf_scenario_catalogue() -> Vec<PerfScenario> {
91 let mut v = Vec::new();
92 let stt_models = [
93 "tiny-q5_1",
94 "base",
95 "large-v3-turbo",
96 "profile_speed",
97 "profile_balance",
98 "profile_quality",
99 ];
100 let durations = [("30s", 5), ("5m", 5), ("long_form", 5)];
101 for m in stt_models {
102 for (d, reps) in durations {
103 v.push(PerfScenario {
104 id: format!("stt_local/{m}/{d}/warm"),
105 kind: "stt_local".into(),
106 description: format!("Local STT {m} {d} warm"),
107 release_gated: m == "tiny-q5_1" || m == "base",
108 min_repetitions: reps,
109 warmups: 1,
110 });
111 }
112 v.push(PerfScenario {
113 id: format!("stt_local/{m}/cold_load"),
114 kind: "stt_local".into(),
115 description: format!("Cold process model load {m}"),
116 release_gated: m == "tiny-q5_1",
117 min_repetitions: 5,
118 warmups: 0,
119 });
120 for conc in [1u32, 2, 4] {
121 v.push(PerfScenario {
122 id: format!("stt_local/{m}/concurrency_{conc}"),
123 kind: "governor".into(),
124 description: format!("STT concurrency {conc} with governor ({m})"),
125 release_gated: m == "tiny-q5_1" && conc <= 2,
126 min_repetitions: 5,
127 warmups: 1,
128 });
129 }
130 }
131 for (model, voice) in [("kitten-nano-int8", "Luna"), ("kokoro-82m-int8", "default")] {
132 for phrase in ["short", "paragraph", "multi_chunk"] {
133 v.push(PerfScenario {
134 id: format!("tts_local/{model}/{voice}/{phrase}"),
135 kind: "tts_local".into(),
136 description: format!("Local TTS {model}/{voice} {phrase}"),
137 release_gated: model == "kitten-nano-int8" && phrase == "short",
138 min_repetitions: 5,
139 warmups: 1,
140 });
141 }
142 }
143 for (id, desc, gated, reps) in [
144 (
145 "workflow/cli_stt_one_file",
146 "One-file CLI STT decode+commit",
147 true,
148 20u32,
149 ),
150 (
151 "workflow/batch_20_small",
152 "Resumable batch of ≥20 small files",
153 true,
154 5,
155 ),
156 (
157 "workflow/doctor_startup",
158 "doctor / cache status startup",
159 true,
160 20,
161 ),
162 (
163 "workflow/c_abi_job_overhead",
164 "C ABI job start/poll/take overhead",
165 false,
166 20,
167 ),
168 (
169 "workflow/long_form_mock_remote",
170 "Long-form chunk orchestration with mock provider",
171 false,
172 5,
173 ),
174 (
175 "remote_info/stt_upload_latency",
176 "Remote STT informational latency",
177 false,
178 5,
179 ),
180 ] {
181 v.push(PerfScenario {
182 id: id.into(),
183 kind: if id.starts_with("remote") {
184 "remote_info".into()
185 } else {
186 "workflow".into()
187 },
188 description: desc.into(),
189 release_gated: gated,
190 min_repetitions: reps,
191 warmups: if reps >= 20 { 2 } else { 1 },
192 });
193 }
194 v
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct PerfScenarioResult {
204 pub scenario_id: String,
205 pub samples_ms: Vec<f64>,
207 pub p50_ms: f64,
208 pub p95_ms: f64,
209 pub mean_ms: f64,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub audio_or_synth_duration_ms: Option<f64>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub rtf_p50: Option<f64>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub peak_rss_bytes: Option<u64>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub steady_rss_bytes: Option<u64>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub queue_wait_p50_ms: Option<f64>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub inference_p50_ms: Option<f64>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub throughput_ops_per_s: Option<f64>,
224 #[serde(default)]
225 pub concurrency: u32,
226 #[serde(default)]
227 pub warm: bool,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub download_ms: Option<f64>,
231 #[serde(default)]
232 pub release_gated: bool,
233}
234
235impl PerfScenarioResult {
236 pub fn from_samples(
237 scenario_id: &str,
238 mut samples_ms: Vec<f64>,
239 audio_or_synth_duration_ms: Option<f64>,
240 concurrency: u32,
241 warm: bool,
242 release_gated: bool,
243 ) -> Result<Self> {
244 if samples_ms.is_empty() {
245 return Err(UserError::Other {
246 message: format!("scenario '{scenario_id}' has no samples"),
247 }
248 .into());
249 }
250 for s in &samples_ms {
251 if !s.is_finite() || *s < 0.0 {
252 return Err(UserError::Other {
253 message: format!("scenario '{scenario_id}' has invalid sample {s}"),
254 }
255 .into());
256 }
257 }
258 samples_ms.sort_by(|a, b| a.partial_cmp(b).unwrap());
259 let p50 = percentile_sorted(&samples_ms, 0.50);
260 let p95 = percentile_sorted(&samples_ms, 0.95);
261 let mean = samples_ms.iter().sum::<f64>() / samples_ms.len() as f64;
262 let rtf_p50 = audio_or_synth_duration_ms.map(|d| if d <= 0.0 { 0.0 } else { p50 / d });
263 Ok(Self {
264 scenario_id: scenario_id.into(),
265 samples_ms,
266 p50_ms: p50,
267 p95_ms: p95,
268 mean_ms: mean,
269 audio_or_synth_duration_ms,
270 rtf_p50,
271 peak_rss_bytes: None,
272 steady_rss_bytes: None,
273 queue_wait_p50_ms: None,
274 inference_p50_ms: None,
275 throughput_ops_per_s: None,
276 concurrency,
277 warm,
278 download_ms: None,
279 release_gated,
280 })
281 }
282}
283
284pub fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
286 if sorted.is_empty() {
287 return 0.0;
288 }
289 if sorted.len() == 1 {
290 return sorted[0];
291 }
292 let p = p.clamp(0.0, 1.0);
293 let rank = p * (sorted.len() as f64 - 1.0);
294 let lo = rank.floor() as usize;
295 let hi = rank.ceil() as usize;
296 if lo == hi {
297 sorted[lo]
298 } else {
299 let w = rank - lo as f64;
300 sorted[lo] * (1.0 - w) + sorted[hi] * w
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
306pub struct PerfReport {
307 pub schema_version: u32,
308 pub evidence_version: String,
309 pub hardware: NamedHardwareProfile,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub aurum_version: Option<String>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub commit: Option<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub rustc: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub target_triple: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub build_profile: Option<String>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub features: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub whisper_cpp_version: Option<String>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub onnx_runtime_version: Option<String>,
326 #[serde(default)]
328 pub model_digests: BTreeMap<String, String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub cache_state: Option<String>,
331 pub scenarios: Vec<PerfScenarioResult>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub notes: Option<String>,
334}
335
336impl PerfReport {
337 pub fn new(hardware: NamedHardwareProfile) -> Self {
338 Self {
339 schema_version: PERF_SCHEMA_VERSION,
340 evidence_version: PERF_EVIDENCE_VERSION.into(),
341 hardware,
342 aurum_version: Some(env!("CARGO_PKG_VERSION").into()),
343 commit: std::env::var("GITHUB_SHA")
344 .or_else(|_| std::env::var("AURUM_BENCH_COMMIT"))
345 .ok(),
346 rustc: None,
347 target_triple: Some(format!(
348 "{}-{}",
349 std::env::consts::ARCH,
350 std::env::consts::OS
351 )),
352 build_profile: None,
353 features: None,
354 whisper_cpp_version: None,
355 onnx_runtime_version: None,
356 model_digests: BTreeMap::new(),
357 cache_state: None,
358 scenarios: Vec::new(),
359 notes: None,
360 }
361 }
362
363 pub fn load(path: &Path) -> Result<Self> {
364 let data = fs::read_to_string(path).map_err(|e| UserError::Other {
365 message: format!("read perf report {}: {e}", path.display()),
366 })?;
367 serde_json::from_str(&data).map_err(|e| {
368 UserError::Other {
369 message: format!("parse perf report: {e}"),
370 }
371 .into()
372 })
373 }
374
375 pub fn to_json_pretty(&self) -> Result<String> {
376 serde_json::to_string_pretty(self).map_err(|e| {
377 UserError::Other {
378 message: format!("serialize perf report: {e}"),
379 }
380 .into()
381 })
382 }
383
384 pub fn to_markdown(&self) -> String {
385 let mut out = String::new();
386 out.push_str("# Performance report\n\n");
387 out.push_str(&format!(
388 "- **Evidence:** {}\n- **Profile:** `{}` ({})\n- **CPU:** {} ({} cores)\n- **Memory:** {} GiB\n- **OS:** {}\n",
389 self.evidence_version,
390 self.hardware.profile_id,
391 self.hardware.tier.as_str(),
392 self.hardware.cpu_label,
393 self.hardware.core_count,
394 self.hardware.memory_gib,
395 self.hardware.os_label
396 ));
397 if let Some(ref c) = self.commit {
398 out.push_str(&format!("- **Commit:** `{c}`\n"));
399 }
400 out.push_str(
401 "\n| Scenario | p50 ms | p95 ms | RTF p50 | RSS | conc | gated |\n|----------|--------|--------|---------|-----|------|-------|\n",
402 );
403 let mut scenarios = self.scenarios.clone();
404 scenarios.sort_by(|a, b| a.scenario_id.cmp(&b.scenario_id));
405 for s in &scenarios {
406 out.push_str(&format!(
407 "| {} | {:.1} | {:.1} | {} | {} | {} | {} |\n",
408 s.scenario_id,
409 s.p50_ms,
410 s.p95_ms,
411 s.rtf_p50
412 .map(|r| format!("{r:.3}"))
413 .unwrap_or_else(|| "—".into()),
414 s.peak_rss_bytes
415 .map(|b| format!("{:.0} MiB", b as f64 / (1024.0 * 1024.0)))
416 .unwrap_or_else(|| "—".into()),
417 s.concurrency,
418 s.release_gated
419 ));
420 }
421 out.push('\n');
422 out
423 }
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432pub struct PerfScenarioBudget {
433 pub scenario_id: String,
434 pub baseline_p50_ms: f64,
435 pub baseline_p95_ms: f64,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub baseline_rtf_p50: Option<f64>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub baseline_peak_rss_bytes: Option<u64>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub baseline_throughput_ops_per_s: Option<f64>,
442 #[serde(default = "default_p50_warn")]
444 pub max_p50_relative_warn: f64,
445 #[serde(default = "default_p95_fail")]
447 pub max_p95_relative_fail: f64,
448 #[serde(default = "default_rss_rel")]
450 pub max_rss_relative_fail: f64,
451 #[serde(default = "default_rss_abs")]
452 pub max_rss_absolute_bytes: u64,
453 #[serde(default = "default_tp_rel")]
454 pub max_throughput_relative_drop: f64,
455}
456
457fn default_p50_warn() -> f64 {
458 0.10
459}
460fn default_p95_fail() -> f64 {
461 0.15
462}
463fn default_rss_rel() -> f64 {
464 0.15
465}
466fn default_rss_abs() -> u64 {
467 256 * 1024 * 1024
468}
469fn default_tp_rel() -> f64 {
470 0.15
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
475pub struct PerfBudget {
476 pub schema_version: u32,
477 pub evidence_version: String,
478 pub hardware_profile_id: String,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub model_digest_pin: Option<String>,
481 pub scenarios: Vec<PerfScenarioBudget>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub notes: Option<String>,
484}
485
486impl PerfBudget {
487 pub fn load(path: &Path) -> Result<Self> {
488 let data = fs::read_to_string(path).map_err(|e| UserError::Other {
489 message: format!("read perf budget {}: {e}", path.display()),
490 })?;
491 serde_json::from_str(&data).map_err(|e| {
492 UserError::Other {
493 message: format!("parse perf budget: {e}"),
494 }
495 .into()
496 })
497 }
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(rename_all = "snake_case")]
502pub enum PerfSeverity {
503 Pass,
504 Warn,
505 Fail,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
509pub struct PerfFinding {
510 pub severity: PerfSeverity,
511 pub scenario_id: String,
512 pub code: String,
513 pub message: String,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
517pub struct PerfComparison {
518 pub hardware_profile_id: String,
519 pub passed: bool,
520 pub findings: Vec<PerfFinding>,
521}
522
523pub fn compare_perf_budget(report: &PerfReport, budget: &PerfBudget) -> PerfComparison {
525 let mut findings = Vec::new();
526
527 if report.hardware.profile_id != budget.hardware_profile_id {
528 findings.push(PerfFinding {
529 severity: PerfSeverity::Fail,
530 scenario_id: "*".into(),
531 code: "hardware_mismatch".into(),
532 message: format!(
533 "report profile '{}' != budget '{}'",
534 report.hardware.profile_id, budget.hardware_profile_id
535 ),
536 });
537 }
538
539 if let Some(ref pin) = budget.model_digest_pin {
540 let any = report.model_digests.values().any(|d| d == pin);
541 if !report.model_digests.is_empty() && !any {
542 findings.push(PerfFinding {
543 severity: PerfSeverity::Fail,
544 scenario_id: "*".into(),
545 code: "model_digest_mismatch".into(),
546 message: format!("budget model_digest_pin {pin} not present in report digests"),
547 });
548 }
549 }
550
551 let by_id: BTreeMap<_, _> = report
552 .scenarios
553 .iter()
554 .map(|s| (s.scenario_id.as_str(), s))
555 .collect();
556
557 for b in &budget.scenarios {
558 let Some(cand) = by_id.get(b.scenario_id.as_str()) else {
559 findings.push(PerfFinding {
560 severity: PerfSeverity::Fail,
561 scenario_id: b.scenario_id.clone(),
562 code: "missing_scenario".into(),
563 message: "budget scenario missing from candidate report".into(),
564 });
565 continue;
566 };
567
568 let p50_lim = b.baseline_p50_ms * (1.0 + b.max_p50_relative_warn);
570 if cand.p50_ms > p50_lim + f64::EPSILON {
571 findings.push(PerfFinding {
572 severity: PerfSeverity::Warn,
573 scenario_id: b.scenario_id.clone(),
574 code: "p50_regression".into(),
575 message: format!(
576 "p50 {:.1}ms exceeds warn {:.1}ms (baseline {:.1}, +{:.0}%)",
577 cand.p50_ms,
578 p50_lim,
579 b.baseline_p50_ms,
580 b.max_p50_relative_warn * 100.0
581 ),
582 });
583 }
584
585 let p95_lim = b.baseline_p95_ms * (1.0 + b.max_p95_relative_fail);
587 if cand.p95_ms > p95_lim + f64::EPSILON {
588 findings.push(PerfFinding {
589 severity: PerfSeverity::Fail,
590 scenario_id: b.scenario_id.clone(),
591 code: "p95_regression".into(),
592 message: format!(
593 "p95 {:.1}ms exceeds fail {:.1}ms (baseline {:.1}, +{:.0}%)",
594 cand.p95_ms,
595 p95_lim,
596 b.baseline_p95_ms,
597 b.max_p95_relative_fail * 100.0
598 ),
599 });
600 }
601
602 if let (Some(base_rtf), Some(cand_rtf)) = (b.baseline_rtf_p50, cand.rtf_p50) {
604 let lim = base_rtf * (1.0 + b.max_p50_relative_warn);
605 if cand_rtf > lim + f64::EPSILON {
606 findings.push(PerfFinding {
607 severity: PerfSeverity::Warn,
608 scenario_id: b.scenario_id.clone(),
609 code: "rtf_p50_regression".into(),
610 message: format!(
611 "RTF p50 {cand_rtf:.4} exceeds warn {lim:.4} (baseline {base_rtf:.4})"
612 ),
613 });
614 }
615 }
616
617 if let (Some(base_rss), Some(cand_rss)) = (b.baseline_peak_rss_bytes, cand.peak_rss_bytes) {
619 let rel_lim = (base_rss as f64 * (1.0 + b.max_rss_relative_fail)) as u64;
620 let abs_lim = base_rss.saturating_add(b.max_rss_absolute_bytes);
621 let lim = rel_lim.max(abs_lim);
622 if cand_rss > lim {
623 findings.push(PerfFinding {
624 severity: PerfSeverity::Fail,
625 scenario_id: b.scenario_id.clone(),
626 code: "rss_regression".into(),
627 message: format!(
628 "peak RSS {cand_rss} exceeds limit {lim} (baseline {base_rss})"
629 ),
630 });
631 }
632 }
633
634 if let (Some(base_tp), Some(cand_tp)) =
636 (b.baseline_throughput_ops_per_s, cand.throughput_ops_per_s)
637 {
638 let floor = base_tp * (1.0 - b.max_throughput_relative_drop);
639 if cand_tp + f64::EPSILON < floor {
640 findings.push(PerfFinding {
641 severity: PerfSeverity::Fail,
642 scenario_id: b.scenario_id.clone(),
643 code: "throughput_regression".into(),
644 message: format!(
645 "throughput {cand_tp:.3} ops/s below floor {floor:.3} (baseline {base_tp:.3})"
646 ),
647 });
648 }
649 }
650 }
651
652 if findings.is_empty() {
653 findings.push(PerfFinding {
654 severity: PerfSeverity::Pass,
655 scenario_id: "*".into(),
656 code: "ok".into(),
657 message: "all performance budget checks passed".into(),
658 });
659 }
660
661 let passed = findings.iter().all(|f| f.severity != PerfSeverity::Fail);
662 PerfComparison {
663 hardware_profile_id: budget.hardware_profile_id.clone(),
664 passed,
665 findings,
666 }
667}
668
669pub fn perf_budget_exit_code(cmp: &PerfComparison) -> i32 {
670 if cmp.passed {
671 0
672 } else {
673 1
674 }
675}
676
677pub fn tier_a_profile_templates() -> Vec<NamedHardwareProfile> {
679 vec![
680 NamedHardwareProfile {
681 profile_id: "tier_a_macos_arm64".into(),
682 tier: HardwareTier::MacosArm64,
683 cpu_label: "Apple Silicon (exact chip recorded per run)".into(),
684 core_count: 0,
685 memory_gib: 0,
686 os_label: "macOS (version recorded per run)".into(),
687 power_mode: Some("performance_or_default".into()),
688 },
689 NamedHardwareProfile {
690 profile_id: "tier_a_linux_x86_64_gnu".into(),
691 tier: HardwareTier::LinuxX86_64Gnu,
692 cpu_label: "x86_64 (exact CPU recorded per run)".into(),
693 core_count: 0,
694 memory_gib: 0,
695 os_label: "Linux GNU (distro/kernel recorded per run)".into(),
696 power_mode: None,
697 },
698 NamedHardwareProfile {
699 profile_id: "tier_a_windows_x86_64_msvc".into(),
700 tier: HardwareTier::WindowsX86_64Msvc,
701 cpu_label: "x86_64 (exact CPU recorded per run)".into(),
702 core_count: 0,
703 memory_gib: 0,
704 os_label: "Windows MSVC (build recorded per run)".into(),
705 power_mode: None,
706 },
707 ]
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 fn sample_hardware() -> NamedHardwareProfile {
715 NamedHardwareProfile {
716 profile_id: "tier_a_macos_arm64".into(),
717 tier: HardwareTier::MacosArm64,
718 cpu_label: "Apple M2".into(),
719 core_count: 8,
720 memory_gib: 16,
721 os_label: "macOS 15.0".into(),
722 power_mode: None,
723 }
724 }
725
726 #[test]
727 fn percentiles() {
728 let s = [1.0, 2.0, 3.0, 4.0, 5.0];
729 assert!((percentile_sorted(&s, 0.50) - 3.0).abs() < 1e-9);
730 assert!(percentile_sorted(&s, 0.95) > 4.0);
731 assert_eq!(percentile_sorted(&[42.0], 0.95), 42.0);
732 }
733
734 #[test]
735 fn catalogue_has_tier_a_scenarios() {
736 let c = perf_scenario_catalogue();
737 assert!(c.len() > 20);
738 assert!(c.iter().any(|s| s.id.contains("tiny-q5_1")));
739 assert!(c.iter().any(|s| s.id.contains("tts_local")));
740 assert!(c.iter().any(|s| s.release_gated));
741 assert!(c.iter().any(|s| !s.release_gated));
742 }
743
744 #[test]
745 fn budget_pass_and_p95_fail() {
746 let mut report = PerfReport::new(sample_hardware());
747 let ok = PerfScenarioResult::from_samples(
748 "workflow/cli_stt_one_file",
749 vec![100.0, 102.0, 101.0, 99.0, 100.0],
750 Some(5000.0),
751 1,
752 true,
753 true,
754 )
755 .unwrap();
756 report.scenarios.push(ok);
757
758 let budget = PerfBudget {
759 schema_version: PERF_SCHEMA_VERSION,
760 evidence_version: PERF_EVIDENCE_VERSION.into(),
761 hardware_profile_id: "tier_a_macos_arm64".into(),
762 model_digest_pin: None,
763 scenarios: vec![PerfScenarioBudget {
764 scenario_id: "workflow/cli_stt_one_file".into(),
765 baseline_p50_ms: 100.0,
766 baseline_p95_ms: 110.0,
767 baseline_rtf_p50: Some(0.02),
768 baseline_peak_rss_bytes: None,
769 baseline_throughput_ops_per_s: None,
770 max_p50_relative_warn: 0.10,
771 max_p95_relative_fail: 0.15,
772 max_rss_relative_fail: 0.15,
773 max_rss_absolute_bytes: 256 * 1024 * 1024,
774 max_throughput_relative_drop: 0.15,
775 }],
776 notes: None,
777 };
778 let cmp = compare_perf_budget(&report, &budget);
779 assert!(cmp.passed, "{:?}", cmp.findings);
780
781 report.scenarios[0] = PerfScenarioResult::from_samples(
783 "workflow/cli_stt_one_file",
784 vec![200.0, 210.0, 220.0, 230.0, 250.0],
785 Some(5000.0),
786 1,
787 true,
788 true,
789 )
790 .unwrap();
791 let cmp2 = compare_perf_budget(&report, &budget);
792 assert!(!cmp2.passed);
793 assert_eq!(perf_budget_exit_code(&cmp2), 1);
794 assert!(cmp2.findings.iter().any(|f| f.code == "p95_regression"));
795 }
796
797 #[test]
798 fn hardware_mismatch_fails() {
799 let report = PerfReport::new(sample_hardware());
800 let budget = PerfBudget {
801 schema_version: PERF_SCHEMA_VERSION,
802 evidence_version: PERF_EVIDENCE_VERSION.into(),
803 hardware_profile_id: "tier_a_linux_x86_64_gnu".into(),
804 model_digest_pin: None,
805 scenarios: vec![],
806 notes: None,
807 };
808 let cmp = compare_perf_budget(&report, &budget);
809 assert!(!cmp.passed);
810 assert!(cmp.findings.iter().any(|f| f.code == "hardware_mismatch"));
811 }
812
813 #[test]
814 fn markdown_deterministic_order() {
815 let mut report = PerfReport::new(sample_hardware());
816 report
817 .scenarios
818 .push(PerfScenarioResult::from_samples("b", vec![2.0], None, 1, true, false).unwrap());
819 report
820 .scenarios
821 .push(PerfScenarioResult::from_samples("a", vec![1.0], None, 1, true, false).unwrap());
822 let md = report.to_markdown();
823 let ia = md.find("| a |").unwrap();
824 let ib = md.find("| b |").unwrap();
825 assert!(ia < ib);
826 }
827}