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