Skip to main content

ferrum_types/
config.rs

1//! Configuration types for Ferrum components
2
3use crate::{
4    parse_bool_env_value, parse_path_env_value, parse_usize_env_value, AttentionExecutionPolicy,
5    DataType, Device, ModelId, ModelInfo, ObservabilityProfileDetail, ProfileEntrypoint,
6    RuntimeConfigSnapshot, SamplingParams, SamplingPresets, TokenId,
7};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::{collections::HashMap, path::PathBuf, time::Duration};
11
12/// Product policy for proving that a sequence fits before prefill admission.
13///
14/// This is a non-reserving fit gate: it does not claim future KV blocks. The
15/// execution runtime still acquires exact live-frontier resources transactionally.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum SequenceFitPolicy {
19    FullInputMustFit,
20    ImmediateOnly,
21}
22
23impl SequenceFitPolicy {
24    pub const fn as_runtime_value(self) -> &'static str {
25        match self {
26            Self::FullInputMustFit => "full-input-must-fit",
27            Self::ImmediateOnly => "immediate-only",
28        }
29    }
30
31    pub fn parse_runtime_value(raw: &str) -> std::result::Result<Self, String> {
32        match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() {
33            "full-input-must-fit" => Ok(Self::FullInputMustFit),
34            "immediate-only" => Ok(Self::ImmediateOnly),
35            _ => Err(format!(
36                "expected full-input-must-fit or immediate-only; got {raw:?}"
37            )),
38        }
39    }
40}
41
42impl Default for SequenceFitPolicy {
43    fn default() -> Self {
44        Self::ImmediateOnly
45    }
46}
47
48/// Explicit one-shot faults used to prove product-path failure attribution.
49/// These are never inferred and remain disabled in normal execution.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum VNextDiagnosticFault {
53    PrefillResourceAfterSubmitOnce,
54}
55
56impl VNextDiagnosticFault {
57    pub const fn as_runtime_value(self) -> &'static str {
58        match self {
59            Self::PrefillResourceAfterSubmitOnce => "prefill-resource-after-submit-once",
60        }
61    }
62
63    pub fn parse_runtime_value(raw: &str) -> std::result::Result<Self, String> {
64        match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() {
65            "prefill-resource-after-submit-once" => Ok(Self::PrefillResourceAfterSubmitOnce),
66            _ => Err(format!(
67                "expected prefill-resource-after-submit-once; got {raw:?}"
68            )),
69        }
70    }
71}
72
73/// Explicit diagnostic capture of semantic vNext activations. Product paths
74/// leave this unset; release tooling must supply a dedicated empty directory.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct VNextCheckpointCaptureConfig {
77    pub output_dir: PathBuf,
78    pub value_ids: Vec<String>,
79    pub maximum_prefill_waves: usize,
80    #[serde(default)]
81    pub maximum_decode_waves: usize,
82    /// Persist the product output that the executor already reads back. Unlike
83    /// `value_ids`, this does not retain an activation or alter memory planning.
84    #[serde(default)]
85    pub capture_product_output: bool,
86    /// Optional canonical output history for a same-history numerical
87    /// diagnostic. The vNext executor persists each unmodified full-logits
88    /// result before forcing the corresponding token into the engine-facing
89    /// copy. Ordinary product inference leaves this unset.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub teacher_forcing: Option<VNextTeacherForcingConfig>,
92}
93
94pub const MAX_VNEXT_TEACHER_FORCED_TOKENS: usize = 512;
95
96/// Bounded token history used only by explicit vNext checkpoint diagnostics.
97///
98/// Construction and executor binding both validate this contract. The latter
99/// is required because deserialization can bypass `new` and only model binding
100/// knows the live vocabulary size.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct VNextTeacherForcingConfig {
103    token_ids: Vec<TokenId>,
104}
105
106impl VNextTeacherForcingConfig {
107    pub fn new(token_ids: Vec<TokenId>) -> std::result::Result<Self, String> {
108        let value = Self { token_ids };
109        value.validate()?;
110        Ok(value)
111    }
112
113    pub fn validate(&self) -> std::result::Result<(), String> {
114        if self.token_ids.is_empty() || self.token_ids.len() > MAX_VNEXT_TEACHER_FORCED_TOKENS {
115            return Err(format!(
116                "vNext checkpoint teacher forcing requires 1..={MAX_VNEXT_TEACHER_FORCED_TOKENS} tokens"
117            ));
118        }
119        Ok(())
120    }
121
122    pub fn token_ids(&self) -> &[TokenId] {
123        &self.token_ids
124    }
125
126    pub fn token_count(&self) -> usize {
127        self.token_ids.len()
128    }
129
130    /// Canonical identity used by release validators: concatenated little-
131    /// endian u32 token IDs, with no JSON or path-dependent representation.
132    pub fn token_ids_sha256(&self) -> String {
133        let mut digest = Sha256::new();
134        for token in &self.token_ids {
135            digest.update(token.get().to_le_bytes());
136        }
137        format!("{:x}", digest.finalize())
138    }
139}
140
141/// Engine runtime knobs the CLI/autosizer resolves and injects via the
142/// runtime-config snapshot. The continuous engine reads these from the typed
143/// config instead of `std::env::vars()`, so the env bridge stays at the
144/// composition root and tests can vary the knobs per `EngineConfig`.
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct RuntimeKnobs {
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub startup_memory_request: Option<crate::StartupMemoryRequest>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub startup_memory_plan: Option<crate::StartupMemoryPlan>,
151    pub kv_capacity: Option<usize>,
152    pub max_model_len: Option<usize>,
153    pub chunked_prefill_size: Option<usize>,
154    pub batch_decode_prof: bool,
155    pub next_batch_prof: bool,
156    pub rbd_prof: bool,
157    #[serde(default)]
158    pub profile_jsonl: Option<PathBuf>,
159    pub scheduler_trace_jsonl: Option<PathBuf>,
160    pub legacy_scheduler_trace_jsonl: Option<PathBuf>,
161    pub profile_entrypoint: Option<ProfileEntrypoint>,
162    pub profile_detail: ObservabilityProfileDetail,
163    pub unified_post_prof: bool,
164    pub prefix_cache_enabled: bool,
165    /// Retain independent vNext sequence-state checkpoints across requests.
166    #[serde(default)]
167    pub prefix_state_cache_enabled: bool,
168    pub recurrent_state_max_slots: Option<usize>,
169    pub attention_execution_policy: AttentionExecutionPolicy,
170
171    // Engine-build composition knobs. Previously read directly from the
172    // environment by `builder.rs` (FERRUM_MODEL_PATH / FERRUM_SPEC_DRAFT /
173    // FERRUM_SPEC_N) and `registry.rs` (FERRUM_DTYPE / FERRUM_METAL_DTYPE /
174    // FERRUM_TP). The CLI composition root now resolves them into this typed
175    // field so the engine builder and component registry read the snapshot,
176    // not `std::env`.
177    pub model_path: Option<String>,
178    pub spec_draft: Option<String>,
179    pub spec_n: Option<usize>,
180    pub dtype: Option<String>,
181    pub metal_dtype: Option<String>,
182    pub tp: Option<usize>,
183    #[serde(default)]
184    pub vnext_checkpoint_capture: Option<VNextCheckpointCaptureConfig>,
185    #[serde(default)]
186    pub vnext_diagnostic_fault: Option<VNextDiagnosticFault>,
187}
188
189/// Engine configuration
190#[derive(Debug, Clone, Serialize, Deserialize, Default)]
191pub struct EngineConfig {
192    /// Numerical policy is resolved against family contracts and the actual
193    /// runtime catalog before any model weight allocation.
194    #[serde(default)]
195    pub numerical_execution: crate::NumericalExecutionPolicy,
196    pub model: EngineModelConfig,
197    pub scheduler: SchedulerConfig,
198    pub sampling: SamplingConfig,
199    pub backend: BackendConfig,
200    pub kv_cache: KvCacheConfig,
201    pub memory: MemoryConfig,
202    pub batching: BatchConfig,
203    pub monitoring: MonitoringConfig,
204    #[serde(default)]
205    pub runtime: RuntimeKnobs,
206}
207
208impl EngineConfig {
209    pub fn apply_runtime_config_snapshot(
210        &mut self,
211        snapshot: &RuntimeConfigSnapshot,
212    ) -> std::result::Result<(), String> {
213        self.scheduler.apply_runtime_config_snapshot(snapshot)?;
214        if let Some(value) = runtime_config_value(snapshot, "FERRUM_KV_MAX_BLOCKS") {
215            self.kv_cache.max_blocks =
216                parse_required_positive_usize("FERRUM_KV_MAX_BLOCKS", value)?;
217        }
218        if let Some(value) = runtime_config_value(snapshot, "FERRUM_MAX_BATCHED_TOKENS") {
219            self.batching.max_num_batched_tokens =
220                parse_required_positive_usize("FERRUM_MAX_BATCHED_TOKENS", value)?;
221        }
222        if let Some(value) = runtime_config_value(snapshot, "FERRUM_PAGED_MAX_SEQS") {
223            self.scheduler.max_running_requests =
224                parse_required_positive_usize("FERRUM_PAGED_MAX_SEQS", value)?;
225        }
226        if let Some(value) = runtime_config_value(snapshot, "FERRUM_RUNTIME_MEMORY_BUDGET_BYTES") {
227            self.memory.usable_capacity_bytes = Some(parse_required_positive_usize(
228                "FERRUM_RUNTIME_MEMORY_BUDGET_BYTES",
229                value,
230            )?);
231        }
232        if let Some(value) = runtime_config_value(snapshot, "FERRUM_BATCHED_GRAPH") {
233            self.backend.enable_cuda_graphs = parse_presence_bool(value)?;
234        }
235        if let Some(value) = runtime_config_value(snapshot, "FERRUM_REUSABLE_EXECUTION") {
236            self.backend.enable_reusable_execution = parse_presence_bool(value)?;
237        }
238        if let Some(value) = runtime_config_value(snapshot, "FERRUM_REUSABLE_EXECUTION_PREPARATION")
239        {
240            self.backend.reusable_execution_capture.preparation = value.parse()?;
241        }
242        if let Some(value) =
243            runtime_config_value(snapshot, "FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS")
244        {
245            let widths =
246                parse_positive_usize_list("FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS", value)?;
247            if widths
248                .iter()
249                .any(|width| *width > MAXIMUM_REUSABLE_EXECUTION_STARTUP_CAPTURE_WIDTH)
250            {
251                return Err(format!(
252                    "FERRUM_REUSABLE_EXECUTION_EXACT_DECODE_WIDTHS: startup capture widths must be within 1..={MAXIMUM_REUSABLE_EXECUTION_STARTUP_CAPTURE_WIDTH}"
253                ));
254            }
255            self.backend.reusable_execution_capture.exact_decode_widths = Some(widths);
256        }
257        if let Some(value) = runtime_config_value(
258            snapshot,
259            "FERRUM_REUSABLE_EXECUTION_MAX_AUTOMATIC_EXACT_DECODE_WIDTH",
260        ) {
261            let maximum = parse_required_positive_usize(
262                "FERRUM_REUSABLE_EXECUTION_MAX_AUTOMATIC_EXACT_DECODE_WIDTH",
263                value,
264            )?;
265            if maximum > MAXIMUM_REUSABLE_EXECUTION_STARTUP_CAPTURE_WIDTH {
266                return Err(format!(
267                    "FERRUM_REUSABLE_EXECUTION_MAX_AUTOMATIC_EXACT_DECODE_WIDTH: must be within 1..={MAXIMUM_REUSABLE_EXECUTION_STARTUP_CAPTURE_WIDTH}"
268                ));
269            }
270            self.backend
271                .reusable_execution_capture
272                .maximum_automatic_exact_decode_width = maximum;
273        }
274        // Engine runtime knobs (previously read by the engine from env). The
275        // CLI/autosizer resolves these into the snapshot; the engine reads the
276        // typed `runtime` field instead of `std::env::vars()`.
277        if let Some(value) = runtime_config_value(snapshot, "FERRUM_KV_CAPACITY") {
278            self.runtime.kv_capacity =
279                Some(parse_required_positive_usize("FERRUM_KV_CAPACITY", value)?);
280        }
281        if let Some(value) = runtime_config_value(snapshot, "FERRUM_MAX_MODEL_LEN") {
282            self.runtime.max_model_len = Some(parse_required_positive_usize(
283                "FERRUM_MAX_MODEL_LEN",
284                value,
285            )?);
286        }
287        if let Some(value) = runtime_config_value(snapshot, "FERRUM_RECURRENT_STATE_MAX_SLOTS") {
288            self.runtime.recurrent_state_max_slots = Some(parse_required_positive_usize(
289                "FERRUM_RECURRENT_STATE_MAX_SLOTS",
290                value,
291            )?);
292        }
293        if let Some(value) = runtime_config_value(snapshot, "FERRUM_ATTENTION_POLICY") {
294            self.runtime.attention_execution_policy =
295                AttentionExecutionPolicy::parse_runtime_value(value)
296                    .map_err(|reason| format!("FERRUM_ATTENTION_POLICY: {reason}"))?;
297        }
298        if let Some(value) = runtime_config_value(snapshot, "FERRUM_CHUNKED_PREFILL") {
299            self.runtime.chunked_prefill_size =
300                parse_usize_env_value(value).ok().filter(|&v| v > 0);
301        }
302        self.runtime.batch_decode_prof |=
303            runtime_config_value(snapshot, "FERRUM_BATCH_DECODE_PROF").is_some();
304        self.runtime.next_batch_prof |=
305            runtime_config_value(snapshot, "FERRUM_NEXT_BATCH_PROF").is_some();
306        self.runtime.rbd_prof |= runtime_config_value(snapshot, "FERRUM_RBD_PROF").is_some();
307        if let Some(value) = runtime_config_value(snapshot, "FERRUM_PROFILE_JSONL") {
308            self.runtime.profile_jsonl = Some(parse_path_env_value(value)?);
309        }
310        if let Some(value) = runtime_config_value(snapshot, "FERRUM_SCHEDULER_TRACE_JSONL") {
311            self.runtime.scheduler_trace_jsonl = Some(parse_path_env_value(value)?);
312        }
313        if let Some(value) = runtime_config_value(snapshot, "FERRUM_LEGACY_SCHEDULER_TRACE_JSONL") {
314            self.runtime.legacy_scheduler_trace_jsonl = Some(parse_path_env_value(value)?);
315        }
316        if let Some(value) = runtime_config_value(snapshot, "FERRUM_PROFILE_ENTRYPOINT") {
317            self.runtime.profile_entrypoint = Some(parse_profile_entrypoint(
318                "FERRUM_PROFILE_ENTRYPOINT",
319                value,
320            )?);
321        }
322        if let Some(value) = runtime_config_value(snapshot, "FERRUM_PROFILE_DETAIL") {
323            self.runtime.profile_detail =
324                ObservabilityProfileDetail::parse(value).ok_or_else(|| {
325                    format!(
326                    "FERRUM_PROFILE_DETAIL: expected one of off, basic, resource, latency, kernel, debug, replay, verify, full; got {value:?}"
327                    )
328                })?;
329        }
330        if let Some(value) = runtime_config_value(snapshot, "FERRUM_VNEXT_DIAGNOSTIC_FAULT") {
331            self.runtime.vnext_diagnostic_fault = Some(
332                VNextDiagnosticFault::parse_runtime_value(value)
333                    .map_err(|reason| format!("FERRUM_VNEXT_DIAGNOSTIC_FAULT: {reason}"))?,
334            );
335        }
336        self.runtime.unified_post_prof |=
337            runtime_config_value(snapshot, "FERRUM_UNIFIED_POST_PROF").is_some();
338        self.runtime.prefix_cache_enabled |=
339            runtime_config_value(snapshot, "FERRUM_WHOLE_PROMPT_PREFIX_CACHE")
340                .map(|v| v == "1")
341                .unwrap_or(false);
342        if let Some(value) = runtime_config_value(snapshot, "FERRUM_PREFIX_CACHE") {
343            self.runtime.prefix_state_cache_enabled = parse_bool_env_value(value)
344                .map_err(|reason| format!("FERRUM_PREFIX_CACHE: {reason}"))?;
345        }
346
347        // Engine-build composition knobs (previously read by builder.rs /
348        // registry.rs from env). Only overwrite when the key is present so a
349        // later snapshot apply without the key keeps an earlier value.
350        if let Some(value) = runtime_config_value(snapshot, "FERRUM_MODEL_PATH") {
351            self.runtime.model_path = Some(value.to_string());
352        }
353        if let Some(value) = runtime_config_value(snapshot, "FERRUM_SPEC_DRAFT") {
354            self.runtime.spec_draft = if value.is_empty() {
355                None
356            } else {
357                Some(value.to_string())
358            };
359        }
360        if let Some(value) = runtime_config_value(snapshot, "FERRUM_SPEC_N") {
361            self.runtime.spec_n = value.parse::<usize>().ok();
362        }
363        if let Some(value) = runtime_config_value(snapshot, "FERRUM_DTYPE") {
364            self.runtime.dtype = Some(value.to_string());
365        }
366        if let Some(value) = runtime_config_value(snapshot, "FERRUM_METAL_DTYPE") {
367            self.runtime.metal_dtype = Some(value.to_string());
368        }
369        if let Some(value) = runtime_config_value(snapshot, "FERRUM_TP") {
370            self.runtime.tp = value.parse::<usize>().ok();
371        }
372
373        // Publish the resolved snapshot process-wide. Model code (which is not
374        // threaded an EngineConfig) reads `active_runtime_snapshot()` for the
375        // remaining FERRUM_* toggles instead of `std::env`, keeping the env
376        // bridge at this single composition-root call.
377        crate::install_runtime_snapshot(snapshot.clone());
378        Ok(())
379    }
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct EngineModelConfig {
384    pub model_id: ModelId,
385    pub model_info: Option<ModelInfo>,
386    pub tokenizer: TokenizerConfig,
387    /// Typed identity of the source requested by the product entrypoint.
388    /// The resolved local path remains a runtime/backend concern, while this
389    /// value preserves repository/revision provenance for product composition.
390    #[serde(default)]
391    pub source: Option<crate::ModelSource>,
392}
393
394impl Default for EngineModelConfig {
395    fn default() -> Self {
396        Self {
397            model_id: ModelId::new("default"),
398            model_info: None,
399            tokenizer: TokenizerConfig::default(),
400            source: None,
401        }
402    }
403}
404
405/// Scheduler configuration
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct SchedulerConfig {
408    /// Scheduling policy
409    pub policy: SchedulingPolicy,
410    /// Maximum waiting queue size
411    pub max_waiting_requests: usize,
412    /// Maximum running requests
413    pub max_running_requests: usize,
414    /// Enable request preemption
415    pub enable_preemption: bool,
416    /// Enable load balancing
417    pub enable_load_balancing: bool,
418    /// Fair share weights per client
419    pub fair_share_weights: HashMap<String, f32>,
420    /// SLA enforcement enabled
421    pub enable_sla_enforcement: bool,
422    /// Use prompt-token metadata for initial continuous-batch admission estimates.
423    #[serde(default = "default_prompt_token_estimate")]
424    pub prompt_token_estimate: bool,
425    /// Prefer new prefills over early decodes until this many requests are active.
426    #[serde(default)]
427    pub prefill_first_until_active: Option<usize>,
428    /// Opt in to one bounded wait for another request's exact in-flight prefix.
429    /// Expiry releases the optional dependency and resumes cold admission.
430    #[serde(default)]
431    pub prefix_rendezvous_max_wait_ms: Option<std::num::NonZeroU64>,
432    /// Optional hard cap for per-request prefill chunks. `None` spends the
433    /// live per-step token budget and lets capacity feedback narrow or regrow
434    /// each request independently.
435    #[serde(default)]
436    pub prefill_step_chunk: Option<usize>,
437    /// Cap prefill admission chunks only while decode requests are already active.
438    #[serde(default)]
439    pub active_decode_prefill_chunk: Option<usize>,
440    /// Emit diagnostic scheduler None/SOME decisions.
441    #[serde(default)]
442    pub scheduler_none_prof: bool,
443    /// Non-reserving sequence fit gate used before prefill admission.
444    #[serde(default)]
445    pub sequence_fit_policy: SequenceFitPolicy,
446}
447
448impl Default for SchedulerConfig {
449    fn default() -> Self {
450        Self {
451            policy: SchedulingPolicy::Priority,
452            max_waiting_requests: 1000,
453            max_running_requests: 32,
454            enable_preemption: true,
455            enable_load_balancing: false,
456            fair_share_weights: HashMap::new(),
457            enable_sla_enforcement: false,
458            prompt_token_estimate: default_prompt_token_estimate(),
459            prefill_first_until_active: None,
460            prefix_rendezvous_max_wait_ms: None,
461            prefill_step_chunk: None,
462            active_decode_prefill_chunk: None,
463            scheduler_none_prof: false,
464            sequence_fit_policy: SequenceFitPolicy::default(),
465        }
466    }
467}
468
469fn default_prompt_token_estimate() -> bool {
470    true
471}
472
473impl SchedulerConfig {
474    pub fn apply_runtime_config_snapshot(
475        &mut self,
476        snapshot: &RuntimeConfigSnapshot,
477    ) -> std::result::Result<(), String> {
478        if let Some(value) = runtime_config_value(snapshot, "FERRUM_PREFIX_RENDEZVOUS_MAX_WAIT_MS")
479        {
480            self.prefix_rendezvous_max_wait_ms = Some(
481                value
482                    .parse::<std::num::NonZeroU64>()
483                    .map_err(|error| format!("FERRUM_PREFIX_RENDEZVOUS_MAX_WAIT_MS: {error}"))?,
484            );
485        }
486        if let Some(value) = runtime_config_value(snapshot, "FERRUM_SCHED_PROMPT_TOKEN_ESTIMATE") {
487            self.prompt_token_estimate = parse_bool_env_value(value)
488                .map_err(|reason| format!("FERRUM_SCHED_PROMPT_TOKEN_ESTIMATE: {reason}"))?;
489        }
490        if let Some(value) =
491            runtime_config_value(snapshot, "FERRUM_SCHED_PREFILL_FIRST_UNTIL_ACTIVE")
492        {
493            self.prefill_first_until_active =
494                parse_optional_positive_usize("FERRUM_SCHED_PREFILL_FIRST_UNTIL_ACTIVE", value)?;
495        }
496        if let Some(value) = runtime_config_value(snapshot, "FERRUM_SCHED_PREFILL_STEP_CHUNK") {
497            self.prefill_step_chunk =
498                parse_optional_positive_usize("FERRUM_SCHED_PREFILL_STEP_CHUNK", value)?;
499        }
500        if let Some(value) = runtime_config_value(snapshot, "FERRUM_ACTIVE_DECODE_PREFILL_CHUNK") {
501            self.active_decode_prefill_chunk =
502                parse_optional_positive_usize("FERRUM_ACTIVE_DECODE_PREFILL_CHUNK", value)?;
503        }
504        if let Some(value) = runtime_config_value(snapshot, "FERRUM_SCHED_NONE_PROF") {
505            self.scheduler_none_prof = parse_presence_bool(value)
506                .map_err(|reason| format!("FERRUM_SCHED_NONE_PROF: {reason}"))?;
507        }
508        if let Some(value) = runtime_config_value(snapshot, "FERRUM_SEQUENCE_FIT_POLICY") {
509            self.sequence_fit_policy = SequenceFitPolicy::parse_runtime_value(value)
510                .map_err(|reason| format!("FERRUM_SEQUENCE_FIT_POLICY: {reason}"))?;
511        }
512        Ok(())
513    }
514}
515
516fn runtime_config_value<'a>(snapshot: &'a RuntimeConfigSnapshot, key: &str) -> Option<&'a str> {
517    snapshot
518        .entries
519        .iter()
520        .find(|entry| entry.key == key)
521        .map(|entry| entry.effective_value.as_str())
522}
523
524fn parse_optional_positive_usize(
525    key: &str,
526    value: &str,
527) -> std::result::Result<Option<usize>, String> {
528    let parsed = parse_usize_env_value(value).map_err(|reason| format!("{key}: {reason}"))?;
529    Ok((parsed > 0).then_some(parsed))
530}
531
532fn parse_required_positive_usize(key: &str, value: &str) -> std::result::Result<usize, String> {
533    let parsed = parse_usize_env_value(value).map_err(|reason| format!("{key}: {reason}"))?;
534    if parsed == 0 {
535        Err(format!("{key}: must be greater than zero"))
536    } else {
537        Ok(parsed)
538    }
539}
540
541fn parse_positive_usize_list(key: &str, value: &str) -> std::result::Result<Vec<usize>, String> {
542    let values = value
543        .split(',')
544        .map(str::trim)
545        .map(|value| parse_required_positive_usize(key, value))
546        .collect::<std::result::Result<Vec<_>, _>>()?;
547    if values.is_empty() {
548        Err(format!("{key}: must contain at least one width"))
549    } else {
550        Ok(values)
551    }
552}
553
554fn parse_profile_entrypoint(
555    key: &str,
556    value: &str,
557) -> std::result::Result<ProfileEntrypoint, String> {
558    ProfileEntrypoint::parse(value).ok_or_else(|| {
559        format!("{key}: expected one of run, serve, bench_serve, synthetic; got {value:?}")
560    })
561}
562
563fn parse_presence_bool(value: &str) -> std::result::Result<bool, String> {
564    if value.trim().is_empty() {
565        Ok(true)
566    } else {
567        parse_bool_env_value(value)
568    }
569}
570
571/// Scheduling policies
572#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
573pub enum SchedulingPolicy {
574    /// First-Come-First-Served
575    FCFS,
576    /// Priority-based scheduling
577    Priority,
578    /// Fair-share scheduling
579    FairShare,
580    /// Shortest-Job-First
581    SJF,
582    /// Round-Robin
583    RoundRobin,
584    /// Iteration-level continuous batching with preemption
585    ContinuousBatch,
586}
587
588/// KV Cache configuration
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct KvCacheConfig {
591    /// Cache implementation type
592    pub cache_type: KvCacheType,
593    /// Element dtype (Dim 5 polymorphism point). FP16 is the
594    /// validated production path; INT8 / FP8 require a backend impl
595    /// of `BackendKvDtype<KvInt8>` / `BackendKvDtype<KvFp8>` and a
596    /// model wired through `KvCacheQuant<B, K>`.
597    #[serde(default)]
598    pub dtype: KvCacheDtype,
599    /// Block size for paged attention
600    pub block_size: usize,
601    /// Maximum number of blocks
602    pub max_blocks: usize,
603    /// Enable cache compression
604    pub enable_compression: bool,
605    /// Compression ratio target
606    pub compression_ratio: f32,
607    /// Enable multi-level caching (GPU + CPU)
608    pub enable_multi_level: bool,
609    /// Swap threshold (when to move to CPU)
610    pub swap_threshold: f32,
611    /// Enable prefix caching
612    pub enable_prefix_caching: bool,
613    /// Prefix cache size
614    pub prefix_cache_size: usize,
615}
616
617impl Default for KvCacheConfig {
618    fn default() -> Self {
619        // 2048 blocks covers c=32 ShareGPT prompts (~32×500/16 = 1000
620        // blocks). The previous 1024 floor crashed at c≥16 on real
621        // workloads with "Block pool exhausted". Runtime overrides are
622        // applied through EngineConfig::apply_runtime_config_snapshot.
623        Self {
624            cache_type: KvCacheType::Contiguous,
625            dtype: KvCacheDtype::default(),
626            block_size: 16,
627            max_blocks: 2048,
628            enable_compression: false,
629            compression_ratio: 0.5,
630            enable_multi_level: true,
631            swap_threshold: 0.8,
632            enable_prefix_caching: true,
633            prefix_cache_size: 100,
634        }
635    }
636}
637
638/// KV Cache implementation types
639#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
640pub enum KvCacheType {
641    /// Simple contiguous memory allocation
642    Contiguous,
643    /// Paged attention with block-based allocation
644    Paged,
645    /// Tree-based cache for prefix sharing
646    Tree,
647}
648
649/// KV Cache element dtype (Dim 5 polymorphism point).
650///
651/// Mirrors `ferrum_interfaces::kv_dtype::KvDtypeKind` markers but
652/// lives here because `KvCacheConfig` is part of the user-facing
653/// `EngineConfig` and needs `Serialize` / `Deserialize`.
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
655#[serde(rename_all = "lowercase")]
656pub enum KvCacheDtype {
657    /// FP16 K/V — the validated production path on every backend.
658    #[default]
659    Fp16,
660    /// BF16 K/V — same memory cost as FP16, slightly different precision.
661    /// Marker only; no backend impl ships yet.
662    Bf16,
663    /// INT8 K/V. The selected execution plan declares the scale and layout ABI;
664    /// vNext uses per-token/head F32 scales, while legacy CUDA uses F16 scales.
665    /// This is an explicit lossy storage request, subject to model/backend
666    /// support. Memory savings include scale, alignment and workspace overhead.
667    Int8,
668    /// FP8 (E4M3) K/V. Marker only; CUDA kernels pending.
669    Fp8,
670}
671
672impl KvCacheDtype {
673    /// Parse from a CLI / env-var string. Accepts fp16/f16/bf16/int8/fp8/f8e4m3.
674    pub fn parse(s: &str) -> Option<Self> {
675        match s.trim().to_ascii_lowercase().as_str() {
676            "fp16" | "f16" | "float16" => Some(Self::Fp16),
677            "bf16" | "bfloat16" => Some(Self::Bf16),
678            "int8" | "i8" => Some(Self::Int8),
679            "fp8" | "f8" | "f8e4m3" | "e4m3" => Some(Self::Fp8),
680            _ => None,
681        }
682    }
683
684    /// Short label for display + telemetry.
685    pub fn as_str(&self) -> &'static str {
686        match self {
687            Self::Fp16 => "fp16",
688            Self::Bf16 => "bf16",
689            Self::Int8 => "int8",
690            Self::Fp8 => "fp8",
691        }
692    }
693}
694
695/// Memory management configuration
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct MemoryConfig {
698    /// Memory pool size in bytes
699    pub pool_size: Option<usize>,
700    /// Exact device-wide usable runtime budget in bytes. When present this
701    /// overrides the pressure-threshold calculation without changing the raw
702    /// device or pool capacity.
703    #[serde(default)]
704    pub usable_capacity_bytes: Option<usize>,
705    /// Enable memory pooling
706    pub enable_pooling: bool,
707    /// Memory alignment in bytes
708    pub alignment: usize,
709    /// Enable memory defragmentation
710    pub enable_defragmentation: bool,
711    /// Defragmentation threshold
712    pub defragmentation_threshold: f32,
713    /// Enable memory statistics tracking
714    pub enable_memory_stats: bool,
715    /// Memory pressure warning threshold
716    pub pressure_warning_threshold: f32,
717    /// Memory pressure critical threshold
718    pub pressure_critical_threshold: f32,
719}
720
721/// Resolved device-wide memory budget consumed by runtime planning.
722#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
723pub struct MemoryCapacityBudget {
724    pub capacity_bytes: u64,
725    pub usable_capacity_bytes: u64,
726    pub reserve_bytes: u64,
727}
728
729impl MemoryConfig {
730    pub fn resolve_capacity_budget(
731        &self,
732        device_capacity_bytes: u64,
733    ) -> std::result::Result<MemoryCapacityBudget, String> {
734        if device_capacity_bytes == 0 {
735            return Err("runtime device memory capacity must be greater than zero".to_string());
736        }
737        let capacity_bytes = self
738            .pool_size
739            .map(|bytes| bytes as u64)
740            .unwrap_or(device_capacity_bytes)
741            .min(device_capacity_bytes);
742        if capacity_bytes == 0 {
743            return Err("runtime memory capacity must be greater than zero".to_string());
744        }
745
746        let usable_capacity_bytes = if let Some(bytes) = self.usable_capacity_bytes {
747            let bytes = bytes as u64;
748            if bytes == 0 || bytes > capacity_bytes {
749                return Err(format!(
750                    "memory.usable_capacity_bytes must be in 1..={capacity_bytes}, got {bytes}"
751                ));
752            }
753            bytes
754        } else {
755            let critical = self.pressure_critical_threshold;
756            if !critical.is_finite() || critical <= 0.0 || critical > 1.0 {
757                return Err(format!(
758                    "memory.pressure_critical_threshold must be in (0, 1], got {critical}"
759                ));
760            }
761            let threshold_bytes = ((capacity_bytes as f64) * f64::from(critical)).floor() as u64;
762            capacity_bytes.saturating_sub(
763                capacity_bytes
764                    .saturating_sub(threshold_bytes)
765                    .min(capacity_bytes - 1),
766            )
767        };
768        Ok(MemoryCapacityBudget {
769            capacity_bytes,
770            usable_capacity_bytes,
771            reserve_bytes: capacity_bytes - usable_capacity_bytes,
772        })
773    }
774}
775
776impl Default for MemoryConfig {
777    fn default() -> Self {
778        Self {
779            pool_size: None,
780            usable_capacity_bytes: None,
781            enable_pooling: true,
782            alignment: 256,
783            enable_defragmentation: false,
784            defragmentation_threshold: 0.7,
785            enable_memory_stats: true,
786            pressure_warning_threshold: 0.8,
787            pressure_critical_threshold: 0.95,
788        }
789    }
790}
791
792/// Non-configurable upper bound for startup decode capture width.
793///
794/// This bound is independent from scheduler admission. It protects startup
795/// worker/resource creation even when a user supplies an extreme concurrency
796/// value or an explicit capture list. Wider runtime batches remain valid and
797/// use the documented eager fallback.
798pub const MAXIMUM_REUSABLE_EXECUTION_STARTUP_CAPTURE_WIDTH: usize = 32;
799
800/// Default upper bound for automatically generated exact decode capture widths.
801///
802/// This is an independent startup-work safety bound, not a concurrency limit.
803/// A resolver may automatically request every exact width through the minimum
804/// of admission, this ceiling, and the hard startup bound. Admission above the
805/// bound remains valid; wider runtime waves use eager fallback unless a future
806/// independently bounded policy supports them.
807pub const DEFAULT_MAXIMUM_AUTOMATIC_EXACT_DECODE_WIDTH: usize =
808    MAXIMUM_REUSABLE_EXECUTION_STARTUP_CAPTURE_WIDTH;
809
810/// When a supported backend prepares reusable device programs.
811#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(rename_all = "snake_case")]
813pub enum ReusableExecutionPreparationMode {
814    /// Select on-demand preparation when declared by the runtime, otherwise startup.
815    #[default]
816    Auto,
817    /// Prepare and validate the configured matrix before accepting requests.
818    Startup,
819    /// Warm up with ordinary requests, then capture recurring shapes within the budget.
820    OnDemand,
821}
822
823impl ReusableExecutionPreparationMode {
824    pub const fn as_runtime_value(self) -> &'static str {
825        match self {
826            Self::Auto => "auto",
827            Self::Startup => "startup",
828            Self::OnDemand => "on_demand",
829        }
830    }
831
832    pub fn resolve(self, on_demand_supported: bool) -> Result<Self, String> {
833        match (self, on_demand_supported) {
834            (Self::Auto, true) => Ok(Self::OnDemand),
835            (Self::Auto, false) => Ok(Self::Startup),
836            (Self::OnDemand, false) => Err(
837                "runtime.reusable_execution_preparation=on_demand requires a runtime declaring on-demand reusable execution support".to_owned(),
838            ),
839            (mode, _) => Ok(mode),
840        }
841    }
842}
843
844impl std::str::FromStr for ReusableExecutionPreparationMode {
845    type Err = String;
846
847    fn from_str(value: &str) -> Result<Self, Self::Err> {
848        match value.trim() {
849            "auto" => Ok(Self::Auto),
850            "startup" => Ok(Self::Startup),
851            "on_demand" => Ok(Self::OnDemand),
852            _ => {
853                Err("reusable execution preparation must be auto, startup, or on_demand".to_owned())
854            }
855        }
856    }
857}
858
859/// Product policy for bounded reusable-execution capture.
860///
861/// `None` requests an automatically resolved exact-width matrix. Explicit
862/// widths let operators bound startup work deliberately; widths omitted from
863/// that matrix remain eligible for the runtime's documented eager fallback.
864/// Neither form may exceed
865/// [`MAXIMUM_REUSABLE_EXECUTION_STARTUP_CAPTURE_WIDTH`].
866/// Validation against scheduler admission and backend capabilities belongs to
867/// the runtime-policy resolver, where those resolved limits are available.
868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
869#[serde(default)]
870pub struct ReusableExecutionCaptureConfig {
871    /// Preparation lifecycle, resolved from declared device capabilities.
872    pub preparation: ReusableExecutionPreparationMode,
873    /// Exact concurrent decode widths to prepare. `None` selects automatic
874    /// resolution from the admitted runtime capacity.
875    pub exact_decode_widths: Option<Vec<usize>>,
876    /// Safety ceiling for automatic exact-width expansion. This does not cap
877    /// user concurrency and may only lower the independent startup hard bound.
878    pub maximum_automatic_exact_decode_width: usize,
879}
880
881impl Default for ReusableExecutionCaptureConfig {
882    fn default() -> Self {
883        Self {
884            preparation: ReusableExecutionPreparationMode::default(),
885            exact_decode_widths: None,
886            maximum_automatic_exact_decode_width: DEFAULT_MAXIMUM_AUTOMATIC_EXACT_DECODE_WIDTH,
887        }
888    }
889}
890
891/// Backend configuration
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct BackendConfig {
894    /// Backend type
895    pub backend_type: BackendType,
896    /// Target device
897    pub device: Device,
898    /// Data type for computation
899    pub dtype: DataType,
900    /// Enable optimizations
901    pub enable_optimizations: bool,
902    /// Optimization level (0-3)
903    pub optimization_level: u8,
904    /// Enable CUDA graphs
905    pub enable_cuda_graphs: bool,
906    /// Prepare backend-owned reusable device programs when supported.
907    #[serde(default = "default_enable_reusable_execution")]
908    pub enable_reusable_execution: bool,
909    /// Typed reusable-program capture policy shared by every product
910    /// entrypoint. This is deliberately not a backend option or hidden env
911    /// bridge: the resolved execution policy must fingerprint its outcome.
912    #[serde(default)]
913    pub reusable_execution_capture: ReusableExecutionCaptureConfig,
914    /// Enable kernel fusion
915    pub enable_kernel_fusion: bool,
916    /// Custom backend-specific options
917    pub backend_options: HashMap<String, serde_json::Value>,
918}
919
920impl Default for BackendConfig {
921    fn default() -> Self {
922        Self {
923            backend_type: BackendType::Candle,
924            device: Device::CPU,
925            dtype: DataType::FP16,
926            enable_optimizations: true,
927            optimization_level: 2,
928            enable_cuda_graphs: false,
929            enable_reusable_execution: default_enable_reusable_execution(),
930            reusable_execution_capture: ReusableExecutionCaptureConfig::default(),
931            enable_kernel_fusion: true,
932            backend_options: HashMap::new(),
933        }
934    }
935}
936
937const fn default_enable_reusable_execution() -> bool {
938    true
939}
940
941/// Supported backend types
942#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
943pub enum BackendType {
944    /// Candle framework
945    Candle,
946    /// ONNX Runtime
947    OnnxRuntime,
948    /// TensorRT
949    TensorRT,
950    /// Custom backend
951    Custom,
952}
953
954/// Tokenizer configuration
955#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct TokenizerConfig {
957    /// Tokenizer type
958    pub tokenizer_type: TokenizerType,
959    /// Path to tokenizer files
960    pub tokenizer_path: Option<String>,
961    /// Enable fast tokenization
962    pub enable_fast: bool,
963    /// Add special tokens
964    pub add_special_tokens: bool,
965    /// Truncation strategy
966    pub truncation: Option<TruncationConfig>,
967    /// Padding strategy
968    pub padding: Option<PaddingConfig>,
969}
970
971impl Default for TokenizerConfig {
972    fn default() -> Self {
973        Self {
974            tokenizer_type: TokenizerType::BPE,
975            tokenizer_path: None,
976            enable_fast: true,
977            add_special_tokens: true,
978            truncation: None,
979            padding: None,
980        }
981    }
982}
983
984/// Tokenizer algorithms
985#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
986pub enum TokenizerType {
987    /// Byte Pair Encoding
988    BPE,
989    /// WordPiece tokenizer (BERT-style)
990    WordPiece,
991    /// SentencePiece tokenizer
992    SentencePiece,
993    /// Tiktoken tokenizer family
994    Tiktoken,
995    /// Any custom tokenizer implementation
996    Custom,
997}
998
999/// Truncation configuration
1000#[derive(Debug, Clone, Serialize, Deserialize)]
1001pub struct TruncationConfig {
1002    /// Maximum length
1003    pub max_length: usize,
1004    /// Truncation strategy
1005    pub strategy: TruncationStrategy,
1006}
1007
1008/// Truncation strategies
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub enum TruncationStrategy {
1011    /// Remove from the beginning
1012    TruncateStart,
1013    /// Remove from the end
1014    TruncateEnd,
1015    /// Remove from both sides
1016    TruncateBoth,
1017}
1018
1019/// Padding configuration
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct PaddingConfig {
1022    /// Padding strategy
1023    pub strategy: PaddingStrategy,
1024    /// Padding token ID
1025    pub token_id: u32,
1026    /// Target length
1027    pub target_length: Option<usize>,
1028}
1029
1030/// Padding strategies
1031#[derive(Debug, Clone, Serialize, Deserialize)]
1032pub enum PaddingStrategy {
1033    /// No padding
1034    None,
1035    /// Pad to maximum length in batch
1036    MaxLength,
1037    /// Pad to specific length
1038    FixedLength,
1039}
1040
1041/// Sampling configuration presets
1042
1043/// Security configuration
1044#[derive(Debug, Clone, Serialize, Deserialize)]
1045pub struct SecurityConfig {
1046    /// Enable API authentication
1047    pub enable_auth: bool,
1048    /// API keys for authentication
1049    pub api_keys: Vec<String>,
1050    /// Enable rate limiting
1051    pub enable_rate_limiting: bool,
1052    /// Rate limit per client (requests per minute)
1053    pub rate_limit_rpm: u32,
1054    /// Enable content filtering
1055    pub enable_content_filter: bool,
1056    /// Maximum prompt length
1057    pub max_prompt_length: usize,
1058    /// Enable prompt validation
1059    pub enable_prompt_validation: bool,
1060    /// Allowed file extensions for uploads
1061    pub allowed_extensions: Vec<String>,
1062}
1063
1064impl Default for SecurityConfig {
1065    fn default() -> Self {
1066        Self {
1067            enable_auth: false,
1068            api_keys: vec![],
1069            enable_rate_limiting: true,
1070            rate_limit_rpm: 60,
1071            enable_content_filter: false,
1072            max_prompt_length: 32768,
1073            enable_prompt_validation: true,
1074            allowed_extensions: vec!["txt".to_string(), "json".to_string()],
1075        }
1076    }
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1080pub struct SamplingConfig {
1081    pub default_params: SamplingParams,
1082    pub presets: SamplingPresets,
1083    pub enable_custom_processors: bool,
1084}
1085
1086#[derive(Debug, Clone, Serialize, Deserialize)]
1087pub struct MonitoringConfig {
1088    pub enable_metrics: bool,
1089    pub enable_tracing: bool,
1090    pub export_interval: Duration,
1091}
1092
1093impl Default for MonitoringConfig {
1094    fn default() -> Self {
1095        Self {
1096            enable_metrics: true,
1097            enable_tracing: true,
1098            export_interval: Duration::from_secs(5),
1099        }
1100    }
1101}
1102
1103#[derive(Debug, Clone, Serialize, Deserialize)]
1104pub struct BatchConfig {
1105    pub max_batch_size: usize,
1106    pub max_wait_ms: u64,
1107    pub enable_dynamic: bool,
1108    pub enable_continuous: bool,
1109    /// vLLM-style per-iteration token budget. The scheduler emits a
1110    /// mixed prefill+decode batch summing to at most this many Q
1111    /// tokens (decode = 1 each, prefill chunk = its chunk size).
1112    /// Default 2048. Runtime snapshots can override this with
1113    /// `FERRUM_MAX_BATCHED_TOKENS`, usually from the GPU autosizer or a
1114    /// named workload preset rather than a user hand-written env bundle.
1115    #[serde(default = "BatchConfig::default_max_num_batched_tokens")]
1116    pub max_num_batched_tokens: usize,
1117}
1118
1119impl BatchConfig {
1120    fn default_max_num_batched_tokens() -> usize {
1121        2048
1122    }
1123}
1124
1125impl Default for BatchConfig {
1126    fn default() -> Self {
1127        Self {
1128            max_batch_size: 32,
1129            max_wait_ms: 8,
1130            enable_dynamic: true,
1131            enable_continuous: false,
1132            max_num_batched_tokens: Self::default_max_num_batched_tokens(),
1133        }
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140
1141    #[test]
1142    fn prefix_state_cache_canonical_boolean_overrides_and_missing_key_preserves() {
1143        let mut config = EngineConfig::default();
1144        for (value, expected) in [("1", true), ("0", false), ("true", true), ("false", false)] {
1145            config
1146                .apply_runtime_config_snapshot(&RuntimeConfigSnapshot::from_env_vars([(
1147                    "FERRUM_PREFIX_CACHE",
1148                    value,
1149                )]))
1150                .unwrap();
1151            assert_eq!(config.runtime.prefix_state_cache_enabled, expected);
1152            assert!(!config.runtime.prefix_cache_enabled);
1153            config
1154                .apply_runtime_config_snapshot(&RuntimeConfigSnapshot::default())
1155                .unwrap();
1156            assert_eq!(config.runtime.prefix_state_cache_enabled, expected);
1157        }
1158        assert!(config
1159            .apply_runtime_config_snapshot(&RuntimeConfigSnapshot::from_env_vars([(
1160                "FERRUM_PREFIX_CACHE",
1161                "invalid"
1162            ),]))
1163            .is_err());
1164    }
1165
1166    #[test]
1167    fn prefix_state_cache_is_independent_of_whole_prompt_flag_and_defaults_on_old_wire() {
1168        let mut config = EngineConfig::default();
1169        config
1170            .apply_runtime_config_snapshot(&RuntimeConfigSnapshot::from_env_vars([(
1171                "FERRUM_WHOLE_PROMPT_PREFIX_CACHE",
1172                "1",
1173            )]))
1174            .unwrap();
1175        assert!(config.runtime.prefix_cache_enabled);
1176        assert!(!config.runtime.prefix_state_cache_enabled);
1177        config.runtime.prefix_state_cache_enabled = true;
1178        config
1179            .apply_runtime_config_snapshot(&RuntimeConfigSnapshot::from_env_vars([(
1180                "FERRUM_PREFIX_CACHE",
1181                "false",
1182            )]))
1183            .unwrap();
1184        assert!(config.runtime.prefix_cache_enabled);
1185        assert!(!config.runtime.prefix_state_cache_enabled);
1186        let mut old = serde_json::to_value(&config.runtime).unwrap();
1187        old.as_object_mut()
1188            .unwrap()
1189            .remove("prefix_state_cache_enabled");
1190        let restored: RuntimeKnobs = serde_json::from_value(old).unwrap();
1191        assert!(!restored.prefix_state_cache_enabled);
1192        assert!(restored.prefix_cache_enabled);
1193    }
1194
1195    #[test]
1196    fn scheduler_keeps_immediate_fit_default_until_full_input_policy_is_gated() {
1197        assert_eq!(
1198            SchedulerConfig::default().sequence_fit_policy,
1199            SequenceFitPolicy::ImmediateOnly
1200        );
1201    }
1202
1203    #[test]
1204    fn sequence_fit_policy_uses_canonical_product_values() {
1205        assert_eq!(
1206            serde_json::to_string(&SequenceFitPolicy::FullInputMustFit).unwrap(),
1207            "\"full-input-must-fit\""
1208        );
1209        assert_eq!(
1210            serde_json::from_str::<SequenceFitPolicy>("\"immediate-only\"").unwrap(),
1211            SequenceFitPolicy::ImmediateOnly
1212        );
1213    }
1214
1215    #[test]
1216    fn diagnostic_fault_uses_one_canonical_product_value() {
1217        assert_eq!(
1218            VNextDiagnosticFault::parse_runtime_value("prefill_resource_after_submit_once")
1219                .unwrap(),
1220            VNextDiagnosticFault::PrefillResourceAfterSubmitOnce
1221        );
1222        assert_eq!(
1223            VNextDiagnosticFault::PrefillResourceAfterSubmitOnce.as_runtime_value(),
1224            "prefill-resource-after-submit-once"
1225        );
1226        assert!(VNextDiagnosticFault::parse_runtime_value("resource-failure").is_err());
1227    }
1228
1229    #[test]
1230    fn engine_config_applies_typed_diagnostic_fault() {
1231        let mut config = EngineConfig::default();
1232        let snapshot = RuntimeConfigSnapshot::from_env_vars([(
1233            "FERRUM_VNEXT_DIAGNOSTIC_FAULT",
1234            "prefill-resource-after-submit-once",
1235        )]);
1236
1237        config
1238            .apply_runtime_config_snapshot(&snapshot)
1239            .expect("runtime config should apply");
1240
1241        assert_eq!(
1242            config.runtime.vnext_diagnostic_fault,
1243            Some(VNextDiagnosticFault::PrefillResourceAfterSubmitOnce)
1244        );
1245    }
1246
1247    #[test]
1248    fn engine_config_rejects_unknown_diagnostic_fault() {
1249        let mut config = EngineConfig::default();
1250        let snapshot = RuntimeConfigSnapshot::from_env_vars([(
1251            "FERRUM_VNEXT_DIAGNOSTIC_FAULT",
1252            "resource-failure",
1253        )]);
1254
1255        let error = config
1256            .apply_runtime_config_snapshot(&snapshot)
1257            .expect_err("unknown diagnostic fault must fail closed");
1258
1259        assert!(error.contains("FERRUM_VNEXT_DIAGNOSTIC_FAULT"));
1260    }
1261
1262    #[test]
1263    fn scheduler_deserialization_without_fit_policy_keeps_legacy_default() {
1264        let mut serialized = serde_json::to_value(SchedulerConfig::default()).unwrap();
1265        serialized
1266            .as_object_mut()
1267            .unwrap()
1268            .remove("sequence_fit_policy");
1269
1270        let scheduler: SchedulerConfig = serde_json::from_value(serialized).unwrap();
1271
1272        assert_eq!(
1273            scheduler.sequence_fit_policy,
1274            SequenceFitPolicy::ImmediateOnly
1275        );
1276    }
1277
1278    #[test]
1279    fn checkpoint_capture_deserialization_keeps_decode_capture_disabled_by_default() {
1280        let capture: VNextCheckpointCaptureConfig = serde_json::from_value(serde_json::json!({
1281            "output_dir": "capture",
1282            "value_ids": ["value.output.logits"],
1283            "maximum_prefill_waves": 1
1284        }))
1285        .unwrap();
1286
1287        assert_eq!(capture.maximum_decode_waves, 0);
1288        assert!(!capture.capture_product_output);
1289    }
1290
1291    #[test]
1292    fn engine_config_applies_typed_sequence_fit_policy() {
1293        let mut config = EngineConfig::default();
1294        let snapshot = RuntimeConfigSnapshot::from_env_vars([(
1295            "FERRUM_SEQUENCE_FIT_POLICY",
1296            "full-input-must-fit",
1297        )]);
1298
1299        config
1300            .apply_runtime_config_snapshot(&snapshot)
1301            .expect("runtime config should apply");
1302
1303        assert_eq!(
1304            config.scheduler.sequence_fit_policy,
1305            SequenceFitPolicy::FullInputMustFit
1306        );
1307    }
1308
1309    #[test]
1310    fn engine_config_rejects_unknown_sequence_fit_policy() {
1311        let mut config = EngineConfig::default();
1312        let snapshot = RuntimeConfigSnapshot::from_env_vars([(
1313            "FERRUM_SEQUENCE_FIT_POLICY",
1314            "reserve-everything",
1315        )]);
1316
1317        let error = config
1318            .apply_runtime_config_snapshot(&snapshot)
1319            .expect_err("unknown fit policy must fail closed");
1320
1321        assert!(error.contains("FERRUM_SEQUENCE_FIT_POLICY"));
1322    }
1323
1324    #[test]
1325    fn engine_config_applies_recurrent_state_max_slots_runtime_key() {
1326        let mut config = EngineConfig::default();
1327        let snapshot =
1328            RuntimeConfigSnapshot::from_env_vars([("FERRUM_RECURRENT_STATE_MAX_SLOTS", "16")]);
1329
1330        config
1331            .apply_runtime_config_snapshot(&snapshot)
1332            .expect("runtime config should apply");
1333
1334        assert_eq!(config.runtime.recurrent_state_max_slots, Some(16));
1335    }
1336
1337    #[test]
1338    fn engine_config_does_not_apply_removed_qwen35_slot_alias() {
1339        let mut config = EngineConfig::default();
1340        let snapshot =
1341            RuntimeConfigSnapshot::from_env_vars([("FERRUM_QWEN35_LINEAR_STATE_MAX_SLOTS", "16")]);
1342
1343        config
1344            .apply_runtime_config_snapshot(&snapshot)
1345            .expect("runtime config should apply");
1346
1347        assert_eq!(config.runtime.recurrent_state_max_slots, None);
1348    }
1349
1350    #[test]
1351    fn engine_config_uses_generic_recurrent_state_slots_when_removed_alias_is_present() {
1352        let mut config = EngineConfig::default();
1353        let snapshot = RuntimeConfigSnapshot::from_env_vars([
1354            ("FERRUM_RECURRENT_STATE_MAX_SLOTS", "8"),
1355            ("FERRUM_QWEN35_LINEAR_STATE_MAX_SLOTS", "16"),
1356        ]);
1357
1358        config
1359            .apply_runtime_config_snapshot(&snapshot)
1360            .expect("runtime config should apply");
1361
1362        assert_eq!(config.runtime.recurrent_state_max_slots, Some(8));
1363    }
1364
1365    #[test]
1366    fn engine_config_applies_profile_entrypoint_runtime_key() {
1367        let mut config = EngineConfig::default();
1368        let snapshot = RuntimeConfigSnapshot::from_env_vars([("FERRUM_PROFILE_ENTRYPOINT", "run")]);
1369
1370        config
1371            .apply_runtime_config_snapshot(&snapshot)
1372            .expect("runtime config should apply");
1373
1374        assert_eq!(
1375            config.runtime.profile_entrypoint,
1376            Some(ProfileEntrypoint::Run)
1377        );
1378    }
1379
1380    #[test]
1381    fn engine_config_applies_typed_profile_detail_runtime_key() {
1382        let mut config = EngineConfig::default();
1383        let snapshot = RuntimeConfigSnapshot::from_env_vars([("FERRUM_PROFILE_DETAIL", "full")]);
1384
1385        config
1386            .apply_runtime_config_snapshot(&snapshot)
1387            .expect("runtime config should apply");
1388
1389        assert_eq!(
1390            config.runtime.profile_detail,
1391            ObservabilityProfileDetail::Full
1392        );
1393    }
1394
1395    #[test]
1396    fn engine_config_applies_typed_latency_profile_detail_runtime_key() {
1397        let mut config = EngineConfig::default();
1398        let snapshot = RuntimeConfigSnapshot::from_env_vars([("FERRUM_PROFILE_DETAIL", "latency")]);
1399
1400        config
1401            .apply_runtime_config_snapshot(&snapshot)
1402            .expect("latency profile detail should apply");
1403
1404        assert_eq!(
1405            config.runtime.profile_detail,
1406            ObservabilityProfileDetail::Latency
1407        );
1408    }
1409
1410    #[test]
1411    fn engine_config_applies_typed_replay_profile_detail_runtime_key() {
1412        let mut config = EngineConfig::default();
1413        let snapshot = RuntimeConfigSnapshot::from_env_vars([("FERRUM_PROFILE_DETAIL", "replay")]);
1414
1415        config
1416            .apply_runtime_config_snapshot(&snapshot)
1417            .expect("runtime config should apply");
1418
1419        assert_eq!(
1420            config.runtime.profile_detail,
1421            ObservabilityProfileDetail::Replay
1422        );
1423    }
1424
1425    #[test]
1426    fn engine_config_applies_typed_verification_profile_detail_runtime_key() {
1427        let mut config = EngineConfig::default();
1428        let snapshot = RuntimeConfigSnapshot::from_env_vars([("FERRUM_PROFILE_DETAIL", "verify")]);
1429
1430        config
1431            .apply_runtime_config_snapshot(&snapshot)
1432            .expect("runtime config should apply");
1433
1434        assert_eq!(
1435            config.runtime.profile_detail,
1436            ObservabilityProfileDetail::Verify
1437        );
1438    }
1439
1440    #[test]
1441    fn engine_config_applies_typed_profile_jsonl_runtime_key() {
1442        let mut config = EngineConfig::default();
1443        let snapshot =
1444            RuntimeConfigSnapshot::from_env_vars([("FERRUM_PROFILE_JSONL", "/tmp/profile.jsonl")]);
1445
1446        config
1447            .apply_runtime_config_snapshot(&snapshot)
1448            .expect("runtime config should apply");
1449
1450        assert_eq!(
1451            config.runtime.profile_jsonl.as_deref(),
1452            Some(std::path::Path::new("/tmp/profile.jsonl"))
1453        );
1454    }
1455
1456    #[test]
1457    fn engine_config_rejects_unknown_profile_detail() {
1458        let mut config = EngineConfig::default();
1459        let snapshot =
1460            RuntimeConfigSnapshot::from_env_vars([("FERRUM_PROFILE_DETAIL", "everything")]);
1461
1462        let error = config
1463            .apply_runtime_config_snapshot(&snapshot)
1464            .expect_err("unknown profile detail must fail closed");
1465
1466        assert!(error.contains("FERRUM_PROFILE_DETAIL"));
1467    }
1468}