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