Skip to main content

ferrum_cli/commands/
run.rs

1//! Run command - Interactive chat with a model (ollama-style)
2
3use crate::config::CliConfig;
4use chrono::Utc;
5use clap::{Args, ValueEnum};
6use colored::*;
7use console::{measure_text_width, Key, Term};
8use ferrum_models::source::{ModelFormat, ResolvedModelSource};
9use ferrum_server::chat_template::{ChatTemplateOptions, ModelChatTemplate, PromptMessage};
10use ferrum_types::{
11    has_unclosed_thinking_block, parse_reasoning_response_for_prompt, FerrumConfigBuilder,
12    FerrumError, FinishReason, InferenceRequest, InferenceResponse, ModelCapabilities, Priority,
13    RequestId, ResolvedFerrumConfig, ResponseCompletionBoundary, Result, RuntimeConfigEntry,
14    RuntimeConfigSnapshot, RuntimeConfigSource, SamplingParams, StreamChunk, TokenUsage,
15    WorkloadProfile, DEFAULT_CHAT_REPETITION_PENALTY, THINK_END_TAG, THINK_START_TAG,
16};
17use futures::StreamExt;
18use indicatif::{ProgressBar, ProgressStyle};
19use sha2::{Digest, Sha256};
20use std::collections::HashMap;
21use std::io::{self, BufRead, IsTerminal, Write};
22#[cfg(unix)]
23use std::mem;
24#[cfg(unix)]
25use std::os::fd::{AsRawFd, RawFd};
26use std::path::{Path, PathBuf};
27use std::pin::Pin;
28use uuid::Uuid;
29
30#[cfg(test)]
31use crate::source_resolver::tokenizer_sibling_repo;
32
33const RUN_INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY: &str = "ferrum_initial_forbidden_token_texts";
34const RUN_JSONL_SCHEMA_VERSION: u32 = 2;
35
36/// Output format for `ferrum run`. JSONL mode emits one record per event
37/// (assistant generation result, user input, exit) on stdout — used by
38/// integration tests and scripting. Text mode is the default interactive UX.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)]
40pub enum OutputFormat {
41    /// Streaming text on stdout, stats on stderr (default — interactive UX).
42    #[default]
43    Text,
44    /// One JSON record per event on stdout (machine-readable; tests).
45    Jsonl,
46}
47
48fn finish_reason_str(r: FinishReason) -> &'static str {
49    match r {
50        FinishReason::Length => "length",
51        FinishReason::Stop => "stop",
52        FinishReason::EOS => "eos",
53        FinishReason::Cancelled => "cancelled",
54        FinishReason::Error => "error",
55        FinishReason::ContentFilter => "content_filter",
56    }
57}
58
59fn emit_jsonl_ready(session_id: &str, requested_model: &str, resolved_model: &str, backend: &str) {
60    let record = serde_json::json!({
61        "schema_version": RUN_JSONL_SCHEMA_VERSION,
62        "event": "ready",
63        "session_id": session_id,
64        "history_epoch": 0,
65        "model": resolved_model,
66        "requested_model": requested_model,
67        "resolved_model": resolved_model,
68        "backend": backend,
69    });
70    emit_jsonl_record(&record);
71}
72
73fn emit_jsonl_user(
74    session_id: &str,
75    history_epoch: usize,
76    request_id: &str,
77    turn: usize,
78    content: &str,
79    history: &[(String, String)],
80) {
81    let record = serde_json::json!({
82        "schema_version": RUN_JSONL_SCHEMA_VERSION,
83        "event": "user",
84        "session_id": session_id,
85        "history_epoch": history_epoch,
86        "request_id": request_id,
87        "turn": turn,
88        "content": content,
89        "history_before": history_evidence(history),
90    });
91    emit_jsonl_record(&record);
92}
93
94fn emit_jsonl_assistant_delta(
95    session_id: &str,
96    history_epoch: usize,
97    request_id: &str,
98    turn: usize,
99    index: usize,
100    raw_text_delta: &str,
101    token_id: Option<u32>,
102) {
103    emit_jsonl_record(&jsonl_assistant_delta_record(
104        session_id,
105        history_epoch,
106        request_id,
107        turn,
108        index,
109        raw_text_delta,
110        token_id,
111    ));
112}
113
114fn jsonl_assistant_delta_record(
115    session_id: &str,
116    history_epoch: usize,
117    request_id: &str,
118    turn: usize,
119    index: usize,
120    raw_text_delta: &str,
121    token_id: Option<u32>,
122) -> serde_json::Value {
123    serde_json::json!({
124        "schema_version": RUN_JSONL_SCHEMA_VERSION,
125        "event": "assistant_delta",
126        "session_id": session_id,
127        "history_epoch": history_epoch,
128        "request_id": request_id,
129        "turn": turn,
130        "index": index,
131        "raw_text_delta": raw_text_delta,
132        "utf8_bytes": raw_text_delta.len(),
133        "token_id": token_id,
134    })
135}
136
137fn emit_jsonl_assistant(
138    session_id: &str,
139    history_epoch: usize,
140    request_id: &str,
141    turn: usize,
142    content: &str,
143    reasoning: Option<&str>,
144    history: &[(String, String)],
145    finish_reason: Option<FinishReason>,
146    usage: Option<&TokenUsage>,
147    n_tokens: usize,
148    chunk_count: usize,
149    raw_text: &str,
150    ms: f64,
151) {
152    emit_jsonl_record(&jsonl_assistant_record(
153        session_id,
154        history_epoch,
155        request_id,
156        turn,
157        content,
158        reasoning,
159        history,
160        finish_reason,
161        usage,
162        n_tokens,
163        chunk_count,
164        raw_text,
165        ms,
166    ));
167}
168
169fn jsonl_assistant_record(
170    session_id: &str,
171    history_epoch: usize,
172    request_id: &str,
173    turn: usize,
174    content: &str,
175    reasoning: Option<&str>,
176    history: &[(String, String)],
177    finish_reason: Option<FinishReason>,
178    usage: Option<&TokenUsage>,
179    n_tokens: usize,
180    chunk_count: usize,
181    raw_text: &str,
182    ms: f64,
183) -> serde_json::Value {
184    serde_json::json!({
185        "schema_version": RUN_JSONL_SCHEMA_VERSION,
186        "event": "assistant",
187        "session_id": session_id,
188        "history_epoch": history_epoch,
189        "request_id": request_id,
190        "turn": turn,
191        "content": content,
192        "reasoning": reasoning,
193        "history_before": history_evidence(history),
194        "finish_reason": finish_reason.map(finish_reason_str),
195        "usage": usage,
196        "n_tokens": n_tokens,
197        "chunk_count": chunk_count,
198        "raw_text_sha256": sha256_text(raw_text),
199        "ms": ms,
200    })
201}
202
203fn emit_jsonl_exit(session_id: &str, history_epoch: usize, reason: &str) {
204    let record = serde_json::json!({
205        "schema_version": RUN_JSONL_SCHEMA_VERSION,
206        "event": "exit",
207        "session_id": session_id,
208        "history_epoch": history_epoch,
209        "reason": reason,
210    });
211    emit_jsonl_record(&record);
212}
213
214fn emit_jsonl_record(record: &serde_json::Value) {
215    println!("{record}");
216    io::stdout().flush().ok();
217}
218
219fn sha256_text(value: &str) -> String {
220    format!("{:x}", Sha256::digest(value.as_bytes()))
221}
222
223fn history_evidence(history: &[(String, String)]) -> serde_json::Value {
224    let encoded = serde_json::to_vec(history).expect("run history serialization cannot fail");
225    serde_json::json!({
226        "message_count": history.len(),
227        "turn_count": history.iter().filter(|(role, _)| role == "user").count(),
228        "sha256": format!("{:x}", Sha256::digest(encoded)),
229    })
230}
231
232fn run_request_metadata(
233    prompt: &str,
234    chat_template_options: &ChatTemplateOptions,
235) -> HashMap<String, serde_json::Value> {
236    let mut metadata = HashMap::new();
237    if !has_unclosed_thinking_block(prompt) {
238        let mut forbidden = vec![serde_json::Value::String(THINK_END_TAG.to_string())];
239        if chat_template_options.enable_thinking == Some(false) {
240            forbidden.push(serde_json::Value::String(THINK_START_TAG.to_string()));
241        }
242        metadata.insert(
243            RUN_INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY.to_string(),
244            serde_json::Value::Array(forbidden),
245        );
246    }
247    metadata
248}
249
250#[derive(Debug, Clone)]
251struct RunPromptPlan {
252    prompt: String,
253    sampling_params: SamplingParams,
254    prompt_token_ids: Option<Vec<u32>>,
255    prompt_tokens: Option<usize>,
256    kv_capacity: Option<usize>,
257    dropped_history_messages: usize,
258    dropped_history_turns: usize,
259    max_tokens_clamped_from: Option<usize>,
260}
261
262#[derive(Debug, Clone)]
263struct RunPromptTokenization {
264    token_ids: Option<Vec<u32>>,
265    token_count: Option<usize>,
266}
267
268struct RunBudget {
269    tokenizer: Option<tokenizers::Tokenizer>,
270    kv_capacity: Option<usize>,
271    #[cfg(test)]
272    prompt_token_id_mapper: Option<fn(&str) -> Vec<u32>>,
273    #[cfg(test)]
274    prompt_token_counter: Option<fn(&str) -> usize>,
275}
276
277impl RunBudget {
278    fn from_product_sources(
279        explicit_tokenizer: Option<&Path>,
280        product_sources: Option<&ferrum_models::vnext::ProductionModelSourceBundle>,
281        legacy_source_path: &Path,
282        snapshot: &RuntimeConfigSnapshot,
283    ) -> Result<Self> {
284        let tokenizer = if let Some(path) = explicit_tokenizer {
285            Some(tokenizers::Tokenizer::from_file(path).map_err(|error| {
286                FerrumError::model(format!(
287                    "failed to load explicit tokenizer {}: {error}",
288                    path.display()
289                ))
290            })?)
291        } else if let Some(sources) = product_sources {
292            Some(
293                tokenizers::Tokenizer::from_bytes(sources.tokenizer_json()).map_err(|error| {
294                    FerrumError::model(format!(
295                        "failed to parse tokenizer from product source {}: {error}",
296                        sources.tokenizer_file().display()
297                    ))
298                })?,
299            )
300        } else {
301            discover_run_tokenizer_path(legacy_source_path)
302                .and_then(|path| tokenizers::Tokenizer::from_file(path).ok())
303        };
304        let kv_capacity =
305            crate::runtime_env::runtime_snapshot_value(snapshot, "FERRUM_KV_CAPACITY")
306                .and_then(|value| value.parse::<usize>().ok())
307                .filter(|&value| value > 0);
308        Ok(Self {
309            tokenizer,
310            kv_capacity,
311            #[cfg(test)]
312            prompt_token_id_mapper: None,
313            #[cfg(test)]
314            prompt_token_counter: None,
315        })
316    }
317
318    fn prompt_tokenization(&self, prompt: &str) -> RunPromptTokenization {
319        #[cfg(test)]
320        if let Some(mapper) = self.prompt_token_id_mapper {
321            let token_ids = mapper(prompt);
322            return RunPromptTokenization {
323                token_count: Some(token_ids.len()),
324                token_ids: Some(token_ids),
325            };
326        }
327        #[cfg(test)]
328        if let Some(counter) = self.prompt_token_counter {
329            return RunPromptTokenization {
330                token_ids: None,
331                token_count: Some(counter(prompt)),
332            };
333        }
334        if let Some(encoding) = self
335            .tokenizer
336            .as_ref()
337            .and_then(|tok| tok.encode(prompt, true).ok())
338        {
339            let token_ids = encoding.get_ids().to_vec();
340            return RunPromptTokenization {
341                token_count: Some(token_ids.len()),
342                token_ids: Some(token_ids),
343            };
344        }
345        RunPromptTokenization {
346            token_ids: None,
347            token_count: None,
348        }
349    }
350}
351
352fn display_response_text(text: &str) -> String {
353    text.trim().to_string()
354}
355
356struct CollectedRunGeneration {
357    request_id: String,
358    raw_text: String,
359    finish_reason: Option<FinishReason>,
360    usage: Option<TokenUsage>,
361    token_count: usize,
362    token_ids: Vec<u32>,
363    chunk_count: usize,
364    execution_evidence: Option<ferrum_types::InferenceExecutionEvidence>,
365}
366
367impl CollectedRunGeneration {
368    fn from_response(response: InferenceResponse) -> Self {
369        Self {
370            request_id: response.request_id.to_string(),
371            raw_text: response.text,
372            finish_reason: Some(response.finish_reason),
373            usage: Some(response.usage),
374            token_count: response.tokens.len(),
375            token_ids: response
376                .tokens
377                .into_iter()
378                .map(|token| token.get())
379                .collect(),
380            chunk_count: 1,
381            execution_evidence: response.execution_evidence,
382        }
383    }
384}
385
386type RunResponseStream = Pin<Box<dyn futures::Stream<Item = Result<StreamChunk>> + Send + 'static>>;
387
388async fn collect_run_stream(
389    mut stream: RunResponseStream,
390    trace_tokens: bool,
391    turn: usize,
392    session_id: &str,
393    history_epoch: usize,
394    expected_request_id: &str,
395) -> Result<CollectedRunGeneration> {
396    let mut request_id = None;
397    let mut raw_text = String::new();
398    let mut finish_reason = None;
399    let mut latest_usage = None;
400    let mut token_count = 0usize;
401    let mut token_ids = Vec::new();
402    let mut chunk_count = 0usize;
403    let mut execution_evidence = None;
404    while let Some(chunk) = stream.next().await {
405        let mut chunk = chunk?;
406        let chunk_request_id = chunk.request_id.to_string();
407        if chunk_request_id != expected_request_id {
408            return Err(FerrumError::internal(format!(
409                "run stream request id drift: expected {expected_request_id}, got {chunk_request_id}"
410            )));
411        }
412        request_id.get_or_insert_with(|| chunk_request_id.clone());
413        let token_id = chunk.token.map(|token| token.get());
414        if !chunk.text.is_empty() {
415            raw_text.push_str(&chunk.text);
416            emit_jsonl_assistant_delta(
417                session_id,
418                history_epoch,
419                expected_request_id,
420                turn,
421                chunk_count,
422                &chunk.text,
423                token_id,
424            );
425            chunk_count += 1;
426        }
427        if let Some(token_id) = token_id {
428            if trace_tokens {
429                eprintln!(
430                    "[run-token-trace] turn={turn} token={} text={:?}",
431                    token_id, chunk.text
432                );
433            }
434            token_ids.push(token_id);
435            token_count += 1;
436        }
437        if let Some(usage) = chunk.usage.as_ref() {
438            token_count = usage.completion_tokens;
439            latest_usage = Some(usage.clone());
440        }
441        if chunk.finish_reason.is_some() {
442            finish_reason = chunk.finish_reason;
443        }
444        if let Some(evidence) = chunk.execution_evidence.take() {
445            if execution_evidence.replace(evidence).is_some() {
446                return Err(FerrumError::internal(
447                    "run stream emitted engine execution evidence more than once",
448                ));
449            }
450        }
451    }
452    Ok(CollectedRunGeneration {
453        request_id: request_id.unwrap_or_else(|| expected_request_id.to_string()),
454        raw_text,
455        finish_reason,
456        usage: latest_usage,
457        token_count,
458        token_ids,
459        chunk_count,
460        execution_evidence,
461    })
462}
463
464async fn collect_run_text_stream(
465    mut stream: RunResponseStream,
466    trace_tokens: bool,
467    turn: usize,
468    expected_request_id: &RequestId,
469    stdin_is_tty: bool,
470    capture_token_ids: bool,
471) -> Result<CollectedRunGeneration> {
472    let mut first_token_indicator = start_first_token_indicator(stdin_is_tty);
473    let mut request_id = None;
474    let mut raw_text = String::new();
475    let mut finish_reason = None;
476    let mut latest_usage = None;
477    let mut token_count = 0usize;
478    let mut token_ids = Vec::new();
479    let mut chunk_count = 0usize;
480    let mut execution_evidence = None;
481    while let Some(chunk) = stream.next().await {
482        let mut chunk = match chunk {
483            Ok(chunk) => chunk,
484            Err(error) => {
485                clear_first_token_indicator(&mut first_token_indicator);
486                return Err(error);
487            }
488        };
489        if &chunk.request_id != expected_request_id {
490            clear_first_token_indicator(&mut first_token_indicator);
491            return Err(FerrumError::internal(format!(
492                "run stream request id drift: expected {expected_request_id}, got {}",
493                chunk.request_id
494            )));
495        }
496        request_id.get_or_insert_with(|| chunk.request_id.clone());
497        let token_id = chunk.token.map(|token| token.get());
498        if first_token_indicator.is_some()
499            && (!chunk.text.is_empty() || token_id.is_some() || chunk.finish_reason.is_some())
500        {
501            clear_first_token_indicator(&mut first_token_indicator);
502        }
503        if trace_tokens {
504            if let Some(token_id) = token_id {
505                eprintln!(
506                    "[run-token-trace] turn={turn} token={} text={:?}",
507                    token_id, chunk.text
508                );
509            }
510        }
511        if !chunk.text.is_empty() {
512            raw_text.push_str(&chunk.text);
513            print!("{}", chunk.text);
514            io::stdout().flush().ok();
515            chunk_count += 1;
516        }
517        if let Some(token_id) = token_id {
518            if capture_token_ids {
519                token_ids.push(token_id);
520            }
521            token_count += 1;
522        }
523        if let Some(usage) = chunk.usage.as_ref() {
524            token_count = usage.completion_tokens;
525            latest_usage = Some(usage.clone());
526        }
527        if chunk.finish_reason.is_some() {
528            finish_reason = chunk.finish_reason;
529        }
530        if let Some(evidence) = chunk.execution_evidence.take() {
531            if execution_evidence.replace(evidence).is_some() {
532                clear_first_token_indicator(&mut first_token_indicator);
533                return Err(FerrumError::internal(
534                    "run stream emitted engine execution evidence more than once",
535                ));
536            }
537        }
538    }
539    clear_first_token_indicator(&mut first_token_indicator);
540    Ok(CollectedRunGeneration {
541        request_id: request_id
542            .unwrap_or_else(|| expected_request_id.clone())
543            .to_string(),
544        raw_text,
545        finish_reason,
546        usage: latest_usage,
547        token_count,
548        token_ids,
549        chunk_count,
550        execution_evidence,
551    })
552}
553
554#[derive(Args)]
555pub struct RunCommand {
556    /// Model name (release alias, Hugging Face repository, local directory, or `.gguf` file).
557    #[arg(value_name = "MODEL")]
558    pub model: Option<String>,
559
560    #[command(flatten)]
561    pub product_sources: crate::source_resolver::ProductSourceArgs,
562
563    /// System prompt (interactive chat mode only).
564    #[arg(long)]
565    pub system: Option<String>,
566
567    /// Maximum output-token ceiling. Context planning shrinks it to the
568    /// remaining KV capacity before dropping any complete history turn.
569    #[arg(long, default_value = "4096")]
570    pub max_tokens: u32,
571
572    /// Stop generation when this text appears. Can be provided multiple times.
573    #[arg(long, value_name = "TEXT")]
574    pub stop: Vec<String>,
575
576    /// Enable model reasoning for chat templates that support it.
577    #[arg(long, conflicts_with = "disable_thinking")]
578    pub enable_thinking: bool,
579
580    /// Disable model reasoning for chat templates that support it.
581    #[arg(long, conflicts_with = "enable_thinking")]
582    pub disable_thinking: bool,
583
584    /// Disable CLI context shift. By default, `ferrum run` keeps the REPL
585    /// alive by shrinking this turn's output budget before dropping history.
586    /// Oldest complete turns are removed only when the rendered prompt itself
587    /// no longer fits in KV.
588    #[arg(long)]
589    pub no_context_shift: bool,
590
591    /// Sampling temperature (0.0–2.0). 0.0 = greedy / argmax (deterministic,
592    /// what you want for benchmarks). >0 = softmax sample with `--top-k`
593    /// and `--top-p` filtering applied.
594    #[arg(long, default_value = "0.0")]
595    pub temperature: f32,
596
597    /// Backend: auto, cpu, metal, cuda (default: auto)
598    #[arg(long, default_value = "auto")]
599    pub backend: String,
600
601    /// CUDA GPU ids to use, comma-separated. Multi-GPU requests select
602    /// layer-split for supported Llama-family safetensors models.
603    #[arg(long, value_name = "IDS")]
604    pub gpu_devices: Option<String>,
605
606    /// Layer-split decode pipeline mode for multi-GPU CUDA runs.
607    #[arg(long, value_enum)]
608    pub layer_split_pipeline_mode: Option<crate::layer_split_pipeline::LayerSplitPipelineModeArg>,
609
610    /// One-shot prompt (skip interactive REPL). When supplied, ferrum runs a
611    /// single prefill+decode and exits — useful for benchmarking and shell
612    /// scripting. For `.gguf` paths, omitting this drops into the GGUF REPL.
613    #[arg(long)]
614    pub prompt: Option<String>,
615
616    /// Path to a HuggingFace `tokenizer.json` (only used for `.gguf` paths).
617    /// If omitted, ferrum looks for `<gguf-stem>.tokenizer.json` and then
618    /// `tokenizer.json` next to the `.gguf` file.
619    #[arg(long)]
620    pub tokenizer: Option<PathBuf>,
621
622    /// Bench mode: skip generated text output, print only timing summary.
623    /// Implies one-shot (`--prompt` is required).
624    #[arg(long)]
625    pub bench_mode: bool,
626
627    /// Top-K sampling cutoff (0 disables — keep all). Only the K highest-
628    /// probability tokens compete in the softmax sample. Default 50, a
629    /// conservative value that filters obvious garbage without flattening
630    /// the distribution.
631    #[arg(long, default_value = "50")]
632    pub top_k: usize,
633
634    /// Top-P (nucleus) sampling cutoff (0.0 disables, 1.0 keeps all).
635    /// Smallest set of tokens whose cumulative probability exceeds P
636    /// is kept; the rest are zeroed before sampling. Default 0.95.
637    #[arg(long, default_value = "0.95")]
638    pub top_p: f32,
639
640    /// Minimum probability cutoff relative to the most likely token.
641    /// A value of 0 disables min-p filtering.
642    #[arg(long, default_value = "0.0")]
643    pub min_p: f32,
644
645    /// Presence penalty applied to tokens that already occurred in the
646    /// request. Qwen3.5 recommends 1.5 for general thinking workloads.
647    #[arg(long, default_value = "0.0")]
648    pub presence_penalty: f32,
649
650    /// Repetition penalty applied to logits before sampling. >1 discourages
651    /// repeats, <1 encourages, 1.0 disables. Defaults to 1.1 (OpenAI/llama.cpp
652    /// standard) because the chat default is greedy (temperature 0): greedy
653    /// with no penalty deterministically locks into token loops on some
654    /// inputs (the "2D/3D 2D/3D..." degeneration). Pass `--repeat-penalty 1.0`
655    /// for an unpenalized greedy baseline.
656    #[arg(long, default_value_t = DEFAULT_CHAT_REPETITION_PENALTY)]
657    pub repeat_penalty: f32,
658
659    /// Number of recent tokens that the repetition penalty considers.
660    /// Smaller = local repeat avoidance only.
661    #[arg(long, default_value = "64")]
662    pub repeat_last_n: usize,
663
664    /// Random seed for sampling (when temperature > 0). Omit for non-deterministic chat.
665    #[arg(long)]
666    pub seed: Option<u64>,
667
668    /// Fraction of GPU memory ferrum is allowed to use (mirrors vLLM's
669    /// `--gpu-memory-utilization`). Auto-sizes the KV pool: at 0.9
670    /// ferrum will use ≤ 90 % of the GPU's reported total memory,
671    /// reserving ~4 GB scratch + the weight bytes. Set to 1.0 for an
672    /// exclusive GPU; leave at 0.9 if other processes share the card.
673    #[arg(long, default_value = "0.9")]
674    pub gpu_memory_utilization: f32,
675
676    /// Exact device-wide memory budget available to runtime weights and
677    /// dynamic resources. This is a typed capacity ceiling shared by `run`
678    /// and `serve`; omit it to use the normal pressure-threshold policy.
679    #[arg(long, value_name = "BYTES")]
680    pub runtime_memory_budget_bytes: Option<std::num::NonZeroUsize>,
681
682    /// vLLM-compatible alias for `FERRUM_MAX_MODEL_LEN`.
683    #[arg(long, value_name = "N")]
684    pub max_model_len: Option<usize>,
685
686    /// vLLM-compatible alias for `FERRUM_PAGED_MAX_SEQS`.
687    #[arg(long, value_name = "N")]
688    pub max_num_seqs: Option<usize>,
689
690    /// vLLM-compatible alias for `FERRUM_MAX_BATCHED_TOKENS`.
691    #[arg(long, value_name = "N")]
692    pub max_num_batched_tokens: Option<usize>,
693
694    /// Sequence fit gate used before prefill admission.
695    #[arg(long, value_enum)]
696    pub sequence_fit_policy: Option<crate::commands::SequenceFitPolicyArg>,
697
698    /// Enable legacy Llama/Gemma batched decode CUDA graph replay.
699    #[arg(long, conflicts_with = "disable_batched_graph")]
700    pub batched_graph: bool,
701
702    /// Disable legacy Llama/Gemma batched decode CUDA graph replay.
703    #[arg(long, conflicts_with = "batched_graph")]
704    pub disable_batched_graph: bool,
705
706    /// Enable vNext reusable device-program preparation.
707    #[arg(long, conflicts_with = "disable_reusable_execution")]
708    pub reusable_execution: bool,
709
710    /// Disable vNext reusable device-program preparation.
711    #[arg(long, conflicts_with = "reusable_execution")]
712    pub disable_reusable_execution: bool,
713
714    /// Enable Llama/Gemma unified decode CUDA graph replay.
715    #[arg(long, conflicts_with = "disable_unified_graph")]
716    pub unified_graph: bool,
717
718    /// Disable Llama/Gemma unified decode CUDA graph replay.
719    #[arg(long, conflicts_with = "unified_graph")]
720    pub disable_unified_graph: bool,
721
722    /// Capture only Llama/Gemma unified transformer layers in CUDA graph replay.
723    #[arg(long, conflicts_with = "disable_unified_graph_layers_only")]
724    pub unified_graph_layers_only: bool,
725
726    /// Disable layers-only unified CUDA graph capture scope.
727    #[arg(long, conflicts_with = "unified_graph_layers_only")]
728    pub disable_unified_graph_layers_only: bool,
729
730    /// Capture unified layers plus final packing; leave lm_head eager.
731    #[arg(long, conflicts_with = "disable_unified_graph_lm_head_eager")]
732    pub unified_graph_lm_head_eager: bool,
733
734    /// Disable lm-head-eager unified CUDA graph capture scope.
735    #[arg(long, conflicts_with = "unified_graph_lm_head_eager")]
736    pub disable_unified_graph_lm_head_eager: bool,
737
738    /// KV cache element dtype (Dim 5 polymorphism point). Accepts
739    /// `fp16`, `bf16`, `int8`, `fp8`. Default `fp16`. INT8 / FP8
740    /// require model wire-up; today only the kernel + type layer ships.
741    /// Override via `FERRUM_KV_DTYPE` env var.
742    #[arg(long, value_name = "DTYPE")]
743    pub kv_dtype: Option<String>,
744
745    /// Per-sequence KV token capacity (`FERRUM_KV_CAPACITY`).
746    #[arg(long, value_name = "N")]
747    pub kv_capacity: Option<usize>,
748
749    /// KV block budget (`FERRUM_KV_MAX_BLOCKS`).
750    #[arg(long, value_name = "N")]
751    pub kv_max_blocks: Option<usize>,
752
753    /// Write resolved startup runtime config JSON and exit artifacts.
754    #[arg(long)]
755    pub effective_config_json: Option<PathBuf>,
756
757    /// Write one auto-config decision JSON record per line.
758    #[arg(long)]
759    pub decision_trace_jsonl: Option<PathBuf>,
760
761    /// Generate a synthetic/no-weight observability vertical-slice artifact and exit.
762    #[arg(long, value_name = "DIR")]
763    pub observability_vertical_slice_out: Option<PathBuf>,
764
765    #[command(flatten)]
766    pub vnext_checkpoint: crate::commands::vnext_checkpoint::VNextCheckpointArgs,
767
768    /// Write product observability profile events to this JSONL path.
769    #[arg(long, value_name = "PATH")]
770    pub profile_jsonl: Option<PathBuf>,
771
772    /// Product observability detail level.
773    #[arg(long, value_enum, default_value_t = crate::observability_product::ProfileDetailArg::Off)]
774    pub profile_detail: crate::observability_product::ProfileDetailArg,
775
776    /// Inject one typed vNext diagnostic fault. Requires a latency profile.
777    #[arg(long, value_enum)]
778    pub vnext_diagnostic_fault: Option<crate::commands::VNextDiagnosticFaultArg>,
779
780    /// Write product memory profile events to this JSONL path.
781    #[arg(long, value_name = "PATH")]
782    pub memory_profile_jsonl: Option<PathBuf>,
783
784    /// Write scheduler/admission trace events to this JSONL path.
785    #[arg(long, value_name = "PATH")]
786    pub scheduler_trace_jsonl: Option<PathBuf>,
787
788    /// Write a sanitized request/replay bundle to this directory.
789    #[arg(long, value_name = "DIR")]
790    pub request_dump_dir: Option<PathBuf>,
791
792    /// Product observability sampling rate for resource lifecycle events.
793    #[arg(long, default_value_t = crate::observability_product::default_profile_sample_rate())]
794    pub profile_sample_rate: f64,
795
796    /// Output format. `text` (default) — streaming text + stats UX.
797    /// `jsonl` — one JSON record per event on stdout; used by tests and scripts.
798    #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
799    pub output_format: OutputFormat,
800}
801
802pub async fn execute(cmd: RunCommand, config: CliConfig) -> Result<()> {
803    if let Some(out_dir) = cmd.observability_vertical_slice_out.as_ref() {
804        crate::observability_vertical_slice::write_observability_vertical_slice(
805            ferrum_types::ProfileEntrypoint::Run,
806            out_dir,
807        )?;
808        println!(
809            "OBSERVABILITY VERTICAL SLICE ARTIFACT: {}",
810            out_dir.display()
811        );
812        return Ok(());
813    }
814    let model = cmd.model.as_deref().ok_or_else(|| {
815        FerrumError::config(crate::source_resolver::first_success_model_help("run"))
816    })?;
817    let product_observability = crate::observability_product::ProductObservabilityConfig::new(
818        ferrum_types::ProfileEntrypoint::Run,
819        model,
820        cmd.profile_jsonl.as_ref(),
821        cmd.profile_detail,
822        cmd.memory_profile_jsonl.as_ref(),
823        cmd.scheduler_trace_jsonl.as_ref(),
824        cmd.request_dump_dir.as_ref(),
825        cmd.profile_sample_rate,
826    );
827    let memory_sampler = crate::memory_profile::ProcessMemorySampler;
828    let product_memory_enabled = product_observability.enabled();
829    let process_start_sample = product_memory_enabled
830        .then(|| memory_sampler.sample())
831        .flatten();
832    let process_start_memory = process_start_sample
833        .clone()
834        .map(crate::memory_profile::ProcessMemoryObservation::from_sample);
835    if product_observability.synthetic_no_weight_enabled() {
836        let written = crate::observability_product::write_synthetic_product_observability(
837            &product_observability,
838        )?;
839        println!(
840            "OBSERVABILITY PRODUCT ARTIFACTS: {}",
841            written
842                .iter()
843                .map(|path| path.display().to_string())
844                .collect::<Vec<_>>()
845                .join(",")
846        );
847        return Ok(());
848    }
849
850    // Resolve graph-clean Qwen3-MoE defaults as typed entries first, then
851    // materialize them only for legacy backend readers.
852    let moe_graph_defaults = crate::runtime_env::moe_graph_default_entries(
853        &ferrum_types::RuntimeConfigSnapshot::capture_current(),
854        ferrum_types::RuntimeConfigSource::Default,
855    );
856    crate::runtime_env::materialize_runtime_env_defaults(&moe_graph_defaults);
857    crate::runtime_env::warn_if_moe_graph_needs_unbuilt_vllm_moe(
858        &ferrum_types::RuntimeConfigSnapshot::capture_current(),
859    );
860
861    // Select device before model resolution so CPU runs do not materialize
862    // GPU/Metal chat-profile defaults such as paged KV.
863    let mut device = select_device(&cmd.backend)?;
864    let mut gpu_selection =
865        crate::gpu_devices::resolve_cuda_gpu_devices(cmd.gpu_devices.as_deref(), &device)?;
866    if let Some(selection) = &gpu_selection {
867        device = selection.primary_device();
868        eprintln!(
869            "{} {} ({})",
870            "CUDA GPUs:".dimmed(),
871            selection.selected_csv(),
872            selection.selected_distributed_strategy
873        );
874    }
875    let backend_initialized_sample = product_memory_enabled
876        .then(|| memory_sampler.sample())
877        .flatten();
878    let backend_initialized_memory = process_memory_observation_between(
879        process_start_sample.clone(),
880        backend_initialized_sample.clone(),
881    );
882    let mut startup_cli_runtime_entries =
883        run_startup_cli_runtime_entries(&cmd, gpu_selection.as_ref());
884    // The source resolver still contains a small environment compatibility
885    // bridge for GPU autosizing. Materialize only the two typed run-entrypoint
886    // defaults it must see before model resolution, then remove those bridge
887    // values from the later environment snapshot so effective-config evidence
888    // retains the real Default/ConfigFile/CLI source.
889    let early_runtime_config =
890        run_base_runtime_config(&config, RuntimeConfigSnapshot::capture_current());
891    let early_effective_runtime_config =
892        run_effective_runtime_config(&early_runtime_config, &startup_cli_runtime_entries);
893    let run_product_bridge = run_product_runtime_bridge(&early_effective_runtime_config);
894    let materialized_run_product_keys =
895        crate::runtime_env::materialize_runtime_env_effective(&run_product_bridge);
896    materialize_run_cli_runtime_entries(&startup_cli_runtime_entries);
897    let autosize = run_autosize_for_device(&device, cmd.gpu_memory_utilization);
898
899    // Resolve the model through the central source resolver. Handles
900    // .gguf paths, local model dirs, HF cache hits, and HF download in
901    // one entry; runs the chat-profile GPU autosize + (for GGUF) sets
902    // the per-arch KV / MoE env-var defaults that `ferrum run` needs
903    // for a single-user multi-turn REPL. The engine then picks up
904    // either the safetensors path (via NativeSafetensorsLoader) or the
905    // GGUF path (via gguf_engine_loader, routed by
906    // `WeightFormat::detect()` inside `LlmExecutorFactory`).
907    let cache_dir = crate::source_resolver::hf_cache_dir(&config);
908    let resolved = crate::source_resolver::resolve_model_source_with_product_sources(
909        model,
910        &cache_dir,
911        crate::source_resolver::DownloadPolicy::AutoDownload,
912        autosize,
913        &cmd.product_sources,
914    )
915    .await?;
916    let product_input = resolved.into_product_engine_input();
917    let requested_model = product_input.requested_model.clone();
918    let model_id = product_input.public_model_id.clone();
919    let source = product_input.source;
920    let mut engine_config = product_input.engine_config;
921    let model_sources = product_input.model_sources;
922    let prepared_model = model_sources
923        .as_ref()
924        .map(crate::source_resolver::prepare_registered_product_model)
925        .transpose()?
926        .flatten();
927    let model_definition_for_config = if prepared_model.is_none() {
928        load_run_model_definition(&source, model_sources.as_deref()).await?
929    } else {
930        None
931    };
932    let model_layer_count = prepared_model
933        .as_ref()
934        .map(|prepared| prepared.descriptor().layer_count())
935        .or_else(|| {
936            model_definition_for_config
937                .as_ref()
938                .map(|definition| definition.num_hidden_layers)
939        });
940    if let (Some(selection), Some(layer_count)) = (gpu_selection.as_mut(), model_layer_count) {
941        if selection.apply_model_layer_count(layer_count)? {
942            if let Some(plan) = selection.selected_layer_split_plan.as_deref() {
943                eprintln!("{}", format!("CUDA layer split plan: {plan}").dimmed());
944            }
945            startup_cli_runtime_entries =
946                run_startup_cli_runtime_entries(&cmd, gpu_selection.as_ref());
947            materialize_run_cli_runtime_entries(&startup_cli_runtime_entries);
948        }
949    }
950    let model_chat_template = match prepared_model.as_deref() {
951        Some(prepared) => Some(crate::source_resolver::load_prepared_product_chat_template(
952            prepared,
953        )?),
954        None => match model_sources.as_deref() {
955            Some(sources) => crate::source_resolver::load_product_chat_template(sources),
956            None => crate::source_resolver::load_model_chat_template(&source.local_path),
957        },
958    };
959    let product_source_identity = prepared_model
960        .as_deref()
961        .map(|prepared| {
962            crate::source_resolver::prepared_product_source_identity(
963                prepared,
964                &requested_model,
965                &model_id,
966                model_chat_template.as_ref(),
967            )
968        })
969        .transpose()?;
970    let chat_template_options = build_chat_template_options(&cmd, model_chat_template.as_ref());
971    eprintln!("{}", format!("Loading {}...", model_id).dimmed());
972
973    let engine_model_path = source.local_path.to_string_lossy().to_string();
974
975    let device_label = format!("{device:?}");
976    eprintln!("{}", format!("Using {device_label} backend").dimmed());
977    let metal_moe_entries = crate::source_resolver::metal_gguf_moe_correctness_entries(
978        &source.local_path,
979        &device,
980        &ferrum_types::RuntimeConfigSnapshot::capture_current(),
981        ferrum_types::RuntimeConfigSource::Default,
982    );
983    crate::runtime_env::materialize_runtime_env_defaults(&metal_moe_entries);
984
985    // Create engine. Big-model loads (15-60 GB safetensors) are slow on
986    // first run — print a hint so users don't think it's frozen. Per-
987    // layer INFO logs fire from the model loaders once parsing starts;
988    // utils::setup_logging whitelists them at INFO level.
989    eprintln!(
990        "{}",
991        "Loading weights to GPU... (30s+ for >10 GB models)".dimmed()
992    );
993    let load_start = std::time::Instant::now();
994    engine_config.sampling.default_params = build_sampling_params(&cmd);
995    engine_config.backend.device = device.clone();
996    engine_config.scheduler.policy = ferrum_types::SchedulingPolicy::ContinuousBatch;
997    engine_config.backend.backend_options.insert(
998        "model_path".to_string(),
999        serde_json::Value::String(engine_model_path),
1000    );
1001    let runtime_config = run_base_runtime_config(
1002        &config,
1003        runtime_config_without_keys(
1004            RuntimeConfigSnapshot::capture_current(),
1005            &materialized_run_product_keys,
1006        ),
1007    );
1008    if let Some(selection) = &gpu_selection {
1009        selection.insert_backend_options(&mut engine_config.backend.backend_options);
1010    }
1011    crate::layer_split_pipeline::insert_backend_option_from_runtime(
1012        &runtime_config,
1013        &mut engine_config.backend.backend_options,
1014    )?;
1015    let effective_runtime_config =
1016        run_effective_runtime_config(&runtime_config, &startup_cli_runtime_entries);
1017    let typed_model_capabilities = prepared_model
1018        .as_ref()
1019        .map(|prepared| prepared.model_capabilities())
1020        .transpose()?;
1021    let startup_auto_config = run_startup_auto_config(
1022        &device,
1023        typed_model_capabilities,
1024        if prepared_model.is_some() {
1025            ferrum_types::ExecutionResourceAuthority::PlanRuntime
1026        } else {
1027            ferrum_types::ExecutionResourceAuthority::LegacyEngine
1028        },
1029        model_definition_for_config.as_ref(),
1030        crate::commands::serve::model_weight_bytes_from_path(&source.local_path),
1031        effective_runtime_config,
1032    )?;
1033    // Apply the resolved auto-config knobs the same way `serve` does. Without
1034    // this, `ferrum run` ignored the resolved config (e.g. the CUDA GPTQ-MoE
1035    // fast path FERRUM_VLLM_MOE / MOE_DEVICE_ROUTE) and fell back to the slow
1036    // host-route MoE — ~9.7 vs ~59 tok/s on a 4090 for Qwen3-30B-A3B.
1037    crate::runtime_env::materialize_runtime_env_effective(&startup_auto_config.runtime_config);
1038    crate::commands::serve::write_startup_config_artifacts(
1039        &startup_auto_config,
1040        product_source_identity.as_ref(),
1041        cmd.effective_config_json.as_deref(),
1042        cmd.decision_trace_jsonl.as_deref(),
1043    )?;
1044    let run_budget = RunBudget::from_product_sources(
1045        cmd.tokenizer.as_deref(),
1046        model_sources.as_deref(),
1047        &source.local_path,
1048        &runtime_config,
1049    )?;
1050    engine_config
1051        .apply_runtime_config_snapshot(&startup_auto_config.runtime_config)
1052        .map_err(ferrum_types::FerrumError::config)?;
1053    let vnext_checkpoint_capture = cmd.vnext_checkpoint.to_config()?;
1054    validate_teacher_forced_checkpoint_run(&cmd, vnext_checkpoint_capture.as_ref())?;
1055    let teacher_forcing = vnext_checkpoint_capture
1056        .as_ref()
1057        .and_then(|capture| capture.teacher_forcing.clone());
1058    engine_config.runtime.vnext_checkpoint_capture = vnext_checkpoint_capture;
1059    if runtime_config_bool(&startup_auto_config.runtime_config, "FERRUM_PAGED_KV")
1060        .or_else(|| {
1061            runtime_config_bool(&startup_auto_config.runtime_config, "FERRUM_METAL_PAGED_KV")
1062        })
1063        .unwrap_or(false)
1064    {
1065        engine_config.kv_cache.cache_type = ferrum_types::KvCacheType::Paged;
1066    }
1067    let effective_kv_dtype = cmd
1068        .kv_dtype
1069        .as_deref()
1070        .or_else(|| crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_KV_DTYPE"));
1071    apply_kv_dtype_override(&mut engine_config, effective_kv_dtype)?;
1072    let engine = match (prepared_model, model_sources) {
1073        (Some(prepared), _) => {
1074            ferrum_engine::create_prepared_product_engine(engine_config, prepared).await?
1075        }
1076        (None, Some(sources)) => {
1077            ferrum_engine::create_product_engine(engine_config, sources).await?
1078        }
1079        (None, None) => ferrum_engine::create_default_engine(engine_config).await?,
1080    };
1081    let model_loaded_sample = product_memory_enabled
1082        .then(|| memory_sampler.sample())
1083        .flatten();
1084    let model_loaded_memory = process_memory_observation_between(
1085        backend_initialized_sample
1086            .clone()
1087            .or_else(|| process_start_sample.clone()),
1088        model_loaded_sample.clone(),
1089    );
1090    let model_loaded_duration_us = load_start
1091        .elapsed()
1092        .as_micros()
1093        .try_into()
1094        .unwrap_or(u64::MAX);
1095    let profile_run_done_sample = product_memory_enabled
1096        .then(|| memory_sampler.sample())
1097        .flatten();
1098    let profile_run_done_memory = process_memory_observation_between(
1099        model_loaded_sample.clone(),
1100        profile_run_done_sample.clone(),
1101    );
1102    let cache_allocated_status = if product_memory_enabled {
1103        Some(engine.status().await)
1104    } else {
1105        None
1106    };
1107    let cache_allocated_sample = product_memory_enabled
1108        .then(|| memory_sampler.sample())
1109        .flatten();
1110    let cache_allocated_memory = process_memory_observation_between(
1111        profile_run_done_sample
1112            .clone()
1113            .or_else(|| model_loaded_sample.clone()),
1114        cache_allocated_sample.clone(),
1115    );
1116    eprintln!(
1117        "{}",
1118        format!(
1119            "Model loaded in {:.1}s.",
1120            load_start.elapsed().as_secs_f64()
1121        )
1122        .dimmed()
1123    );
1124    let run_session_id = Uuid::new_v4().to_string();
1125
1126    // One-shot mode: --prompt supplied → run a single request and exit.
1127    // Matches the GGUF run_gguf_one_shot UX. Previously cmd.prompt was
1128    // documented as "one-shot for non-interactive runs" but the alias
1129    // path ignored it and dropped into REPL, which exits silently when
1130    // stdin is not a TTY.
1131    if let Some(one_shot) = cmd.prompt.clone() {
1132        let format = cmd.output_format;
1133        let plan = build_run_prompt_plan(
1134            &[],
1135            &one_shot,
1136            cmd.system.as_deref(),
1137            &model_id,
1138            model_chat_template.as_ref(),
1139            &chat_template_options,
1140            &cmd,
1141            &run_budget,
1142        )?;
1143        if let Some(teacher) = &teacher_forcing {
1144            if plan.sampling_params.max_tokens != teacher.token_count() {
1145                return Err(ferrum_types::FerrumError::config(format!(
1146                    "teacher-forced checkpoint requested {} tokens, but context planning resolved max_tokens={}",
1147                    teacher.token_count(),
1148                    plan.sampling_params.max_tokens
1149                )));
1150            }
1151        }
1152        maybe_warn_context_shift(&plan, format);
1153        let prompt_opened_thinking = has_unclosed_thinking_block(&plan.prompt);
1154        let metadata = run_request_metadata(&plan.prompt, &chat_template_options);
1155        let prompt_chars = plan.prompt.chars().count();
1156        let request_id = RequestId(Uuid::new_v4());
1157        let request_id_text = request_id.to_string();
1158        let one_shot_history = Vec::new();
1159        if format == OutputFormat::Jsonl {
1160            emit_jsonl_ready(&run_session_id, &requested_model, &model_id, &device_label);
1161            emit_jsonl_user(
1162                &run_session_id,
1163                0,
1164                &request_id_text,
1165                0,
1166                &one_shot,
1167                &one_shot_history,
1168            );
1169        }
1170        let request = InferenceRequest {
1171            id: request_id,
1172            model_id: ferrum_types::ModelId(model_id.clone()),
1173            prompt: plan.prompt,
1174            sampling_params: plan.sampling_params.clone(),
1175            stream: format == OutputFormat::Jsonl,
1176            priority: Priority::Normal,
1177            client_id: None,
1178            session_id: None,
1179            created_at: Utc::now(),
1180            api_request: None,
1181            evidence_request: ferrum_types::InferenceEvidenceRequest {
1182                capture_engine_token_timing: product_observability
1183                    .profile_detail
1184                    .captures_engine_token_timing(),
1185                ..Default::default()
1186            },
1187            metadata,
1188        };
1189        let profile_request_id = request.id.to_string();
1190        let memory_before = product_observability
1191            .enabled()
1192            .then(|| memory_sampler.sample())
1193            .flatten();
1194        let start = std::time::Instant::now();
1195        let trace_tokens =
1196            crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_RUN_TRACE_TOKENS")
1197                .is_some();
1198        let generation_result = match format {
1199            OutputFormat::Text => engine
1200                .infer(request)
1201                .await
1202                .map(CollectedRunGeneration::from_response),
1203            OutputFormat::Jsonl => match engine.infer_stream(request).await {
1204                Ok(stream) => {
1205                    collect_run_stream(
1206                        stream,
1207                        trace_tokens,
1208                        0,
1209                        &run_session_id,
1210                        0,
1211                        &request_id_text,
1212                    )
1213                    .await
1214                }
1215                Err(error) => Err(error),
1216            },
1217        };
1218        let generation = match generation_result {
1219            Ok(generation) => generation,
1220            Err(err) => {
1221                let memory_after = product_memory_enabled
1222                    .then(|| memory_sampler.sample())
1223                    .flatten();
1224                let memory = process_memory_observation_between(memory_before, memory_after);
1225                let elapsed = start.elapsed().as_secs_f64();
1226                if let Err(observability_err) =
1227                    crate::observability_product::write_actual_run_failure_observability(
1228                        &product_observability,
1229                        &crate::observability_product::ActualRunFailureObservation {
1230                            request_id: profile_request_id,
1231                            duration_us: (elapsed * 1_000_000.0).max(0.0) as u64,
1232                            sampling_params: plan.sampling_params.clone(),
1233                            prompt_token_ids: plan.prompt_token_ids.clone(),
1234                            prompt_token_count: plan.prompt_tokens,
1235                            prompt_chars,
1236                            failure_kind: err.observability_failure_kind().to_string(),
1237                            error_kind: err.observability_error_kind().to_string(),
1238                            error_message: err.to_string(),
1239                            memory,
1240                            memory_stages: actual_run_memory_stages(
1241                                product_memory_enabled,
1242                                process_start_memory.clone(),
1243                                backend_initialized_memory.clone(),
1244                                model_loaded_memory.clone(),
1245                                model_loaded_duration_us,
1246                                profile_run_done_memory.clone(),
1247                                cache_allocated_memory.clone(),
1248                                cache_allocated_status.clone(),
1249                                None,
1250                            ),
1251                        },
1252                    )
1253                {
1254                    eprintln!("failed to write run failure observability: {observability_err}");
1255                }
1256                return Err(err);
1257            }
1258        };
1259        let memory_after = product_memory_enabled
1260            .then(|| memory_sampler.sample())
1261            .flatten();
1262        let memory = process_memory_observation_between(memory_before, memory_after.clone());
1263        let CollectedRunGeneration {
1264            request_id: response_request_id,
1265            raw_text,
1266            finish_reason,
1267            usage,
1268            token_count: tokens,
1269            token_ids: output_token_ids,
1270            chunk_count,
1271            execution_evidence,
1272        } = generation;
1273        if response_request_id != request_id_text {
1274            return Err(FerrumError::internal(format!(
1275                "run response request id drift: expected {request_id_text}, got {response_request_id}"
1276            )));
1277        }
1278        let raw_response = display_response_text(&raw_text);
1279        let parsed = parse_reasoning_response_for_prompt(&raw_response, prompt_opened_thinking);
1280        let content = display_response_text(&parsed.content);
1281        let reasoning = parsed
1282            .reasoning
1283            .as_deref()
1284            .map(str::trim)
1285            .filter(|value| !value.is_empty());
1286        let bench = cmd.bench_mode;
1287        if format == OutputFormat::Text && !bench {
1288            print!("{}", raw_response);
1289            io::stdout().flush().ok();
1290        }
1291        let elapsed = start.elapsed().as_secs_f64();
1292        let tps = if elapsed > 0.0 {
1293            tokens as f64 / elapsed
1294        } else {
1295            0.0
1296        };
1297        match format {
1298            OutputFormat::Text => {
1299                if !bench {
1300                    println!();
1301                }
1302                eprintln!(
1303                    "{}",
1304                    format!("[{tokens} tokens, {tps:.1} tok/s, {elapsed:.1}s]").dimmed()
1305                );
1306            }
1307            OutputFormat::Jsonl => {
1308                emit_jsonl_assistant(
1309                    &run_session_id,
1310                    0,
1311                    &request_id_text,
1312                    0,
1313                    &content,
1314                    reasoning,
1315                    &one_shot_history,
1316                    finish_reason,
1317                    usage.as_ref(),
1318                    tokens,
1319                    chunk_count,
1320                    &raw_response,
1321                    elapsed * 1000.0,
1322                );
1323            }
1324        }
1325        let shutdown_result = engine.shutdown().await;
1326        let shutdown_after = product_memory_enabled
1327            .then(|| memory_sampler.sample())
1328            .flatten();
1329        let shutdown_memory = process_memory_observation_between(
1330            memory_after
1331                .clone()
1332                .or_else(|| model_loaded_sample.clone())
1333                .or_else(|| backend_initialized_sample.clone())
1334                .or_else(|| process_start_sample.clone()),
1335            shutdown_after,
1336        );
1337        crate::observability_product::write_actual_run_observability(
1338            &product_observability,
1339            &crate::observability_product::ActualRunObservation {
1340                request_id: profile_request_id,
1341                duration_us: (elapsed * 1_000_000.0).max(0.0) as u64,
1342                sampling_params: plan.sampling_params.clone(),
1343                prompt_token_ids: plan.prompt_token_ids.clone(),
1344                prompt_token_count: plan.prompt_tokens,
1345                output_tokens: tokens,
1346                output_token_ids,
1347                chunk_count,
1348                finish_reason: finish_reason.map(finish_reason_str).map(str::to_string),
1349                prompt_chars,
1350                response_chars: raw_response.chars().count(),
1351                response_text: raw_response,
1352                execution_evidence,
1353                memory,
1354                memory_stages: actual_run_memory_stages(
1355                    product_memory_enabled,
1356                    process_start_memory.clone(),
1357                    backend_initialized_memory.clone(),
1358                    model_loaded_memory.clone(),
1359                    model_loaded_duration_us,
1360                    profile_run_done_memory.clone(),
1361                    cache_allocated_memory.clone(),
1362                    cache_allocated_status.clone(),
1363                    shutdown_memory,
1364                ),
1365            },
1366        )?;
1367        shutdown_result?;
1368        if format == OutputFormat::Jsonl {
1369            emit_jsonl_exit(&run_session_id, 0, "one_shot_complete");
1370        }
1371        return Ok(());
1372    }
1373
1374    let mut history: Vec<(String, String)> = Vec::new(); // (role, content)
1375    let mut history_epoch = 0usize;
1376    let mut turn = 0usize;
1377    let mut exit_reason: &str = "eof";
1378
1379    // Print ready message
1380    let format = cmd.output_format;
1381    match format {
1382        OutputFormat::Text => {
1383            eprintln!();
1384            eprintln!("{}", "Ready. Type your message and press Enter.".green());
1385            eprintln!(
1386                "{}",
1387                "Use /clear to reset history; /bye or Ctrl+D to exit.".dimmed()
1388            );
1389            eprintln!();
1390        }
1391        OutputFormat::Jsonl => {
1392            emit_jsonl_ready(&run_session_id, &requested_model, &model_id, &device_label);
1393        }
1394    }
1395
1396    // If stdin is not a TTY (piped input), don't print prompts and just consume lines.
1397    // This enables: `printf "hi\n/bye\n" | ferrum run ...` for automation/profiling.
1398    let stdin_handle = io::stdin();
1399    let stdin_is_tty = stdin_handle.is_terminal();
1400    let term = stdin_is_tty.then(Term::stdout);
1401    let mut stdin = stdin_handle.lock();
1402
1403    loop {
1404        if stdin_is_tty {
1405            // Show prompt
1406            print!("{} ", ">>>".bright_green().bold());
1407            io::stdout().flush().unwrap();
1408        }
1409
1410        // Read input
1411        let mut input = String::new();
1412        match read_repl_input_line(term.as_ref(), &mut stdin, &mut input) {
1413            Ok(0) => break, // EOF
1414            Ok(_) => {
1415                let input = input.trim();
1416                if input.is_empty() {
1417                    continue;
1418                }
1419                if input == "/bye" || input == "exit" || input == "quit" {
1420                    exit_reason = match input {
1421                        "/bye" => "bye",
1422                        "exit" => "exit",
1423                        "quit" => "quit",
1424                        _ => "command",
1425                    };
1426                    break;
1427                }
1428                if input == "/clear" {
1429                    let before = history_evidence(&history);
1430                    history.clear();
1431                    history_epoch += 1;
1432                    turn = 0;
1433                    match format {
1434                        OutputFormat::Text => {
1435                            eprintln!("{}", "History cleared.".dimmed());
1436                        }
1437                        OutputFormat::Jsonl => {
1438                            let record = serde_json::json!({
1439                                "schema_version": RUN_JSONL_SCHEMA_VERSION,
1440                                "event": "history_reset",
1441                                "session_id": run_session_id,
1442                                "history_epoch": history_epoch,
1443                                "turn": turn,
1444                                "history_before": before,
1445                                "history_after": history_evidence(&history),
1446                            });
1447                            emit_jsonl_record(&record);
1448                        }
1449                    }
1450                    continue;
1451                }
1452
1453                let plan = build_run_prompt_plan(
1454                    &history,
1455                    input,
1456                    cmd.system.as_deref(),
1457                    &model_id,
1458                    model_chat_template.as_ref(),
1459                    &chat_template_options,
1460                    &cmd,
1461                    &run_budget,
1462                )?;
1463                maybe_warn_context_shift(&plan, format);
1464                let prompt_opened_thinking = has_unclosed_thinking_block(&plan.prompt);
1465                let observability_sampling_params =
1466                    product_memory_enabled.then(|| plan.sampling_params.clone());
1467                let prompt_token_ids = plan.prompt_token_ids;
1468                let prompt_token_count = plan.prompt_tokens;
1469                let prompt_chars = plan.prompt.chars().count();
1470                let metadata = run_request_metadata(&plan.prompt, &chat_template_options);
1471                let request_id = RequestId(Uuid::new_v4());
1472                let expected_request_id = request_id.clone();
1473                let request_id_text = request_id.to_string();
1474                if format == OutputFormat::Jsonl {
1475                    emit_jsonl_user(
1476                        &run_session_id,
1477                        history_epoch,
1478                        &request_id_text,
1479                        turn,
1480                        input,
1481                        &history,
1482                    );
1483                }
1484                // Create request
1485                let request = InferenceRequest {
1486                    id: request_id,
1487                    model_id: ferrum_types::ModelId(model_id.clone()),
1488                    prompt: plan.prompt,
1489                    sampling_params: plan.sampling_params,
1490                    stream: true,
1491                    priority: Priority::Normal,
1492                    client_id: None,
1493                    session_id: None,
1494                    created_at: Utc::now(),
1495                    api_request: None,
1496                    evidence_request: ferrum_types::InferenceEvidenceRequest {
1497                        capture_engine_token_timing: product_observability
1498                            .profile_detail
1499                            .captures_engine_token_timing(),
1500                        ..Default::default()
1501                    },
1502                    metadata,
1503                };
1504
1505                let memory_before = product_memory_enabled
1506                    .then(|| memory_sampler.sample())
1507                    .flatten();
1508                let memory_stages = if product_memory_enabled && history_epoch == 0 && turn == 0 {
1509                    let mut stages = actual_run_memory_stages(
1510                        product_memory_enabled,
1511                        process_start_memory.clone(),
1512                        backend_initialized_memory.clone(),
1513                        model_loaded_memory.clone(),
1514                        model_loaded_duration_us,
1515                        profile_run_done_memory.clone(),
1516                        cache_allocated_memory.clone(),
1517                        cache_allocated_status.clone(),
1518                        None,
1519                    );
1520                    stages.retain(|stage| stage.stage != "shutdown");
1521                    stages
1522                } else {
1523                    Vec::new()
1524                };
1525                let start = std::time::Instant::now();
1526                let trace_tokens = crate::runtime_env::runtime_snapshot_value(
1527                    &runtime_config,
1528                    "FERRUM_RUN_TRACE_TOKENS",
1529                )
1530                .is_some();
1531                let generation_result = match format {
1532                    OutputFormat::Text => match engine.infer_stream(request).await {
1533                        Ok(stream) => {
1534                            collect_run_text_stream(
1535                                stream,
1536                                trace_tokens,
1537                                turn,
1538                                &expected_request_id,
1539                                stdin_is_tty,
1540                                product_memory_enabled,
1541                            )
1542                            .await
1543                        }
1544                        Err(error) => Err(error),
1545                    },
1546                    OutputFormat::Jsonl => match engine.infer_stream(request).await {
1547                        Ok(stream) => {
1548                            collect_run_stream(
1549                                stream,
1550                                trace_tokens,
1551                                turn,
1552                                &run_session_id,
1553                                history_epoch,
1554                                &request_id_text,
1555                            )
1556                            .await
1557                        }
1558                        Err(error) => Err(error),
1559                    },
1560                };
1561                let generation = match generation_result {
1562                    Ok(generation) => generation,
1563                    Err(error) => {
1564                        let memory_after = product_memory_enabled
1565                            .then(|| memory_sampler.sample())
1566                            .flatten();
1567                        let memory =
1568                            process_memory_observation_between(memory_before, memory_after);
1569                        let elapsed = start.elapsed().as_secs_f64();
1570                        if product_memory_enabled {
1571                            if let Err(observability_error) =
1572                                crate::observability_product::write_actual_run_failure_observability(
1573                                    &product_observability,
1574                                    &crate::observability_product::ActualRunFailureObservation {
1575                                        request_id: request_id_text,
1576                                        duration_us: (elapsed * 1_000_000.0).max(0.0) as u64,
1577                                        sampling_params: observability_sampling_params.expect(
1578                                            "enabled run observability must capture sampling parameters",
1579                                        ),
1580                                        prompt_token_ids,
1581                                        prompt_token_count,
1582                                        prompt_chars,
1583                                        failure_kind: error
1584                                            .observability_failure_kind()
1585                                            .to_string(),
1586                                        error_kind: error.observability_error_kind().to_string(),
1587                                        error_message: error.to_string(),
1588                                        memory,
1589                                        memory_stages,
1590                                    },
1591                                )
1592                            {
1593                                eprintln!(
1594                                    "failed to write interactive run failure observability: {observability_error}"
1595                                );
1596                            }
1597                        }
1598                        return Err(error);
1599                    }
1600                };
1601                let memory_after = product_memory_enabled
1602                    .then(|| memory_sampler.sample())
1603                    .flatten();
1604                let memory = process_memory_observation_between(memory_before, memory_after);
1605                let CollectedRunGeneration {
1606                    request_id: response_request_id,
1607                    raw_text,
1608                    finish_reason,
1609                    usage,
1610                    token_count,
1611                    token_ids: output_token_ids,
1612                    chunk_count,
1613                    execution_evidence,
1614                } = generation;
1615                if response_request_id != request_id_text {
1616                    return Err(FerrumError::internal(format!(
1617                        "run response request id drift: expected {request_id_text}, got {response_request_id}"
1618                    )));
1619                }
1620                let raw_response = display_response_text(&raw_text);
1621                let (clean_response, reasoning) = match format {
1622                    OutputFormat::Text => (raw_response.clone(), None),
1623                    OutputFormat::Jsonl => {
1624                        let parsed = parse_reasoning_response_for_prompt(
1625                            &raw_response,
1626                            prompt_opened_thinking,
1627                        );
1628                        (
1629                            display_response_text(&parsed.content),
1630                            parsed
1631                                .reasoning
1632                                .map(|value| value.trim().to_string())
1633                                .filter(|value| !value.is_empty()),
1634                        )
1635                    }
1636                };
1637
1638                let elapsed = start.elapsed();
1639                let elapsed_s = elapsed.as_secs_f64();
1640                let tps = if elapsed_s > 0.0 {
1641                    token_count as f64 / elapsed_s
1642                } else {
1643                    0.0
1644                };
1645
1646                match format {
1647                    OutputFormat::Text => {
1648                        println!();
1649                        eprintln!(
1650                            "{}",
1651                            format!("[{token_count} tokens, {tps:.1} tok/s, {elapsed_s:.1}s]")
1652                                .dimmed()
1653                        );
1654                        eprintln!();
1655                    }
1656                    OutputFormat::Jsonl => {
1657                        emit_jsonl_assistant(
1658                            &run_session_id,
1659                            history_epoch,
1660                            &request_id_text,
1661                            turn,
1662                            &clean_response,
1663                            reasoning.as_deref(),
1664                            &history,
1665                            finish_reason,
1666                            usage.as_ref(),
1667                            token_count,
1668                            chunk_count,
1669                            &raw_response,
1670                            elapsed_s * 1000.0,
1671                        );
1672                    }
1673                }
1674
1675                if product_memory_enabled {
1676                    crate::observability_product::write_actual_run_observability(
1677                        &product_observability,
1678                        &crate::observability_product::ActualRunObservation {
1679                            request_id: request_id_text.clone(),
1680                            duration_us: (elapsed_s * 1_000_000.0).max(0.0) as u64,
1681                            sampling_params: observability_sampling_params.expect(
1682                                "enabled run observability must capture sampling parameters",
1683                            ),
1684                            prompt_token_ids,
1685                            prompt_token_count,
1686                            output_tokens: token_count,
1687                            output_token_ids,
1688                            chunk_count,
1689                            finish_reason: finish_reason.map(finish_reason_str).map(str::to_string),
1690                            prompt_chars,
1691                            response_chars: raw_response.chars().count(),
1692                            response_text: raw_response.clone(),
1693                            execution_evidence,
1694                            memory,
1695                            memory_stages,
1696                        },
1697                    )?;
1698                }
1699
1700                // In non-interactive mode, don't wait for terminal formatting/spacing.
1701                if !stdin_is_tty {
1702                    io::stdout().flush().ok();
1703                    io::stderr().flush().ok();
1704                }
1705
1706                // Add to history
1707                history.push(("user".to_string(), input.to_string()));
1708                if !raw_response.is_empty() {
1709                    history.push(("assistant".to_string(), raw_response));
1710                }
1711
1712                // Limit history
1713                while history.len() > 10 {
1714                    history.remove(0);
1715                }
1716                turn += 1;
1717            }
1718            Err(e) if e.kind() == io::ErrorKind::Interrupted => {
1719                exit_reason = "interrupt";
1720                break;
1721            }
1722            Err(e) => {
1723                eprintln!("{} {}", "Error reading input:".red(), e);
1724                exit_reason = "read_error";
1725                break;
1726            }
1727        }
1728    }
1729
1730    match format {
1731        OutputFormat::Text => {
1732            eprintln!("{}", "Goodbye!".bright_yellow());
1733        }
1734        OutputFormat::Jsonl => {
1735            emit_jsonl_exit(&run_session_id, history_epoch, exit_reason);
1736        }
1737    }
1738    engine.shutdown().await?;
1739    Ok(())
1740}
1741
1742fn process_memory_observation_between(
1743    before: Option<crate::memory_profile::ProcessMemorySample>,
1744    after: Option<crate::memory_profile::ProcessMemorySample>,
1745) -> Option<crate::memory_profile::ProcessMemoryObservation> {
1746    after.map(|after| crate::memory_profile::ProcessMemoryObservation::from_samples(before, after))
1747}
1748
1749fn actual_run_memory_stages(
1750    enabled: bool,
1751    process_start_memory: Option<crate::memory_profile::ProcessMemoryObservation>,
1752    backend_initialized_memory: Option<crate::memory_profile::ProcessMemoryObservation>,
1753    model_loaded_memory: Option<crate::memory_profile::ProcessMemoryObservation>,
1754    model_loaded_duration_us: u64,
1755    profile_run_done_memory: Option<crate::memory_profile::ProcessMemoryObservation>,
1756    cache_allocated_memory: Option<crate::memory_profile::ProcessMemoryObservation>,
1757    cache_allocated_status: Option<ferrum_types::EngineStatus>,
1758    shutdown_memory: Option<crate::memory_profile::ProcessMemoryObservation>,
1759) -> Vec<crate::observability_product::ActualMemoryStageObservation> {
1760    if !enabled {
1761        return Vec::new();
1762    }
1763    let profile_run_done = crate::observability_product::ActualMemoryStageObservation::new(
1764        "actual_run_profile_run_done",
1765        "profile_run_done",
1766        None,
1767        profile_run_done_memory,
1768    )
1769    .with_profile_run_status(
1770        false,
1771        "not_configured",
1772        "product_basic_profile_does_not_execute_extra_warmup",
1773    );
1774    let mut cache_allocated = crate::observability_product::ActualMemoryStageObservation::new(
1775        "actual_run_cache_allocated",
1776        "cache_allocated",
1777        None,
1778        cache_allocated_memory,
1779    );
1780    if let Some(status) = cache_allocated_status.as_ref() {
1781        cache_allocated = cache_allocated.with_engine_cache_status(status);
1782    }
1783    vec![
1784        crate::observability_product::ActualMemoryStageObservation::new(
1785            "actual_run_process_start",
1786            "process_start",
1787            None,
1788            process_start_memory,
1789        ),
1790        crate::observability_product::ActualMemoryStageObservation::new(
1791            "actual_run_backend_initialized",
1792            "backend_initialized",
1793            None,
1794            backend_initialized_memory,
1795        ),
1796        crate::observability_product::ActualMemoryStageObservation::new(
1797            "actual_run_model_loaded",
1798            "model_loaded",
1799            Some(model_loaded_duration_us),
1800            model_loaded_memory,
1801        ),
1802        profile_run_done,
1803        cache_allocated,
1804        crate::observability_product::ActualMemoryStageObservation::new(
1805            "actual_run_shutdown",
1806            "shutdown",
1807            None,
1808            shutdown_memory,
1809        ),
1810    ]
1811}
1812
1813#[cfg(unix)]
1814fn read_repl_input_line<R: BufRead + AsRawFd>(
1815    term: Option<&Term>,
1816    stdin: &mut R,
1817    input: &mut String,
1818) -> io::Result<usize> {
1819    if let Some(term) = term {
1820        let Some(line) = read_tty_input_line(term, stdin)? else {
1821            return Ok(0);
1822        };
1823        input.push_str(&line);
1824        return Ok(input.len().max(1));
1825    }
1826    stdin.read_line(input)
1827}
1828
1829#[cfg(not(unix))]
1830fn read_repl_input_line<R: BufRead>(
1831    term: Option<&Term>,
1832    stdin: &mut R,
1833    input: &mut String,
1834) -> io::Result<usize> {
1835    if let Some(term) = term {
1836        let Some(line) = read_tty_input_line(term)? else {
1837            return Ok(0);
1838        };
1839        input.push_str(&line);
1840        return Ok(input.len().max(1));
1841    }
1842    stdin.read_line(input)
1843}
1844
1845#[cfg(unix)]
1846struct RawModeGuard {
1847    fd: RawFd,
1848    original: libc::termios,
1849}
1850
1851#[cfg(unix)]
1852impl RawModeGuard {
1853    fn new(fd: RawFd) -> io::Result<Self> {
1854        let mut termios = mem::MaybeUninit::uninit();
1855        if unsafe { libc::tcgetattr(fd, termios.as_mut_ptr()) } != 0 {
1856            return Err(io::Error::last_os_error());
1857        }
1858        let original = unsafe { termios.assume_init() };
1859        let mut raw = original;
1860        unsafe { libc::cfmakeraw(&mut raw) };
1861        raw.c_oflag = original.c_oflag;
1862        if unsafe { libc::tcsetattr(fd, libc::TCSADRAIN, &raw) } != 0 {
1863            return Err(io::Error::last_os_error());
1864        }
1865        Ok(Self { fd, original })
1866    }
1867}
1868
1869#[cfg(unix)]
1870impl Drop for RawModeGuard {
1871    fn drop(&mut self) {
1872        unsafe {
1873            libc::tcsetattr(self.fd, libc::TCSADRAIN, &self.original);
1874        }
1875    }
1876}
1877
1878#[cfg(unix)]
1879fn read_tty_input_line<R: AsRawFd>(term: &Term, stdin: &mut R) -> io::Result<Option<String>> {
1880    let _raw_mode = RawModeGuard::new(stdin.as_raw_fd())?;
1881    let mut chars: Vec<char> = Vec::new();
1882    loop {
1883        match term.read_key_raw()? {
1884            Key::Backspace => {
1885                if let Some(ch) = chars.pop() {
1886                    let width = measure_text_width(&ch.to_string());
1887                    if width > 0 {
1888                        term.clear_chars(width)?;
1889                    }
1890                    term.flush()?;
1891                }
1892            }
1893            Key::Char('\u{4}') => {
1894                term.write_str("\n")?;
1895                term.flush()?;
1896                if chars.is_empty() {
1897                    return Ok(None);
1898                }
1899                break;
1900            }
1901            Key::CtrlC | Key::Char('\u{3}') => {
1902                term.write_str("^C\n")?;
1903                term.flush()?;
1904                return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted"));
1905            }
1906            Key::Enter => {
1907                term.write_str("\n")?;
1908                term.flush()?;
1909                break;
1910            }
1911            Key::Char(ch) if !ch.is_ascii_control() => {
1912                chars.push(ch);
1913                term.write_str(&ch.to_string())?;
1914                term.flush()?;
1915            }
1916            _ => {}
1917        }
1918    }
1919    Ok(Some(chars.into_iter().collect()))
1920}
1921
1922#[cfg(not(unix))]
1923fn read_tty_input_line(term: &Term) -> io::Result<Option<String>> {
1924    let mut chars: Vec<char> = Vec::new();
1925    loop {
1926        match term.read_key()? {
1927            Key::Backspace => {
1928                if let Some(ch) = chars.pop() {
1929                    let width = measure_text_width(&ch.to_string());
1930                    if width > 0 {
1931                        term.clear_chars(width)?;
1932                    }
1933                    term.flush()?;
1934                }
1935            }
1936            Key::Char('\u{4}') => {
1937                term.write_str("\n")?;
1938                term.flush()?;
1939                if chars.is_empty() {
1940                    return Ok(None);
1941                }
1942                break;
1943            }
1944            Key::CtrlC | Key::Char('\u{3}') => {
1945                term.write_str("^C\n")?;
1946                term.flush()?;
1947                return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted"));
1948            }
1949            Key::Enter => {
1950                term.write_str("\n")?;
1951                term.flush()?;
1952                break;
1953            }
1954            Key::Char(ch) if !ch.is_ascii_control() => {
1955                chars.push(ch);
1956                term.write_str(&ch.to_string())?;
1957                term.flush()?;
1958            }
1959            _ => {}
1960        }
1961    }
1962    Ok(Some(chars.into_iter().collect()))
1963}
1964
1965fn start_first_token_indicator(enabled: bool) -> Option<ProgressBar> {
1966    if !enabled {
1967        return None;
1968    }
1969    let progress = ProgressBar::new_spinner();
1970    let style = ProgressStyle::with_template("{spinner} Working ({elapsed})")
1971        .unwrap_or_else(|_| ProgressStyle::default_spinner());
1972    progress.set_style(style);
1973    progress.enable_steady_tick(std::time::Duration::from_millis(120));
1974    progress.tick();
1975    Some(progress)
1976}
1977
1978fn clear_first_token_indicator(progress: &mut Option<ProgressBar>) {
1979    if let Some(progress) = progress.take() {
1980        progress.finish_and_clear();
1981    }
1982}
1983
1984fn runtime_config_bool(snapshot: &RuntimeConfigSnapshot, key: &str) -> Option<bool> {
1985    crate::runtime_env::runtime_snapshot_value(snapshot, key).map(|value| {
1986        matches!(
1987            value.trim().to_ascii_lowercase().as_str(),
1988            "" | "1" | "true" | "yes" | "on"
1989        )
1990    })
1991}
1992
1993fn run_autosize_for_device(
1994    device: &ferrum_types::Device,
1995    gpu_memory_utilization: f32,
1996) -> Option<(crate::gpu_mem_autosize::AutoSizeProfile, f32)> {
1997    match device {
1998        ferrum_types::Device::CPU => None,
1999        _ => Some((
2000            crate::gpu_mem_autosize::AutoSizeProfile::Chat,
2001            gpu_memory_utilization,
2002        )),
2003    }
2004}
2005
2006fn build_sampling_params(cmd: &RunCommand) -> SamplingParams {
2007    let greedy = cmd.temperature <= 0.0;
2008    let mut stop_sequences = vec![
2009        "<|im_end|>".to_string(),
2010        "</s>".to_string(),
2011        "<|endoftext|>".to_string(),
2012    ];
2013    stop_sequences.extend(cmd.stop.iter().filter(|stop| !stop.is_empty()).cloned());
2014    SamplingParams {
2015        max_tokens: cmd.max_tokens as usize,
2016        temperature: cmd.temperature,
2017        top_p: if greedy { 1.0 } else { cmd.top_p },
2018        top_k: if greedy || cmd.top_k == 0 {
2019            None
2020        } else {
2021            Some(cmd.top_k)
2022        },
2023        min_p: (cmd.min_p != 0.0).then_some(cmd.min_p),
2024        presence_penalty: cmd.presence_penalty,
2025        repetition_penalty: cmd.repeat_penalty,
2026        stop_sequences,
2027        seed: cmd.seed,
2028        ..Default::default()
2029    }
2030}
2031
2032fn validate_teacher_forced_checkpoint_run(
2033    cmd: &RunCommand,
2034    capture: Option<&ferrum_types::VNextCheckpointCaptureConfig>,
2035) -> Result<()> {
2036    let Some(teacher) = capture.and_then(|capture| capture.teacher_forcing.as_ref()) else {
2037        return Ok(());
2038    };
2039    if cmd.prompt.is_none() {
2040        return Err(ferrum_types::FerrumError::config(
2041            "teacher-forced checkpoint capture requires one-shot --prompt",
2042        ));
2043    }
2044    if cmd.max_tokens as usize != teacher.token_count() {
2045        return Err(ferrum_types::FerrumError::config(format!(
2046            "teacher-forced checkpoint requires --max-tokens {}, got {}",
2047            teacher.token_count(),
2048            cmd.max_tokens
2049        )));
2050    }
2051    if cmd.max_num_seqs != Some(1) {
2052        return Err(ferrum_types::FerrumError::config(
2053            "teacher-forced checkpoint capture requires --max-num-seqs 1",
2054        ));
2055    }
2056    if cmd.temperature != 0.0
2057        || cmd.top_k != 0
2058        || cmd.top_p != 1.0
2059        || cmd.min_p != 0.0
2060        || cmd.presence_penalty != 0.0
2061        || cmd.repeat_penalty != 1.0
2062        || !cmd.stop.is_empty()
2063    {
2064        return Err(ferrum_types::FerrumError::config(
2065            "teacher-forced checkpoint capture requires unpenalized greedy settings: \
2066             --temperature 0 --top-k 0 --top-p 1 --min-p 0 \
2067             --presence-penalty 0 --repeat-penalty 1 and no --stop",
2068        ));
2069    }
2070    Ok(())
2071}
2072
2073fn sampling_params_for_prompt(mut sampling_params: SamplingParams, prompt: &str) -> SamplingParams {
2074    if has_unclosed_thinking_block(prompt) {
2075        sampling_params.response_completion_boundary =
2076            ResponseCompletionBoundary::AfterDelimiterAndPayload {
2077                delimiter: THINK_END_TAG.to_string(),
2078                alternate_envelope: None,
2079            };
2080    }
2081    sampling_params
2082}
2083
2084fn build_chat_template_options(
2085    cmd: &RunCommand,
2086    model_template: Option<&ModelChatTemplate>,
2087) -> ChatTemplateOptions {
2088    let mut options = ChatTemplateOptions::default_for_template(model_template);
2089    if cmd.enable_thinking {
2090        options.enable_thinking = Some(true);
2091    } else if cmd.disable_thinking {
2092        options.enable_thinking = Some(false);
2093    }
2094    options
2095}
2096
2097fn build_run_prompt_plan(
2098    history: &[(String, String)],
2099    user_input: &str,
2100    system: Option<&str>,
2101    model_id: &str,
2102    model_template: Option<&ModelChatTemplate>,
2103    chat_template_options: &ChatTemplateOptions,
2104    cmd: &RunCommand,
2105    budget: &RunBudget,
2106) -> Result<RunPromptPlan> {
2107    let base_sampling = build_sampling_params(cmd);
2108
2109    if cmd.no_context_shift {
2110        let prompt = build_chat_prompt(
2111            history,
2112            user_input,
2113            system,
2114            model_id,
2115            model_template,
2116            chat_template_options,
2117        )?;
2118        let prompt_tokenization = budget.prompt_tokenization(&prompt);
2119        let prompt_tokens = prompt_tokenization.token_count;
2120        if !fits_kv_budget(&base_sampling, prompt_tokens, budget.kv_capacity) {
2121            return Err(FerrumError::invalid_request(format!(
2122                "This model context is limited to {} tokens, but this turn needs {} input tokens + {} output tokens. Reduce --max-tokens, use /clear, or shorten the prompt.",
2123                budget.kv_capacity.unwrap_or(0),
2124                prompt_tokens.unwrap_or(0),
2125                base_sampling.max_tokens,
2126            )));
2127        }
2128
2129        let sampling_params = sampling_params_for_prompt(base_sampling, &prompt);
2130        return Ok(RunPromptPlan {
2131            prompt,
2132            sampling_params,
2133            prompt_token_ids: prompt_tokenization.token_ids,
2134            prompt_tokens,
2135            kv_capacity: budget.kv_capacity,
2136            dropped_history_messages: 0,
2137            dropped_history_turns: 0,
2138            max_tokens_clamped_from: None,
2139        });
2140    }
2141
2142    let mut history_start = 0usize;
2143    loop {
2144        let prompt = build_chat_prompt(
2145            &history[history_start..],
2146            user_input,
2147            system,
2148            model_id,
2149            model_template,
2150            chat_template_options,
2151        )?;
2152        let prompt_tokenization = budget.prompt_tokenization(&prompt);
2153        let prompt_tokens = prompt_tokenization.token_count;
2154
2155        let Some(kv_capacity) = budget.kv_capacity else {
2156            let sampling_params = sampling_params_for_prompt(base_sampling, &prompt);
2157            return Ok(RunPromptPlan {
2158                prompt,
2159                sampling_params,
2160                prompt_token_ids: prompt_tokenization.token_ids,
2161                prompt_tokens,
2162                kv_capacity: None,
2163                dropped_history_messages: history_start,
2164                dropped_history_turns: count_user_turns(&history[..history_start]),
2165                max_tokens_clamped_from: None,
2166            });
2167        };
2168        let Some(prompt_tokens) = prompt_tokens else {
2169            let sampling_params = sampling_params_for_prompt(base_sampling, &prompt);
2170            return Ok(RunPromptPlan {
2171                prompt,
2172                sampling_params,
2173                prompt_token_ids: prompt_tokenization.token_ids,
2174                prompt_tokens: None,
2175                kv_capacity: Some(kv_capacity),
2176                dropped_history_messages: history_start,
2177                dropped_history_turns: count_user_turns(&history[..history_start]),
2178                max_tokens_clamped_from: None,
2179            });
2180        };
2181
2182        if prompt_tokens < kv_capacity {
2183            let remaining = kv_capacity - prompt_tokens;
2184            let mut sampling_params = base_sampling.clone();
2185            let max_tokens_clamped_from = if sampling_params.max_tokens > remaining {
2186                let old = sampling_params.max_tokens;
2187                sampling_params.max_tokens = remaining;
2188                Some(old)
2189            } else {
2190                None
2191            };
2192            let sampling_params = sampling_params_for_prompt(sampling_params, &prompt);
2193            return Ok(RunPromptPlan {
2194                prompt,
2195                sampling_params,
2196                prompt_token_ids: prompt_tokenization.token_ids,
2197                prompt_tokens: Some(prompt_tokens),
2198                kv_capacity: Some(kv_capacity),
2199                dropped_history_messages: history_start,
2200                dropped_history_turns: count_user_turns(&history[..history_start]),
2201                max_tokens_clamped_from,
2202            });
2203        }
2204
2205        if history_start >= history.len() {
2206            return Err(FerrumError::invalid_request(format!(
2207                "This model context is limited to {kv_capacity} tokens, but the current turn needs {prompt_tokens} input tokens before generation. Use a shorter prompt or increase KV capacity.",
2208            )));
2209        }
2210        history_start = next_context_shift_history_start(history, history_start);
2211    }
2212}
2213
2214fn next_context_shift_history_start(history: &[(String, String)], start: usize) -> usize {
2215    if start + 1 < history.len()
2216        && history[start].0 == "user"
2217        && history[start + 1].0 == "assistant"
2218    {
2219        start + 2
2220    } else {
2221        start + 1
2222    }
2223}
2224
2225fn count_user_turns(history: &[(String, String)]) -> usize {
2226    history.iter().filter(|(role, _)| role == "user").count()
2227}
2228
2229fn maybe_warn_context_shift(plan: &RunPromptPlan, format: OutputFormat) {
2230    if format != OutputFormat::Text {
2231        return;
2232    }
2233    if plan.dropped_history_messages == 0 && plan.max_tokens_clamped_from.is_none() {
2234        return;
2235    }
2236
2237    let prompt_tokens = plan
2238        .prompt_tokens
2239        .map(|value| value.to_string())
2240        .unwrap_or_else(|| "?".to_string());
2241    let kv_capacity = plan
2242        .kv_capacity
2243        .map(|value| value.to_string())
2244        .unwrap_or_else(|| "?".to_string());
2245    let mut parts = Vec::new();
2246    if plan.dropped_history_messages > 0 {
2247        parts.push(format!(
2248            "dropped {} old message(s) / {} turn(s)",
2249            plan.dropped_history_messages, plan.dropped_history_turns
2250        ));
2251    }
2252    if let Some(old) = plan.max_tokens_clamped_from {
2253        parts.push(format!(
2254            "max_tokens {} -> {}",
2255            old, plan.sampling_params.max_tokens
2256        ));
2257    }
2258
2259    eprintln!(
2260        "{}",
2261        format!(
2262            "[context-shift] {} (prompt_tokens={}, kv_capacity={})",
2263            parts.join("; "),
2264            prompt_tokens,
2265            kv_capacity
2266        )
2267        .dimmed()
2268    );
2269}
2270
2271fn fits_kv_budget(
2272    base: &SamplingParams,
2273    prompt_tokens: Option<usize>,
2274    kv_capacity: Option<usize>,
2275) -> bool {
2276    let (Some(prompt_tokens), Some(kv_capacity)) = (prompt_tokens, kv_capacity) else {
2277        return true;
2278    };
2279    prompt_tokens < kv_capacity && prompt_tokens + base.max_tokens <= kv_capacity
2280}
2281
2282fn discover_run_tokenizer_path(source_path: &Path) -> Option<PathBuf> {
2283    if source_path.is_file()
2284        && source_path
2285            .extension()
2286            .map(|e| e.eq_ignore_ascii_case("gguf"))
2287            .unwrap_or(false)
2288    {
2289        return ferrum_models::gguf_engine_loader::auto_discover_tokenizer_path(source_path);
2290    }
2291    let tokenizer = source_path.join("tokenizer.json");
2292    tokenizer.is_file().then_some(tokenizer)
2293}
2294
2295pub fn select_device(backend: &str) -> Result<ferrum_types::Device> {
2296    match backend.trim().to_lowercase().as_str() {
2297        "cpu" => Ok(ferrum_types::Device::CPU),
2298        "metal" => {
2299            #[cfg(all(target_os = "macos", feature = "metal"))]
2300            {
2301                return Ok(ferrum_types::Device::Metal);
2302            }
2303            #[cfg(not(all(target_os = "macos", feature = "metal")))]
2304            {
2305                Err(FerrumError::config(
2306                    "requested backend 'metal' but this ferrum binary was not built with Metal support; use --backend auto/cpu or build with the metal feature",
2307                ))
2308            }
2309        }
2310        "cuda" => {
2311            #[cfg(feature = "cuda")]
2312            {
2313                return Ok(ferrum_types::Device::CUDA(0));
2314            }
2315            #[cfg(not(feature = "cuda"))]
2316            {
2317                Err(FerrumError::config(
2318                    "requested backend 'cuda' but this ferrum binary was not built with CUDA support; use --backend auto/cpu or build with the cuda feature",
2319                ))
2320            }
2321        }
2322        "auto" => {
2323            #[cfg(all(target_os = "macos", feature = "metal"))]
2324            {
2325                return Ok(ferrum_types::Device::Metal);
2326            }
2327            #[cfg(feature = "cuda")]
2328            {
2329                return Ok(ferrum_types::Device::CUDA(0));
2330            }
2331            #[allow(unreachable_code)]
2332            Ok(ferrum_types::Device::CPU)
2333        }
2334        other => Err(FerrumError::config(format!(
2335            "unknown backend {other:?}; expected one of: auto, cpu, metal, cuda"
2336        ))),
2337    }
2338}
2339
2340fn build_chat_prompt(
2341    history: &[(String, String)],
2342    user_input: &str,
2343    system: Option<&str>,
2344    model_id: &str,
2345    model_template: Option<&ModelChatTemplate>,
2346    chat_template_options: &ChatTemplateOptions,
2347) -> Result<String> {
2348    let mut messages = Vec::new();
2349    if let Some(sys) = system {
2350        messages.push(PromptMessage::new("system", sys));
2351    }
2352    for (role, content) in history {
2353        messages.push(PromptMessage::new(role, content));
2354    }
2355    messages.push(PromptMessage::new("user", user_input));
2356    ferrum_server::chat_template::render_prompt_messages_with_options(
2357        &messages,
2358        model_id,
2359        model_template,
2360        chat_template_options,
2361    )
2362}
2363
2364async fn load_run_model_definition(
2365    source: &ResolvedModelSource,
2366    product_sources: Option<&ferrum_models::vnext::ProductionModelSourceBundle>,
2367) -> Result<Option<ferrum_models::ModelDefinition>> {
2368    if let Some(sources) = product_sources {
2369        let mut config_manager = ferrum_models::ConfigManager::new();
2370        return config_manager
2371            .load_from_bytes(sources.config_json())
2372            .map(Some);
2373    }
2374    if source.format != ModelFormat::SafeTensors {
2375        return Ok(None);
2376    }
2377    let mut config_manager = ferrum_models::ConfigManager::new();
2378    Ok(Some(
2379        config_manager.load_from_path(&source.local_path).await?,
2380    ))
2381}
2382
2383fn run_effective_runtime_config(
2384    runtime_config: &RuntimeConfigSnapshot,
2385    cli_runtime_entries: &[RuntimeConfigEntry],
2386) -> RuntimeConfigSnapshot {
2387    let mut snapshot = runtime_config.clone();
2388    for entry in cli_runtime_entries {
2389        snapshot.upsert_entry(entry.clone());
2390    }
2391    snapshot
2392}
2393
2394fn run_base_runtime_config(
2395    config: &CliConfig,
2396    env_snapshot: RuntimeConfigSnapshot,
2397) -> RuntimeConfigSnapshot {
2398    let mut config_entries = run_product_default_runtime_entries();
2399    config_entries.extend(config.runtime.runtime_config_entries());
2400    crate::commands::serve::merge_runtime_config_sources(config_entries, env_snapshot, Vec::new())
2401}
2402
2403const RUN_PRODUCT_RUNTIME_KEYS: [&str; 2] = [
2404    "FERRUM_PAGED_MAX_SEQS",
2405    "FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS",
2406];
2407
2408fn run_product_default_runtime_entries() -> Vec<RuntimeConfigEntry> {
2409    vec![
2410        RuntimeConfigEntry::new("FERRUM_PAGED_MAX_SEQS", "1", RuntimeConfigSource::Default),
2411        RuntimeConfigEntry::new(
2412            "FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS",
2413            "1",
2414            RuntimeConfigSource::Default,
2415        ),
2416    ]
2417}
2418
2419fn run_product_runtime_bridge(snapshot: &RuntimeConfigSnapshot) -> RuntimeConfigSnapshot {
2420    RuntimeConfigSnapshot::from_entries(
2421        snapshot
2422            .entries
2423            .iter()
2424            .filter(|entry| RUN_PRODUCT_RUNTIME_KEYS.contains(&entry.key.as_str()))
2425            .cloned()
2426            .collect::<Vec<_>>(),
2427    )
2428}
2429
2430fn runtime_config_without_keys(
2431    mut snapshot: RuntimeConfigSnapshot,
2432    keys: &[String],
2433) -> RuntimeConfigSnapshot {
2434    snapshot
2435        .entries
2436        .retain(|entry| !keys.iter().any(|key| key == &entry.key));
2437    snapshot
2438}
2439
2440fn run_startup_cli_runtime_entries(
2441    cmd: &RunCommand,
2442    gpu_selection: Option<&crate::gpu_devices::GpuDeviceSelection>,
2443) -> Vec<RuntimeConfigEntry> {
2444    let mut entries = Vec::new();
2445    entries.push(RuntimeConfigEntry::new(
2446        "FERRUM_PROFILE_DETAIL",
2447        cmd.profile_detail.as_str(),
2448        RuntimeConfigSource::Cli,
2449    ));
2450    crate::runtime_env::push_cli_runtime_entry(
2451        &mut entries,
2452        "FERRUM_KV_DTYPE",
2453        cmd.kv_dtype.as_deref(),
2454    );
2455    crate::runtime_env::push_cli_runtime_usize(&mut entries, "FERRUM_KV_CAPACITY", cmd.kv_capacity);
2456    crate::runtime_env::push_cli_runtime_usize(
2457        &mut entries,
2458        "FERRUM_KV_MAX_BLOCKS",
2459        cmd.kv_max_blocks,
2460    );
2461    crate::runtime_env::push_cli_runtime_usize(
2462        &mut entries,
2463        "FERRUM_MAX_MODEL_LEN",
2464        cmd.max_model_len,
2465    );
2466    crate::runtime_env::push_cli_runtime_usize(
2467        &mut entries,
2468        "FERRUM_PAGED_MAX_SEQS",
2469        cmd.max_num_seqs,
2470    );
2471    crate::runtime_env::push_cli_runtime_usize(
2472        &mut entries,
2473        "FERRUM_MAX_BATCHED_TOKENS",
2474        cmd.max_num_batched_tokens,
2475    );
2476    crate::runtime_env::push_cli_runtime_entry(
2477        &mut entries,
2478        "FERRUM_SEQUENCE_FIT_POLICY",
2479        cmd.sequence_fit_policy
2480            .map(crate::commands::SequenceFitPolicyArg::as_runtime_value),
2481    );
2482    crate::runtime_env::push_cli_runtime_entry(
2483        &mut entries,
2484        "FERRUM_VNEXT_DIAGNOSTIC_FAULT",
2485        cmd.vnext_diagnostic_fault
2486            .map(crate::commands::VNextDiagnosticFaultArg::as_runtime_value),
2487    );
2488    crate::runtime_env::push_cli_runtime_usize(
2489        &mut entries,
2490        "FERRUM_RUNTIME_MEMORY_BUDGET_BYTES",
2491        cmd.runtime_memory_budget_bytes
2492            .map(std::num::NonZeroUsize::get),
2493    );
2494    if let Some(enabled) = bool_cli_override(cmd.batched_graph, cmd.disable_batched_graph) {
2495        entries.push(RuntimeConfigEntry::new(
2496            "FERRUM_BATCHED_GRAPH",
2497            if enabled { "1" } else { "0" },
2498            RuntimeConfigSource::Cli,
2499        ));
2500    }
2501    if let Some(enabled) = bool_cli_override(cmd.reusable_execution, cmd.disable_reusable_execution)
2502    {
2503        entries.push(RuntimeConfigEntry::new(
2504            "FERRUM_REUSABLE_EXECUTION",
2505            if enabled { "1" } else { "0" },
2506            RuntimeConfigSource::Cli,
2507        ));
2508    }
2509    if let Some(enabled) = bool_cli_override(cmd.unified_graph, cmd.disable_unified_graph) {
2510        entries.push(RuntimeConfigEntry::new(
2511            "FERRUM_UNIFIED_GRAPH",
2512            if enabled { "1" } else { "0" },
2513            RuntimeConfigSource::Cli,
2514        ));
2515    }
2516    if let Some(enabled) = bool_cli_override(
2517        cmd.unified_graph_layers_only,
2518        cmd.disable_unified_graph_layers_only,
2519    ) {
2520        entries.push(RuntimeConfigEntry::new(
2521            "FERRUM_UNIFIED_GRAPH_LAYERS_ONLY",
2522            if enabled { "1" } else { "0" },
2523            RuntimeConfigSource::Cli,
2524        ));
2525    }
2526    if let Some(enabled) = bool_cli_override(
2527        cmd.unified_graph_lm_head_eager,
2528        cmd.disable_unified_graph_lm_head_eager,
2529    ) {
2530        entries.push(RuntimeConfigEntry::new(
2531            "FERRUM_UNIFIED_GRAPH_LM_HEAD_EAGER",
2532            if enabled { "1" } else { "0" },
2533            RuntimeConfigSource::Cli,
2534        ));
2535    }
2536    crate::layer_split_pipeline::push_cli_runtime_entry(
2537        &mut entries,
2538        cmd.layer_split_pipeline_mode,
2539    );
2540    if let Some(path) = &cmd.profile_jsonl {
2541        entries.push(RuntimeConfigEntry::new(
2542            "FERRUM_PROFILE_JSONL",
2543            path.to_string_lossy().to_string(),
2544            RuntimeConfigSource::Cli,
2545        ));
2546    }
2547    if let Some(path) = &cmd.scheduler_trace_jsonl {
2548        entries.push(RuntimeConfigEntry::new(
2549            "FERRUM_SCHEDULER_TRACE_JSONL",
2550            path.to_string_lossy().to_string(),
2551            RuntimeConfigSource::Cli,
2552        ));
2553    }
2554    if cmd.profile_jsonl.is_some() || cmd.scheduler_trace_jsonl.is_some() {
2555        entries.push(RuntimeConfigEntry::new(
2556            "FERRUM_PROFILE_ENTRYPOINT",
2557            "run",
2558            RuntimeConfigSource::Cli,
2559        ));
2560    }
2561    if let Some(selection) = gpu_selection {
2562        entries.extend(selection.runtime_config_entries());
2563    }
2564    entries
2565}
2566
2567fn bool_cli_override(enable: bool, disable: bool) -> Option<bool> {
2568    if enable {
2569        Some(true)
2570    } else if disable {
2571        Some(false)
2572    } else {
2573        None
2574    }
2575}
2576
2577fn materialize_run_cli_runtime_entries(entries: &[RuntimeConfigEntry]) {
2578    if entries.is_empty() {
2579        return;
2580    }
2581    crate::runtime_env::materialize_runtime_env_effective(&RuntimeConfigSnapshot::from_entries(
2582        entries.to_vec(),
2583    ));
2584}
2585
2586fn run_startup_auto_config(
2587    device: &ferrum_types::Device,
2588    typed_model_capabilities: Option<ModelCapabilities>,
2589    execution_resource_authority: ferrum_types::ExecutionResourceAuthority,
2590    model_definition: Option<&ferrum_models::ModelDefinition>,
2591    model_weight_bytes: Option<u64>,
2592    runtime_config: RuntimeConfigSnapshot,
2593) -> Result<ResolvedFerrumConfig> {
2594    let hardware = crate::commands::serve::hardware_capabilities_for_device(device);
2595    let model = typed_model_capabilities
2596        .or_else(|| model_definition.map(|definition| {
2597            crate::commands::serve::model_capabilities_from_definition_with_weight_bytes_for_hardware(
2598                definition,
2599                model_weight_bytes,
2600                &hardware,
2601            )
2602        }))
2603        .unwrap_or_else(ModelCapabilities::unknown);
2604    let workload = WorkloadProfile::serving_default();
2605    FerrumConfigBuilder::new(runtime_config)
2606        .with_model_capabilities(model)
2607        .with_hardware_capabilities(hardware)
2608        .with_workload_profile(workload)
2609        .with_execution_resource_authority(execution_resource_authority)
2610        .resolve()
2611        .map_err(|err| ferrum_types::FerrumError::config(format!("invalid auto config: {err}")))
2612}
2613
2614/// Apply the resolved `--kv-dtype` / runtime-config override to an engine
2615/// config, validating early. Default is FP16 (the production-validated path on
2616/// every backend); selecting INT8 / FP8 is rejected with a helpful message
2617/// until model integration ships.
2618pub fn apply_kv_dtype_override(
2619    engine_config: &mut ferrum_types::EngineConfig,
2620    raw: Option<&str>,
2621) -> ferrum_types::Result<()> {
2622    use ferrum_types::KvCacheDtype;
2623    let Some(raw) = raw else {
2624        // No override → keep config default (FP16).
2625        return Ok(());
2626    };
2627    let parsed = KvCacheDtype::parse(raw).ok_or_else(|| {
2628        ferrum_types::FerrumError::config(format!(
2629            "Unknown --kv-dtype value '{}'. Accepts: fp16, bf16, int8, fp8.",
2630            raw
2631        ))
2632    })?;
2633    match parsed {
2634        KvCacheDtype::Fp16 => {
2635            engine_config.kv_cache.dtype = KvCacheDtype::Fp16;
2636            Ok(())
2637        }
2638        KvCacheDtype::Int8 => {
2639            // Dim 5 PR C: end-to-end INT8 KV path on CUDA via
2640            // LlamaFamilyModel<CudaBackend, KvInt8>. Registry rejects
2641            // (CPU/Metal, Int8) and (CUDA Qwen3-MoE, Int8) with helpful
2642            // messages.
2643            engine_config.kv_cache.dtype = KvCacheDtype::Int8;
2644            Ok(())
2645        }
2646        KvCacheDtype::Fp8 => Err(ferrum_types::FerrumError::unsupported(
2647            "FP8 KV cache: kernels not yet implemented. Tracked as PR D.",
2648        )),
2649        KvCacheDtype::Bf16 => Err(ferrum_types::FerrumError::unsupported(
2650            "BF16 KV cache: marker only, no backend impl ships yet.",
2651        )),
2652    }
2653}
2654
2655#[cfg(test)]
2656mod tests {
2657    use super::*;
2658    use ferrum_types::{RuntimeConfigSource, SequenceFitPolicy, TokenId};
2659
2660    fn default_params(max_tokens: usize) -> SamplingParams {
2661        SamplingParams {
2662            max_tokens,
2663            ..SamplingParams::default()
2664        }
2665    }
2666
2667    fn test_run_cmd() -> RunCommand {
2668        RunCommand {
2669            model: Some("tinyllama".to_string()),
2670            product_sources: crate::source_resolver::ProductSourceArgs::default(),
2671            system: None,
2672            max_tokens: 4096,
2673            stop: Vec::new(),
2674            no_context_shift: false,
2675            enable_thinking: false,
2676            disable_thinking: false,
2677            temperature: 0.0,
2678            backend: "auto".to_string(),
2679            gpu_devices: None,
2680            layer_split_pipeline_mode: None,
2681            prompt: None,
2682            tokenizer: None,
2683            bench_mode: false,
2684            top_k: 50,
2685            top_p: 0.95,
2686            min_p: 0.0,
2687            presence_penalty: 0.0,
2688            repeat_penalty: 1.0,
2689            repeat_last_n: 64,
2690            seed: None,
2691            gpu_memory_utilization: 0.9,
2692            runtime_memory_budget_bytes: None,
2693            max_model_len: None,
2694            max_num_seqs: None,
2695            max_num_batched_tokens: None,
2696            sequence_fit_policy: None,
2697            batched_graph: false,
2698            disable_batched_graph: false,
2699            reusable_execution: false,
2700            disable_reusable_execution: false,
2701            unified_graph: false,
2702            disable_unified_graph: false,
2703            unified_graph_layers_only: false,
2704            disable_unified_graph_layers_only: false,
2705            unified_graph_lm_head_eager: false,
2706            disable_unified_graph_lm_head_eager: false,
2707            kv_dtype: None,
2708            kv_capacity: None,
2709            kv_max_blocks: None,
2710            effective_config_json: None,
2711            decision_trace_jsonl: None,
2712            observability_vertical_slice_out: None,
2713            vnext_checkpoint: Default::default(),
2714            profile_jsonl: None,
2715            profile_detail: crate::observability_product::ProfileDetailArg::Off,
2716            vnext_diagnostic_fault: None,
2717            memory_profile_jsonl: None,
2718            scheduler_trace_jsonl: None,
2719            request_dump_dir: None,
2720            profile_sample_rate: crate::observability_product::default_profile_sample_rate(),
2721            output_format: OutputFormat::Text,
2722        }
2723    }
2724
2725    #[test]
2726    fn teacher_forced_checkpoint_is_one_shot_single_sequence_and_deterministic() {
2727        let teacher =
2728            ferrum_types::VNextTeacherForcingConfig::new(vec![TokenId::new(7), TokenId::new(11)])
2729                .unwrap();
2730        let capture = ferrum_types::VNextCheckpointCaptureConfig {
2731            output_dir: PathBuf::from("capture"),
2732            value_ids: Vec::new(),
2733            maximum_prefill_waves: 1,
2734            maximum_decode_waves: 1,
2735            capture_product_output: true,
2736            teacher_forcing: Some(teacher),
2737        };
2738        let mut cmd = test_run_cmd();
2739        cmd.prompt = Some("hello".to_owned());
2740        cmd.max_tokens = 2;
2741        cmd.max_num_seqs = Some(1);
2742        cmd.top_k = 0;
2743        cmd.top_p = 1.0;
2744        assert!(validate_teacher_forced_checkpoint_run(&cmd, Some(&capture)).is_ok());
2745
2746        cmd.prompt = None;
2747        assert!(validate_teacher_forced_checkpoint_run(&cmd, Some(&capture)).is_err());
2748        cmd.prompt = Some("hello".to_owned());
2749        cmd.max_num_seqs = Some(2);
2750        assert!(validate_teacher_forced_checkpoint_run(&cmd, Some(&capture)).is_err());
2751        cmd.max_num_seqs = Some(1);
2752        cmd.temperature = 0.5;
2753        assert!(validate_teacher_forced_checkpoint_run(&cmd, Some(&capture)).is_err());
2754    }
2755
2756    fn whitespace_budget(kv_capacity: usize) -> RunBudget {
2757        RunBudget {
2758            tokenizer: None,
2759            kv_capacity: Some(kv_capacity),
2760            prompt_token_id_mapper: None,
2761            prompt_token_counter: Some(|prompt| prompt.split_whitespace().count()),
2762        }
2763    }
2764
2765    fn mapped_token_budget(kv_capacity: usize) -> RunBudget {
2766        RunBudget {
2767            tokenizer: None,
2768            kv_capacity: Some(kv_capacity),
2769            prompt_token_id_mapper: Some(|prompt| {
2770                prompt
2771                    .split_whitespace()
2772                    .enumerate()
2773                    .map(|(index, _)| (index + 1) as u32)
2774                    .collect()
2775            }),
2776            prompt_token_counter: None,
2777        }
2778    }
2779
2780    fn default_template_options() -> ChatTemplateOptions {
2781        ChatTemplateOptions::default()
2782    }
2783
2784    #[test]
2785    fn run_effective_runtime_config_records_cli_kv_dtype() {
2786        let snapshot = RuntimeConfigSnapshot::from_entries(Vec::new());
2787        let mut cmd = test_run_cmd();
2788        cmd.kv_dtype = Some("int8".to_string());
2789        let cli_entries = run_startup_cli_runtime_entries(&cmd, None);
2790        let effective = run_effective_runtime_config(&snapshot, &cli_entries);
2791        let entry = effective
2792            .entries
2793            .iter()
2794            .find(|entry| entry.key == "FERRUM_KV_DTYPE")
2795            .expect("missing kv dtype entry");
2796        assert_eq!(entry.effective_value, "int8");
2797        assert_eq!(entry.source, RuntimeConfigSource::Cli);
2798    }
2799
2800    #[test]
2801    fn run_exposes_typed_diagnostic_fault_and_records_cli_authority() {
2802        use clap::Parser;
2803
2804        #[derive(Parser)]
2805        struct TestCli {
2806            #[command(flatten)]
2807            run: RunCommand,
2808        }
2809
2810        let parsed = TestCli::parse_from([
2811            "ferrum",
2812            "Qwen/Qwen3.5-4B",
2813            "--vnext-diagnostic-fault",
2814            "prefill-resource-after-submit-once",
2815        ]);
2816        let entries = run_startup_cli_runtime_entries(&parsed.run, None);
2817        let entry = entries
2818            .iter()
2819            .find(|entry| entry.key == "FERRUM_VNEXT_DIAGNOSTIC_FAULT")
2820            .expect("diagnostic fault CLI entry");
2821
2822        assert_eq!(
2823            parsed.run.vnext_diagnostic_fault,
2824            Some(crate::commands::VNextDiagnosticFaultArg::PrefillResourceAfterSubmitOnce)
2825        );
2826        assert_eq!(entry.effective_value, "prefill-resource-after-submit-once");
2827        assert_eq!(entry.source, RuntimeConfigSource::Cli);
2828    }
2829
2830    #[test]
2831    fn run_effective_runtime_config_records_memory_budget() {
2832        let snapshot = RuntimeConfigSnapshot::from_entries(Vec::new());
2833        let mut cmd = test_run_cmd();
2834        cmd.runtime_memory_budget_bytes = std::num::NonZeroUsize::new(12_345);
2835
2836        let effective =
2837            run_effective_runtime_config(&snapshot, &run_startup_cli_runtime_entries(&cmd, None));
2838        let entry = effective
2839            .entries
2840            .iter()
2841            .find(|entry| entry.key == "FERRUM_RUNTIME_MEMORY_BUDGET_BYTES")
2842            .expect("missing runtime memory budget entry");
2843
2844        assert_eq!(entry.effective_value, "12345");
2845        assert_eq!(entry.source, RuntimeConfigSource::Cli);
2846    }
2847
2848    #[test]
2849    fn run_effective_runtime_config_records_observability_paths() {
2850        let snapshot = RuntimeConfigSnapshot::from_entries(Vec::new());
2851        let mut cmd = test_run_cmd();
2852        cmd.profile_jsonl = Some(PathBuf::from("/tmp/run-profile.jsonl"));
2853        cmd.scheduler_trace_jsonl = Some(PathBuf::from("/tmp/run-scheduler.jsonl"));
2854
2855        let cli_entries = run_startup_cli_runtime_entries(&cmd, None);
2856        let effective = run_effective_runtime_config(&snapshot, &cli_entries);
2857        let entry = |key: &str| {
2858            effective
2859                .entries
2860                .iter()
2861                .find(|entry| entry.key == key)
2862                .unwrap_or_else(|| panic!("missing {key} entry"))
2863        };
2864
2865        assert_eq!(
2866            entry("FERRUM_PROFILE_JSONL").effective_value,
2867            "/tmp/run-profile.jsonl"
2868        );
2869        assert_eq!(
2870            entry("FERRUM_SCHEDULER_TRACE_JSONL").effective_value,
2871            "/tmp/run-scheduler.jsonl"
2872        );
2873        assert_eq!(entry("FERRUM_PROFILE_ENTRYPOINT").effective_value, "run");
2874        assert!(entry("FERRUM_PROFILE_ENTRYPOINT")
2875            .affects
2876            .contains(&ferrum_types::RuntimeConfigEffect::Diagnostics));
2877    }
2878
2879    #[test]
2880    fn run_full_profile_detail_reaches_typed_engine_config() {
2881        let mut cmd = test_run_cmd();
2882        cmd.profile_detail = crate::observability_product::ProfileDetailArg::Full;
2883        let effective = run_effective_runtime_config(
2884            &RuntimeConfigSnapshot::from_entries(Vec::new()),
2885            &run_startup_cli_runtime_entries(&cmd, None),
2886        );
2887        let mut engine = ferrum_types::EngineConfig::default();
2888
2889        engine
2890            .apply_runtime_config_snapshot(&effective)
2891            .expect("full profile detail should apply");
2892
2893        assert_eq!(
2894            engine.runtime.profile_detail,
2895            ferrum_types::ObservabilityProfileDetail::Full
2896        );
2897    }
2898
2899    #[test]
2900    fn run_latency_profile_detail_reaches_typed_engine_config() {
2901        let mut cmd = test_run_cmd();
2902        cmd.profile_detail = crate::observability_product::ProfileDetailArg::Latency;
2903        let effective = run_effective_runtime_config(
2904            &RuntimeConfigSnapshot::from_entries(Vec::new()),
2905            &run_startup_cli_runtime_entries(&cmd, None),
2906        );
2907        let mut engine = ferrum_types::EngineConfig::default();
2908
2909        engine
2910            .apply_runtime_config_snapshot(&effective)
2911            .expect("latency profile detail should apply");
2912
2913        assert_eq!(
2914            engine.runtime.profile_detail,
2915            ferrum_types::ObservabilityProfileDetail::Latency
2916        );
2917    }
2918
2919    #[test]
2920    fn run_replay_profile_detail_reaches_typed_engine_config() {
2921        let mut cmd = test_run_cmd();
2922        cmd.profile_detail = crate::observability_product::ProfileDetailArg::Replay;
2923        let effective = run_effective_runtime_config(
2924            &RuntimeConfigSnapshot::from_entries(Vec::new()),
2925            &run_startup_cli_runtime_entries(&cmd, None),
2926        );
2927        let mut engine = ferrum_types::EngineConfig::default();
2928
2929        engine
2930            .apply_runtime_config_snapshot(&effective)
2931            .expect("replay profile detail should apply");
2932
2933        assert_eq!(
2934            engine.runtime.profile_detail,
2935            ferrum_types::ObservabilityProfileDetail::Replay
2936        );
2937    }
2938
2939    #[test]
2940    fn run_verify_profile_detail_reaches_typed_engine_config() {
2941        let mut cmd = test_run_cmd();
2942        cmd.profile_detail = crate::observability_product::ProfileDetailArg::Verify;
2943        let effective = run_effective_runtime_config(
2944            &RuntimeConfigSnapshot::from_entries(Vec::new()),
2945            &run_startup_cli_runtime_entries(&cmd, None),
2946        );
2947        let mut engine = ferrum_types::EngineConfig::default();
2948
2949        engine
2950            .apply_runtime_config_snapshot(&effective)
2951            .expect("verify profile detail should apply");
2952
2953        assert_eq!(
2954            engine.runtime.profile_detail,
2955            ferrum_types::ObservabilityProfileDetail::Verify
2956        );
2957    }
2958
2959    #[test]
2960    fn run_effective_runtime_config_records_layer_split_pipeline_mode() {
2961        let snapshot = RuntimeConfigSnapshot::from_entries(Vec::new());
2962        let mut cmd = test_run_cmd();
2963        cmd.layer_split_pipeline_mode =
2964            Some(crate::layer_split_pipeline::LayerSplitPipelineModeArg::Batch);
2965        let cli_entries = run_startup_cli_runtime_entries(&cmd, None);
2966        let effective = run_effective_runtime_config(&snapshot, &cli_entries);
2967        let entry = effective
2968            .entries
2969            .iter()
2970            .find(|entry| entry.key == crate::layer_split_pipeline::LAYER_SPLIT_PIPELINE_MODE_KEY)
2971            .expect("missing layer split pipeline mode entry");
2972        assert_eq!(entry.effective_value, "batch");
2973        assert_eq!(entry.source, RuntimeConfigSource::Cli);
2974    }
2975
2976    #[test]
2977    fn run_effective_runtime_config_records_batched_graph_flag() {
2978        let snapshot = RuntimeConfigSnapshot::from_entries(Vec::new());
2979        let mut cmd = test_run_cmd();
2980        cmd.batched_graph = true;
2981        cmd.disable_reusable_execution = true;
2982        cmd.unified_graph = true;
2983        cmd.unified_graph_layers_only = true;
2984        cmd.unified_graph_lm_head_eager = true;
2985        let cli_entries = run_startup_cli_runtime_entries(&cmd, None);
2986        let effective = run_effective_runtime_config(&snapshot, &cli_entries);
2987        let entry = |key: &str| {
2988            effective
2989                .entries
2990                .iter()
2991                .find(|entry| entry.key == key)
2992                .unwrap_or_else(|| panic!("missing {key} entry"))
2993        };
2994        assert_eq!(entry("FERRUM_BATCHED_GRAPH").effective_value, "1");
2995        assert_eq!(
2996            entry("FERRUM_BATCHED_GRAPH").source,
2997            RuntimeConfigSource::Cli
2998        );
2999        assert_eq!(entry("FERRUM_REUSABLE_EXECUTION").effective_value, "0");
3000        assert_eq!(
3001            entry("FERRUM_REUSABLE_EXECUTION").source,
3002            RuntimeConfigSource::Cli
3003        );
3004        assert_eq!(entry("FERRUM_UNIFIED_GRAPH").effective_value, "1");
3005        assert_eq!(
3006            entry("FERRUM_UNIFIED_GRAPH").source,
3007            RuntimeConfigSource::Cli
3008        );
3009        assert_eq!(
3010            entry("FERRUM_UNIFIED_GRAPH_LAYERS_ONLY").effective_value,
3011            "1"
3012        );
3013        assert_eq!(
3014            entry("FERRUM_UNIFIED_GRAPH_LAYERS_ONLY").source,
3015            RuntimeConfigSource::Cli
3016        );
3017        assert_eq!(
3018            entry("FERRUM_UNIFIED_GRAPH_LM_HEAD_EAGER").effective_value,
3019            "1"
3020        );
3021        assert_eq!(
3022            entry("FERRUM_UNIFIED_GRAPH_LM_HEAD_EAGER").source,
3023            RuntimeConfigSource::Cli
3024        );
3025    }
3026
3027    #[test]
3028    fn run_effective_runtime_config_records_gpu_device_selection() {
3029        let selection = crate::gpu_devices::GpuDeviceSelection {
3030            raw_cli_value: "1".to_string(),
3031            requested_gpu_devices: vec![1],
3032            selected_gpu_devices: vec![1],
3033            cuda_device_count: 2,
3034            selected_distributed_strategy: "single_gpu".to_string(),
3035            selected_layer_split_plan: None,
3036            selected_layer_split_stages: None,
3037        };
3038        let snapshot = RuntimeConfigSnapshot::from_entries(Vec::new());
3039        let cmd = test_run_cmd();
3040        let cli_entries = run_startup_cli_runtime_entries(&cmd, Some(&selection));
3041        let effective = run_effective_runtime_config(&snapshot, &cli_entries);
3042        let entry = |key: &str| {
3043            effective
3044                .entries
3045                .iter()
3046                .find(|entry| entry.key == key)
3047                .unwrap_or_else(|| panic!("missing {key}"))
3048        };
3049
3050        assert_eq!(entry("FERRUM_BACKEND").effective_value, "cuda");
3051        assert_eq!(entry("FERRUM_REQUESTED_GPU_DEVICES").effective_value, "1");
3052        assert_eq!(entry("FERRUM_SELECTED_GPU_DEVICES").effective_value, "1");
3053        assert_eq!(
3054            entry("FERRUM_SELECTED_DISTRIBUTED_STRATEGY").effective_value,
3055            "single_gpu"
3056        );
3057    }
3058
3059    #[test]
3060    fn run_effective_runtime_config_records_cli_runtime_limits() {
3061        let mut cmd = test_run_cmd();
3062        cmd.kv_capacity = Some(2048);
3063        cmd.kv_max_blocks = Some(4096);
3064        cmd.max_model_len = Some(8192);
3065        cmd.max_num_seqs = Some(8);
3066        cmd.max_num_batched_tokens = Some(1024);
3067        cmd.sequence_fit_policy = Some(crate::commands::SequenceFitPolicyArg::FullInputMustFit);
3068        let snapshot = RuntimeConfigSnapshot::from_entries(Vec::new());
3069        let cli_entries = run_startup_cli_runtime_entries(&cmd, None);
3070        let effective = run_effective_runtime_config(&snapshot, &cli_entries);
3071        let entry = |key: &str| {
3072            effective
3073                .entries
3074                .iter()
3075                .find(|entry| entry.key == key)
3076                .unwrap_or_else(|| panic!("missing {key}"))
3077        };
3078
3079        assert_eq!(entry("FERRUM_KV_CAPACITY").effective_value, "2048");
3080        assert_eq!(entry("FERRUM_KV_MAX_BLOCKS").effective_value, "4096");
3081        assert_eq!(entry("FERRUM_MAX_MODEL_LEN").effective_value, "8192");
3082        assert_eq!(entry("FERRUM_PAGED_MAX_SEQS").effective_value, "8");
3083        assert_eq!(entry("FERRUM_MAX_BATCHED_TOKENS").effective_value, "1024");
3084        assert_eq!(
3085            entry("FERRUM_SEQUENCE_FIT_POLICY").effective_value,
3086            "full-input-must-fit"
3087        );
3088        assert_eq!(
3089            entry("FERRUM_MAX_MODEL_LEN").source,
3090            RuntimeConfigSource::Cli
3091        );
3092    }
3093
3094    #[test]
3095    fn run_runtime_config_precedence_is_config_then_env_then_cli() {
3096        let mut config = CliConfig::default();
3097        config.runtime.sequence_fit_policy = Some(SequenceFitPolicy::FullInputMustFit);
3098
3099        let config_only =
3100            run_base_runtime_config(&config, RuntimeConfigSnapshot::from_entries(Vec::new()));
3101        let config_entry = config_only
3102            .entries
3103            .iter()
3104            .find(|entry| entry.key == "FERRUM_SEQUENCE_FIT_POLICY")
3105            .expect("config sequence fit policy is missing");
3106        assert_eq!(config_entry.effective_value, "full-input-must-fit");
3107        assert_eq!(config_entry.source, RuntimeConfigSource::ConfigFile);
3108
3109        let env_wins = run_base_runtime_config(
3110            &config,
3111            RuntimeConfigSnapshot::from_entries([RuntimeConfigEntry::new(
3112                "FERRUM_SEQUENCE_FIT_POLICY",
3113                "immediate-only",
3114                RuntimeConfigSource::Env,
3115            )]),
3116        );
3117        let env_entry = env_wins
3118            .entries
3119            .iter()
3120            .find(|entry| entry.key == "FERRUM_SEQUENCE_FIT_POLICY")
3121            .expect("env sequence fit policy is missing");
3122        assert_eq!(env_entry.effective_value, "immediate-only");
3123        assert_eq!(env_entry.source, RuntimeConfigSource::Env);
3124
3125        let mut cmd = test_run_cmd();
3126        cmd.sequence_fit_policy = Some(crate::commands::SequenceFitPolicyArg::FullInputMustFit);
3127        let effective =
3128            run_effective_runtime_config(&env_wins, &run_startup_cli_runtime_entries(&cmd, None));
3129        let cli_entry = effective
3130            .entries
3131            .iter()
3132            .find(|entry| entry.key == "FERRUM_SEQUENCE_FIT_POLICY")
3133            .expect("CLI sequence fit policy is missing");
3134        assert_eq!(cli_entry.effective_value, "full-input-must-fit");
3135        assert_eq!(cli_entry.source, RuntimeConfigSource::Cli);
3136    }
3137
3138    #[test]
3139    fn run_product_defaults_are_typed_and_apply_to_engine_config() {
3140        let config = CliConfig::default();
3141        let effective =
3142            run_base_runtime_config(&config, RuntimeConfigSnapshot::from_entries(Vec::new()));
3143        let entry = |key: &str| {
3144            effective
3145                .entries
3146                .iter()
3147                .find(|entry| entry.key == key)
3148                .unwrap_or_else(|| panic!("missing {key}"))
3149        };
3150
3151        assert_eq!(entry("FERRUM_PAGED_MAX_SEQS").effective_value, "1");
3152        assert_eq!(
3153            entry("FERRUM_PAGED_MAX_SEQS").source,
3154            RuntimeConfigSource::Default
3155        );
3156        assert_eq!(
3157            entry("FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS").effective_value,
3158            "1"
3159        );
3160        assert_eq!(
3161            entry("FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS").source,
3162            RuntimeConfigSource::Default
3163        );
3164
3165        let mut engine_config = ferrum_types::EngineConfig::default();
3166        engine_config
3167            .apply_runtime_config_snapshot(&effective)
3168            .expect("run defaults should apply to engine config");
3169        assert_eq!(engine_config.scheduler.max_running_requests, 1);
3170        assert_eq!(
3171            engine_config
3172                .backend
3173                .reusable_execution_capture
3174                .exact_decode_widths,
3175            Some(vec![1])
3176        );
3177    }
3178
3179    #[test]
3180    fn run_product_config_and_env_override_entrypoint_defaults() {
3181        let mut config = CliConfig::default();
3182        config.runtime.paged_max_seqs = Some(4);
3183        config.runtime.reusable_execution_exact_decode_widths = Some(vec![1, 2, 4]);
3184
3185        let config_only =
3186            run_base_runtime_config(&config, RuntimeConfigSnapshot::from_entries(Vec::new()));
3187        let config_entry = |key: &str| {
3188            config_only
3189                .entries
3190                .iter()
3191                .find(|entry| entry.key == key)
3192                .unwrap_or_else(|| panic!("missing {key}"))
3193        };
3194        assert_eq!(config_entry("FERRUM_PAGED_MAX_SEQS").effective_value, "4");
3195        assert_eq!(
3196            config_entry("FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS").effective_value,
3197            "1,2,4"
3198        );
3199        assert_eq!(
3200            config_entry("FERRUM_PAGED_MAX_SEQS").source,
3201            RuntimeConfigSource::ConfigFile
3202        );
3203
3204        let env_wins = run_base_runtime_config(
3205            &config,
3206            RuntimeConfigSnapshot::from_entries([
3207                RuntimeConfigEntry::new("FERRUM_PAGED_MAX_SEQS", "8", RuntimeConfigSource::Env),
3208                RuntimeConfigEntry::new(
3209                    "FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS",
3210                    "1,2,4,8",
3211                    RuntimeConfigSource::Env,
3212                ),
3213            ]),
3214        );
3215        let env_entry = |key: &str| {
3216            env_wins
3217                .entries
3218                .iter()
3219                .find(|entry| entry.key == key)
3220                .unwrap_or_else(|| panic!("missing {key}"))
3221        };
3222        assert_eq!(env_entry("FERRUM_PAGED_MAX_SEQS").effective_value, "8");
3223        assert_eq!(
3224            env_entry("FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS").effective_value,
3225            "1,2,4,8"
3226        );
3227        assert_eq!(
3228            env_entry("FERRUM_PAGED_MAX_SEQS").source,
3229            RuntimeConfigSource::Env
3230        );
3231
3232        let mut cmd = test_run_cmd();
3233        cmd.max_num_seqs = Some(16);
3234        let cli_wins =
3235            run_effective_runtime_config(&env_wins, &run_startup_cli_runtime_entries(&cmd, None));
3236        let admission = cli_wins
3237            .entries
3238            .iter()
3239            .find(|entry| entry.key == "FERRUM_PAGED_MAX_SEQS")
3240            .expect("CLI admission is missing");
3241        assert_eq!(admission.effective_value, "16");
3242        assert_eq!(admission.source, RuntimeConfigSource::Cli);
3243    }
3244
3245    #[test]
3246    fn run_effective_runtime_config_applies_recurrent_state_slots_to_engine_config() {
3247        let cmd = test_run_cmd();
3248        let snapshot = RuntimeConfigSnapshot::from_entries([RuntimeConfigEntry::new(
3249            "FERRUM_RECURRENT_STATE_MAX_SLOTS",
3250            "16",
3251            RuntimeConfigSource::ConfigFile,
3252        )]);
3253        let cli_entries = run_startup_cli_runtime_entries(&cmd, None);
3254        let effective = run_effective_runtime_config(&snapshot, &cli_entries);
3255        let mut engine_config = ferrum_types::EngineConfig::default();
3256
3257        engine_config
3258            .apply_runtime_config_snapshot(&effective)
3259            .expect("run effective runtime config should apply");
3260
3261        assert_eq!(engine_config.runtime.recurrent_state_max_slots, Some(16));
3262    }
3263
3264    #[test]
3265    fn run_startup_auto_config_renders_effective_config_schema() {
3266        let resolved = run_startup_auto_config(
3267            &ferrum_types::Device::CPU,
3268            None,
3269            ferrum_types::ExecutionResourceAuthority::LegacyEngine,
3270            None,
3271            None,
3272            RuntimeConfigSnapshot::from_entries(Vec::new()),
3273        )
3274        .expect("auto config");
3275        let doc = resolved.effective_config_document();
3276        assert_eq!(doc["schema_version"], 1);
3277        assert!(doc["entries"].is_array());
3278        assert!(doc["model_capabilities"].is_object());
3279        assert!(doc["hardware_capabilities"].is_object());
3280        assert!(doc["workload_profile"].is_object());
3281        assert_eq!(doc["workload_profile"]["target_concurrency"], 1);
3282        assert_eq!(doc["admission"]["effective_max_concurrent"], 1);
3283        assert!(doc["decisions"].is_array());
3284    }
3285
3286    #[test]
3287    fn unknown_backend_is_rejected() {
3288        let err = select_device("not-a-backend").expect_err("unknown backend must fail");
3289        assert!(
3290            err.to_string().contains("unknown backend"),
3291            "unexpected error: {err}"
3292        );
3293    }
3294
3295    #[cfg(not(all(target_os = "macos", feature = "metal")))]
3296    #[test]
3297    fn explicit_metal_backend_without_compiled_support_is_rejected() {
3298        let err = select_device("metal").expect_err("unsupported explicit Metal must fail");
3299        assert!(
3300            err.to_string()
3301                .contains("requested backend 'metal' but this ferrum binary was not built"),
3302            "unexpected error: {err}"
3303        );
3304    }
3305
3306    #[cfg(not(feature = "cuda"))]
3307    #[test]
3308    fn explicit_cuda_backend_without_compiled_support_is_rejected() {
3309        let err = select_device("cuda").expect_err("unsupported explicit CUDA must fail");
3310        assert!(
3311            err.to_string()
3312                .contains("requested backend 'cuda' but this ferrum binary was not built"),
3313            "unexpected error: {err}"
3314        );
3315    }
3316
3317    #[test]
3318    fn run_metadata_forbids_initial_thinking_close_without_open_block() {
3319        let metadata =
3320            run_request_metadata("<|im_start|>assistant\n", &ChatTemplateOptions::default());
3321        let forbidden = metadata
3322            .get(RUN_INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY)
3323            .and_then(|value| value.as_array())
3324            .expect("initial forbidden token texts");
3325        assert_eq!(
3326            forbidden,
3327            &[serde_json::Value::String(THINK_END_TAG.to_string())]
3328        );
3329    }
3330
3331    #[test]
3332    fn run_thinking_options_preserve_model_default_and_explicit_overrides() {
3333        let template = ModelChatTemplate::new(
3334            "{% if enable_thinking is defined %}{{ enable_thinking }}{% endif %}",
3335            "thinking-template",
3336        );
3337        let mut cmd = test_run_cmd();
3338
3339        assert_eq!(
3340            build_chat_template_options(&cmd, Some(&template)).enable_thinking,
3341            None
3342        );
3343
3344        cmd.enable_thinking = true;
3345        assert_eq!(
3346            build_chat_template_options(&cmd, Some(&template)).enable_thinking,
3347            Some(true)
3348        );
3349
3350        cmd.enable_thinking = false;
3351        cmd.disable_thinking = true;
3352        assert_eq!(
3353            build_chat_template_options(&cmd, Some(&template)).enable_thinking,
3354            Some(false)
3355        );
3356    }
3357
3358    #[test]
3359    fn run_metadata_forbids_initial_thinking_start_when_template_disables_thinking() {
3360        let metadata = run_request_metadata(
3361            "<|im_start|>assistant\n<think>\n\n</think>\n\n",
3362            &ChatTemplateOptions {
3363                enable_thinking: Some(false),
3364                ..Default::default()
3365            },
3366        );
3367        let forbidden = metadata
3368            .get(RUN_INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY)
3369            .and_then(|value| value.as_array())
3370            .expect("initial forbidden token texts");
3371        assert_eq!(
3372            forbidden,
3373            &[
3374                serde_json::Value::String(THINK_END_TAG.to_string()),
3375                serde_json::Value::String(THINK_START_TAG.to_string()),
3376            ]
3377        );
3378    }
3379
3380    #[test]
3381    fn run_metadata_allows_thinking_close_when_prompt_opened_block() {
3382        let metadata = run_request_metadata(
3383            "<|im_start|>assistant\n<think>\n",
3384            &ChatTemplateOptions::default(),
3385        );
3386        assert!(!metadata.contains_key(RUN_INITIAL_FORBIDDEN_TOKEN_TEXTS_METADATA_KEY));
3387    }
3388
3389    #[test]
3390    fn jsonl_v2_assistant_binds_reasoning_usage_and_history() {
3391        let history = vec![
3392            ("user".to_string(), "first".to_string()),
3393            (
3394                "assistant".to_string(),
3395                "<think>why</think>answer".to_string(),
3396            ),
3397        ];
3398        let usage = TokenUsage::new(7, 3);
3399        let record = jsonl_assistant_record(
3400            "session-1",
3401            2,
3402            "request-1",
3403            1,
3404            "answer",
3405            Some("why"),
3406            &history,
3407            Some(FinishReason::EOS),
3408            Some(&usage),
3409            3,
3410            2,
3411            "<think>why</think>answer",
3412            12.5,
3413        );
3414        assert_eq!(record["schema_version"], RUN_JSONL_SCHEMA_VERSION);
3415        assert_eq!(record["session_id"], "session-1");
3416        assert_eq!(record["history_epoch"], 2);
3417        assert_eq!(record["request_id"], "request-1");
3418        assert_eq!(record["content"], "answer");
3419        assert_eq!(record["reasoning"], "why");
3420        assert_eq!(record["usage"]["prompt_tokens"], 7);
3421        assert_eq!(record["usage"]["completion_tokens"], 3);
3422        assert_eq!(record["usage"]["total_tokens"], 10);
3423        assert_eq!(record["history_before"]["message_count"], 2);
3424        assert_eq!(record["history_before"]["turn_count"], 1);
3425        assert_eq!(
3426            record["raw_text_sha256"],
3427            sha256_text("<think>why</think>answer")
3428        );
3429    }
3430
3431    #[test]
3432    fn history_evidence_changes_when_reasoning_history_changes() {
3433        let first = vec![("assistant".to_string(), "answer".to_string())];
3434        let second = vec![(
3435            "assistant".to_string(),
3436            "<think>why</think>answer".to_string(),
3437        )];
3438        assert_ne!(
3439            history_evidence(&first)["sha256"],
3440            history_evidence(&second)["sha256"]
3441        );
3442    }
3443
3444    #[test]
3445    fn jsonl_v2_delta_preserves_utf8_bytes_and_request_binding() {
3446        let record =
3447            jsonl_assistant_delta_record("session-1", 3, "request-1", 4, 2, "🙂", Some(9271));
3448        assert_eq!(record["event"], "assistant_delta");
3449        assert_eq!(record["session_id"], "session-1");
3450        assert_eq!(record["history_epoch"], 3);
3451        assert_eq!(record["request_id"], "request-1");
3452        assert_eq!(record["turn"], 4);
3453        assert_eq!(record["index"], 2);
3454        assert_eq!(record["raw_text_delta"], "🙂");
3455        assert_eq!(record["utf8_bytes"], 4);
3456        assert_eq!(record["token_id"], 9271);
3457    }
3458
3459    #[test]
3460    fn cli_display_preserves_thinking_markers() {
3461        assert_eq!(
3462            display_response_text("<think>\nreasoning\n</think>\n\n最终答案"),
3463            "<think>\nreasoning\n</think>\n\n最终答案"
3464        );
3465    }
3466
3467    #[test]
3468    fn cli_display_preserves_orphan_think_close() {
3469        assert_eq!(
3470            display_response_text("</think>\n\n你好!很高兴见到你。"),
3471            "</think>\n\n你好!很高兴见到你。"
3472        );
3473    }
3474
3475    #[test]
3476    fn default_run_temperature_is_greedy() {
3477        let cmd = test_run_cmd();
3478        assert_eq!(build_sampling_params(&cmd).temperature, 0.0);
3479        assert_eq!(build_sampling_params(&cmd).max_tokens, 4096);
3480    }
3481
3482    #[test]
3483    fn run_propagates_min_p_and_presence_penalty() {
3484        use clap::Parser;
3485
3486        #[derive(Parser)]
3487        struct TestCli {
3488            #[command(flatten)]
3489            run: RunCommand,
3490        }
3491
3492        let parsed = TestCli::parse_from([
3493            "ferrum",
3494            "qwen3.5",
3495            "--temperature",
3496            "1.0",
3497            "--min-p",
3498            "0.05",
3499            "--presence-penalty",
3500            "1.5",
3501        ]);
3502        let params = build_sampling_params(&parsed.run);
3503        assert_eq!(params.min_p, Some(0.05));
3504        assert_eq!(params.presence_penalty, 1.5);
3505        params
3506            .validate()
3507            .expect("official sampling controls must validate");
3508
3509        let disabled = TestCli::parse_from(["ferrum", "qwen3.5", "--min-p", "0.0"]);
3510        assert_eq!(build_sampling_params(&disabled.run).min_p, None);
3511    }
3512
3513    #[test]
3514    fn chat_default_applies_repetition_penalty() {
3515        // The chat default is greedy (temperature 0). Greedy with NO repetition
3516        // penalty deterministically locks into token loops on some inputs (the
3517        // "2D/3D 2D/3D..." degeneration a user hit). The clap default must carry
3518        // a penalty (OpenAI/llama.cpp standard 1.1) so the out-of-box chat does
3519        // not loop. Parsing the real CLI (not the struct-literal test fixture)
3520        // is what pins the actual default users get.
3521        use clap::Parser;
3522        #[derive(Parser)]
3523        struct TestCli {
3524            #[command(flatten)]
3525            run: RunCommand,
3526        }
3527        let parsed = TestCli::parse_from(["ferrum", "qwen3:0.6b"]);
3528        assert!(
3529            parsed.run.repeat_penalty > 1.0,
3530            "chat default repeat_penalty must discourage repeats, got {}",
3531            parsed.run.repeat_penalty
3532        );
3533        assert!(
3534            build_sampling_params(&parsed.run).repetition_penalty > 1.0,
3535            "build_sampling_params must propagate the default penalty"
3536        );
3537    }
3538
3539    #[test]
3540    fn run_rejects_removed_qwen35_flag() {
3541        use clap::Parser;
3542
3543        #[derive(Parser)]
3544        struct TestCli {
3545            #[command(flatten)]
3546            run: RunCommand,
3547        }
3548
3549        let error = match TestCli::try_parse_from(["ferrum", "qwen3.5", "--qwen35-reference"]) {
3550            Ok(_) => panic!("product CLI exposed the legacy Qwen3.5 reference adapter"),
3551            Err(error) => error,
3552        };
3553
3554        assert!(error.to_string().contains("--qwen35-reference"));
3555    }
3556
3557    #[test]
3558    fn run_sampling_params_include_cli_stop_sequences() {
3559        let mut cmd = test_run_cmd();
3560        cmd.stop = vec!["\n".to_string(), String::new(), "END".to_string()];
3561        let params = build_sampling_params(&cmd);
3562        assert!(params.stop_sequences.contains(&"\n".to_string()));
3563        assert!(params.stop_sequences.contains(&"END".to_string()));
3564        assert!(!params.stop_sequences.contains(&String::new()));
3565    }
3566
3567    #[test]
3568    fn response_completion_contract_is_set_on_run_prompt_plan() {
3569        let cmd = test_run_cmd();
3570        let budget = whitespace_budget(8192);
3571        let template = ModelChatTemplate::new(
3572            "{% if add_generation_prompt %}<assistant><think>\n{% endif %}",
3573            "thinking-test-template",
3574        );
3575        let plan = build_run_prompt_plan(
3576            &[],
3577            "demo",
3578            None,
3579            "tinyllama",
3580            Some(&template),
3581            &ChatTemplateOptions::default(),
3582            &cmd,
3583            &budget,
3584        )
3585        .unwrap();
3586
3587        assert!(has_unclosed_thinking_block(&plan.prompt));
3588        assert_eq!(
3589            plan.sampling_params.response_completion_boundary,
3590            ResponseCompletionBoundary::AfterDelimiterAndPayload {
3591                delimiter: THINK_END_TAG.to_string(),
3592                alternate_envelope: None,
3593            }
3594        );
3595    }
3596
3597    #[test]
3598    fn cpu_run_skips_gpu_chat_autosize_defaults() {
3599        assert!(run_autosize_for_device(&ferrum_types::Device::CPU, 0.9).is_none());
3600    }
3601
3602    #[cfg(any(all(target_os = "macos", feature = "metal"), feature = "cuda"))]
3603    #[test]
3604    fn accelerator_run_keeps_chat_autosize_defaults() {
3605        #[cfg(all(target_os = "macos", feature = "metal"))]
3606        let device = ferrum_types::Device::Metal;
3607        #[cfg(all(feature = "cuda", not(all(target_os = "macos", feature = "metal"))))]
3608        let device = ferrum_types::Device::CUDA(0);
3609
3610        let autosize = run_autosize_for_device(&device, 0.75);
3611        assert_eq!(
3612            autosize,
3613            Some((crate::gpu_mem_autosize::AutoSizeProfile::Chat, 0.75))
3614        );
3615    }
3616
3617    #[test]
3618    fn context_shift_clamps_output_to_remaining_kv_budget() {
3619        let cmd = test_run_cmd();
3620        let budget = whitespace_budget(64);
3621        let options = default_template_options();
3622        let plan = build_run_prompt_plan(
3623            &[],
3624            "demo",
3625            None,
3626            "tinyllama",
3627            None,
3628            &options,
3629            &cmd,
3630            &budget,
3631        )
3632        .unwrap();
3633
3634        let prompt_tokens = plan.prompt_tokens.unwrap();
3635        assert!(prompt_tokens < 64);
3636        assert_eq!(plan.max_tokens_clamped_from, Some(4096));
3637        assert_eq!(plan.sampling_params.max_tokens, 64 - prompt_tokens);
3638    }
3639
3640    #[test]
3641    fn run_prompt_plan_retains_prompt_token_ids_for_observability() {
3642        let cmd = test_run_cmd();
3643        let budget = mapped_token_budget(64);
3644        let options = default_template_options();
3645        let plan = build_run_prompt_plan(
3646            &[],
3647            "demo prompt",
3648            None,
3649            "tinyllama",
3650            None,
3651            &options,
3652            &cmd,
3653            &budget,
3654        )
3655        .unwrap();
3656
3657        let token_ids = plan
3658            .prompt_token_ids
3659            .as_ref()
3660            .expect("prompt token ids should be retained");
3661        assert_eq!(plan.prompt_tokens, Some(token_ids.len()));
3662        assert!(!token_ids.is_empty());
3663    }
3664
3665    #[test]
3666    fn context_shift_drops_oldest_history_until_prompt_fits() {
3667        let cmd = test_run_cmd();
3668        let budget = whitespace_budget(64);
3669        let long = std::iter::repeat_n("old", 80).collect::<Vec<_>>().join(" ");
3670        let history = vec![
3671            ("user".to_string(), long.clone()),
3672            ("assistant".to_string(), long),
3673        ];
3674        let options = default_template_options();
3675        let plan = build_run_prompt_plan(
3676            &history,
3677            "demo",
3678            None,
3679            "tinyllama",
3680            None,
3681            &options,
3682            &cmd,
3683            &budget,
3684        )
3685        .unwrap();
3686
3687        assert_eq!(plan.dropped_history_messages, 2);
3688        assert_eq!(plan.dropped_history_turns, 1);
3689        assert!(plan.prompt_tokens.unwrap() < 64);
3690    }
3691
3692    #[test]
3693    fn context_shift_clamps_output_before_dropping_history() {
3694        let mut cmd = test_run_cmd();
3695        cmd.max_tokens = 1024;
3696        let budget = whitespace_budget(64);
3697        let history = vec![
3698            (
3699                "user".to_string(),
3700                "Remember the identifier G00-c03-001-OK.".to_string(),
3701            ),
3702            ("assistant".to_string(), "ACKNOWLEDGED".to_string()),
3703        ];
3704        let options = default_template_options();
3705        let plan = build_run_prompt_plan(
3706            &history,
3707            "What identifier did I ask you to remember?",
3708            None,
3709            "tinyllama",
3710            None,
3711            &options,
3712            &cmd,
3713            &budget,
3714        )
3715        .unwrap();
3716
3717        let prompt_tokens = plan.prompt_tokens.unwrap();
3718        assert!(prompt_tokens < 64);
3719        assert!(plan.prompt.contains("G00-c03-001-OK"));
3720        assert_eq!(plan.dropped_history_messages, 0);
3721        assert_eq!(plan.dropped_history_turns, 0);
3722        assert_eq!(plan.max_tokens_clamped_from, Some(1024));
3723        assert_eq!(plan.sampling_params.max_tokens, 64 - prompt_tokens);
3724    }
3725
3726    #[test]
3727    fn kv_budget_accepts_request_inside_capacity() {
3728        assert!(fits_kv_budget(&default_params(512), Some(64), Some(2048)));
3729    }
3730
3731    #[test]
3732    fn kv_budget_rejects_output_past_capacity() {
3733        assert!(!fits_kv_budget(&default_params(2048), Some(64), Some(2048)));
3734    }
3735
3736    #[test]
3737    fn kv_budget_rejects_prompt_at_capacity() {
3738        assert!(!fits_kv_budget(&default_params(1), Some(2048), Some(2048)));
3739    }
3740
3741    #[test]
3742    fn sibling_repo_strips_gguf_suffix_by_default() {
3743        assert_eq!(
3744            tokenizer_sibling_repo("Qwen/Qwen3-0.6B-GGUF").as_deref(),
3745            Some("Qwen/Qwen3-0.6B")
3746        );
3747        assert_eq!(tokenizer_sibling_repo("Qwen/Qwen3-0.6B"), None);
3748    }
3749
3750    #[test]
3751    fn sibling_repo_explicit_mappings_beat_strip_convention() {
3752        // bartowski/* have no safetensors mirrors; stripping `-GGUF`
3753        // would point at repos that don't exist.
3754        assert_eq!(
3755            tokenizer_sibling_repo("bartowski/Qwen2.5-Coder-32B-Instruct-GGUF").as_deref(),
3756            Some("Qwen/Qwen2.5-Coder-32B-Instruct")
3757        );
3758        // mistralai upstream ships tekken-format tokenizers only.
3759        assert_eq!(
3760            tokenizer_sibling_repo("bartowski/mistralai_Mistral-Small-3.2-24B-Instruct-2506-GGUF")
3761                .as_deref(),
3762            Some("unsloth/Mistral-Small-3.2-24B-Instruct-2506")
3763        );
3764        // meta-llama upstream is gated; unsloth mirror is not.
3765        assert_eq!(
3766            tokenizer_sibling_repo("bartowski/Meta-Llama-3.1-8B-Instruct-GGUF").as_deref(),
3767            Some("unsloth/Meta-Llama-3.1-8B-Instruct")
3768        );
3769    }
3770}