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