Skip to main content

ferrum_cli/commands/
bench_serve.rs

1//! Tokenizer-aware HTTP bench client — emits the canonical schema
2//! defined in `crates/ferrum-bench-core` and gated by
3//! `docs/bench/PLAYBOOK.md` § 7.
4//!
5//! Scenarios (PLAYBOOK § 2):
6//!   - **Closed-loop** — `--concurrency K`, K workers in tight send→wait
7//!     loop. Headline = throughput. The "capacity knee" measurement.
8//!   - **Concurrency sweep** — `--concurrency-sweep 1,4,16,32`, runs N
9//!     closed-loop cells back-to-back to find the knee.
10//!   - **Open-loop** — `--request-rate R`, Poisson(R) arrivals. The
11//!     ONLY scenario in which goodput is meaningful (§ 0.4).
12//!
13//! Each cell runs `--n-repeats` independent times; the per-run percentiles
14//! are aggregated with mean + sample stddev + Student-t 95% CI half-width.
15//! Cells where `n_repeats < 3` emit `mean` only (PLAYBOOK § 0.4 contract).
16
17use clap::{Args, ValueEnum};
18use colored::*;
19use ferrum_bench_core::{
20    arrivals::poisson_arrival_times, compute_metrics, BenchReport, Env, ItlEvidenceSource,
21    OutputTokenCountSource, QualityIssueCounts, RequestItlEvidence, RequestRecord, RunRecord,
22    Scenario, Slo, TokenLengthStats, WarmupSummary,
23};
24use ferrum_types::Result;
25use rand::{rngs::StdRng, Rng, SeedableRng};
26use serde::Deserialize;
27use sha2::{Digest, Sha256};
28use std::path::PathBuf;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31use tokio::sync::Semaphore;
32use tokio_stream::StreamExt;
33
34use crate::config::CliConfig;
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
37pub enum BenchTargetBackend {
38    Cpu,
39    Metal,
40    Cuda,
41}
42
43impl BenchTargetBackend {
44    fn as_str(self) -> &'static str {
45        match self {
46            Self::Cpu => "cpu",
47            Self::Metal => "metal",
48            Self::Cuda => "cuda",
49        }
50    }
51}
52
53#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
54pub enum BenchHttpConnectionMode {
55    #[default]
56    Pooled,
57    Fresh,
58}
59
60impl BenchHttpConnectionMode {
61    fn as_str(self) -> &'static str {
62        match self {
63            Self::Pooled => "pooled",
64            Self::Fresh => "fresh",
65        }
66    }
67}
68
69#[derive(Args, Clone)]
70pub struct BenchServeCommand {
71    /// Base URL of the ferrum (or other OpenAI-compatible) server.
72    #[arg(long)]
73    pub base_url: String,
74
75    /// Model identifier sent in request body's `model` field.
76    /// Use the local path so `vllm bench serve` numbers compare 1:1.
77    #[arg(long)]
78    pub model: String,
79
80    /// Path to the model directory containing `tokenizer.json`. Used to
81    /// generate exact-length random token sequences.
82    #[arg(long)]
83    pub tokenizer: PathBuf,
84
85    /// Backend of the server under test. This is independent of the HTTP
86    /// client's own compile-time accelerator features.
87    #[arg(long, value_enum)]
88    pub target_backend: Option<BenchTargetBackend>,
89
90    /// HTTP connection lifecycle used by the benchmark client.
91    #[arg(long, value_enum, default_value = "pooled")]
92    pub http_connection_mode: BenchHttpConnectionMode,
93
94    // ─── Workload selection (pick one mode) ────────────────────────
95    /// Closed-loop concurrency (single cell). Default when no other
96    /// mode is given. Alias: `--max-concurrency` (legacy vLLM naming).
97    #[arg(long, default_value_t = 32, alias = "max-concurrency")]
98    pub concurrency: u32,
99
100    /// Closed-loop concurrency sweep. Overrides `--concurrency`. E.g.
101    /// `--concurrency-sweep 1,4,16,32` runs four closed-loop cells.
102    #[arg(long, value_delimiter = ',')]
103    pub concurrency_sweep: Vec<u32>,
104
105    /// Open-loop arrival rate (req/s, Poisson). When set, overrides
106    /// the closed-loop modes — this is the goodput-relevant scenario.
107    #[arg(long)]
108    pub request_rate: Option<f64>,
109
110    // ─── Dataset ───────────────────────────────────────────────────
111    /// Dataset: `random` (tokenizer-aware), `sharegpt` (load from JSONL),
112    /// `shared-prefix` (1024-tok shared prefix + unique suffix).
113    /// PLAYBOOK § 2 Scenario A (sharegpt) / Scenario C (shared-prefix).
114    #[arg(long, default_value = "random")]
115    pub dataset: String,
116
117    /// Number of *tokens* per random prompt (`--dataset random` only).
118    #[arg(long, default_value_t = 256)]
119    pub random_input_len: usize,
120
121    /// Max output tokens per request.
122    #[arg(long, default_value_t = 128)]
123    pub random_output_len: usize,
124
125    /// Send vLLM-compatible `ignore_eos=true` so fixed-output benchmark
126    /// requests run until `max_tokens` instead of stopping on model EOS.
127    #[arg(long)]
128    pub ignore_eos: bool,
129
130    /// Typed chat-template thinking control. Omitted from payloads unless set.
131    #[arg(long, action = clap::ArgAction::Set)]
132    pub enable_thinking: Option<bool>,
133
134    /// Path to a ShareGPT-format JSONL file (`--dataset sharegpt`).
135    /// Each line should be a `{"conversations": [{"from": "...", "value":
136    /// "..."}, ...]}` object (HF anon8231489123/ShareGPT_Vicuna format).
137    #[arg(long)]
138    pub sharegpt_path: Option<PathBuf>,
139
140    /// Shared prefix length in *tokens* (`--dataset shared-prefix` only).
141    #[arg(long, default_value_t = 1024)]
142    pub shared_prefix_len: usize,
143
144    /// Per-request unique suffix length in *tokens* (`--dataset shared-prefix`).
145    #[arg(long, default_value_t = 64)]
146    pub shared_suffix_len: usize,
147
148    // ─── Run shape ─────────────────────────────────────────────────
149    /// Total prompts sent per run (warmup is counted separately).
150    #[arg(long, default_value_t = 100)]
151    pub num_prompts: u32,
152
153    /// Warmup requests sent before measurement begins each run.
154    /// Discarded from the metrics; PLAYBOOK § 0.3 mandates ≥ 10 for
155    /// committed reports.
156    #[arg(long, default_value_t = 10)]
157    pub warmup_requests: u32,
158
159    /// Independent repeats per cell. PLAYBOOK § 0.4: ≥ 3 unlocks
160    /// stddev + CI95; n < 3 emits mean only.
161    #[arg(long, default_value_t = 1)]
162    pub n_repeats: u32,
163
164    /// SLO triple for goodput. Format: `ttft:500 tpot:50 e2el:30000`
165    /// (or comma-separated). Goodput is reported only when all three
166    /// are set.
167    #[arg(long, value_parser = parse_slo)]
168    pub goodput: Option<Slo>,
169
170    /// Per-request HTTP timeout in seconds.
171    #[arg(long, default_value_t = 600.0)]
172    pub timeout: f64,
173
174    /// Exit non-zero when any measured request errors. Warmup failures always fail.
175    /// Release gates must set this.
176    #[arg(long)]
177    pub fail_on_error: bool,
178
179    /// Maximum measured request error rate allowed when error enforcement is active.
180    #[arg(long)]
181    pub max_error_rate: Option<f64>,
182
183    /// Require n_repeats >= 3 so reports include CI/stddev evidence.
184    #[arg(long)]
185    pub require_ci: bool,
186
187    /// Deterministic prompt-generation seed. Repeat i uses a stable derivation.
188    #[arg(long)]
189    pub seed: Option<u64>,
190
191    // ─── Output ────────────────────────────────────────────────────
192    /// Output format: `json` (BenchReport), `jsonl` (append one
193    /// `BenchReport` per line — used by `scripts/compare-commits.sh`),
194    /// `md` (human-readable markdown).
195    #[arg(long, default_value = "json")]
196    pub output: String,
197
198    /// Output file path. For `jsonl`, the file is opened append-mode so
199    /// repeated invocations against the same DB accumulate rows.
200    /// Alias: `--result-file` (legacy).
201    #[arg(long, alias = "result-file")]
202    pub out: Option<PathBuf>,
203
204    // ─── Env / parity ──────────────────────────────────────────────
205    /// Override `env.hw_id` (defaults to auto-detected).
206    #[arg(long)]
207    pub hw_id: Option<String>,
208
209    /// Override `env.commit_sha` (defaults to `git rev-parse --short HEAD`).
210    #[arg(long)]
211    pub commit_sha: Option<String>,
212
213    /// Tag string written into the report's `model` field's suffix —
214    /// useful for the `bench_vs_vllm.sh` script which tags ferrum/vllm.
215    #[arg(long)]
216    pub tag: Option<String>,
217}
218
219// ─────────────────────────────────────────────────────────────────────
220// SLO parser
221// ─────────────────────────────────────────────────────────────────────
222
223pub(super) fn parse_slo(s: &str) -> std::result::Result<Slo, String> {
224    let mut ttft: Option<f64> = None;
225    let mut tpot: Option<f64> = None;
226    let mut e2el: Option<f64> = None;
227    for tok in s.split(|c: char| c == ',' || c.is_whitespace()) {
228        if tok.is_empty() {
229            continue;
230        }
231        let (k, v) = tok
232            .split_once(':')
233            .ok_or_else(|| format!("bad SLO token '{tok}', expected key:value"))?;
234        let v: f64 = v.parse().map_err(|e| format!("bad SLO value '{v}': {e}"))?;
235        if !v.is_finite() || v <= 0.0 {
236            return Err(format!(
237                "bad SLO value '{v}': expected a positive finite number"
238            ));
239        }
240        match k {
241            "ttft" => ttft = Some(v),
242            "tpot" => tpot = Some(v),
243            "e2el" | "e2e" => e2el = Some(v),
244            other => return Err(format!("unknown SLO key '{other}'")),
245        }
246    }
247    Ok(Slo {
248        ttft_p99_ms: ttft.ok_or("missing ttft in --goodput")?,
249        tpot_p99_ms: tpot.ok_or("missing tpot in --goodput")?,
250        e2e_p99_ms: e2el.ok_or("missing e2el in --goodput")?,
251    })
252}
253
254// ─────────────────────────────────────────────────────────────────────
255// OpenAI SSE chunk types
256// ─────────────────────────────────────────────────────────────────────
257
258#[derive(Debug, Deserialize)]
259struct OpenAiStreamChunk {
260    choices: Option<Vec<OpenAiStreamChoice>>,
261    usage: Option<OpenAiUsage>,
262    error: Option<OpenAiStreamError>,
263}
264
265#[derive(Debug, Deserialize)]
266struct OpenAiStreamError {
267    message: String,
268    #[serde(rename = "type")]
269    error_type: Option<String>,
270    code: Option<serde_json::Value>,
271}
272
273#[derive(Debug, Deserialize)]
274struct OpenAiStreamChoice {
275    delta: Option<OpenAiStreamDelta>,
276}
277
278#[derive(Debug, Deserialize)]
279struct OpenAiStreamDelta {
280    content: Option<String>,
281    reasoning: Option<String>,
282    reasoning_content: Option<String>,
283}
284
285#[derive(Debug, Deserialize)]
286struct OpenAiUsage {
287    completion_tokens: Option<u32>,
288}
289
290#[derive(Clone)]
291struct PromptCase {
292    text: String,
293    input_tokens: u32,
294    sha256: String,
295}
296
297// ─────────────────────────────────────────────────────────────────────
298// Single-request streamer
299// ─────────────────────────────────────────────────────────────────────
300
301async fn stream_one(
302    client: &reqwest::Client,
303    base_url: &str,
304    model: &str,
305    prompt: PromptCase,
306    max_tokens: usize,
307    ignore_eos: bool,
308    enable_thinking: Option<bool>,
309    timeout_s: f64,
310) -> RequestRecord {
311    let PromptCase {
312        text,
313        input_tokens,
314        sha256: prompt_sha256,
315    } = prompt;
316    let body = chat_completion_body(model, &text, max_tokens, ignore_eos, enable_thinking);
317    let start = Instant::now();
318    let mut state = StreamState::for_prompt(start, input_tokens, prompt_sha256.clone());
319
320    let resp = match client
321        .post(format!("{}/v1/chat/completions", base_url))
322        .json(&body)
323        .timeout(Duration::from_secs_f64(timeout_s))
324        .send()
325        .await
326    {
327        Ok(r) => r,
328        Err(e) => {
329            eprintln!(
330                "[err] post prompt_sha256={prompt_sha256} input_tokens={} \
331                 timeout={} connect={} request={} body={} decode={}: display={e}; debug={e:?}",
332                input_tokens,
333                e.is_timeout(),
334                e.is_connect(),
335                e.is_request(),
336                e.is_body(),
337                e.is_decode(),
338            );
339            let mut quality_issues = QualityIssueCounts::default();
340            quality_issues.malformed_stream = 1;
341            return failed_record(input_tokens, start, quality_issues);
342        }
343    };
344    if !resp.status().is_success() {
345        let status = resp.status();
346        let txt = resp.text().await.unwrap_or_default();
347        eprintln!(
348            "[err] http prompt_sha256={prompt_sha256} {status}: {}",
349            clipped_debug_text(&txt, 200)
350        );
351        let mut quality_issues = QualityIssueCounts::default();
352        if status.as_u16() == 500 {
353            quality_issues.http_500 = 1;
354        }
355        if looks_like_panic(&txt) {
356            quality_issues.panic = 1;
357        }
358        return failed_record(input_tokens, start, quality_issues);
359    }
360
361    let mut stream = resp.bytes_stream();
362    let mut sse = SseLineBuffer::default();
363    while let Some(chunk) = stream.next().await {
364        let chunk = match chunk {
365            Ok(c) => c,
366            Err(e) => {
367                eprintln!("[err] stream prompt_sha256={prompt_sha256}: display={e}; debug={e:?}");
368                state.stream_error = Some(e.to_string());
369                state.quality_issues.malformed_stream = 1;
370                break;
371            }
372        };
373        let before_output_events = state.output_delta_events;
374        sse.push(&chunk, &mut state);
375        state.note_transport_chunk(
376            state
377                .output_delta_events
378                .saturating_sub(before_output_events),
379        );
380    }
381    sse.finish(&mut state);
382    state.finish()
383}
384
385#[derive(Default)]
386struct SseLineBuffer {
387    pending: Vec<u8>,
388}
389
390impl SseLineBuffer {
391    fn push(&mut self, chunk: &[u8], state: &mut StreamState) {
392        self.pending.extend_from_slice(chunk);
393        let mut consumed = 0;
394        while let Some(relative_newline) = self.pending[consumed..]
395            .iter()
396            .position(|byte| *byte == b'\n')
397        {
398            let newline = consumed + relative_newline;
399            let line = self.pending[consumed..newline].to_vec();
400            consumed = newline + 1;
401            Self::process_line(&line, state);
402        }
403        if consumed > 0 {
404            self.pending.drain(..consumed);
405        }
406    }
407
408    fn finish(&mut self, state: &mut StreamState) {
409        if !self.pending.is_empty() {
410            let line = std::mem::take(&mut self.pending);
411            Self::process_line(&line, state);
412        }
413    }
414
415    fn process_line(raw_line: &[u8], state: &mut StreamState) {
416        let raw_line = raw_line.strip_suffix(b"\r").unwrap_or(raw_line);
417        let line = match std::str::from_utf8(raw_line) {
418            Ok(line) => line.trim(),
419            Err(error) => {
420                let message = format!("invalid UTF-8 in SSE line: {error}");
421                eprintln!("[err] prompt_sha256={} {message}", state.prompt_sha256);
422                state.stream_error = Some(message);
423                state.quality_issues.malformed_stream = 1;
424                state.quality_issues.bad_output = 1;
425                return;
426            }
427        };
428        let Some(payload) = line.strip_prefix("data:").map(str::trim) else {
429            return;
430        };
431        if payload == "[DONE]" {
432            state.done_count = state
433                .done_count
434                .checked_add(1)
435                .expect("SSE done count overflow");
436            if state.done_count > 1 {
437                state.quality_issues.duplicate_done = 1;
438            }
439            return;
440        }
441        if let Err(error) = state.handle_payload(payload) {
442            eprintln!(
443                "[err] malformed sse json prompt_sha256={}: {error}",
444                state.prompt_sha256
445            );
446            state.stream_error = Some(error);
447            state.quality_issues.malformed_stream = 1;
448        }
449    }
450}
451
452fn chat_completion_body(
453    model: &str,
454    prompt_text: &str,
455    max_tokens: usize,
456    ignore_eos: bool,
457    enable_thinking: Option<bool>,
458) -> serde_json::Value {
459    let mut body = serde_json::json!({
460        "model": model,
461        "messages": [{"role": "user", "content": prompt_text}],
462        "max_tokens": max_tokens,
463        "stream": true,
464        "stream_options": {"include_usage": true},
465        "temperature": 0.0,
466    });
467    if let Some(enable_thinking) = enable_thinking {
468        body["chat_template_kwargs"] = serde_json::json!({"enable_thinking": enable_thinking});
469    }
470    if ignore_eos {
471        body["ignore_eos"] = serde_json::json!(true);
472    }
473    body
474}
475
476fn failed_record(
477    input_tokens: u32,
478    start: Instant,
479    quality_issues: QualityIssueCounts,
480) -> RequestRecord {
481    RequestRecord {
482        success: false,
483        ttft_ms: 0.0,
484        e2e_ms: start.elapsed().as_secs_f64() * 1000.0,
485        input_tokens,
486        output_tokens: 0,
487        output_token_count_source: OutputTokenCountSource::None,
488        itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
489        quality_issues,
490        itl_ms: vec![],
491    }
492}
493
494fn join_failed_record(input_tokens: u32) -> RequestRecord {
495    let mut quality_issues = QualityIssueCounts::default();
496    quality_issues.panic = 1;
497    RequestRecord {
498        success: false,
499        ttft_ms: 0.0,
500        e2e_ms: 0.0,
501        input_tokens,
502        output_tokens: 0,
503        output_token_count_source: OutputTokenCountSource::None,
504        itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
505        quality_issues,
506        itl_ms: vec![],
507    }
508}
509
510async fn collect_measured_handles(
511    handles: Vec<(u32, tokio::task::JoinHandle<RequestRecord>)>,
512) -> Vec<RequestRecord> {
513    let mut records = Vec::with_capacity(handles.len());
514    for (input_tokens, handle) in handles {
515        match handle.await {
516            Ok(record) => records.push(record),
517            Err(error) => {
518                eprintln!("[err] measured request task: {error}");
519                records.push(join_failed_record(input_tokens));
520            }
521        }
522    }
523    records
524}
525
526struct StreamState {
527    start: Instant,
528    input_tokens: u32,
529    first_token_time: Option<Instant>,
530    last_token_time: Option<Instant>,
531    output_delta_events: u32,
532    usage_completion_tokens: Option<u32>,
533    itl_ms: Vec<f64>,
534    transport_coalesced_output_chunks: u32,
535    done_count: u32,
536    stream_error: Option<String>,
537    quality_issues: QualityIssueCounts,
538    prompt_sha256: String,
539}
540
541impl StreamState {
542    #[cfg(test)]
543    fn new(start: Instant, input_tokens: u32) -> Self {
544        Self::for_prompt(start, input_tokens, "unknown".to_string())
545    }
546
547    fn for_prompt(start: Instant, input_tokens: u32, prompt_sha256: String) -> Self {
548        Self {
549            start,
550            input_tokens,
551            first_token_time: None,
552            last_token_time: None,
553            output_delta_events: 0,
554            usage_completion_tokens: None,
555            itl_ms: Vec::new(),
556            transport_coalesced_output_chunks: 0,
557            done_count: 0,
558            stream_error: None,
559            quality_issues: QualityIssueCounts::default(),
560            prompt_sha256,
561        }
562    }
563
564    fn handle_payload(&mut self, payload: &str) -> std::result::Result<(), String> {
565        let chunk: OpenAiStreamChunk =
566            serde_json::from_str(payload).map_err(|e| format!("{e}: {payload}"))?;
567        if let Some(error) = chunk.error {
568            return Err(format!(
569                "OpenAI stream error type={} code={} message={}",
570                error.error_type.as_deref().unwrap_or("unknown"),
571                error
572                    .code
573                    .as_ref()
574                    .map(serde_json::Value::to_string)
575                    .unwrap_or_else(|| "null".to_owned()),
576                error.message
577            ));
578        }
579        if let Some(usage) = chunk.usage {
580            if let Some(tokens) = usage.completion_tokens {
581                self.usage_completion_tokens = Some(tokens);
582            }
583        }
584        if let Some(choices) = chunk.choices {
585            if let Some(first) = choices.into_iter().next() {
586                if let Some(delta) = first.delta {
587                    if let Some(text) = first_non_empty_delta_text(&delta) {
588                        let now = Instant::now();
589                        if self.first_token_time.is_none() {
590                            self.first_token_time = Some(now);
591                        } else if let Some(prev) = self.last_token_time {
592                            self.itl_ms.push((now - prev).as_secs_f64() * 1000.0);
593                        }
594                        self.last_token_time = Some(now);
595                        self.output_delta_events = self
596                            .output_delta_events
597                            .checked_add(1)
598                            .expect("stream output event count overflow");
599                        if let Some(reason) = bad_output_reason(text) {
600                            eprintln!(
601                                "[err] bad output prompt_sha256={} {reason}: {}",
602                                self.prompt_sha256,
603                                clipped_debug_text(text, 160)
604                            );
605                            self.quality_issues.bad_output = 1;
606                        }
607                        if looks_like_panic(text) {
608                            self.quality_issues.panic = 1;
609                        }
610                    }
611                }
612            }
613        }
614        Ok(())
615    }
616
617    fn note_transport_chunk(&mut self, output_events: u32) {
618        if output_events > 1 {
619            self.transport_coalesced_output_chunks = self
620                .transport_coalesced_output_chunks
621                .checked_add(1)
622                .expect("coalesced output transport chunk count overflow");
623        }
624    }
625
626    fn finish(mut self) -> RequestRecord {
627        let (output_tokens, source) = match self.usage_completion_tokens {
628            Some(tokens) => (tokens, OutputTokenCountSource::Usage),
629            None if self.output_delta_events > 0 => (
630                self.output_delta_events,
631                OutputTokenCountSource::StreamChunks,
632            ),
633            None => (0, OutputTokenCountSource::None),
634        };
635        if self.done_count == 0 {
636            self.quality_issues.missing_done = 1;
637        } else if self.done_count > 1 {
638            self.quality_issues.duplicate_done = 1;
639        }
640        if output_tokens == 0 {
641            self.quality_issues.zero_output_tokens = 1;
642        }
643        let e2e_ms = self.start.elapsed().as_secs_f64() * 1000.0;
644        let ttft_ms = self
645            .first_token_time
646            .map(|t| t.duration_since(self.start).as_secs_f64() * 1000.0)
647            .unwrap_or(e2e_ms);
648        let success = self.done_count == 1
649            && output_tokens > 0
650            && self.stream_error.is_none()
651            && self.quality_issues.request_error_count() == 0;
652        let observed_intervals =
653            u32::try_from(self.itl_ms.len()).expect("stream ITL interval count overflow");
654        let itl_evidence = RequestItlEvidence::sse(
655            success,
656            self.output_delta_events,
657            self.usage_completion_tokens,
658            observed_intervals,
659            self.transport_coalesced_output_chunks,
660        );
661        RequestRecord {
662            success,
663            ttft_ms,
664            e2e_ms,
665            input_tokens: self.input_tokens,
666            output_tokens,
667            output_token_count_source: source,
668            itl_evidence,
669            quality_issues: self.quality_issues,
670            itl_ms: self.itl_ms,
671        }
672    }
673}
674
675fn first_non_empty_delta_text(delta: &OpenAiStreamDelta) -> Option<&str> {
676    delta
677        .content
678        .as_deref()
679        .filter(|s| !s.is_empty())
680        .or_else(|| delta.reasoning.as_deref().filter(|s| !s.is_empty()))
681        .or_else(|| delta.reasoning_content.as_deref().filter(|s| !s.is_empty()))
682}
683
684#[cfg(test)]
685fn has_bad_output_text(text: &str) -> bool {
686    bad_output_reason(text).is_some()
687}
688
689fn bad_output_reason(text: &str) -> Option<&'static str> {
690    const BAD_FRAGMENTS: &[(&str, &str)] = &[
691        ("<unk>", "reserved-token"),
692        ("[PAD", "reserved-token"),
693        ("<pad>", "reserved-token"),
694        ("<|endoftext|>", "reserved-token"),
695        ("<|im_start|>", "reserved-token"),
696        ("<|im_end|>", "reserved-token"),
697        ("<|reserved_special_token", "reserved-token"),
698        ("\u{fffd}", "invalid-utf8"),
699    ];
700    for (fragment, reason) in BAD_FRAGMENTS {
701        if text.contains(fragment) {
702            return Some(reason);
703        }
704    }
705    contains_mojibake_sequence(text).then_some("mojibake")
706}
707
708fn contains_mojibake_sequence(text: &str) -> bool {
709    let mut chars = text.chars().peekable();
710    while let Some(ch) = chars.next() {
711        match ch {
712            // Common UTF-8-as-Latin-1/Windows-1252 mojibake starts. Treat
713            // standalone lead characters as ordinary text; require the
714            // following non-ASCII continuation to avoid false positives from
715            // tokenizer byte-fallback fragments in random benchmark prompts.
716            '\u{00c2}' | '\u{00c3}' => {
717                if chars.peek().is_some_and(|next| !next.is_ascii()) {
718                    return true;
719                }
720            }
721            // Most smart quote, dash, ellipsis, and bullet mojibake starts
722            // with "â€" after UTF-8 bytes are decoded through Windows-1252.
723            '\u{00e2}' => {
724                if chars.peek().is_some_and(|next| *next == '\u{20ac}') {
725                    return true;
726                }
727            }
728            _ => {}
729        }
730    }
731    false
732}
733
734fn clipped_debug_text(text: &str, max_chars: usize) -> String {
735    text.escape_debug().take(max_chars).collect()
736}
737
738fn sha256_hex(bytes: &[u8]) -> String {
739    let mut hasher = Sha256::new();
740    hasher.update(bytes);
741    format!("{:x}", hasher.finalize())
742}
743
744fn looks_like_panic(text: &str) -> bool {
745    let lower = text.to_ascii_lowercase();
746    lower.contains("panicked at") || lower.contains("thread '") && lower.contains("panicked")
747}
748
749// ─────────────────────────────────────────────────────────────────────
750// Dataset generation
751// ─────────────────────────────────────────────────────────────────────
752
753/// Draw a random token sequence of exact length `n_tokens`.
754fn gen_random_prompt(
755    tok: &tokenizers::Tokenizer,
756    n_tokens: usize,
757    rng: &mut (impl Rng + ?Sized),
758) -> String {
759    if let Some(text) = gen_random_prompt_target_len(tok, n_tokens, rng) {
760        return text;
761    }
762    // Never fall back to arbitrary byte-level token ids: decoding those ids
763    // can inject U+FFFD into a request and turn a workload bug into a server
764    // correctness or transport failure.
765    " x".repeat(n_tokens)
766}
767
768fn generated_prompt_is_safe(text: &str) -> bool {
769    !text.is_empty()
770        && !text.contains("<|")
771        && bad_output_reason(text).is_none()
772        && text
773            .chars()
774            .all(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t'))
775}
776
777fn sample_safe_token_ids(
778    tok: &tokenizers::Tokenizer,
779    count: usize,
780    rng: &mut (impl Rng + ?Sized),
781    lo: u32,
782    hi: u32,
783) -> Option<Vec<u32>> {
784    let mut ids = Vec::with_capacity(count);
785    for _ in 0..count {
786        let mut selected = None;
787        for _ in 0..256 {
788            let id = rng.random_range(lo..=hi);
789            let Ok(piece) = tok.decode(&[id], false) else {
790                continue;
791            };
792            if generated_prompt_is_safe(&piece) {
793                selected = Some(id);
794                break;
795            }
796        }
797        ids.push(selected?);
798    }
799    Some(ids)
800}
801
802fn gen_random_prompt_target_len(
803    tok: &tokenizers::Tokenizer,
804    target_len: usize,
805    rng: &mut (impl Rng + ?Sized),
806) -> Option<String> {
807    if target_len == 0 {
808        return Some(String::new());
809    }
810    let vocab_size = tok.get_vocab_size(false) as u32;
811    let lo: u32 = 256.min(vocab_size.saturating_sub(1));
812    let hi: u32 = vocab_size.saturating_sub(1);
813    if hi < lo {
814        return None;
815    }
816
817    let mut sample_len = target_len;
818    let mut best_under: Option<(usize, String)> = None;
819    let mut best_any: Option<(usize, String)> = None;
820    for _ in 0..64 {
821        let ids = sample_safe_token_ids(tok, sample_len, rng, lo, hi)?;
822        let text = match tok.decode(&ids, false) {
823            Ok(text) if generated_prompt_is_safe(&text) => text,
824            _ => continue,
825        };
826        let len = match token_count(tok, &text) {
827            Some(len) => len,
828            None => continue,
829        };
830        if len == target_len {
831            return Some(text);
832        }
833        let delta = len.abs_diff(target_len);
834        if best_any
835            .as_ref()
836            .map(|(best_len, _)| delta < best_len.abs_diff(target_len))
837            .unwrap_or(true)
838        {
839            best_any = Some((len, text.clone()));
840        }
841        if len < target_len
842            && best_under
843                .as_ref()
844                .map(|(best_len, _)| len > *best_len)
845                .unwrap_or(true)
846        {
847            best_under = Some((len, text));
848        }
849
850        // Decoding arbitrary tokenizer ids often changes the token count on
851        // re-encode. Move the next draw in the direction of the target.
852        sample_len = if len > target_len {
853            sample_len.saturating_sub(len - target_len).max(1)
854        } else {
855            sample_len + (target_len - len).max(1)
856        };
857    }
858
859    if let Some((len, text)) = best_under {
860        return fill_random_prompt_to_len(tok, text, len, target_len, rng, lo, hi);
861    }
862    best_any.map(|(_, text)| text)
863}
864
865fn token_count(tok: &tokenizers::Tokenizer, text: &str) -> Option<usize> {
866    tok.encode(text, false).ok().map(|enc| enc.len())
867}
868
869fn fill_random_prompt_to_len(
870    tok: &tokenizers::Tokenizer,
871    mut text: String,
872    mut len: usize,
873    target_len: usize,
874    rng: &mut (impl Rng + ?Sized),
875    lo: u32,
876    hi: u32,
877) -> Option<String> {
878    for _ in 0..target_len.saturating_mul(8).max(16) {
879        if len == target_len {
880            return Some(text);
881        }
882        let next = random_one_token_extension(tok, &text, len, rng, lo, hi)?;
883        text = next.0;
884        len = next.1;
885    }
886    (len == target_len).then_some(text)
887}
888
889fn random_one_token_extension(
890    tok: &tokenizers::Tokenizer,
891    base: &str,
892    base_len: usize,
893    rng: &mut (impl Rng + ?Sized),
894    lo: u32,
895    hi: u32,
896) -> Option<(String, usize)> {
897    // Try random vocab pieces first so prompts remain high-entropy.
898    for _ in 0..128 {
899        let id = rng.random_range(lo..=hi);
900        if let Some(candidate) = append_decoded_piece(tok, base, base_len, id) {
901            return Some(candidate);
902        }
903    }
904    // Deterministic fallbacks cover tokenizers where most high vocab ids are
905    // byte-fallback fragments that merge or expand at text boundaries.
906    for piece in [" x", " y", " z", ".", ",", "\n"] {
907        let candidate = format!("{base}{piece}");
908        if token_count(tok, &candidate) == Some(base_len + 1) {
909            return Some((candidate, base_len + 1));
910        }
911    }
912    None
913}
914
915fn append_decoded_piece(
916    tok: &tokenizers::Tokenizer,
917    base: &str,
918    base_len: usize,
919    id: u32,
920) -> Option<(String, usize)> {
921    let piece = tok.decode(&[id], false).ok()?;
922    if !generated_prompt_is_safe(&piece) {
923        return None;
924    }
925    let candidate = format!("{base}{piece}");
926    (generated_prompt_is_safe(&candidate) && token_count(tok, &candidate) == Some(base_len + 1))
927        .then_some((candidate, base_len + 1))
928}
929
930fn build_prompts(
931    cmd: &BenchServeCommand,
932    tok: &tokenizers::Tokenizer,
933    rng: &mut (impl Rng + ?Sized),
934    count: usize,
935) -> Result<Vec<PromptCase>> {
936    match cmd.dataset.as_str() {
937        "random" => (0..count)
938            .map(|_| {
939                let text = gen_random_prompt(tok, cmd.random_input_len, rng);
940                prompt_case(tok, text)
941            })
942            .collect(),
943        "shared-prefix" => gen_shared_prefix_prompts(
944            tok,
945            count,
946            cmd.shared_prefix_len,
947            cmd.shared_suffix_len,
948            rng,
949        ),
950        "sharegpt" => {
951            let p = cmd.sharegpt_path.as_ref().ok_or_else(|| {
952                ferrum_types::FerrumError::model("--dataset sharegpt requires --sharegpt-path PATH")
953            })?;
954            load_sharegpt_prompts(p, tok, count, rng)
955        }
956        other => Err(ferrum_types::FerrumError::model(format!(
957            "unknown --dataset '{}': allowed values are random, sharegpt, shared-prefix",
958            other
959        ))),
960    }
961}
962
963fn prompt_case(tok: &tokenizers::Tokenizer, text: String) -> Result<PromptCase> {
964    let encoding = tok
965        .encode(text.as_str(), false)
966        .map_err(|e| ferrum_types::FerrumError::model(format!("tokenize generated prompt: {e}")))?;
967    let input_tokens = u32::try_from(encoding.len()).map_err(|_| {
968        ferrum_types::FerrumError::model("generated prompt token count exceeds report capacity")
969    })?;
970    let sha256 = sha256_hex(text.as_bytes());
971    Ok(PromptCase {
972        text,
973        input_tokens,
974        sha256,
975    })
976}
977
978/// Generate `count` prompts that all share a 1024-token (or whatever
979/// `shared_prefix_len` is) prefix, with a unique random suffix per
980/// request. The shared prefix is sampled ONCE from the tokenizer's
981/// mid-vocab range and decoded into a UTF-8 string the server can
982/// re-tokenize back to ~the same length.
983///
984/// Used by PLAYBOOK § 2 Scenario C (prefix-cache thundering herd):
985/// closed-loop ShareGPT can't reproduce this because each ShareGPT
986/// conversation has a different prefix.
987fn gen_shared_prefix_prompts(
988    tok: &tokenizers::Tokenizer,
989    count: usize,
990    prefix_len: usize,
991    suffix_len: usize,
992    rng: &mut (impl Rng + ?Sized),
993) -> Result<Vec<PromptCase>> {
994    // Shared prefix sampled once. The random generator rejects byte-fallback
995    // fragments that do not independently decode to safe Unicode text.
996    let prefix = gen_random_prompt(tok, prefix_len, rng);
997    (0..count)
998        .map(|_| {
999            let suffix = gen_random_prompt(tok, suffix_len, rng);
1000            // Insert a newline so the prefix is a clear boundary — helps
1001            // server-side prefix-cache hashing key on the same prefix
1002            // even when suffixes differ.
1003            prompt_case(tok, format!("{prefix}\n{suffix}"))
1004        })
1005        .collect()
1006}
1007
1008/// Load up to `count` user-turn prompts from a ShareGPT-format JSONL.
1009///
1010/// Accepts either:
1011///   - HF `anon8231489123/ShareGPT_Vicuna` format: each line is
1012///     `{"id": "...", "conversations": [{"from": "human"/"gpt", "value": "..."}]}`
1013///   - vLLM-style: `{"input": "..."}` per line
1014///
1015/// Picks the first `human` turn per conversation (matches vLLM's
1016/// benchmark_serving.py heuristic). If `count` < records available,
1017/// randomly samples; if `count` > available, cycles with replacement.
1018fn load_sharegpt_prompts(
1019    path: &std::path::Path,
1020    tok: &tokenizers::Tokenizer,
1021    count: usize,
1022    rng: &mut (impl Rng + ?Sized),
1023) -> Result<Vec<PromptCase>> {
1024    use std::io::BufRead;
1025    let f = std::fs::File::open(path).map_err(|e| {
1026        ferrum_types::FerrumError::model(format!("open sharegpt {}: {e}", path.display()))
1027    })?;
1028    let mut prompts: Vec<String> = Vec::new();
1029    for (idx, line) in std::io::BufReader::new(f).lines().enumerate() {
1030        let line = line.map_err(|e| {
1031            ferrum_types::FerrumError::model(format!("read line {idx} of {}: {e}", path.display()))
1032        })?;
1033        if line.trim().is_empty() {
1034            continue;
1035        }
1036        let v: serde_json::Value = match serde_json::from_str(&line) {
1037            Ok(v) => v,
1038            Err(e) => {
1039                eprintln!("[warn] sharegpt line {idx}: skip (parse error: {e})");
1040                continue;
1041            }
1042        };
1043        // Try ShareGPT-Vicuna format first.
1044        let prompt: Option<String> = v
1045            .get("conversations")
1046            .and_then(|c| c.as_array())
1047            .and_then(|arr| {
1048                arr.iter()
1049                    .find(|t| t.get("from").and_then(|f| f.as_str()) == Some("human"))
1050                    .and_then(|t| {
1051                        t.get("value")
1052                            .and_then(|x| x.as_str())
1053                            .map(|s| s.to_string())
1054                    })
1055            })
1056            // Fallback: simple {"input": "..."} format.
1057            .or_else(|| {
1058                v.get("input")
1059                    .and_then(|s| s.as_str())
1060                    .map(|s| s.to_string())
1061            });
1062        if let Some(p) = prompt {
1063            if !p.is_empty() {
1064                prompts.push(p);
1065            }
1066        }
1067    }
1068    if prompts.is_empty() {
1069        return Err(ferrum_types::FerrumError::model(format!(
1070            "sharegpt {}: no usable prompts found",
1071            path.display()
1072        )));
1073    }
1074    // Sample `count` with replacement (cycles deterministically for the
1075    // given seed via rng).
1076    let mut out = Vec::with_capacity(count);
1077    for _ in 0..count {
1078        let idx = rng.random_range(0..prompts.len());
1079        out.push(prompt_case(tok, prompts[idx].clone())?);
1080    }
1081    Ok(out)
1082}
1083
1084// ─────────────────────────────────────────────────────────────────────
1085// Scenario runners
1086// ─────────────────────────────────────────────────────────────────────
1087
1088struct RunContext {
1089    client: Arc<reqwest::Client>,
1090    base_url: Arc<String>,
1091    model: Arc<String>,
1092    max_out: usize,
1093    ignore_eos: bool,
1094    enable_thinking: Option<bool>,
1095    timeout_s: f64,
1096}
1097
1098fn summarize_warmup(
1099    expected: usize,
1100    records: &[RequestRecord],
1101    join_errors: usize,
1102) -> WarmupSummary {
1103    assert_eq!(records.len() + join_errors, expected);
1104    let completed = records.iter().filter(|record| record.success).count() as u32;
1105    let mut quality_issues = QualityIssueCounts::default();
1106    for record in records {
1107        quality_issues.add_assign(&record.quality_issues);
1108    }
1109    quality_issues.panic = quality_issues
1110        .panic
1111        .checked_add(u32::try_from(join_errors).expect("warmup join error count overflow"))
1112        .expect("warmup panic count overflow");
1113    let expected = u32::try_from(expected).expect("warmup expected count overflow");
1114    WarmupSummary {
1115        expected,
1116        completed,
1117        errored: expected
1118            .checked_sub(completed)
1119            .expect("warmup completed count exceeds expected"),
1120        quality_issues,
1121    }
1122}
1123
1124/// Closed-loop: K workers in a tight loop. Warmup prompts run sequentially
1125/// at full concurrency (just to load caches), then the measurement window
1126/// begins.
1127async fn run_closed_loop(
1128    ctx: &RunContext,
1129    prompts: Vec<PromptCase>,
1130    warmup_requests: u32,
1131    concurrency: u32,
1132) -> RunRecord {
1133    let n_warmup = warmup_requests as usize;
1134    let total = prompts.len();
1135    assert!(
1136        total > n_warmup,
1137        "num_prompts ({total}) must exceed warmup_requests ({n_warmup})"
1138    );
1139
1140    // Warmup window — fire and discard.
1141    let warmup = {
1142        let sem = Arc::new(Semaphore::new(concurrency as usize));
1143        let mut handles = Vec::new();
1144        for prompt in prompts.iter().take(n_warmup) {
1145            let permit = sem.clone().acquire_owned().await.expect("semaphore");
1146            let ctx_c = ctx.clone_inner();
1147            let p = prompt.clone();
1148            handles.push(tokio::spawn(async move {
1149                let _g = permit;
1150                stream_one(
1151                    &ctx_c.client,
1152                    &ctx_c.base_url,
1153                    &ctx_c.model,
1154                    p,
1155                    ctx_c.max_out,
1156                    ctx_c.ignore_eos,
1157                    ctx_c.enable_thinking,
1158                    ctx_c.timeout_s,
1159                )
1160                .await
1161            }));
1162        }
1163        let mut records = Vec::with_capacity(n_warmup);
1164        let mut join_errors = 0;
1165        for handle in handles {
1166            match handle.await {
1167                Ok(record) => records.push(record),
1168                Err(_) => join_errors += 1,
1169            }
1170        }
1171        summarize_warmup(n_warmup, &records, join_errors)
1172    };
1173
1174    // Measurement window.
1175    let sem = Arc::new(Semaphore::new(concurrency as usize));
1176    let start = Instant::now();
1177    let mut handles = Vec::with_capacity(total - n_warmup);
1178    for prompt in prompts.into_iter().skip(n_warmup) {
1179        let input_tokens = prompt.input_tokens;
1180        let permit = sem.clone().acquire_owned().await.expect("semaphore");
1181        let ctx_c = ctx.clone_inner();
1182        handles.push((
1183            input_tokens,
1184            tokio::spawn(async move {
1185                let _g = permit;
1186                stream_one(
1187                    &ctx_c.client,
1188                    &ctx_c.base_url,
1189                    &ctx_c.model,
1190                    prompt,
1191                    ctx_c.max_out,
1192                    ctx_c.ignore_eos,
1193                    ctx_c.enable_thinking,
1194                    ctx_c.timeout_s,
1195                )
1196                .await
1197            }),
1198        ));
1199    }
1200    let records = collect_measured_handles(handles).await;
1201    let duration_s = start.elapsed().as_secs_f64();
1202    RunRecord {
1203        records,
1204        expected_requests: u32::try_from(total - n_warmup)
1205            .expect("measured request count overflow"),
1206        duration_s,
1207        warmup,
1208    }
1209}
1210
1211/// Open-loop: Poisson(rate) arrivals. The arrival schedule is fixed
1212/// before sending so that slow responses don't push later arrivals.
1213async fn run_open_loop(
1214    ctx: &RunContext,
1215    prompts: Vec<PromptCase>,
1216    warmup_requests: u32,
1217    rate: f64,
1218) -> RunRecord {
1219    let n_warmup = warmup_requests as usize;
1220    let total = prompts.len();
1221    assert!(total > n_warmup);
1222
1223    // Warmup: send a few sequentially to load caches.
1224    let mut warmup_records = Vec::with_capacity(n_warmup);
1225    for prompt in prompts.iter().take(n_warmup) {
1226        warmup_records.push(
1227            stream_one(
1228                &ctx.client,
1229                &ctx.base_url,
1230                &ctx.model,
1231                prompt.clone(),
1232                ctx.max_out,
1233                ctx.ignore_eos,
1234                ctx.enable_thinking,
1235                ctx.timeout_s,
1236            )
1237            .await,
1238        );
1239    }
1240    let warmup = summarize_warmup(n_warmup, &warmup_records, 0);
1241
1242    // Pre-compute arrival schedule (re-zeroed after warmup).
1243    let mut rng = rand::rng();
1244    let measurement_count = total - n_warmup;
1245    let schedule = poisson_arrival_times(rate, measurement_count, &mut rng);
1246
1247    let start = Instant::now();
1248    let mut handles = Vec::with_capacity(measurement_count);
1249    for (i, prompt) in prompts.into_iter().skip(n_warmup).enumerate() {
1250        let target = schedule[i];
1251        let now = start.elapsed().as_secs_f64();
1252        if target > now {
1253            tokio::time::sleep(Duration::from_secs_f64(target - now)).await;
1254        }
1255        let ctx_c = ctx.clone_inner();
1256        let input_tokens = prompt.input_tokens;
1257        handles.push((
1258            input_tokens,
1259            tokio::spawn(async move {
1260                stream_one(
1261                    &ctx_c.client,
1262                    &ctx_c.base_url,
1263                    &ctx_c.model,
1264                    prompt,
1265                    ctx_c.max_out,
1266                    ctx_c.ignore_eos,
1267                    ctx_c.enable_thinking,
1268                    ctx_c.timeout_s,
1269                )
1270                .await
1271            }),
1272        ));
1273    }
1274    let records = collect_measured_handles(handles).await;
1275    let duration_s = start.elapsed().as_secs_f64();
1276    RunRecord {
1277        records,
1278        expected_requests: u32::try_from(measurement_count)
1279            .expect("measured request count overflow"),
1280        duration_s,
1281        warmup,
1282    }
1283}
1284
1285impl RunContext {
1286    fn clone_inner(&self) -> Self {
1287        Self {
1288            client: self.client.clone(),
1289            base_url: self.base_url.clone(),
1290            model: self.model.clone(),
1291            max_out: self.max_out,
1292            ignore_eos: self.ignore_eos,
1293            enable_thinking: self.enable_thinking,
1294            timeout_s: self.timeout_s,
1295        }
1296    }
1297}
1298
1299// ─────────────────────────────────────────────────────────────────────
1300// Env construction
1301// ─────────────────────────────────────────────────────────────────────
1302
1303fn build_env(cmd: &BenchServeCommand, features: Vec<String>) -> Env {
1304    let commit_sha = cmd
1305        .commit_sha
1306        .clone()
1307        .or_else(|| {
1308            std::process::Command::new("git")
1309                .args(["rev-parse", "--short", "HEAD"])
1310                .output()
1311                .ok()
1312                .and_then(|o| String::from_utf8(o.stdout).ok())
1313                .map(|s| s.trim().to_string())
1314        })
1315        .unwrap_or_else(|| "unknown".to_string());
1316
1317    let mut env = Env::capture_minimal(commit_sha, features);
1318    env.http_connection_mode = Some(cmd.http_connection_mode.as_str().to_string());
1319    if let Some(hw) = cmd.hw_id.clone() {
1320        env.hw_id = hw;
1321    }
1322    env
1323}
1324
1325fn detect_features() -> Vec<String> {
1326    let mut v = Vec::new();
1327    #[cfg(feature = "metal")]
1328    v.push("metal".to_string());
1329    #[cfg(feature = "cuda")]
1330    v.push("cuda".to_string());
1331    #[cfg(feature = "vllm-marlin")]
1332    v.push("vllm-marlin".to_string());
1333    #[cfg(feature = "vllm-moe-marlin")]
1334    v.push("vllm-moe-marlin".to_string());
1335    #[cfg(feature = "vllm-paged-attn-v2")]
1336    v.push("vllm-paged-attn-v2".to_string());
1337    #[cfg(feature = "triton-kernels")]
1338    v.push("triton-kernels".to_string());
1339    v.sort();
1340    v.dedup();
1341    v
1342}
1343
1344// ─────────────────────────────────────────────────────────────────────
1345// Cell execution + multi-cell driver
1346// ─────────────────────────────────────────────────────────────────────
1347
1348#[derive(Clone, Copy)]
1349enum Cell {
1350    Closed(u32),
1351    Open(f64),
1352}
1353
1354async fn execute_cell(
1355    cmd: &BenchServeCommand,
1356    ctx: &RunContext,
1357    cell: Cell,
1358) -> Result<BenchReport> {
1359    // A neutral HTTP client can measure a different backend, so canonical
1360    // collectors supply the target explicitly. Direct product invocations keep
1361    // the build-feature fallback and never infer from runtime environment state.
1362    let backend = cmd
1363        .target_backend
1364        .map(BenchTargetBackend::as_str)
1365        .unwrap_or_else(|| {
1366            if cfg!(feature = "cuda") {
1367                "cuda"
1368            } else if cfg!(feature = "metal") {
1369                "metal"
1370            } else {
1371                "cpu"
1372            }
1373        });
1374
1375    let tokenizer_path = cmd.tokenizer.join("tokenizer.json");
1376    let tok = tokenizers::Tokenizer::from_file(&tokenizer_path).map_err(|e| {
1377        ferrum_types::FerrumError::model(format!(
1378            "Load tokenizer at {}: {}",
1379            tokenizer_path.display(),
1380            e
1381        ))
1382    })?;
1383    let total_prompts_u32 = cmd
1384        .num_prompts
1385        .checked_add(cmd.warmup_requests)
1386        .ok_or_else(|| {
1387            ferrum_types::FerrumError::model("num_prompts + warmup_requests overflow")
1388        })?;
1389    let total_prompts = usize::try_from(total_prompts_u32)
1390        .map_err(|_| ferrum_types::FerrumError::model("prompt count exceeds platform capacity"))?;
1391
1392    let mut runs: Vec<RunRecord> = Vec::with_capacity(cmd.n_repeats as usize);
1393    let mut actual_input_lengths: Vec<u32> = Vec::new();
1394    let mut actual_input_tokens_per_request: Vec<Vec<u32>> =
1395        Vec::with_capacity(cmd.n_repeats as usize);
1396    for repeat_idx in 0..cmd.n_repeats {
1397        let mut seeded_rng;
1398        let mut thread_rng;
1399        let rng: &mut dyn rand::RngCore = if let Some(seed) = cmd.seed {
1400            seeded_rng =
1401                StdRng::seed_from_u64(seed ^ ((repeat_idx as u64) << 32) ^ cell_seed(cell));
1402            &mut seeded_rng
1403        } else {
1404            thread_rng = rand::rng();
1405            &mut thread_rng
1406        };
1407        let prompts = build_prompts(cmd, &tok, rng, total_prompts)?;
1408        let measured_input_lengths: Vec<u32> = prompts
1409            .iter()
1410            .skip(cmd.warmup_requests as usize)
1411            .map(|p| p.input_tokens)
1412            .collect();
1413        actual_input_lengths.extend(measured_input_lengths.iter().copied());
1414        actual_input_tokens_per_request.push(measured_input_lengths);
1415        eprintln!(
1416            "{}",
1417            format!(
1418                "  cell {} — repeat {}/{} ({} prompts including {} warmup)",
1419                cell_label(cell),
1420                repeat_idx + 1,
1421                cmd.n_repeats,
1422                total_prompts,
1423                cmd.warmup_requests
1424            )
1425            .dimmed()
1426        );
1427        let run = match cell {
1428            Cell::Closed(c) => run_closed_loop(ctx, prompts, cmd.warmup_requests, c).await,
1429            Cell::Open(r) => run_open_loop(ctx, prompts, cmd.warmup_requests, r).await,
1430        };
1431        eprintln!(
1432            "    {} completed / {} errored / {:.1}s",
1433            run.n_completed(),
1434            run.n_errored(),
1435            run.duration_s
1436        );
1437        runs.push(run);
1438    }
1439    let requested_input_len = requested_input_len(cmd)?;
1440    let requested_output_len = u32::try_from(cmd.random_output_len).map_err(|_| {
1441        ferrum_types::FerrumError::model("random output length exceeds report capacity")
1442    })?;
1443    let actual_input_tokens = input_token_stats(&actual_input_lengths, requested_input_len);
1444    let token_count_source = output_token_count_source_from_runs(&runs);
1445
1446    let env = build_env(cmd, detect_features());
1447    let slo = cmd.goodput.unwrap_or_else(Slo::unbounded);
1448    let model_field = match &cmd.tag {
1449        Some(t) => format!("{}#{}", cmd.model, t),
1450        None => cmd.model.clone(),
1451    };
1452
1453    let (scenario, concurrency, request_rate) = match cell {
1454        Cell::Closed(c) => (Scenario::ClosedLoop, Some(c), None),
1455        Cell::Open(r) => (Scenario::OpenLoop, None, Some(r)),
1456    };
1457
1458    let mut report = compute_metrics(
1459        model_field,
1460        backend.to_string(),
1461        scenario,
1462        concurrency,
1463        request_rate,
1464        requested_input_len,
1465        requested_output_len,
1466        cmd.warmup_requests,
1467        slo,
1468        runs,
1469        env,
1470    );
1471    report.actual_input_tokens = Some(actual_input_tokens);
1472    report.actual_input_tokens_per_request = Some(actual_input_tokens_per_request);
1473    report.output_token_count_source = Some(token_count_source);
1474    Ok(report)
1475}
1476
1477fn requested_input_len(cmd: &BenchServeCommand) -> Result<u32> {
1478    match cmd.dataset.as_str() {
1479        "shared-prefix" => cmd
1480            .shared_prefix_len
1481            .checked_add(cmd.shared_suffix_len)
1482            .and_then(|value| u32::try_from(value).ok())
1483            .ok_or_else(|| {
1484                ferrum_types::FerrumError::model(
1485                    "shared prefix + suffix length exceeds report capacity",
1486                )
1487            }),
1488        _ => u32::try_from(cmd.random_input_len).map_err(|_| {
1489            ferrum_types::FerrumError::model("random input length exceeds report capacity")
1490        }),
1491    }
1492}
1493
1494fn input_token_stats(lengths: &[u32], requested: u32) -> TokenLengthStats {
1495    let min = lengths.iter().copied().min().unwrap_or(0);
1496    let max = lengths.iter().copied().max().unwrap_or(0);
1497    let mean = if lengths.is_empty() {
1498        0.0
1499    } else {
1500        lengths.iter().map(|&n| n as f64).sum::<f64>() / lengths.len() as f64
1501    };
1502    TokenLengthStats {
1503        requested,
1504        min,
1505        max,
1506        mean,
1507    }
1508}
1509
1510fn output_token_count_source_from_runs(runs: &[RunRecord]) -> String {
1511    let mut saw_usage = false;
1512    let mut saw_stream_chunks = false;
1513    let mut saw_none = false;
1514    for record in runs.iter().flat_map(|run| &run.records) {
1515        match record.output_token_count_source {
1516            OutputTokenCountSource::Usage => saw_usage = true,
1517            OutputTokenCountSource::StreamChunks => saw_stream_chunks = true,
1518            OutputTokenCountSource::None => saw_none = true,
1519        }
1520    }
1521    match (saw_usage, saw_stream_chunks, saw_none) {
1522        (true, false, false) => "usage".to_string(),
1523        (false, true, false) => "stream_chunks".to_string(),
1524        (false, false, true) => "none".to_string(),
1525        _ => "mixed".to_string(),
1526    }
1527}
1528
1529fn cell_seed(cell: Cell) -> u64 {
1530    match cell {
1531        Cell::Closed(c) => 0xC10C_ED00_0000_0000u64 ^ c as u64,
1532        Cell::Open(r) => 0x0FEE_D000_0000_0000u64 ^ r.to_bits(),
1533    }
1534}
1535
1536fn cell_label(cell: Cell) -> String {
1537    match cell {
1538        Cell::Closed(c) => format!("closed_loop c={c}"),
1539        Cell::Open(r) => format!("open_loop rate={r}"),
1540    }
1541}
1542
1543// ─────────────────────────────────────────────────────────────────────
1544// Top-level entry
1545// ─────────────────────────────────────────────────────────────────────
1546
1547pub async fn execute(cmd: BenchServeCommand, _cfg: CliConfig) -> Result<()> {
1548    validate_command(&cmd)?;
1549    eprintln!(
1550        "{}",
1551        format!(
1552            "ferrum bench-serve — dataset={} num_prompts={} warmup={} n_repeats={}",
1553            cmd.dataset, cmd.num_prompts, cmd.warmup_requests, cmd.n_repeats
1554        )
1555        .dimmed()
1556    );
1557    if cmd.n_repeats < 3 {
1558        eprintln!(
1559            "{}",
1560            "  [warn] n_repeats < 3 — emitting mean only, no stddev/CI95 (PLAYBOOK § 0.4)".yellow()
1561        );
1562    }
1563
1564    // Build cells from CLI flags. Order: request_rate > concurrency_sweep > concurrency.
1565    let cells: Vec<Cell> = if let Some(rate) = cmd.request_rate {
1566        vec![Cell::Open(rate)]
1567    } else if !cmd.concurrency_sweep.is_empty() {
1568        cmd.concurrency_sweep
1569            .iter()
1570            .copied()
1571            .map(Cell::Closed)
1572            .collect()
1573    } else {
1574        vec![Cell::Closed(cmd.concurrency)]
1575    };
1576
1577    let pool_max_idle_per_host = match cmd.http_connection_mode {
1578        BenchHttpConnectionMode::Pooled => 64,
1579        BenchHttpConnectionMode::Fresh => 0,
1580    };
1581    let client = Arc::new(
1582        reqwest::Client::builder()
1583            .pool_max_idle_per_host(pool_max_idle_per_host)
1584            .build()
1585            .map_err(|e| ferrum_types::FerrumError::model(format!("reqwest client: {e}")))?,
1586    );
1587    let ctx = RunContext {
1588        client,
1589        base_url: Arc::new(cmd.base_url.clone()),
1590        model: Arc::new(cmd.model.clone()),
1591        max_out: cmd.random_output_len,
1592        ignore_eos: cmd.ignore_eos,
1593        enable_thinking: cmd.enable_thinking,
1594        timeout_s: cmd.timeout,
1595    };
1596
1597    let mut reports: Vec<BenchReport> = Vec::with_capacity(cells.len());
1598    for cell in cells {
1599        eprintln!("{}", format!("→ {}", cell_label(cell)).bold());
1600        let r = execute_cell(&cmd, &ctx, cell).await?;
1601        emit_summary_line(&r);
1602        reports.push(r);
1603    }
1604
1605    emit_then_enforce_error_policy(&cmd, &reports)
1606}
1607
1608fn emit_then_enforce_error_policy(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1609    // Emit final report before enforcing error policy so failed release cells
1610    // still leave quality/error-count evidence in --out artifacts.
1611    emit_reports(cmd, reports)?;
1612
1613    // PLAYBOOK § 1.5: static globals don't drop on Rust process exit.
1614    ferrum_bench_core::trace::flush_global_trace();
1615
1616    enforce_error_policy(cmd, reports)
1617}
1618
1619fn emit_reports(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1620    match cmd.output.as_str() {
1621        "json" => emit_json(cmd, reports)?,
1622        "jsonl" => emit_jsonl(cmd, reports)?,
1623        "md" => emit_markdown(cmd, reports)?,
1624        other => {
1625            return Err(ferrum_types::FerrumError::model(format!(
1626                "unknown --output '{other}': allowed values are json, jsonl, md"
1627            )))
1628        }
1629    }
1630    Ok(())
1631}
1632
1633fn validate_command(cmd: &BenchServeCommand) -> Result<()> {
1634    if let Some(rate) = cmd.request_rate {
1635        if rate <= 0.0 || !rate.is_finite() {
1636            return Err(ferrum_types::FerrumError::model(
1637                "--request-rate must be a positive finite number",
1638            ));
1639        }
1640    }
1641    if cmd.timeout <= 0.0
1642        || !cmd.timeout.is_finite()
1643        || Duration::try_from_secs_f64(cmd.timeout).is_err()
1644    {
1645        return Err(ferrum_types::FerrumError::model(
1646            "--timeout must be a positive finite duration",
1647        ));
1648    }
1649    if cmd.concurrency == 0 {
1650        return Err(ferrum_types::FerrumError::model(
1651            "--concurrency must be > 0",
1652        ));
1653    }
1654    if cmd.num_prompts == 0 {
1655        return Err(ferrum_types::FerrumError::model(
1656            "--num-prompts must be > 0",
1657        ));
1658    }
1659    if cmd.n_repeats == 0 {
1660        return Err(ferrum_types::FerrumError::model("--n-repeats must be > 0"));
1661    }
1662    if cmd.random_input_len == 0 {
1663        return Err(ferrum_types::FerrumError::model(
1664            "--random-input-len must be > 0",
1665        ));
1666    }
1667    if cmd.random_output_len == 0 {
1668        return Err(ferrum_types::FerrumError::model(
1669            "--random-output-len must be > 0",
1670        ));
1671    }
1672    u32::try_from(cmd.random_output_len).map_err(|_| {
1673        ferrum_types::FerrumError::model("--random-output-len exceeds report capacity")
1674    })?;
1675    requested_input_len(cmd)?;
1676    if cmd.goodput.is_some_and(|slo| !slo.is_valid()) {
1677        return Err(ferrum_types::FerrumError::model(
1678            "--goodput values must be positive finite numbers",
1679        ));
1680    }
1681    if cmd.require_ci && cmd.n_repeats < 3 {
1682        return Err(ferrum_types::FerrumError::model(
1683            "--require-ci requires --n-repeats >= 3",
1684        ));
1685    }
1686    if let Some(max_error_rate) = cmd.max_error_rate {
1687        if !(0.0..=1.0).contains(&max_error_rate) || !max_error_rate.is_finite() {
1688            return Err(ferrum_types::FerrumError::model(
1689                "--max-error-rate must be in [0.0, 1.0]",
1690            ));
1691        }
1692    }
1693    for cell in &cmd.concurrency_sweep {
1694        if *cell == 0 {
1695            return Err(ferrum_types::FerrumError::model(
1696                "--concurrency-sweep values must be > 0",
1697            ));
1698        }
1699    }
1700    let _total_prompts = cmd
1701        .num_prompts
1702        .checked_add(cmd.warmup_requests)
1703        .ok_or_else(|| {
1704            ferrum_types::FerrumError::model("num_prompts + warmup_requests overflow")
1705        })?;
1706    Ok(())
1707}
1708
1709fn enforce_error_policy(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1710    let max_error_rate = cmd.max_error_rate.unwrap_or(0.0);
1711    for report in reports {
1712        let warmup_errored: u64 = report
1713            .repeat_metrics
1714            .iter()
1715            .map(|repeat| repeat.warmup_errored as u64)
1716            .sum();
1717        let warmup_has_quality_issue = report
1718            .repeat_metrics
1719            .iter()
1720            .any(|repeat| repeat.warmup_quality_issues != QualityIssueCounts::default());
1721        if warmup_errored > 0 || warmup_has_quality_issue {
1722            return Err(ferrum_types::FerrumError::model(format!(
1723                "bench-serve warmup failed for {}: {} errored request(s)",
1724                report.model, warmup_errored
1725            )));
1726        }
1727        if !cmd.fail_on_error && cmd.max_error_rate.is_none() {
1728            continue;
1729        }
1730
1731        let completed: u64 = report
1732            .completed_per_run
1733            .iter()
1734            .map(|value| *value as u64)
1735            .sum();
1736        let errored: u64 = report
1737            .errored_per_run
1738            .iter()
1739            .map(|value| *value as u64)
1740            .sum();
1741        let total = completed
1742            .checked_add(errored)
1743            .ok_or_else(|| ferrum_types::FerrumError::model("measured request count overflow"))?;
1744        let error_rate = if total == 0 {
1745            1.0
1746        } else {
1747            errored as f64 / total as f64
1748        };
1749        if error_rate > max_error_rate {
1750            return Err(ferrum_types::FerrumError::model(format!(
1751                "bench-serve error rate {:.4} exceeds max {:.4} for {}",
1752                error_rate, max_error_rate, report.model
1753            )));
1754        }
1755    }
1756    Ok(())
1757}
1758
1759fn emit_jsonl(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1760    use std::io::Write as _;
1761    let out_path = cmd.out.as_ref().ok_or_else(|| {
1762        ferrum_types::FerrumError::model("--output jsonl requires --out PATH (append-mode log)")
1763    })?;
1764    let mut f = std::fs::OpenOptions::new()
1765        .create(true)
1766        .append(true)
1767        .open(out_path)
1768        .map_err(|e| {
1769            ferrum_types::FerrumError::model(format!("open {}: {e}", out_path.display()))
1770        })?;
1771    for r in reports {
1772        let line = serde_json::to_string(r).expect("serialize");
1773        writeln!(f, "{}", line)
1774            .map_err(|e| ferrum_types::FerrumError::model(format!("write jsonl: {e}")))?;
1775    }
1776    eprintln!(
1777        "\n→ appended {} record(s) to {}",
1778        reports.len(),
1779        out_path.display()
1780    );
1781    Ok(())
1782}
1783
1784fn emit_markdown(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1785    use ferrum_bench_core::report::{render_single, render_sweep};
1786    let md = if reports.len() == 1 {
1787        render_single(&reports[0])
1788    } else {
1789        render_sweep(reports)
1790    };
1791    if let Some(out) = cmd.out.as_ref() {
1792        std::fs::write(out, &md).map_err(|e| {
1793            ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
1794        })?;
1795        eprintln!("\n→ wrote {}", out.display());
1796    } else {
1797        println!("{}", md);
1798    }
1799    Ok(())
1800}
1801
1802fn emit_summary_line(r: &BenchReport) {
1803    let scenario_str = match r.scenario {
1804        Scenario::ClosedLoop => format!("c={}", r.concurrency.unwrap_or(0)),
1805        Scenario::OpenLoop => format!("rate={}", r.request_rate.unwrap_or(0.0)),
1806        Scenario::SharedPrefix => "shared_prefix".to_string(),
1807        Scenario::Cli => "cli".to_string(),
1808    };
1809    let ci = if r.n_repeats >= 3 {
1810        format!(" (n_repeats={}, ± = ci95_hw)", r.n_repeats)
1811    } else {
1812        format!(" (n_repeats={}, no CI)", r.n_repeats)
1813    };
1814    eprintln!("    {} {}{}", "summary".bold(), scenario_str, ci);
1815    fmt_metric("TTFT_ms ", &r.ttft_ms, r.n_repeats);
1816    fmt_metric("TPOT_ms ", &r.tpot_ms, r.n_repeats);
1817    if r.has_complete_itl_evidence() {
1818        fmt_metric("ITL_ms  ", &r.itl_ms, r.n_repeats);
1819    } else {
1820        eprintln!("      ITL_ms   unavailable");
1821    }
1822    let thr = &r.output_throughput_tps;
1823    let good = &r.goodput_rps;
1824    if r.n_repeats >= 3 {
1825        eprintln!(
1826            "      throughput      {:.1} ± {:.1} tok/s",
1827            thr.mean, thr.ci95_hw
1828        );
1829        eprintln!(
1830            "      goodput         {:.2} ± {:.2} req/s",
1831            good.mean, good.ci95_hw
1832        );
1833    } else {
1834        eprintln!("      throughput      {:.1} tok/s", thr.mean);
1835        eprintln!("      goodput         {:.2} req/s", good.mean);
1836    }
1837}
1838
1839fn fmt_metric(name: &str, m: &ferrum_bench_core::MetricSet, n_repeats: u32) {
1840    if n_repeats >= 3 {
1841        eprintln!(
1842            "      {} p50={:.1}±{:.1}  p95={:.1}±{:.1}  p99={:.1}±{:.1}",
1843            name, m.p50.mean, m.p50.ci95_hw, m.p95.mean, m.p95.ci95_hw, m.p99.mean, m.p99.ci95_hw
1844        );
1845    } else {
1846        eprintln!(
1847            "      {} p50={:.1}  p95={:.1}  p99={:.1}",
1848            name, m.p50.mean, m.p95.mean, m.p99.mean
1849        );
1850    }
1851}
1852
1853fn emit_json(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1854    let value = if reports.len() == 1 {
1855        serde_json::to_value(&reports[0]).expect("serialize")
1856    } else {
1857        serde_json::to_value(reports).expect("serialize")
1858    };
1859    let pretty = serde_json::to_string_pretty(&value).expect("pretty");
1860    if let Some(out) = cmd.out.as_ref() {
1861        std::fs::write(out, &pretty).map_err(|e| {
1862            ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
1863        })?;
1864        eprintln!("\n→ wrote {}", out.display());
1865    } else {
1866        println!("{}", pretty);
1867    }
1868    Ok(())
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873    use super::*;
1874    use ferrum_bench_core::ItlEligibility;
1875
1876    fn parse_sse_chunks<'a>(chunks: impl IntoIterator<Item = &'a [u8]>) -> RequestRecord {
1877        let mut state = StreamState::new(Instant::now(), 7);
1878        let mut parser = SseLineBuffer::default();
1879        for chunk in chunks {
1880            let before_output_events = state.output_delta_events;
1881            parser.push(chunk, &mut state);
1882            let output_events = state
1883                .output_delta_events
1884                .saturating_sub(before_output_events);
1885            state.note_transport_chunk(output_events);
1886        }
1887        parser.finish(&mut state);
1888        state.finish()
1889    }
1890
1891    fn stream_semantics(
1892        record: &RequestRecord,
1893    ) -> (
1894        bool,
1895        u32,
1896        OutputTokenCountSource,
1897        QualityIssueCounts,
1898        RequestItlEvidence,
1899    ) {
1900        (
1901            record.success,
1902            record.output_tokens,
1903            record.output_token_count_source,
1904            record.quality_issues.clone(),
1905            record.itl_evidence.clone(),
1906        )
1907    }
1908
1909    #[test]
1910    fn slo_parses_space_separated() {
1911        let s = parse_slo("ttft:500 tpot:50 e2el:30000").unwrap();
1912        assert_eq!(s.ttft_p99_ms, 500.0);
1913        assert_eq!(s.tpot_p99_ms, 50.0);
1914        assert_eq!(s.e2e_p99_ms, 30000.0);
1915    }
1916
1917    #[test]
1918    fn slo_parses_comma_separated() {
1919        let s = parse_slo("ttft:500,tpot:50,e2el:30000").unwrap();
1920        assert_eq!(s.ttft_p99_ms, 500.0);
1921    }
1922
1923    #[test]
1924    fn slo_accepts_e2e_alias() {
1925        let s = parse_slo("ttft:1 tpot:2 e2e:3").unwrap();
1926        assert_eq!(s.e2e_p99_ms, 3.0);
1927    }
1928
1929    #[test]
1930    fn slo_rejects_missing_key() {
1931        assert!(parse_slo("ttft:500 tpot:50").is_err());
1932    }
1933
1934    #[test]
1935    fn slo_rejects_unknown_key() {
1936        assert!(parse_slo("ttft:500 tpot:50 e2el:30000 bogus:1").is_err());
1937    }
1938
1939    #[test]
1940    fn slo_rejects_nonfinite_and_nonpositive_values() {
1941        for value in ["NaN", "inf", "0", "-1"] {
1942            assert!(
1943                parse_slo(&format!("ttft:{value} tpot:50 e2el:30000")).is_err(),
1944                "SLO value {value} should be rejected"
1945            );
1946        }
1947    }
1948
1949    #[test]
1950    fn chat_completion_body_omits_ignore_eos_by_default() {
1951        let body = chat_completion_body("model", "prompt", 128, false, None);
1952        assert_eq!(body["model"], serde_json::json!("model"));
1953        assert_eq!(body["max_tokens"], serde_json::json!(128));
1954        assert_eq!(body["stream"], serde_json::json!(true));
1955        assert!(body.get("chat_template_kwargs").is_none());
1956        assert_eq!(
1957            body["stream_options"]["include_usage"],
1958            serde_json::json!(true)
1959        );
1960        assert!(body.get("ignore_eos").is_none());
1961    }
1962
1963    #[test]
1964    fn chat_completion_body_sends_ignore_eos_when_requested() {
1965        let body = chat_completion_body("model", "prompt", 128, true, None);
1966        assert_eq!(body["ignore_eos"], serde_json::json!(true));
1967    }
1968
1969    #[test]
1970    fn chat_completion_body_sends_typed_thinking_values() {
1971        let disabled = chat_completion_body("model", "prompt", 128, false, Some(false));
1972        assert_eq!(
1973            disabled["chat_template_kwargs"]["enable_thinking"],
1974            serde_json::json!(false)
1975        );
1976        let enabled = chat_completion_body("model", "prompt", 128, false, Some(true));
1977        assert_eq!(
1978            enabled["chat_template_kwargs"]["enable_thinking"],
1979            serde_json::json!(true)
1980        );
1981    }
1982
1983    #[test]
1984    fn enable_thinking_cli_is_tri_state() {
1985        use clap::Parser as _;
1986
1987        #[derive(clap::Parser)]
1988        struct TestCli {
1989            #[command(flatten)]
1990            command: BenchServeCommand,
1991        }
1992
1993        let base = [
1994            "test",
1995            "--base-url",
1996            "http://127.0.0.1:8000",
1997            "--model",
1998            "model",
1999            "--tokenizer",
2000            ".",
2001        ];
2002        let absent = TestCli::parse_from(base).command;
2003        assert_eq!(absent.enable_thinking, None);
2004        assert_eq!(absent.http_connection_mode, BenchHttpConnectionMode::Pooled);
2005
2006        let disabled =
2007            TestCli::parse_from(base.into_iter().chain(["--enable-thinking", "false"])).command;
2008        assert_eq!(disabled.enable_thinking, Some(false));
2009
2010        let enabled =
2011            TestCli::parse_from(base.into_iter().chain(["--enable-thinking", "true"])).command;
2012        assert_eq!(enabled.enable_thinking, Some(true));
2013
2014        let metal =
2015            TestCli::parse_from(base.into_iter().chain(["--target-backend", "metal"])).command;
2016        assert_eq!(metal.target_backend, Some(BenchTargetBackend::Metal));
2017
2018        let fresh =
2019            TestCli::parse_from(base.into_iter().chain(["--http-connection-mode", "fresh"]))
2020                .command;
2021        assert_eq!(fresh.http_connection_mode, BenchHttpConnectionMode::Fresh);
2022    }
2023
2024    #[test]
2025    fn bench_env_locks_http_connection_mode() {
2026        let pooled_cmd = test_command();
2027        let pooled = build_env(&pooled_cmd, vec![]);
2028        assert_eq!(pooled.http_connection_mode.as_deref(), Some("pooled"));
2029
2030        let mut fresh_cmd = pooled_cmd;
2031        fresh_cmd.http_connection_mode = BenchHttpConnectionMode::Fresh;
2032        let fresh = build_env(&fresh_cmd, vec![]);
2033        assert_eq!(fresh.http_connection_mode.as_deref(), Some("fresh"));
2034        assert_ne!(pooled.hash(), fresh.hash());
2035    }
2036
2037    #[test]
2038    fn incremental_sse_utf8_semantics_are_invariant_to_every_byte_cut() {
2039        let stream = concat!(
2040            "data: {\"choices\":[{\"delta\":{\"content\":\"你好🙂\"}}],\"usage\":null}\n\n",
2041            "data: {\"choices\":[],\"usage\":{\"completion_tokens\":1}}\n\n",
2042            "data: [DONE]\n\n",
2043        )
2044        .as_bytes();
2045        let baseline = parse_sse_chunks([stream]);
2046        assert!(baseline.success);
2047        assert_eq!(baseline.quality_issues, QualityIssueCounts::default());
2048
2049        for cut in 0..=stream.len() {
2050            let split = parse_sse_chunks([&stream[..cut], &stream[cut..]]);
2051            assert_eq!(
2052                stream_semantics(&split),
2053                stream_semantics(&baseline),
2054                "SSE semantics changed at byte cut {cut}"
2055            );
2056        }
2057
2058        let bytewise = parse_sse_chunks(stream.chunks(1));
2059        assert_eq!(stream_semantics(&bytewise), stream_semantics(&baseline));
2060    }
2061
2062    #[test]
2063    fn transport_coalescing_is_diagnostic_not_stream_bulk_failure() {
2064        const FIRST: &[u8] =
2065            b"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}],\"usage\":null}\n\n";
2066        const SECOND: &[u8] =
2067            b"data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}],\"usage\":null}\n\n";
2068        const TAIL: &[u8] =
2069            b"data: {\"choices\":[],\"usage\":{\"completion_tokens\":2}}\n\ndata: [DONE]\n\n";
2070        let together_bytes = [FIRST, SECOND, TAIL].concat();
2071        let together = parse_sse_chunks([together_bytes.as_slice()]);
2072        assert!(together.success);
2073        assert_eq!(together.quality_issues.stream_bulk_flush, 0);
2074        assert_eq!(together.itl_evidence.transport_coalesced_output_chunks, 1);
2075        assert_eq!(
2076            together.itl_evidence.eligibility,
2077            ItlEligibility::TransportCoalesced
2078        );
2079
2080        let separated = parse_sse_chunks([FIRST, SECOND, TAIL]);
2081        assert!(separated.success);
2082        assert_eq!(separated.quality_issues, QualityIssueCounts::default());
2083        assert_eq!(separated.itl_evidence.transport_coalesced_output_chunks, 0);
2084        assert_eq!(separated.itl_evidence.eligibility, ItlEligibility::Eligible);
2085    }
2086
2087    #[test]
2088    fn stream_done_with_usage_succeeds() {
2089        let mut state = StreamState::new(Instant::now(), 7);
2090        state
2091            .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}],"usage":null}"#)
2092            .unwrap();
2093        state
2094            .handle_payload(r#"{"choices":[],"usage":{"completion_tokens":3}}"#)
2095            .unwrap();
2096        state.done_count = 1;
2097        let record = state.finish();
2098        assert!(record.success);
2099        assert_eq!(record.output_tokens, 3);
2100        assert_eq!(
2101            record.output_token_count_source,
2102            OutputTokenCountSource::Usage
2103        );
2104        assert_eq!(
2105            record.itl_evidence.eligibility,
2106            ItlEligibility::EventUsageMismatch
2107        );
2108        assert_eq!(record.itl_evidence.output_events, 1);
2109        assert_eq!(record.itl_evidence.usage_output_tokens, Some(3));
2110    }
2111
2112    #[test]
2113    fn matching_sse_events_usage_and_intervals_are_itl_eligible() {
2114        let mut state = StreamState::new(Instant::now(), 7);
2115        for content in ["a", "b", "c"] {
2116            state
2117                .handle_payload(&format!(
2118                    r#"{{"choices":[{{"delta":{{"content":"{content}"}}}}]}}"#
2119                ))
2120                .unwrap();
2121        }
2122        state
2123            .handle_payload(r#"{"choices":[],"usage":{"completion_tokens":3}}"#)
2124            .unwrap();
2125        state.done_count = 1;
2126        let record = state.finish();
2127        assert!(record.success);
2128        assert_eq!(record.itl_ms.len(), 2);
2129        assert_eq!(record.itl_evidence.eligibility, ItlEligibility::Eligible);
2130    }
2131
2132    #[tokio::test]
2133    async fn measured_join_error_becomes_failed_evidence() {
2134        let good = tokio::spawn(async {
2135            RequestRecord {
2136                success: true,
2137                ttft_ms: 1.0,
2138                e2e_ms: 2.0,
2139                input_tokens: 7,
2140                output_tokens: 1,
2141                output_token_count_source: OutputTokenCountSource::Usage,
2142                itl_evidence: RequestItlEvidence::sse(true, 1, Some(1), 0, 0),
2143                quality_issues: QualityIssueCounts::default(),
2144                itl_ms: vec![],
2145            }
2146        });
2147        let panicked = tokio::spawn(async {
2148            if true {
2149                panic!("measured task panic");
2150            }
2151            join_failed_record(0)
2152        });
2153        let records = collect_measured_handles(vec![(7, good), (11, panicked)]).await;
2154        assert_eq!(records.len(), 2);
2155        assert!(records[0].success);
2156        assert!(!records[1].success);
2157        assert_eq!(records[1].input_tokens, 11);
2158        assert_eq!(records[1].quality_issues.panic, 1);
2159        assert_eq!(
2160            records[1].output_token_count_source,
2161            OutputTokenCountSource::None
2162        );
2163    }
2164
2165    #[test]
2166    fn stream_reasoning_chunk_counts_as_token_event() {
2167        let mut state = StreamState::new(Instant::now(), 7);
2168        state
2169            .handle_payload(r#"{"choices":[{"delta":{"reasoning":"thinking"}}]}"#)
2170            .unwrap();
2171        state.done_count = 1;
2172        let record = state.finish();
2173        assert!(record.success);
2174        assert_eq!(record.output_tokens, 1);
2175        assert_eq!(
2176            record.output_token_count_source,
2177            OutputTokenCountSource::StreamChunks
2178        );
2179    }
2180
2181    #[test]
2182    fn stream_error_after_chunk_fails() {
2183        let mut state = StreamState::new(Instant::now(), 7);
2184        state
2185            .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2186            .unwrap();
2187        state.stream_error = Some("broken stream".into());
2188        state.done_count = 1;
2189        let record = state.finish();
2190        assert!(!record.success);
2191        assert_eq!(
2192            record.output_token_count_source,
2193            OutputTokenCountSource::StreamChunks
2194        );
2195    }
2196
2197    #[test]
2198    fn openai_error_sse_event_fails_even_when_done_follows() {
2199        let record = parse_sse_chunks([concat!(
2200            "data: {\"error\":{\"message\":\"decode failed\",",
2201            "\"type\":\"internal_server_error\",\"param\":null,\"code\":null}}\n\n",
2202            "data: [DONE]\n\n"
2203        )
2204        .as_bytes()]);
2205
2206        assert!(!record.success);
2207        assert_eq!(record.quality_issues.malformed_stream, 1);
2208        assert_eq!(
2209            record.itl_evidence.eligibility,
2210            ItlEligibility::RequestFailed
2211        );
2212    }
2213
2214    #[test]
2215    fn eof_before_done_after_chunk_fails() {
2216        let mut state = StreamState::new(Instant::now(), 7);
2217        state
2218            .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2219            .unwrap();
2220        let record = state.finish();
2221        assert!(!record.success);
2222        assert_eq!(record.output_tokens, 1);
2223        assert_eq!(record.quality_issues.missing_done, 1);
2224    }
2225
2226    #[test]
2227    fn malformed_sse_json_fails() {
2228        let mut state = StreamState::new(Instant::now(), 7);
2229        assert!(state.handle_payload("{bad json}").is_err());
2230        state.stream_error = Some("malformed json".into());
2231        state.quality_issues.malformed_stream = 1;
2232        state.done_count = 1;
2233        let record = state.finish();
2234        assert!(!record.success);
2235        assert_eq!(record.output_tokens, 0);
2236        assert_eq!(record.quality_issues.malformed_stream, 1);
2237    }
2238
2239    #[test]
2240    fn done_with_zero_content_tokens_fails() {
2241        let mut state = StreamState::new(Instant::now(), 7);
2242        state.done_count = 1;
2243        let record = state.finish();
2244        assert!(!record.success);
2245        assert_eq!(record.output_tokens, 0);
2246        assert_eq!(record.quality_issues.zero_output_tokens, 1);
2247        assert_eq!(
2248            record.output_token_count_source,
2249            OutputTokenCountSource::None
2250        );
2251    }
2252
2253    #[test]
2254    fn duplicate_done_fails() {
2255        let mut state = StreamState::new(Instant::now(), 7);
2256        state
2257            .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2258            .unwrap();
2259        state.done_count = 2;
2260        let record = state.finish();
2261        assert!(!record.success);
2262        assert_eq!(record.quality_issues.duplicate_done, 1);
2263    }
2264
2265    #[test]
2266    fn bad_output_text_fails() {
2267        let mut state = StreamState::new(Instant::now(), 7);
2268        state
2269            .handle_payload(r#"{"choices":[{"delta":{"content":"<unk>"}}]}"#)
2270            .unwrap();
2271        state.done_count = 1;
2272        let record = state.finish();
2273        assert!(!record.success);
2274        assert_eq!(record.quality_issues.bad_output, 1);
2275    }
2276
2277    #[test]
2278    fn mojibake_sequences_fail_but_standalone_leads_do_not() {
2279        assert!(has_bad_output_text("caf\u{00c3}\u{00a9}"));
2280        assert!(has_bad_output_text("copyright \u{00c2}\u{00a9}"));
2281        assert!(has_bad_output_text("quote\u{00e2}\u{20ac}\u{2122}"));
2282        assert!(!has_bad_output_text("\u{00c2}"));
2283        assert!(!has_bad_output_text("\u{00c3}"));
2284        assert!(!has_bad_output_text("Grade \u{00c2} report"));
2285    }
2286
2287    #[test]
2288    fn random_prompt_generation_targets_reencoded_length_when_fixture_is_set() {
2289        let Some(path) = ferrum_env_value("FERRUM_BENCH_TOKENIZER_FIXTURE") else {
2290            return;
2291        };
2292        let tok = tokenizers::Tokenizer::from_file(path).expect("load tokenizer fixture");
2293        let mut rng = StdRng::seed_from_u64(9271);
2294        for _ in 0..16 {
2295            let text = gen_random_prompt(&tok, 256, &mut rng);
2296            assert_eq!(token_count(&tok, &text), Some(256));
2297            assert!(generated_prompt_is_safe(&text));
2298        }
2299
2300        let prompts = gen_shared_prefix_prompts(&tok, 4, 128, 16, &mut rng)
2301            .expect("generate shared-prefix prompts");
2302        assert_eq!(prompts.len(), 4);
2303        assert!(prompts
2304            .iter()
2305            .all(|prompt| generated_prompt_is_safe(&prompt.text)));
2306    }
2307
2308    #[test]
2309    fn generated_prompt_safety_rejects_transport_poisoning_text() {
2310        assert!(generated_prompt_is_safe("hello world\nnext"));
2311        assert!(!generated_prompt_is_safe("bad \u{fffd} text"));
2312        assert!(!generated_prompt_is_safe("<|im_start|>"));
2313        assert!(!generated_prompt_is_safe("control \u{7}"));
2314    }
2315
2316    fn ferrum_env_value(key: &str) -> Option<String> {
2317        ferrum_types::RuntimeConfigSnapshot::capture_current()
2318            .entries
2319            .into_iter()
2320            .find(|entry| entry.key == key)
2321            .map(|entry| entry.effective_value)
2322    }
2323
2324    fn test_command() -> BenchServeCommand {
2325        BenchServeCommand {
2326            base_url: "http://127.0.0.1:9".to_string(),
2327            model: "test-model".to_string(),
2328            tokenizer: std::path::PathBuf::from("."),
2329            target_backend: None,
2330            http_connection_mode: BenchHttpConnectionMode::Pooled,
2331            concurrency: 1,
2332            concurrency_sweep: vec![],
2333            request_rate: None,
2334            dataset: "random".to_string(),
2335            random_input_len: 2,
2336            random_output_len: 3,
2337            ignore_eos: false,
2338            enable_thinking: None,
2339            sharegpt_path: None,
2340            shared_prefix_len: 1024,
2341            shared_suffix_len: 64,
2342            num_prompts: 1,
2343            warmup_requests: 0,
2344            n_repeats: 1,
2345            goodput: None,
2346            timeout: 1.0,
2347            fail_on_error: false,
2348            max_error_rate: None,
2349            require_ci: false,
2350            seed: Some(9271),
2351            output: "json".to_string(),
2352            out: None,
2353            hw_id: None,
2354            commit_sha: None,
2355            tag: None,
2356        }
2357    }
2358
2359    fn policy_report(
2360        completed: u32,
2361        errored: u32,
2362        warmup_completed: u32,
2363        warmup_errored: u32,
2364    ) -> BenchReport {
2365        let mut records = Vec::with_capacity((completed + errored) as usize);
2366        for _ in 0..completed {
2367            records.push(RequestRecord {
2368                success: true,
2369                ttft_ms: 10.0,
2370                e2e_ms: 30.0,
2371                input_tokens: 2,
2372                output_tokens: 3,
2373                output_token_count_source: OutputTokenCountSource::Usage,
2374                itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
2375                quality_issues: QualityIssueCounts::default(),
2376                itl_ms: vec![10.0, 10.0],
2377            });
2378        }
2379        for _ in 0..errored {
2380            let mut quality = QualityIssueCounts::default();
2381            quality.missing_done = 1;
2382            records.push(RequestRecord {
2383                success: false,
2384                ttft_ms: 0.0,
2385                e2e_ms: 30.0,
2386                input_tokens: 2,
2387                output_tokens: 0,
2388                output_token_count_source: OutputTokenCountSource::None,
2389                itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
2390                quality_issues: quality,
2391                itl_ms: vec![],
2392            });
2393        }
2394        let warmup_expected = warmup_completed.checked_add(warmup_errored).unwrap();
2395        let mut warmup_quality = QualityIssueCounts::default();
2396        warmup_quality.missing_done = warmup_errored;
2397        compute_metrics(
2398            "test-model".to_string(),
2399            "test-backend".to_string(),
2400            Scenario::ClosedLoop,
2401            Some(1),
2402            None,
2403            2,
2404            3,
2405            warmup_expected,
2406            Slo::default(),
2407            vec![RunRecord {
2408                expected_requests: completed.checked_add(errored).unwrap(),
2409                records,
2410                duration_s: 1.0,
2411                warmup: WarmupSummary {
2412                    expected: warmup_expected,
2413                    completed: warmup_completed,
2414                    errored: warmup_errored,
2415                    quality_issues: warmup_quality,
2416                },
2417            }],
2418            Env::default(),
2419        )
2420    }
2421
2422    #[test]
2423    fn validate_command_rejects_zero_repeats_and_invalid_programmatic_slo() {
2424        let mut cmd = test_command();
2425        cmd.n_repeats = 0;
2426        assert!(validate_command(&cmd).is_err());
2427        cmd.n_repeats = 1;
2428        cmd.goodput = Some(Slo {
2429            ttft_p99_ms: f64::NAN,
2430            ..Slo::default()
2431        });
2432        assert!(validate_command(&cmd).is_err());
2433        cmd.goodput = None;
2434        cmd.timeout = f64::MAX;
2435        assert!(validate_command(&cmd).is_err());
2436    }
2437
2438    #[test]
2439    fn measured_error_rate_excludes_successful_warmups() {
2440        let report = policy_report(99, 1, 10, 0);
2441        let mut cmd = test_command();
2442        cmd.max_error_rate = Some(0.0095);
2443        let err = enforce_error_policy(&cmd, &[report]).expect_err("measured rate is one percent");
2444        assert!(err.to_string().contains("bench-serve error rate"));
2445    }
2446
2447    #[test]
2448    fn warmup_failure_is_independent_of_measured_error_allowance() {
2449        let report = policy_report(100, 0, 9, 1);
2450        let mut cmd = test_command();
2451        cmd.max_error_rate = Some(1.0);
2452        let err = enforce_error_policy(&cmd, &[report]).expect_err("warmup must be perfect");
2453        assert!(err.to_string().contains("bench-serve warmup failed"));
2454    }
2455
2456    #[test]
2457    fn fail_on_error_still_writes_json_report() {
2458        let out = std::env::temp_dir().join(format!(
2459            "ferrum-bench-serve-failed-report-{}-{}.json",
2460            std::process::id(),
2461            std::time::SystemTime::now()
2462                .duration_since(std::time::UNIX_EPOCH)
2463                .unwrap()
2464                .as_nanos()
2465        ));
2466        let _ = std::fs::remove_file(&out);
2467
2468        let mut failed_quality = QualityIssueCounts::default();
2469        failed_quality.http_500 = 1;
2470        let run = RunRecord {
2471            records: vec![
2472                RequestRecord {
2473                    success: true,
2474                    ttft_ms: 10.0,
2475                    e2e_ms: 30.0,
2476                    input_tokens: 4,
2477                    output_tokens: 3,
2478                    output_token_count_source: OutputTokenCountSource::Usage,
2479                    itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
2480                    quality_issues: QualityIssueCounts::default(),
2481                    itl_ms: vec![10.0, 10.0],
2482                },
2483                RequestRecord {
2484                    success: false,
2485                    ttft_ms: 0.0,
2486                    e2e_ms: 50.0,
2487                    input_tokens: 4,
2488                    output_tokens: 0,
2489                    output_token_count_source: OutputTokenCountSource::None,
2490                    itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
2491                    quality_issues: failed_quality,
2492                    itl_ms: vec![],
2493                },
2494            ],
2495            expected_requests: 2,
2496            duration_s: 1.0,
2497            warmup: Default::default(),
2498        };
2499        let report = compute_metrics(
2500            "test-model".to_string(),
2501            "test-backend".to_string(),
2502            Scenario::ClosedLoop,
2503            Some(2),
2504            None,
2505            2,
2506            3,
2507            0,
2508            Slo::default(),
2509            vec![run],
2510            Env::default(),
2511        );
2512        let cmd = BenchServeCommand {
2513            base_url: "http://127.0.0.1:9".to_string(),
2514            model: "test-model".to_string(),
2515            tokenizer: std::path::PathBuf::from("."),
2516            target_backend: None,
2517            http_connection_mode: BenchHttpConnectionMode::Pooled,
2518            concurrency: 2,
2519            concurrency_sweep: vec![],
2520            request_rate: None,
2521            dataset: "random".to_string(),
2522            random_input_len: 4,
2523            random_output_len: 3,
2524            ignore_eos: false,
2525            enable_thinking: None,
2526            sharegpt_path: None,
2527            shared_prefix_len: 1024,
2528            shared_suffix_len: 64,
2529            num_prompts: 2,
2530            warmup_requests: 0,
2531            n_repeats: 1,
2532            goodput: None,
2533            timeout: 1.0,
2534            fail_on_error: true,
2535            max_error_rate: None,
2536            require_ci: false,
2537            seed: Some(9271),
2538            output: "json".to_string(),
2539            out: Some(out.clone()),
2540            hw_id: None,
2541            commit_sha: None,
2542            tag: None,
2543        };
2544
2545        let err = emit_then_enforce_error_policy(&cmd, &[report]).expect_err("error policy");
2546        assert!(
2547            err.to_string().contains("bench-serve error rate"),
2548            "unexpected error: {err}"
2549        );
2550        let raw = std::fs::read_to_string(&out).expect("report written before error");
2551        let json: serde_json::Value = serde_json::from_str(&raw).expect("json report");
2552        assert_eq!(json["completed_per_run"], serde_json::json!([1]));
2553        assert_eq!(json["errored_per_run"], serde_json::json!([1]));
2554        assert_eq!(json["http_500_per_run"], serde_json::json!([1]));
2555        assert_eq!(
2556            json["output_tokens_per_request"],
2557            serde_json::json!([[3, 0]])
2558        );
2559        let _ = std::fs::remove_file(out);
2560    }
2561
2562    #[test]
2563    fn fail_on_error_rejects_warmup_only_failure() {
2564        let mut warmup_quality = QualityIssueCounts::default();
2565        warmup_quality.missing_done = 1;
2566        let report = compute_metrics(
2567            "test-model".to_string(),
2568            "test-backend".to_string(),
2569            Scenario::ClosedLoop,
2570            Some(1),
2571            None,
2572            2,
2573            3,
2574            1,
2575            Slo::default(),
2576            vec![RunRecord {
2577                records: vec![RequestRecord {
2578                    success: true,
2579                    ttft_ms: 10.0,
2580                    e2e_ms: 30.0,
2581                    input_tokens: 2,
2582                    output_tokens: 3,
2583                    output_token_count_source: OutputTokenCountSource::Usage,
2584                    itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
2585                    quality_issues: QualityIssueCounts::default(),
2586                    itl_ms: vec![10.0, 10.0],
2587                }],
2588                expected_requests: 1,
2589                duration_s: 1.0,
2590                warmup: WarmupSummary {
2591                    expected: 1,
2592                    completed: 0,
2593                    errored: 1,
2594                    quality_issues: warmup_quality,
2595                },
2596            }],
2597            Env::default(),
2598        );
2599        let cmd = BenchServeCommand {
2600            base_url: "http://127.0.0.1:9".to_string(),
2601            model: "test-model".to_string(),
2602            tokenizer: std::path::PathBuf::from("."),
2603            target_backend: None,
2604            http_connection_mode: BenchHttpConnectionMode::Pooled,
2605            concurrency: 1,
2606            concurrency_sweep: vec![],
2607            request_rate: None,
2608            dataset: "random".to_string(),
2609            random_input_len: 2,
2610            random_output_len: 3,
2611            ignore_eos: false,
2612            enable_thinking: None,
2613            sharegpt_path: None,
2614            shared_prefix_len: 1024,
2615            shared_suffix_len: 64,
2616            num_prompts: 1,
2617            warmup_requests: 1,
2618            n_repeats: 1,
2619            goodput: None,
2620            timeout: 1.0,
2621            fail_on_error: true,
2622            max_error_rate: None,
2623            require_ci: false,
2624            seed: Some(9271),
2625            output: "json".to_string(),
2626            out: None,
2627            hw_id: None,
2628            commit_sha: None,
2629            tag: None,
2630        };
2631
2632        let err = enforce_error_policy(&cmd, &[report]).expect_err("warmup failure");
2633        assert!(err.to_string().contains("bench-serve warmup failed"));
2634    }
2635}