1use crate::config::CliConfig;
21use chrono::Utc;
22use clap::Args;
23use colored::*;
24use ferrum_bench_core::{
25 compute_metrics, BenchReport, Env, ItlEvidenceSource, OutputTokenCountSource,
26 QualityIssueCounts, RequestItlEvidence, RequestRecord, RunRecord, Scenario, Slo,
27};
28use ferrum_types::{InferenceRequest, Priority, RequestId, Result, SamplingParams};
29use futures::StreamExt;
30use std::collections::HashMap;
31use std::path::PathBuf;
32use std::time::Instant;
33use uuid::Uuid;
34
35#[derive(Args)]
36pub struct BenchCommand {
37 #[arg(default_value = "qwen3:0.6b")]
39 pub model: String,
40
41 #[arg(long, default_value = "5")]
43 pub rounds: usize,
44
45 #[arg(long, default_value = "128")]
47 pub max_tokens: u32,
48
49 #[arg(long, default_value = "auto")]
51 pub backend: String,
52
53 #[arg(long, default_value = "Explain the theory of relativity in detail.")]
55 pub prompt: String,
56
57 #[arg(long, default_value = "1")]
59 pub concurrency: usize,
60
61 #[arg(long)]
63 pub long_context: bool,
64
65 #[arg(long, value_name = "DTYPE")]
67 pub kv_dtype: Option<String>,
68
69 #[arg(long, default_value_t = 1)]
73 pub n_repeats: u32,
74
75 #[arg(long, value_parser = super::bench_serve::parse_slo)]
77 pub goodput: Option<Slo>,
78
79 #[arg(long, default_value = "human")]
82 pub output: String,
83
84 #[arg(long)]
86 pub out: Option<PathBuf>,
87
88 #[arg(long)]
90 pub hw_id: Option<String>,
91
92 #[arg(long)]
94 pub commit_sha: Option<String>,
95}
96
97fn validate_command(cmd: &BenchCommand) -> Result<()> {
98 if cmd.n_repeats == 0 {
99 return Err(ferrum_types::FerrumError::model("--n-repeats must be > 0"));
100 }
101 if cmd.rounds == 0 {
102 return Err(ferrum_types::FerrumError::model("--rounds must be > 0"));
103 }
104 if cmd.concurrency == 0 {
105 return Err(ferrum_types::FerrumError::model(
106 "--concurrency must be > 0",
107 ));
108 }
109 measured_request_count(cmd)?;
110 if cmd.goodput.is_some_and(|slo| !slo.is_valid()) {
111 return Err(ferrum_types::FerrumError::model(
112 "--goodput values must be positive finite numbers",
113 ));
114 }
115 Ok(())
116}
117
118fn measured_request_count(cmd: &BenchCommand) -> Result<u32> {
119 let count = cmd
120 .rounds
121 .checked_mul(cmd.concurrency)
122 .ok_or_else(|| ferrum_types::FerrumError::model("rounds * concurrency overflow"))?;
123 u32::try_from(count).map_err(|_| {
124 ferrum_types::FerrumError::model("rounds * concurrency exceeds report capacity")
125 })
126}
127
128pub async fn execute(cmd: BenchCommand, config: CliConfig) -> Result<()> {
129 validate_command(&cmd)?;
130 let cache_dir = crate::source_resolver::hf_cache_dir(&config);
131 let resolved = crate::source_resolver::resolve_model_source(
132 &cmd.model,
133 &cache_dir,
134 crate::source_resolver::DownloadPolicy::AutoDownload,
135 None,
136 )
137 .await?;
138 let product_input = resolved.into_product_engine_input();
139 let model_id = product_input.public_model_id.clone();
140 let source = product_input.source;
141 let mut engine_config = product_input.engine_config;
142 let model_sources = product_input.model_sources;
143 let prepared_model = model_sources
144 .as_ref()
145 .map(crate::source_resolver::prepare_registered_product_model)
146 .transpose()?
147 .flatten();
148 eprintln!("{}", format!("Ferrum Benchmark - {}", model_id).bold());
149 eprintln!("{}", "=".repeat(60).dimmed());
150
151 let engine_model_path = source.local_path.to_string_lossy().to_string();
152
153 let device = super::run::select_device(&cmd.backend)?;
154 let backend_str = format!("{:?}", device).to_lowercase();
155 eprintln!("{} {:?}", "Device:".dimmed(), device);
156 let runtime_config = ferrum_types::RuntimeConfigSnapshot::capture_current();
157
158 #[cfg(feature = "cuda")]
159 {
160 let graph_mode =
161 crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_CUDA_GRAPH")
162 .is_some();
163 if !graph_mode {
164 if let Ok(name) = ferrum_kernels::cuda_device_name(0) {
165 eprintln!("GPU 0: {name}");
166 }
167 }
168 let tp = crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_TP")
169 .and_then(|v| v.parse::<usize>().ok())
170 .unwrap_or_else(|| ferrum_kernels::cuda_device_count().unwrap_or(1));
171 if tp > 1 {
172 eprintln!("Tensor Parallel: TP={tp}");
173 }
174 }
175
176 engine_config.sampling.default_params = bench_sampling_params(cmd.max_tokens);
177 engine_config.backend.device = device;
178 engine_config.backend.backend_options.insert(
179 "model_path".to_string(),
180 serde_json::Value::String(engine_model_path),
181 );
182 engine_config.scheduler.policy = ferrum_types::SchedulingPolicy::ContinuousBatch;
183 engine_config
184 .apply_runtime_config_snapshot(&runtime_config)
185 .map_err(ferrum_types::FerrumError::config)?;
186 let effective_kv_dtype = cmd
187 .kv_dtype
188 .as_deref()
189 .or_else(|| crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_KV_DTYPE"));
190 super::run::apply_kv_dtype_override(&mut engine_config, effective_kv_dtype)?;
191 let engine = match (prepared_model, model_sources) {
192 (Some(prepared), _) => {
193 ferrum_engine::create_prepared_product_engine(engine_config, prepared).await?
194 }
195 (None, Some(sources)) => {
196 ferrum_engine::create_product_engine(engine_config, sources).await?
197 }
198 (None, None) => ferrum_engine::create_default_engine(engine_config).await?,
199 };
200
201 let prompt = if cmd.long_context {
202 generate_long_prompt()
203 } else {
204 cmd.prompt.clone()
205 };
206
207 let mode_str = if cmd.concurrency > 1 {
208 format!("concurrent({})", cmd.concurrency)
209 } else if cmd.long_context {
210 "long-context".to_string()
211 } else {
212 "sequential".to_string()
213 };
214
215 eprintln!(
216 "{}",
217 format!(
218 "Config: {} rounds × {} repeats, {} max_tokens, mode={}, prompt_len=~{}chars",
219 cmd.rounds,
220 cmd.n_repeats,
221 cmd.max_tokens,
222 mode_str,
223 prompt.len()
224 )
225 .dimmed()
226 );
227 if cmd.n_repeats < 3 {
228 eprintln!(
229 "{}",
230 " [warn] n_repeats < 3 — emitting mean only, no stddev/CI95 (PLAYBOOK § 0.4)".yellow()
231 );
232 }
233 eprintln!("{}", "=".repeat(60).dimmed());
234
235 eprintln!("{}", "Warmup...".dimmed());
237 let warmup = run_single(&*engine, &model_id, "Hello", 16).await?;
238 if !warmup.success {
239 return Err(ferrum_types::FerrumError::model(
240 "benchmark warmup request failed",
241 ));
242 }
243 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
244
245 let mut runs: Vec<RunRecord> = Vec::with_capacity(cmd.n_repeats as usize);
247 for repeat_idx in 0..cmd.n_repeats {
248 eprintln!(
249 "{}",
250 format!("Repeat {}/{}", repeat_idx + 1, cmd.n_repeats).bold()
251 );
252 let run = if cmd.concurrency > 1 {
253 run_concurrent_round(&*engine, &model_id, &prompt, &cmd).await?
254 } else {
255 run_sequential_round(&*engine, &model_id, &prompt, &cmd).await?
256 };
257 eprintln!(
258 " {} requests · {:.1}s · {:.1} tok/s",
259 run.records.len(),
260 run.duration_s,
261 run.records
262 .iter()
263 .map(|r| r.output_tokens as f64)
264 .sum::<f64>()
265 / run.duration_s
266 );
267 runs.push(run);
268 }
269
270 let scenario = if cmd.concurrency > 1 {
272 Scenario::ClosedLoop
274 } else {
275 Scenario::Cli
276 };
277 let concurrency = if cmd.concurrency > 1 {
278 Some(cmd.concurrency as u32)
279 } else {
280 None
281 };
282
283 let env = build_env(&cmd);
284 let slo = cmd.goodput.unwrap_or_else(Slo::unbounded);
285 let prompt_len = u32::try_from(prompt.len()).map_err(|_| {
286 ferrum_types::FerrumError::model("benchmark prompt length exceeds report capacity")
287 })?;
288 let report = compute_metrics(
289 model_id.clone(),
290 backend_str,
291 scenario,
292 concurrency,
293 None,
294 prompt_len, cmd.max_tokens,
296 0, slo,
298 runs,
299 env,
300 );
301
302 emit_then_enforce_bench_report(&cmd, &report, &mode_str)
303}
304
305async fn run_sequential_round(
308 engine: &(dyn ferrum_interfaces::engine::LlmInferenceEngine + Send + Sync),
309 model_id: &str,
310 prompt: &str,
311 cmd: &BenchCommand,
312) -> Result<RunRecord> {
313 let expected_requests = measured_request_count(cmd)?;
314 let mut records = Vec::with_capacity(cmd.rounds);
315 let start = Instant::now();
316 for _ in 0..cmd.rounds {
317 match run_single(engine, model_id, prompt, cmd.max_tokens).await {
318 Ok(record) => records.push(record),
319 Err(error) => {
320 eprintln!(" request start error: {error}");
321 let mut quality = QualityIssueCounts::default();
322 quality.malformed_stream = 1;
323 records.push(failed_bench_record(quality));
324 }
325 }
326 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
328 }
329 let duration_s = start.elapsed().as_secs_f64();
330 Ok(RunRecord {
331 records,
332 expected_requests,
333 duration_s,
334 warmup: Default::default(),
335 })
336}
337
338async fn run_concurrent_round(
339 engine: &(dyn ferrum_interfaces::engine::LlmInferenceEngine + Send + Sync),
340 model_id: &str,
341 prompt: &str,
342 cmd: &BenchCommand,
343) -> Result<RunRecord> {
344 let expected_requests = measured_request_count(cmd)?;
345 let mut records = Vec::with_capacity(expected_requests as usize);
346 let start = Instant::now();
347 for _ in 0..cmd.rounds {
348 let mut handles = Vec::with_capacity(cmd.concurrency);
349 for _ in 0..cmd.concurrency {
350 let request = make_request(model_id, prompt, cmd.max_tokens);
351 match engine.infer_stream(request).await {
352 Ok(stream) => handles.push(tokio::spawn(collect_stream(stream))),
353 Err(error) => {
354 eprintln!(" request start error: {error}");
355 let mut quality = QualityIssueCounts::default();
356 quality.malformed_stream = 1;
357 records.push(failed_bench_record(quality));
358 }
359 }
360 }
361 for handle in handles {
362 match handle.await {
363 Ok(Ok(r)) => records.push(r),
364 Ok(Err(e)) => {
365 eprintln!(" request error: {e}");
366 let mut quality = QualityIssueCounts::default();
367 quality.malformed_stream = 1;
368 records.push(failed_bench_record(quality));
369 }
370 Err(e) => {
371 eprintln!(" join error: {e}");
372 let mut quality = QualityIssueCounts::default();
373 quality.panic = 1;
374 records.push(failed_bench_record(quality));
375 }
376 }
377 }
378 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
379 }
380 let duration_s = start.elapsed().as_secs_f64();
381 Ok(RunRecord {
382 records,
383 expected_requests,
384 duration_s,
385 warmup: Default::default(),
386 })
387}
388
389fn failed_bench_record(quality_issues: QualityIssueCounts) -> RequestRecord {
390 RequestRecord {
391 success: false,
392 ttft_ms: 0.0,
393 e2e_ms: 0.0,
394 input_tokens: 0,
395 output_tokens: 0,
396 output_token_count_source: OutputTokenCountSource::None,
397 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::EngineTokenEvents),
398 quality_issues,
399 itl_ms: vec![],
400 }
401}
402
403fn make_request(model_id: &str, prompt: &str, max_tokens: u32) -> InferenceRequest {
406 InferenceRequest {
407 id: RequestId(Uuid::new_v4()),
408 model_id: ferrum_types::ModelId(model_id.to_string()),
409 prompt: prompt.to_string(),
410 sampling_params: bench_sampling_params(max_tokens),
411 stream: true,
412 priority: Priority::Normal,
413 client_id: None,
414 session_id: None,
415 created_at: Utc::now(),
416 api_request: None,
417 evidence_request: Default::default(),
418 metadata: HashMap::new(),
419 }
420}
421
422fn bench_sampling_params(max_tokens: u32) -> SamplingParams {
423 SamplingParams {
424 max_tokens: max_tokens as usize,
425 temperature: 0.0, top_p: 1.0,
427 repetition_penalty: 1.0,
428 stop_sequences: vec![
429 "<|im_end|>".to_string(),
430 "</s>".to_string(),
431 "<|endoftext|>".to_string(),
432 ],
433 ..Default::default()
434 }
435}
436
437async fn run_single(
438 engine: &(dyn ferrum_interfaces::engine::LlmInferenceEngine + Send + Sync),
439 model_id: &str,
440 prompt: &str,
441 max_tokens: u32,
442) -> Result<RequestRecord> {
443 let request = make_request(model_id, prompt, max_tokens);
444 let stream = engine.infer_stream(request).await?;
445 Ok(collect_stream(stream).await?)
446}
447
448async fn collect_stream(
449 mut stream: std::pin::Pin<
450 Box<
451 dyn futures::Stream<
452 Item = std::result::Result<
453 ferrum_types::StreamChunk,
454 ferrum_types::FerrumError,
455 >,
456 > + Send,
457 >,
458 >,
459) -> Result<RequestRecord> {
460 let start = Instant::now();
461 let mut token_count: u32 = 0;
462 let mut first_token_time: Option<Instant> = None;
463 let mut last_token_time: Option<Instant> = None;
464 let mut itl_ms: Vec<f64> = Vec::new();
465 let mut quality_issues = QualityIssueCounts::default();
466
467 let mut got_finish = false;
468 while let Some(result) = stream.next().await {
469 match result {
470 Ok(chunk) => {
471 if chunk.token.is_some() {
472 let now = Instant::now();
473 if first_token_time.is_none() {
474 first_token_time = Some(now);
475 } else if let Some(prev) = last_token_time {
476 itl_ms.push((now - prev).as_secs_f64() * 1000.0);
477 }
478 last_token_time = Some(now);
479 token_count = token_count
480 .checked_add(1)
481 .expect("benchmark output token count overflow");
482 }
483 if chunk.finish_reason.is_some() {
484 got_finish = true;
485 break;
486 }
487 }
488 Err(_) => {
489 quality_issues.malformed_stream = 1;
490 break;
491 }
492 }
493 }
494 if !got_finish {
495 quality_issues.missing_done = 1;
496 if token_count > 0 {
497 eprintln!(
498 " [warn] stream ended without finish_reason ({} tokens)",
499 token_count
500 );
501 }
502 }
503 if token_count == 0 {
504 quality_issues.zero_output_tokens = 1;
505 }
506
507 let e2e_ms = start.elapsed().as_secs_f64() * 1000.0;
508 let ttft_ms = first_token_time
509 .map(|t| t.duration_since(start).as_secs_f64() * 1000.0)
510 .unwrap_or(e2e_ms);
511
512 let success = token_count > 0 && got_finish && quality_issues.request_error_count() == 0;
513 let observed_intervals =
514 u32::try_from(itl_ms.len()).expect("benchmark ITL interval count overflow");
515 Ok(RequestRecord {
516 success,
517 ttft_ms,
518 e2e_ms,
519 input_tokens: 0, output_tokens: token_count,
521 output_token_count_source: if token_count > 0 {
522 OutputTokenCountSource::StreamChunks
523 } else {
524 OutputTokenCountSource::None
525 },
526 itl_evidence: RequestItlEvidence::engine(success, token_count, observed_intervals),
527 quality_issues,
528 itl_ms,
529 })
530}
531
532fn build_env(cmd: &BenchCommand) -> Env {
535 let commit_sha = cmd
536 .commit_sha
537 .clone()
538 .or_else(|| {
539 std::process::Command::new("git")
540 .args(["rev-parse", "--short", "HEAD"])
541 .output()
542 .ok()
543 .and_then(|o| String::from_utf8(o.stdout).ok())
544 .map(|s| s.trim().to_string())
545 })
546 .unwrap_or_else(|| "unknown".to_string());
547
548 #[allow(unused_mut)]
549 let mut features: Vec<String> = Vec::new();
550 #[cfg(feature = "metal")]
551 features.push("metal".to_string());
552 #[cfg(feature = "cuda")]
553 features.push("cuda".to_string());
554
555 let mut env = Env::capture_minimal(commit_sha, features);
556 if let Some(hw) = cmd.hw_id.clone() {
557 env.hw_id = hw;
558 }
559 env
560}
561
562fn print_human_summary(report: &BenchReport, cmd: &BenchCommand, mode_str: &str) {
565 eprintln!();
566 eprintln!("{}", "=".repeat(60));
567 eprintln!("{}", format!("BENCHMARK RESULTS ({})", mode_str).bold());
568 eprintln!("{}", "=".repeat(60));
569 eprintln!("Model: {}", report.model);
570 eprintln!("Backend: {}", report.backend);
571 eprintln!("Rounds: {}", cmd.rounds);
572 eprintln!("Repeats: {}", report.n_repeats);
573 eprintln!("Max tokens/req: {}", cmd.max_tokens);
574 if let Some(c) = report.concurrency {
575 eprintln!("Concurrency: {}", c);
576 }
577 eprintln!("{}", "-".repeat(60));
578 let fmt = |s: &ferrum_bench_core::ScalarStats| -> String {
579 if report.n_repeats >= 3 {
580 format!("{:.1} ± {:.1}", s.mean, s.ci95_hw)
581 } else {
582 format!("{:.1}", s.mean)
583 }
584 };
585 eprintln!(
586 "TTFT_ms p50={} p95={} p99={}",
587 fmt(&report.ttft_ms.p50),
588 fmt(&report.ttft_ms.p95),
589 fmt(&report.ttft_ms.p99)
590 );
591 eprintln!(
592 "TPOT_ms p50={} p95={} p99={}",
593 fmt(&report.tpot_ms.p50),
594 fmt(&report.tpot_ms.p95),
595 fmt(&report.tpot_ms.p99)
596 );
597 if report.has_complete_itl_evidence() {
598 eprintln!(
599 "ITL_ms p50={} p95={} p99={}",
600 fmt(&report.itl_ms.p50),
601 fmt(&report.itl_ms.p95),
602 fmt(&report.itl_ms.p99)
603 );
604 } else {
605 eprintln!("ITL_ms unavailable");
606 }
607 eprintln!("Output thr {} tok/s", fmt(&report.output_throughput_tps));
608 if cmd.goodput.is_some() {
609 eprintln!("Goodput {} req/s", fmt(&report.goodput_rps));
610 }
611 eprintln!("env_hash: {}", report.env_hash);
612 eprintln!("{}", "=".repeat(60));
613}
614
615fn emit_json(cmd: &BenchCommand, report: &BenchReport) -> Result<()> {
616 let pretty = serde_json::to_string_pretty(report).expect("serialize");
617 if let Some(out) = cmd.out.as_ref() {
618 std::fs::write(out, &pretty).map_err(|e| {
619 ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
620 })?;
621 eprintln!("\n→ wrote {}", out.display());
622 } else {
623 println!("{}", pretty);
624 }
625 Ok(())
626}
627
628fn emit_then_enforce_bench_report(
629 cmd: &BenchCommand,
630 report: &BenchReport,
631 mode_str: &str,
632) -> Result<()> {
633 match cmd.output.as_str() {
634 "human" => print_human_summary(report, cmd, mode_str),
635 "json" => emit_json(cmd, report)?,
636 other => {
637 return Err(ferrum_types::FerrumError::model(format!(
638 "unknown --output '{other}': allowed values are human, json"
639 )))
640 }
641 }
642
643 ferrum_bench_core::trace::flush_global_trace();
647 enforce_bench_error_policy(report)
648}
649
650fn enforce_bench_error_policy(report: &BenchReport) -> Result<()> {
651 let errored = report
652 .errored_per_run
653 .iter()
654 .try_fold(0_u64, |total, value| total.checked_add(*value as u64))
655 .ok_or_else(|| ferrum_types::FerrumError::model("benchmark error count overflow"))?;
656 if errored > 0 {
657 return Err(ferrum_types::FerrumError::model(format!(
658 "benchmark measured requests failed: {errored}"
659 )));
660 }
661 Ok(())
662}
663
664fn generate_long_prompt() -> String {
666 let base = "The history of artificial intelligence is a fascinating journey through decades of research, breakthroughs, and setbacks. From the early days of symbolic AI in the 1950s, through the AI winters, to the modern era of deep learning and large language models, the field has undergone remarkable transformations. ";
667 let mut prompt = String::with_capacity(8192);
668 prompt.push_str("Please provide a comprehensive analysis of the following text, identifying key themes, patterns, and insights:\n\n");
669 for _ in 0..25 {
670 prompt.push_str(base);
671 }
672 prompt.push_str("\n\nNow analyze the above text in detail:");
673 prompt
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 fn command() -> BenchCommand {
681 BenchCommand {
682 model: "test".to_string(),
683 rounds: 3,
684 max_tokens: 4,
685 backend: "cpu".to_string(),
686 prompt: "hello".to_string(),
687 concurrency: 2,
688 long_context: false,
689 kv_dtype: None,
690 n_repeats: 1,
691 goodput: None,
692 output: "json".to_string(),
693 out: None,
694 hw_id: None,
695 commit_sha: None,
696 }
697 }
698
699 #[test]
700 fn validates_expected_request_count_and_numeric_inputs() {
701 let mut cmd = command();
702 assert_eq!(measured_request_count(&cmd).unwrap(), 6);
703 assert_eq!(
704 measured_request_count(&cmd).unwrap() as usize,
705 cmd.rounds * cmd.concurrency
706 );
707 assert!(validate_command(&cmd).is_ok());
708 cmd.concurrency = 1;
709 assert_eq!(measured_request_count(&cmd).unwrap() as usize, cmd.rounds);
710 assert!(validate_command(&cmd).is_ok());
711 cmd.n_repeats = 0;
712 assert!(validate_command(&cmd).is_err());
713 cmd.n_repeats = 1;
714 cmd.goodput = Some(Slo {
715 ttft_p99_ms: f64::INFINITY,
716 ..Slo::default()
717 });
718 assert!(validate_command(&cmd).is_err());
719 }
720
721 #[test]
722 fn failed_bench_record_is_explicit_error_evidence() {
723 let mut quality = QualityIssueCounts::default();
724 quality.panic = 1;
725 let record = failed_bench_record(quality);
726 assert!(!record.success);
727 assert_eq!(record.quality_issues.panic, 1);
728 assert_eq!(
729 record.output_token_count_source,
730 OutputTokenCountSource::None
731 );
732 assert_eq!(
733 record.itl_evidence.source,
734 ItlEvidenceSource::EngineTokenEvents
735 );
736 }
737
738 #[test]
739 fn failed_bench_report_is_written_before_nonzero_result() {
740 let out = std::env::temp_dir().join(format!(
741 "ferrum-bench-failed-report-{}-{}.json",
742 std::process::id(),
743 std::time::SystemTime::now()
744 .duration_since(std::time::UNIX_EPOCH)
745 .unwrap()
746 .as_nanos()
747 ));
748 let _ = std::fs::remove_file(&out);
749 let mut quality = QualityIssueCounts::default();
750 quality.malformed_stream = 1;
751 let report = compute_metrics(
752 "test".to_string(),
753 "cpu".to_string(),
754 Scenario::Cli,
755 None,
756 None,
757 1,
758 1,
759 0,
760 Slo::default(),
761 vec![RunRecord {
762 records: vec![failed_bench_record(quality)],
763 expected_requests: 1,
764 duration_s: 1.0,
765 warmup: Default::default(),
766 }],
767 Env::default(),
768 );
769 let mut cmd = command();
770 cmd.out = Some(out.clone());
771 let error = emit_then_enforce_bench_report(&cmd, &report, "test")
772 .expect_err("failed measured request must return nonzero");
773 assert!(error.to_string().contains("measured requests failed"));
774 let json: serde_json::Value =
775 serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
776 assert_eq!(json["errored_per_run"], serde_json::json!([1]));
777 assert_eq!(json["repeat_metrics"][0]["itl_ineligible_requests"], 1);
778 let _ = std::fs::remove_file(out);
779 }
780}