Skip to main content

ferrum_cli/commands/
bench.rs

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